three.js, 3D 모델에 대한 라벨 시각화

3D 모델에 대한 효과적은 라벨 시각화는 그 목표에 따라 매우 주관적이고 다양하게 접근할 수 있습니다. 이 글에서 제공되는 라벨 시각화 역시 많은 다양한 방법 중에 하나인데요. 목표는 3D 모델을 최대한 가리지 않아야 하며 라벨 사이의 충돌을 최소화 해야 합니다. 이런 목표에 따라 만든 기능은 아래와 같습니다.

위의 기능을 프로젝트에 빠르고 쉽게 적용하기 위해 SmartLabel이라는 이름으로 컴포넌트로 만들었는데요. 이 컴포넌트를 적용할 때 고려해야할 코드를 정리하면 다음과 같습니다.

먼저 SmartLabel 컴포넌트와 라벨 관련 데이터를 생성하고 설정해야 합니다.

  _setupLabels() {
    const smartLabel = new SmartLabel(this._scene, this._camera);
    this._smartLabel = smartLabel;

    const labels = {
      "Object": {
        label: "미할당영역",
        textColor: "gray",
      },
      "Part1": {
        label: "정문 계단",
        textColor: "white",
      },
      "Part2": {
        label: "정문",
        textColor: "white",
      },
      "Part3": {
        label: "파손영역A",
        textColor: "red",
      },
      "Part4": {
        label: "파손영역B",
        textColor: "red",
      },
      
      ...
    }

    this._smartLabel.setLabelData(labels);
  }

labels 객체의 key는 Mesh의 이름이고 Value는 표시될 라벨과 텍스트 색상입니다. 라벨이 표시될 Mesh의 지정은 다음과 같습니다.

_setupModel() {
  const loader = new GLTFLoader();
  loader.load("./model.glb", gltf => {
    const object = gltf.scene;
    this._scene.add(object);
    const meshes = [];

    object.traverse(obj => {
      if (obj.isMesh) meshes.push(obj);
    });

    this._smartLabel.setTargetMeshs(meshes);
    this._smartLabel.buildLabels();

    ...
  });
}

매 프레임마다 다음과 같은 코드 호출이 필요합니다.

update() {
  ...

  this._smartLabel.updateOnFrame();
}

렌더링 시 다음과 같은 코드 호출이 필요하구요.

render() {
  this.update();

  ...

  this._smartLabel.render();

  requestAnimationFrame(this.render.bind(this));
}

렌더링 되는 영역의 크기가 변경되면 다음 코드 호출이 필요합니다.

resize() {
  const width = this._divContainer.clientWidth;
  const height = this._divContainer.clientHeight;

  ...  

  this._smartLabel.updateSize(width, height);
}

라벨은 DOM 요소입니다. 이에 대한 스타일이 필요할 수 있는데, 위의 예시의 경우 다음과 같습니다.

.label {
  font-size: 1em;
  border: 2px solid yellow;
  padding: 0.3em 0.8em;
  border-radius: 0.9em;

  background-color: black;
  transition: transform 0.2s ease-out, opacity 1s ease;
  box-shadow: 0 0 5px rgba(0,0,0,0.5);
}

three.js, 선택된 3D 모델에 대한 하이라이팅

3D 그래픽과 관련된 개발을 하다보면 사용자 요구 사항에 대한 기본적인 것들이 몇가지 존재합니다. 그중에 하나가 마우스 등으로 어떤 모델을 클릭해 선택했을때 선택된 모델을 시각적으로 하일라이팅되도록 하는 것입니다. 매우 명확하고 반드시 필요한 요구사항입니다. 아래는 마우스로 모델의 특정 부분을 선택하면 선택된 부분에 대한 하일라이팅입니다.

이런 요구사항을 빠르고 쉽게 반영될 수 있도록 SelectionPassWrapper라는 이름으로 컴포넌트로 만들어 보았는데요. 이름에서도 알 수 있는듯이 Postprocess를 위한 Pass로 만들어졌다는 것을 알 수 있습니다. SelectionPassWrapper를 이용하는 API를 정리하면 다음과 같습니다.

먼저 SelectionPassWrapper를 생성해 Postprocess를 위한 EffectComposer에 추가하는 것으로 시작합니다.

_setupPostprocess() {
  ...

  const selectionPassWrapper = new SelectionPassWrapper(this._renderer, this._scene, this._camera);
  // selectionPassWrapper.debug = false;
  const selectionPass = selectionPassWrapper.pass;
  effectComposer.addPass(selectionPass);
  this._selectionPassWrapper = selectionPassWrapper;

  ...
}

그리고 렌더링 영역의 크기가 변경되었을 때 다음 코드가 필요합니다.

resize() {
  ...

  this._selectionPassWrapper.updateSize();
}

마지막으로 하일라이팅하고자 하는 매시를 지정해야 하는데요. 아래의 코드는 하일라이팅 하고자 하는 메시를 마우스로 더블클릭하는 방식으로 선택하는 코드입니다.

_setupEvents() {
  ...

  const raycaster = new THREE.Raycaster();
  const mouse = new THREE.Vector2();

  window.addEventListener("dblclick", (event) => {
    mouse.x = (event.clientX / this._divContainer.clientWidth) * 2 - 1;
    mouse.y = -(event.clientY / this._divContainer.clientHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, this._camera);
    const intersects = raycaster.intersectObjects(this._scene.children, true);

    if (intersects.length > 0) {
      this._selectionPassWrapper.selectedMeshes.length = 0;

      const intersectedMesh = intersects[0].object;
      if(intersectedMesh.parent.type === "Group") {
        intersectedMesh.parent.traverse(child => {
          if(child.isMesh) {
            this._selectionPassWrapper.selectedMeshes.push(child);
          }
        });
      } else {
        this._selectionPassWrapper.selectedMeshes.push(intersectedMesh);
      }
    } else {
      this._selectionPassWrapper.selectedMeshes.length = 0;
    }

    this._selectionPassWrapper.updateSelection();
  });
}

만약 OrbitControls의 autoRotate나 enableDamping가 활성화된 경우 다음 코드가 필요합니다.

_setupControls() {
  this._orbitControls = new OrbitControls(this._camera, this._divContainer);
  this._orbitControls.addEventListener("change", () => {
    this._selectionPassWrapper.updateSelection();
  });
  this._orbitControls.autoRotate = true;
  this._orbitControls.enableDamping = true;
}