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;
}

WebGPU를 이용한 계산(Computing)

3개의 실수값을 전달하고 이 값에 2를 곱한 결과를 GPU를 통해 병렬로 처리 실행해 그 결과를 얻는 코드입니다. 먼저 GPU에 대한 객체를 얻는 것은 비동기로 처리해야 하므로 다음과 같은 코드가 필요합니다.

async function main() {
  // 여기에 이후 모든 코드가 작성됩니다.
}

await main();

자, 이제 GPU에 대한 객체를 얻습니다.

  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();

실행할 쉐이더 코드를 작성합니다. 쉐이더 코드는 WGSL로 작성됩니다.

  const module = device.createShaderModule({
    label: 'doubling compute module',
    code: /* wgsl */ `
      @group(0) @binding(0) var<storage, read_write> data: array<f32>;
 
      @compute @workgroup_size(1) fn computeSomething(
        @builtin(global_invocation_id) id: vec3u
      ) {
        let i = id.x;
        data[i] = data[i] * 2.0;
      }
    `,
  });

위의 쉐이더 코드를 실행할 파이프라인 객체를 생성합니다.

  const pipeline = device.createComputePipeline({
    label: 'x2 compute pipeline',
    layout: 'auto',
    compute: {
      module,
      entryPoint: 'computeSomething', // 실행할 쉐이더 함수
    },
  });

실행할 쉐이더 함수를 보면 1개의 인자를 받는데, 그 인자를 정의합니다.

  const input = new Float32Array([1, 3, 5]);

위의 인자는 CPU 메모리에 있으므로 이를 GPU 메모리에 만들어서 복사해 줘야 합니다.

  const workBuffer = device.createBuffer({
    label: 'work buffer',
    size: input.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
  });

  device.queue.writeBuffer(workBuffer, 0, input);

GPU를 통해 계산된 최종 결과를 읽기 위한 사본 버퍼를 생성합니다.

  const resultBuffer = device.createBuffer({
    label: 'result buffer',
    size: input.byteLength,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
  });

계산을 위해 사용될 인자에 대한 버퍼가 무엇인지를 명확히 지정합니다.

  const bindGroup = device.createBindGroup({
    label: 'bindGroup for work buffer',
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: { buffer: workBuffer } },
    ],
  });

GPU에서 실행할 명령을 인코딩합니다.

  const encoder = device.createCommandEncoder({ label: 'x2 encoder' });
  const pass = encoder.beginComputePass({ label: 'x2 compute pass' });

  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(input.length);
  pass.end();

계산 결과를 읽어 내기 위해 맵핑용 버퍼에 복사하는 명령을 인코딩합니다.

  encoder.copyBufferToBuffer(workBuffer, 0, resultBuffer, 0, resultBuffer.size);

실해 명령의 실행은 다음 코드를 통해 이루어집니다.

   const commandBuffer = encoder.finish();
   device.queue.submit([commandBuffer]);

실행 결과를 콘솔에 출력하기 위한 코드입니다.

  await resultBuffer.mapAsync(GPUMapMode.READ);
  const result = new Float32Array(resultBuffer.getMappedRange());
 
  console.log('input', input);
  console.log('result', result);
 
  resultBuffer.unmap();

실행 결과를 보면 다음과 같습니다.

실행 결과를 보면 입력값에 2배만큼 곱해졌는데, 이는 GPU를 통해 병렬로 계산된 것입니다.