[Three.js] 최적화

Study·2021년 9월 13일
2

Three.js

목록 보기
5/8
post-thumbnail

다중 요소 렌더링 최적화

Three.js 는 사용자가 Mesh 를 하나 만들 때마다 매번 시스템에 하나 이상의 렌더링 요청을 보낸다. 결과물이 같아도 mesh 를 1개를 렌더링할 때보다 2개를 렌더링할 때 오버헤드가 더 많이 발생한다.
그래서 mesh를 하나로 합치면 오버헤드를 줄일 수 있다.

간단한 예를 들어보자.
WebGL 지구본을 베껴 구현해본다.

인구 통계 데이터도 가져온다.

 ncols         360
 nrows         145
 xllcorner     -180
 yllcorner     -60
 cellsize      0.99999999999994
 NODATA_value  -9999
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...

키/값 데이터 몇줄과 나머지는 격자점 데이터다.
그리고 데이터 한 줄은 각 좌표에 대한 데이터다.

이 데이터를 2D로 구현해보자. 먼저 텍스트 파일을 불러온다.

async function loadFile(url) {
 const res = await fetch(url);
 return res.text;
}

위 함수는 url 의 파일의 내용을 반환하는 Promise 를 반환한다.
다음으로 텍스트 데이터를 파싱하는 함수를 작성한다.

function parseData(text) {
  const data = [];
  const settings = { data };
  let max;
  let min;
  // 각 줄을 쪼갬
  text.split('\n').forEach((line) => {
    // 해당 줄을 공백을 기준으로 쪼갬
    const parts = line.trim().split(/\s+/);
    if (parts.length === 2) {
      // 2개로 나눠졌다면 키/값 쌍 데이터임
      settings[parts[0]] = parseFloat(parts[1]);
    } else if (parts.length > 2) {
      // 2개보다 많다면 좌표 데이터
      const values = parts.map((v) => {
        const value = parseFloat(v);
        if (value === settings.NODATA_value) {
          return undefined;
        }
        max = Math.max(max === undefined ? value : max, value);
        min = Math.min(min === undefined ? value : min, value);
        return value;
      });
      data.push(values);
    }
  });
  return Object.assign(settings, { min, max });
}

위 함수는 데이터 파일의 키/값 쌍, 좌표 데이터를 하나의 배열로 만든 data 속성, 그리고 좌표 데이터를 기반으로 한 min, max 속성을 가진 객체를 반환한다.

그리고 데이터를 렌더링하는 코드를 작성한다.

function drawData(file) {
  const { min, max, data } = file;
  const range = max - min;
  const ctx = document.querySelector('canvas').getContext('2d');
  // 데이터와 같은 크기로 캔버스 해상도를 맞춤
  ctx.canvas.width = ncols;
  ctx.canvas.height = nrows;
  // 캔버스 요소의 크기를 두 배로 지정해 너무 작게 보이지 않도록 함
  ctx.canvas.style.width = px(ncols * 2);
  ctx.canvas.style.height = px(nrows * 2);
  // 배경을 짙은 회색으로 채움
  ctx.fillStyle = '#444';
  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  // 각 좌표에 점을 그림
  data.forEach((row, latNdx) => {
    row.forEach((value, lonNdx) => {
      if (value === undefined) {
        return;
      }
      const amount = (value - min) / range;
      const hue = 1;
      const saturation = 1;
      const lightness = amount;
      ctx.fillStyle = hsl(hue, saturation, lightness);
      ctx.fillRect(lonNdx, latNdx, 1, 1);
    });
  });
}
 
function px(v) {
  return `${ v | 0 }px`;
}
 
function hsl(h, s, l) {
  return `hsl(${ h * 360 | 0 },${ s * 100 | 0 }%,${ l * 100 | 0 }%)`;
}

작성한 함수를 순서대로 실행하면 끝이다.

loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
  .then(parseData)
  .then(drawData);

이제 이 예제로 각 데이터마다 육면체를 만들 것이다.

먼저 아래 텍스처로 간단한 지구본 모형을 만든다.

아래는 지구본 코드이다.

{
  const loader = new THREE.TextureLoader();
  const texture = loader.load('resources/images/world.jpg', render);
  const geometry = new THREE.SphereGeometry(1, 64, 32);
  const material = new THREE.MeshBasicMaterial({ map: texture });
  scene.add(new THREE.Mesh(geometry, material));
}

위 코드에서 텍스처를 불러온 후 render 함수를 호출하게 했다.
화면을 반복해서 렌더링하지 않고 필요할 때만 렌더링하므로, 텍스처를 불러온 뒤 다시 한 번 렌더링 해야 한다.

다음으로 데이터를 하나의 점으로 표시하는 대신 좌표마다 육면체를 하나씩 생성한다.

function addBoxes(file) {
  const { min, max, data } = file;
  const range = max - min;
 
  // 육면체 geometry를 만듦
  const boxWidth = 1;
  const boxHeight = 1;
  const boxDepth = 1;
  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
  // 중심이 아닌 양의 z축 방향으로 커지게끔 만듦
  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
 
  // 아래 헬퍼 Object3D는 육면체들의 위치 변화를 간단하게 만들어줌
  // lonHelper를 Y축으로 돌려 경도(longitude)를 맞출 수 있다
  const lonHelper = new THREE.Object3D();
  scene.add(lonHelper);
  // latHelper를 X축으로 돌려 위도(latitude)를 맞출 수 있다
  const latHelper = new THREE.Object3D();
  lonHelper.add(latHelper);
  // positionHelper는 다른 요소의 기준축을 구체의 끝에 맞추는 역할
  const positionHelper = new THREE.Object3D();
  positionHelper.position.z = 1;
  latHelper.add(positionHelper);
 
  const lonFudge = Math.PI * .5;
  const latFudge = Math.PI * -0.135;
  data.forEach((row, latNdx) => {
    row.forEach((value, lonNdx) => {
      if (value === undefined) {
        return;
      }
      const amount = (value - min) / range;
      const material = new THREE.MeshBasicMaterial();
      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
      const saturation = 1;
      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
      material.color.setHSL(hue, saturation, lightness);
      const mesh = new THREE.Mesh(geometry, material);
      scene.add(mesh);
 
      // 헬퍼들을 특정 위도와 경도로 이동
      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
 
      // positionHelper의 위치를 해당 mesh의 위치로 지정
      positionHelper.updateWorldMatrix(true, false);
      mesh.applyMatrix4(positionHelper.matrixWorld);
 
      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    });
  });
}

위 코드는 아까 만든 테스트 코드의 구조를 그대로 사용한다.

각 육면체를 양의 Z축으로 커지게 만든 건 지구본 위에 그래프가 올라와야 하기 때문이다.

longHelper, letHelper, positionHelper 를 계층 구조로 만든다. 다음은 구체 주위에 육면체를 배치할 좌표를 찾기 위해서이다.

위 초록 막대는 longHelper 로, 자전축 중심으로 경도를 찾는 역할
파란 막대는 latHelper 로, 적도 위 아래로 위도를 찾는 역할
빨간 구체는 positionHelper로, 육면체의 좌표값이다.

좌표를 직접 계산하여 접근할 수 있지만 라이브러리로 대체한다.

위 코드에서 각 데이터 좌표마다 MeshBasicMaterialMesh 를 생성했다.
그리고 positionHelper 의 전역 좌표를 구해 Mesh 에 적용하고, 데이터의 양만큼 이 mesh 를 키운다.

아까와 마찬가지로 longHelper, latHelper, positionHelper 를 따라 생성할 수도 있지만 성능이 느려질 것이다.

육면체를 최대 360X145개, 거의 52000개를 만드는 셈이다.
이 육면체마다 헬퍼를 생성하면 대량의 씬 그래프 요소를 계산하게 된다. 대신 공통으로 사용하면 연산 요청을 모두 줄일 수 있다.

longFudgelatFudge 는 간단하게 설명하자면, longFudge 는 π/2, 90도이다.
이는 텍스처와 좌표가 다른 각도에서 시작하는 것이다. latFudge 의 경우는 π * -0.135 인데, 좌표가 축을 기준으로 정렬하는 것이다.

이제 만든 함수를 호출해보자.

loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
  .then(parseData)
//  .then(drawData)
  .then(addBoxes)
  .then(render);

이제 지구본과 육면체들을 추가한 뒤 render 함수를 직접 호출해보자.

위 예제를 드래그하여 지구본을 돌려보면 뭔가 버벅임을 느낄 수 있다.
개발자 도구에서 FPS 미터로 프레임율을 살펴본다.

위 예제를 최적화하는 방법은 모든 정육면체를 하나의 geometry 로 합치는 방법을 적용하면 된다. 현재 거의 19000개 정도 렌더링이 되어 있는데, 이를 하나로 합치면 연산 요청을 18999회 줄일 수 있다.

function addBoxes(file) {
  const {min, max, data} = file;
  const range = max - min;
 
  /*
  // 육면체 geometry를 만듦
  const boxWidth = 1;
  const boxHeight = 1;
  const boxDepth = 1;
  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
  // 중심이 아닌 양의 z축 방향으로 커지게끔 만듦
  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
  */
 
  // 아래 헬퍼 Object3D는 육면체들의 위치 변화를 간단하게 만들어줌
  // lonHelper를 Y축으로 돌려 경도(longitude)를 맞출 수 있음
  const lonHelper = new THREE.Object3D();
  scene.add(lonHelper);
  // latHelper를 X축으로 돌려 위도(latitude)를 맞출 수 있음
  const latHelper = new THREE.Object3D();
  lonHelper.add(latHelper);
  // positionHelper는 다른 요소의 기준축을 구체의 끝에 맞추는 역할
  const positionHelper = new THREE.Object3D();
  positionHelper.position.z = 1;
  latHelper.add(positionHelper);
  // 육면체의 중심을 옮겨 양의 Z축 방향으로 커지게 함
  const originHelper = new THREE.Object3D();
  originHelper.position.z = 0.5;
  positionHelper.add(originHelper);
 
  const lonFudge = Math.PI * .5;
  const latFudge = Math.PI * -0.135;
  const geometries = [];
  data.forEach((row, latNdx) => {
    row.forEach((value, lonNdx) => {
      if (value === undefined) {
        return;
      }
      const amount = (value - min) / range;
 /*
      const material = new THREE.MeshBasicMaterial();
      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
      const saturation = 1;
      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
      material.color.setHSL(hue, saturation, lightness);
      const mesh = new THREE.Mesh(geometry, material);
      scene.add(mesh);
 */
      const boxWidth = 1;
      const boxHeight = 1;
      const boxDepth = 1;
      const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
 
      // 헬퍼들을 특정 위도와 경도로 이동
      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
 /*
      // positionHelper의 위치를 해당 mesh의 위치로 지정
      positionHelper.updateWorldMatrix(true, false);
      mesh.applyMatrix4(positionHelper.matrixWorld);
 
      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
 */
      // originHelper의 위치를 해당 geometry의 위치로 지정
      positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
      originHelper.updateWorldMatrix(true, false);
      geometry.applyMatrix4(originHelper.matrixWorld);
 
      geometries.push(geometry);
    });
  });
 
  // 생성한 geometry를 전부 합침
  const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(
      geometries, false);
  const material = new THREE.MeshBasicMaterial({ color:'red' });
  const mesh = new THREE.Mesh(mergedGeometry, material);
  scene.add(mesh);
 
}

위 코드에서 육면체를 옮기는 대신 originHelper 를 새로 만들어 중심축을 옮겼다.
이전에는 같은 geometry 를 19000번 재활용했지만, 이번에는 데이터마다 geometry 를 새로 생성했다.
또한 applyMatrix 를 이용해 육면체 자체의 장점을 이동시키므로 메소드를 두 번 쓰는 대신 한 번만 썼다.

그리고 생성한 육면체를 전부 배열에 저장한 뒤 이 배열을 BufferGeometryUtils.mergeBufferGeometries 에 넘겨 하나의 geometry 로 합쳤다.

물론 BufferGeometryUtils 를 불러와야 한다.

import { BufferGeometryUtils } from './resources/threejs/utils/BufferGeometryUtils.js';

이제 적어도 60 프레임 이상이 나올 것이다.

성능 문제는 해결했지만 육면체가 하나의 mesh 이기 전에 색이 전부 달랐다.
여기서 색을 따로 지정하기 위해선 정점 색을 쓰는 방법이 가장 간단하다.

정점 색은 정점마다 색을 지정한다. 각 육면체의 각 정점을 다른 색으로 지정하면 육면체의 색을 다르게 지정할 수 있다.

const color = new THREE.Color();
 
const lonFudge = Math.PI * .5;
const latFudge = Math.PI * -0.135;
const geometries = [];
data.forEach((row, latNdx) => {
  row.forEach((value, lonNdx) => {
    if (value === undefined) {
      return;
    }
    const amount = (value - min) / range;
 
    const boxWidth = 1;
    const boxHeight = 1;
    const boxDepth = 1;
    const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
 
    // 헬퍼들을 특정 위도와 경도로 이동시킵니다.
    lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
    latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
 
    // originHelper의 위치를 해당 geometry의 위치로 지정합니다.
    positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    originHelper.updateWorldMatrix(true, false);
    geometry.applyMatrix4(originHelper.matrixWorld);
 
    // 색상값을 계산합니다.
    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    const saturation = 1;
    const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount);
    color.setHSL(hue, saturation, lightness);
    // RGB 색상값을 0부터 255까지의 배열로 변환합니다.
    const rgb = color.toArray().map(v => v * 255);
 
    // 각 정점의 색을 배열로 저장합니다.
    const numVerts = geometry.getAttribute('position').count;
    const itemSize = 3;  // r, g, b
    const colors = new Uint8Array(itemSize * numVerts);
 
    // 색상값을 각 정점에 지정할 색상으로 변환합니다.
    colors.forEach((v, ndx) => {
      colors[ndx] = rgb[ndx % 3];
    });
 
    const normalized = true;
    const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
    geometry.setAttribute('color', colorAttrib);
 
    geometries.push(geometry);
  });
});

추가한 코드에서는 먼저 geometry의 position 속성을 가져와 정점의 개수를 파악했다.
그 다음 색상을 지정하기 위해 Uint8Array 로 변환한 뒤, 이를 geometry.setAttribute 메소드로 geometry 의 color 속성에 지정했다.

마지막으로 재질이 정점 색상을 사용하도록 설정한다.

const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(
    geometries, false);
// const material = new THREE.MeshBasicMaterial({color:'red'});
const material = new THREE.MeshBasicMaterial({
  vertexColors: true,
});
const mesh = new THREE.Mesh(mergedGeometry, material);
scene.add(mesh);

이제 색이 다시 정상적으로 보일 것이다.

geometry 를 합치는 것은 자주 사용되는 기법이다.

다중 애니메이션 요소 최적화

offscreenCanvas 는 비교적 최근 도입된 브라우저 API로 아직 크로미움 기반 브라우저에서만 사용가능하지만, 갈수록 대부분의 브라우저에서 이 API를 사용할 수 있을 것이다.
offscreenCanvas 를 이용하면 웹 워커에서 캔버스를 렌더링해 복잡한 3D 장면 등의 무거운 작업을 별도 프로세스에서 처리할 수 있다. 이럼 무거운 작업을 처리할 때 브라우저가 덜 버벅이도록 할 수 있다. 또한 데이터도 워커에서 불러와 처리하니 페이지 초기 로드에도 줄일 수 있다.

사용법은 반응형 디자인에 관한 글에서 예제를 가져온다.

이 글에서는 offscreencanvas-cubes.js 라는 별도 파일을 만들어 반응형 디자인 글의 예제의 자바스크립트 코드르 전부 복사해 넣는다. 그 다음 바굴 부분을 바꿔보자.

캔버스 요소를 참조하고 canvas.transferControlToOffscreen 메소드를 호출해 캔버스의 제어권을 offscreen 에 넘겨준다.

function main() {
 const canvas = document.querySelector('#c');
 const offscreen = canvas.transferControlOffscreen();
  
  ...
}

그리고 new Worker 로 워커를 생성하고 워커에 offscreen 객체를 넘긴다.

function main() {
  const canvas = document.querySelector('#c');
  const offscreen = canvas.transferControlToScreen();
  const worker = new Worker('offscreencanvas-cubes.js', {type: 'module'});
  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
}
main();

스크립트를 따로 쓰는 이유는 워커 안에선 DOM 객체에 접근할 수 없기 때문이다.
일반적으로 메시지 이벤트를 통해서만 다른 스크립트와 통신할 수 있다.

워커 메시지를 보내려면 worker.postMessage 에 하나 또는 두 개의 인자를 넘겨 호출하면 된다.
첫 인자는 워커에 전달할 객체로, 이 객체는 그대로 전달되지 않고 복사된다.
두번째 인자는 옵션으로 첫 번째 인자 중 그대로 전달하기 원하는 객체를 배열로 지정한다. 여기에 지정한 객체는 복사되지 않는다. 그리고 모든 객체를 전달할 수 있는게 아닌 특정 타입의 객체만을 전달할 수 있다. 당연히 이 중 offscreenCanvas 도 있다.

워커의 message 이벤트를 이용하면 메시지를 받을 수 있다. postMessage 에서 넘긴 객체는 event.data 에 담겨 리스너에 전달된다. 위 코드 type: 'main' 속성을 객체에 선언해 워커에 넘겨줬다. 이 type 속성은 브라우저의 메인 스레드에서는 쓸 일이 없는 값으로, 워커 내에 다른 함수를 호출하는 키값으로 사용할 것이다. 이럼 메인 스크립트에서 워커 내의 함수를 호출하기가 훨씬 쉬워진다.

const handlers = {
  main,
};
 
self.onmessage = function(e) {
  const fn = handlers[e.data.type];
  if (!fn) {
    throw new Error('no handler for type: ' + e.data.type);
  }
  fn(e.data);
};

type 값을 통해 호출할 함수를 찾고, 함수가 있다면 메인 스크립트에서 넘어온 data 를 인자로 넘겨 호출하도록 했다.

이제 예제의 main 함수를 수정해야 한다.

DOM 에서 캔버스에 접근하는 대신 이벤트의 data 속성에서 캔버스 요소를 받도록 한다.

//function main() {
//  const canvas = document.querySelector('#c');
function main(data) {
  const { canvas } = data;
  const renderer = new THREE.WebGLRenderer({ canvas });
 
  ...

워커에서 DOM에 접근할 수 없다고 했다. 마찬가지로 DOM 속성인 canvas.clientWidthcanvas.clientHeight 에도 접근할 수 없다. resizeRenderToDisplaySize 를 그대로 사용할 수 없다는 것이다.

function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
  const width = canvas.clientWidth;
  const height = canvas.clientHeight;
  const needResize = canvas.width !== width || canvas.height !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}

대신 캔버스 크기가 변경될 때마다 워커에 메시지를 보낼 것이다. 워커에 전역 변수를 하나 생성해 여기에 width 와 height 값을 지정하도록 한다.

const state = {
  width: 300,	// 캔버스 기본값
  height: 150, 	// 캔버스 기본값
};

그리고 size 라는 함수를 만들어 해당 값을 업데이트하도록 한다.

function size(data) {
  state.width = data.width;
  state.height = data.height;
}

const handler = {
  main,
  size
};

resizeRenderToDisplaySizestate.widthstate.height 를 쓰도록 변경한다.

function resizeRenderToDisplaySize(renderer) {
  const canvas = renderer.domElement;
//  const width = canvas.clientWidth;
//  const height = canvas.clientHeight;
  const width = state.width;
  const height = state.height;
  const needResize = canvas.width !== width || canvas.height !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}

마찬가지로 종횡비를 계산하는 코드도 DOM 속성 대신 state 를 쓰게 변경한다.

function render(time) {
  time *= 0.001;
 
  if (resizeRendererToDisplaySize(renderer)) {
//    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.aspect = state.width / state.height;
    camera.updateProjectionMatrix();
  }
 
  ...

메인 스크립트로 돌아와 페이지 크기가 바뀔 때마다 워커의 size 함수를 실행하도록 한다.

const worker = new Worker('offscreencanvas-picking.js', { type: 'module' });
worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
 
function sendSize() {
  worker.postMessage({
    type: 'size',
    width: canvas.clientWidth,
    height: canvas.clientHeight,
  });
}
 
window.addEventListener('resize', sendSize);
sendSize();

또한 직접 호출해 최초에 한 번 값을 보내도록 한다.

브라우저가 OffscreenCanvas 를 완벽히 지원한다면 문제 없이 작동할 것이다.
혹시 모를 브라우저가 OffscreenCanvas 를 지원하지 않을 경우 에러 메시지를 보여주도록 한다.
먼저 에러 메시지를 표시할 HTML 을 작성한다.

<body>
  <canvas id="c"></canvas>
  <div id="noOffscreenCanvas" style="display:none;">
    <div>no OffscreenCanvas support</div>
  </div>
</body>

간단한 스타일도 넣어주자.

#noOffscreenCanvas {
    display: flex;
    width: 100%;
    height: 100%;
    align-items: center;
    justify-content: center;
    background: red;
    color: white;
}

그리고 캔버스 요소에 transferControlToOffscreen 메소드가 있는지 확인해 OffscreenCanvas 의 지원 여부를 확인한다.

function main() {
  const canvas = document.querySelector('#c');
  if (!canvas.transferControlToOffscreen) {
    canvas.style.display = 'none';
    document.querySelector('#noOffscreenCanvas').style.display = '';
    return;
  }
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-picking.js'. { type: 'module' });
  worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
 
  ...


OffscreenCanvas를 단순히 페이지 반응형으로 만드는데 사용하는 것은 의미가 없어 보일 수 있다.
메인 스크립트에서 반응형으로 처리할 때보다 워커에서 처리할 때 오히려 작업이 더 많이 들 수 있다. 하지만 메인 스크립트만 사용할 때보다 워커를 사용할 때 자원을 더 넉넉하게 활용할 수 있다.


먼저 Three.js 관련 코드를 분리해 워커 관련 코드와 그렇지 않은 코드로 나눠야 한다. 같은 코드를 메인 스크립트와 워커에서 모두 쓸 수 있도록 하여 아래와 같이 3개의 파일로 나뉜다.

  1. html 파일 - threejs-offscreencanvas-w-fallback.html
  2. three.js 관련 자바스크립트 파일 - shared-cubes.js
  3. 워커용 스크립트 - offscreencanvas-worker-cubes.js

shared-cubes.jsoffscreencanvas-worker-cubes.js 는 단순히 이전 offscreencanvas-cubes.js 파일을 쪼갠 것이다. 먼저 offscreencanvas-cube.jsshared-cube.js 로 옮긴 뒤, 메인 HTML 파일에 이미 main 함수가 있어 main 함수의 이름만 init 으로 바꿔야 한다. 추가로 initstate 함수를 export 해준다.

import * as THREE from './resources/threejs/r132/build/three.module.js';
 
// const state = {
export const state = {
  width: 300,   // 캔버스 기본값
  height: 150,  // 캔버스 기본값
};
 
// function main(data) {
export function init(data) {
  const { canvas } = data;
  const renderer = new THREE.WebGLRenderer({ canvas });

그리고 Three.js 와 관련 없는 부분을 잘라낸다.

/*
function size(data) {
  state.width = data.width;
  state.height = data.height;
}
 
const handlers = {
  main,
  size,
};
 
self.onmessage = function(e) {
  const fn = handlers[e.data.type];
  if (!fn) {
    throw new Error('no handler for type: ' + e.data.type);
  }
  fn(e.data);
};
*/

잘라낸 부분을 offscreencanvas-worker-cubes.js 에 붙여 놓고, shared-cubes.js 를 import 한다. 또한 main 대신 init 을 호출한다.

const handlers = {
//  main,
  init,
  size,
};

메인 페이지에서도 마찬가지로 Three.js 와 shared-cubes.js 를 추가한다.

<script type="module"></script>
import { init, state } from './shared-cubes.js';

이전에 추가한 에러 메시지용 HTML 과 CSS 를 제거한다.
그리고 워커를 만들기 위해 사용한 코드 전부 startWorker 함수로 옮긴다.

function startWorker(canvas) {
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-worker-cubes.js', { type: 'module' });
  worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
 
  function sendSize() {
    worker.postMessage({
      type: 'size',
      width: canvas.clientWidth,
      height: canvas.clientHeight,
    });
  }
 
  window.addEventListener('resize', sendSize);
  sendSize();
 
  console.log('using OffscreenCanvas');
}

메시지에 main 대신 init 을 보낸다.

//  worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
  worker.postMessage({ type: 'init', canvas: offscreen }, [ offscreen ]);

워커를 사용할 수 없는 경우 다음과 같이 실행한다.

function startMainPage(canvas) {
  init({ canvas });
 
  function sendSize() {
    state.width = canvas.clientWidth;
    state.height = canvas.clientHeight;
  }
  window.addEventListener('resize', sendSize);
  sendSize();
 
  console.log('using regular canvas');
}

이제 OffscreenCanvas 를 지원할 경우에만 OffscreenCanvas 를 사용하고, 지원하지 않는 경우에는 메인 스레드에서 직접 렌더링한다.

여기에 피킹(picking)을 추가해본다.

먼저 shared-cube.js 코드를 shared-picking.js 로 복사한 뒤, 피킹 예제의 PickHelper 를 가져온다.

class PickHelper {
  constructor() {
    this.raycaster = new THREE.Raycaster();
    this.pickedObject = null;
    this.pickedObjectSavedColor = 0;
  }
  pick(normalizedPosition, scene, camera, time) {
    // 이미 다른 물체를 피킹했다면 색을 복원합니다
    if (this.pickedObject) {
      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
      this.pickedObject = undefined;
    }
 
    // 절두체 안에 광선을 쏩니다
    this.raycaster.setFromCamera(normalizedPosition, camera);
    // 광선과 교차하는 물체들을 배열로 만듭니다
    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    if (intersectedObjects.length) {
      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
      this.pickedObject = intersectedObjects[0].object;
      // 기존 색을 저장해둡니다
      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
      // emissive 색을 빨강/노랑으로 빛나게 만듭니다
      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    }
  }
}
 
const pickPosition = { x: 0, y: 0 };
const pickHelper = new PickHelper();

pickPosition 은 마우스 포인터의 좌표를 기록하는 역할을 한다.
이벤트를 통해 해당 속성을 업데이트하도록 했다.

function getCanvasRelativePosition(event) {
  const rect = canvas.getBoundingClientRect();
  return {
    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    y: (event.clientY - rect.top ) * canvas.height / rect.height,
  };
}
 
function setPickPosition(event) {
  const pos = getCanvasRelativePosition(event);
  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // Y축을 뒤집었음
}
window.addEventListener('mousemove', setPickPosition);

워커는 포인터 좌표에 직접 접근할 수 없으니, 반응형 처리에 사용한 코드처럼 포인터 좌표를 메시지로 보내야 한다. 먼저 size 함수와 마찬가지로 mouse 함수를 만들어 pickPosition 을 업데이트하도록 한다.

function size(data) {
  state.width = data.width;
  state.height = data.height;
}
 
function mouse(data) {
  pickPosition.x = data.x;
  pickPosition.y = data.y;
}
 
const handlers = {
  init,
  mouse,
  size,
};
 
self.onmessage = function(e) {
  const fn = handlers[e.data.type];
  if (!fn) {
    throw new Error('no handler for type: ' + e.data.type);
  }
  fn(e.data);
};

그리고 메인 페이지에 분기 함수를 만들어 워커 또는 메인 페이지로 좌표 데이터를 보내도록 한다.

let sendMouse;
 
function startWorker(canvas) {
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-worker-picking.js', { type: 'module' });
  worker.postMessage({ type: 'init', canvas: offscreen }, [ offscreen ]);
 
  sendMouse = (x, y) => {
    worker.postMessage({
      type: 'mouse',
      x,
      y,
    });
  };
 
  function sendSize() {
    worker.postMessage({
      type: 'size',
      width: canvas.clientWidth,
      height: canvas.clientHeight,
    });
  }
 
  window.addEventListener('resize', sendSize);
  sendSize();
 
  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
}
 
function startMainPage(canvas) {
  init({ canvas });
 
  sendMouse = (x, y) => {
    pickPosition.x = x;
    pickPosition.y = y;
  };
 
  function sendSize() {
    state.width = canvas.clientWidth;
    state.height = canvas.clientHeight;
  }
  window.addEventListener('resize', sendSize);
  sendSize();
 
  console.log('using regular canvas');
}

다음으로 마우스 이벤트 관련 코드를 메인 페이지로 옮긴 뒤 sendMouse 함수를 쓰도록 수정한다.

function setPickPosition(event) {
  const pos = getCanvasRelativePosition(event);
//  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
//  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // Y축을 뒤집었음
  sendMouse(
      (pos.x / canvas.clientWidth ) *  2 - 1,
      (pos.y / canvas.clientHeight) * -2 + 1);  // Y축을 뒤집었음
}
 
function clearPickPosition() {
  /**
   * 마우스의 경우는 항상 위치가 있어 그다지 큰
   * 상관이 없지만, 터치 같은 경우 사용자가 손가락을
   * 떼면 피킹을 멈춰야 합니다. 지금은 일단 어떤 것도
   * 선택할 수 없는 값으로 지정해두었습니다
   **/
//  pickPosition.x = -100000;
//  pickPosition.y = -100000;
  sendMouse(-100000, -100000);
}
window.addEventListener('mousemove', setPickPosition);
window.addEventListener('mouseout', clearPickPosition);
window.addEventListener('mouseleave', clearPickPosition);
 
window.addEventListener('touchstart', (event) => {
  event.preventDefault(); // 스크롤 이벤트 방지
  setPickPosition(event.touches[0]);
}, { passive: false });
 
window.addEventListener('touchmove', (event) => {
  setPickPosition(event.touches[0]);
});
 
window.addEventListener('touchend', clearPickPosition);

이제 OffscreenCanvas 에서도 피킹이 정상적으로 작동할 것이다.

OrbitControls 도 추가해보자. DOM에 다양하게 접근하기 위해 처리할 것이 많다.
제대로 작동하기 위해 마우스 이벤트, 터치 이벤트, 키보드 이벤트 모두 처리해야 한다.

여태 전역 state 만 사용했지만, OrbitControls 의 경우는 객체 속성이 너무 많아 하드 코딩하기는 힘들다. OrbitControls 는 필요한 DOM 이벤트 대부분을 인자로 받는 HTMLElement에 바인딩한다.
이를 이용해 DOM 요소와 같은 구조를 객체를 넘겨주면 OrbitControls 에 필요한 기능만 살릴 수 있다.

OrbitControls 소스를 보면 아래의 이벤트를 지원하는 것을 볼 수 있다.

  • contextmenu
  • pointerdown
  • pointermove
  • pointerup
  • touchstart
  • touchmove
  • touchend
  • wheel
  • keydown

마우스 이벤트 중 OrbitControls 가 사용하는 속성은 ctrlKey, metaKey, SiftKey, button, pointerType, clientX, clientY, pageX, pageY 이고,

Keydown 이벤트의 경우 ctrlKey, metaKey, ShiftKey, KeyCode 속성,
wheel 이벤트는 deltaY 속성만,
터치 이벤트의 경우는 touches 속성의 pageX, pageY 속성이 필요하다.

이를 처리할 경유(proxy) 객체를 한 쌍 만들어보자.
한쪽은 메인 페이지에서 위 이벤트를 받아 필요한 속성을 워커에 넘겨주는 역할을 한다. 그리고 다른 한쪽은 워커 안에서 이 이벤트를 받아 OrbitControls 에 넘겨줄 것이다. 이벤트 객체가 DOM 이벤트와 같은 구조이기에 OrbitControls 는 이 이벤트가 DOM 이벤트가 아니란걸 눈치채지 못할 것이다.

아래는 워커 안의 코드다.

import { EventDispatcher } from './resources/threejs/r132/build/three.module.js';
 
class ElementProxyReceiver extends EventDispatcher {
  constructor() {
    super();
  }
  handleEvent(data) {
    this.dispatchEvent(data);
  }
}

위 코드는 단순히 메시지를 받았을 때 그걸 다시 내보내는(dispatch) 역할을 한다. 부모 클래스인 EventDispatcher 는 DOM 요소처럼 addEventListenerremoveEventListener 메소드를 제공하기에 HTML 요소 대신 이 클래스의 인스턴스를 넘겨줘도 문제없이 작동할 거다.

ElementProxyReceiver 는 하나의 요소만 대신할 수 있다. 예제의 경우 하나만 필요하기는 하나 나중에 캔버스를 여러 개 사용할 수도 있으니 여러 ElementProxyReceiver 를 관리하는 클래스를 만들겠다.

class ProxyManager {
  constructor() {
    this.targets = {};
    this.handleEvent = this.handleEvent.bind(this);
  }
  makeProxy(data) {
    const { id } = data;
    const proxy = new ElementProxyReceiver();
    this.targets[id] = proxy;
  }
  getProxy(id) {
    return this.targets[id];
  }
  handleEvent(data) {
    this.targets[data.id].handleEvent(data.data);
  }
}

ProxyManager 의 인스턴스를 만들고 id 값과 함께 makeProxy 메소드를 호출하면 해당 id 에만 응답하는 ElementProxyReceiver 가 생성된다.

이제 이 클래스를 기존 워커 코드와 연동한다.

const proxyManager = new ProxyManager();
 
function start(data) {
  const proxy = proxyManager.getProxy(data.canvasId);
  init({
    canvas: data.canvas,
    inputElement: proxy,
  });
}
 
function makeProxy(data) {
  proxyManager.makeProxy(data);
}
 
...
 
const handlers = {
//  init,
//  mouse,
  start,
  makeProxy,
  event: proxyManager.handleEvent,
   size,
};
 
self.onmessage = function(e) {
  const fn = handlers[e.data.type];
  if (!fn) {
    throw new Error('no handler for type: ' + e.data.type);
  }
  fn(e.data);
};

Three.js 의 공통 코드에 OrbitControls 모듈도 불러와 설정해야 한다.

import * as THREE from './resources/threejs/r132/build/three.module.js';
import { OrbitControls } from './resources/threejs/r132/examples/jsm/controls/OrbitControls.js';
 
export function init(data) {
//  const { canvas } = data;
  const { canvas, inputElement } = data;
  const renderer = new THREE.WebGLRenderer({ canvas });
 
  const controls = new OrbitControls(camera, inputElement);
  controls.target.set(0, 0, 0);
  controls.update();

위 코드에선 이전과 달리 inputElement 로 경유 객체를 OrbitControls 에 넘겨 줬다.

하는 김에 이벤트도 경유 객체를 사용하여 바꾼다.

function getCanvasRelativePosition(event) {
//  const rect = canvas.getBoundingClientRect();
  const rect = inputElement.getBoundingClientRect();
  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  };
}
 
function setPickPosition(event) {
  const pos = getCanvasRelativePosition(event);
//  sendMouse(
//      (pos.x / canvas.clientWidth ) *  2 - 1,
//      (pos.y / canvas.clientHeight) * -2 + 1);  // Y축을 뒤집었음
  pickPosition.x = (pos.x / inputElement.clientWidth ) *  2 - 1;
  pickPosition.y = (pos.y / inputElement.clientHeight) * -2 + 1;  // Y축을 뒤집었음
}
 
function clearPickPosition() {
  /**
   * 마우스의 경우는 항상 위치가 있어 그다지 큰
   * 상관이 없지만, 터치 같은 경우 사용자가 손가락을
   * 떼면 피킹을 멈춰야 합니다. 지금은 일단 어떤 것도
   * 선택할 수 없는 값으로 지정해두었습니다
   **/
//  sendMouse(-100000, -100000);
  pickPosition.x = -100000;
  pickPosition.y = -100000;
}
 
inputElement.addEventListener('mousemove', setPickPosition);
inputElement.addEventListener('mouseout', clearPickPosition);
inputElement.addEventListener('mouseleave', clearPickPosition);
 
inputElement.addEventListener('touchstart', (event) => {
  event.preventDefault(); // 스크롤 이벤트 방지
  setPickPosition(event.touches[0]);
}, { passive: false });
 
inputElement.addEventListener('touchmove', (event) => {
  setPickPosition(event.touches[0]);
});
 
inputElement.addEventListener('touchend', clearPickPosition);

메인 페이지에서 위에 열거한 모든 이벤트가 워커로 메시지를 보내도록 한다.

let nextProxyId = 0;
class ElementProxy {
  constructor(element, worker, eventHandlers) {
    this.id = nextProxyId++;
    this.worker = worker;
    const sendEvent = (data) => {
      this.worker.postMessage({
        type: 'event',
        id: this.id,
        data,
      });
    };
 
    // id를 등록합니다.
    worker.postMessage({
      type: 'makeProxy',
      id: this.id,
    });
    for (const [eventName, handler] of Object.entries(eventHandlers)) {
      element.addEventListener(eventName, function(event) {
        handler(event, sendEvent);
      });
    }
  }
}

ElementProxy 는 이벤트를 우회할 요소를 인자로 받는다. 그리고 고유 id 를 생성해 워커에 makeProxy 메시지로 id 를 등록한다. 그럼 아까 만든 워커는 이 id에 새로운 ElementProxyReceiver 를 생성한다.

다음으로 이벤트를 처리할 핸들러 맵(eventHandlers)를 만든다. 이럼 해당 이벤트가 발생했을 때만 워커에 메시지를 보낸다.

워커를 생성할 때 ElementProxy 에 이 핸들러 맵을 넘겨 새 우회 요소를 생성한다.

function startWorker(canvas) {
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', { type: 'module' });
 
  const eventHandlers = {
    contextmenu: preventDefaultHandler,
    mousedown: mouseEventHandler,
    mousemove: mouseEventHandler,
    mouseup: mouseEventHandler,
    pointerdown: mouseEventHandler,
    pointermove: mouseEventHandler,
    pointerup: mouseEventHandler,
    touchstart: touchEventHandler,
    touchmove: touchEventHandler,
    touchend: touchEventHandler,
    wheel: wheelEventHandler,
    keydown: filteredKeydownEventHandler,
  };
  const proxy = new ElementProxy(canvas, worker, eventHandlers);
  worker.postMessage({
    type: 'start',
    canvas: offscreen,
    canvasId: proxy.id,
  }, [ offscreen ]);
  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
}

핸들러 맵의 핸들러는 넘겨 받은 이벤트의 속성 중 넘겨 받은 키 배열에 해당하는 속성만 복사한다. 그리고 ElementProxy 에서 넘겨 받은 sendEvent 함수를 복사한 데이터와 함께 호출한다. 그럼 sendEvent 함수는 해당하는 id 와 데이터를 워커에 보낸다.

const mouseEventHandler = makeSendPropertiesHandler([
  'ctrlKey',
  'metaKey',
  'shiftKey',
  'button',
  'pointerType',
  'clientX',
  'clientY',
  'pageX',
  'pageY',
]);
const wheelEventHandlerImpl = makeSendPropertiesHandler([
  'deltaX',
  'deltaY',
]);
const keydownEventHandler = makeSendPropertiesHandler([
  'ctrlKey',
  'metaKey',
  'shiftKey',
  'keyCode',
]);
 
function wheelEventHandler(event, sendFn) {
  event.preventDefault();
  wheelEventHandlerImpl(event, sendFn);
}
 
function preventDefaultHandler(event) {
  event.preventDefault();
}
 
function copyProperties(src, properties, dst) {
  for (const name of properties) {
    dst[name] = src[name];
  }
}
 
function makeSendPropertiesHandler(properties) {
  return function sendProperties(event, sendFn) {
    const data = { type: event.type };
    copyProperties(event, properties, data);
    sendFn(data);
  };
}
 
function touchEventHandler(event, sendFn) {
  const touches = [];
  const data = { type: event.type, touches };
  for (let i = 0; i < event.touches.length; ++i) {
    const touch = event.touches[i];
    touches.push({
      pageX: touch.pageX,
      pageY: touch.pageY,
    });
  }
  sendFn(data);
}
 
// 키보드의 화살표 키
const orbitKeys = {
  '37': true,  // 왼쪽
  '38': true,  // 위쪽
  '39': true,  // 오른쪽
  '40': true,  // 아래쪽
};
function filteredKeydownEventHandler(event, sendFn) {
  const { keyCode } = event;
  if (orbitKeys[keyCode]) {
    event.preventDefault();
    keydownEventHandler(event, sendFn);
  }
}

실제로 예제를 실행하면 아직 처리할 것들이 더 있다.

OrbitControlselement.focus 메소드를 호출한다. 이는 워커에서 그다지 쓸모가 없으니 빈 함수로 대체한다.

class ElementProxyReceiver extends THREE.EventDispatcher {
  constructor() {
    super();
  }
  handleEvent(data) {
    this.dispatchEvent(data);
  }
  focus() {
    // 빈 함수(no-operation)
  }
}

event.preventDefaultevent.stopPropagation 도 사용한다. 이는 이미 메인 페이지에서 처리했으니 이 역시 빈 함수로 대체한다.

function noop() {
}
 
class ElementProxyReceiver extends THREE.EventDispatcher {
  constructor() {
    super();
  }
  handleEvent(data) {
    data.preventDefault = noop;
    data.stopPropagation = noop;
    this.dispatchEvent(data);
  }
  focus() {
    // 빈 함수(no-operation)
  }
}

clientWidthclientHeight 도 사용한다.
이전엔 캔버스의 크기값을 따로 넘겨줬는데, 경유 객체들이 이 값도 주고받도록 수정한다.

워커의 경우 다음과 같이 코드를 추가한다.

class ElementProxyReceiver extends THREE.EventDispatcher {
  constructor() {
    super();
  }
  get clientWidth() {
    return this.width;
  }
  get clientHeight() {
    return this.height;
  }
  getBoundingClientRect() {
    return {
      left: this.left,
      top: this.top,
      width: this.width,
      height: this.height,
      right: this.left + this.width,
      bottom: this.top + this.height,
    };
  }
  handleEvent(data) {
    if (data.type === 'size') {
      this.left = data.left;
      this.top = data.top;
      this.width = data.width;
      this.height = data.height;
      return;
    }
    data.preventDefault = noop;
    data.stopPropagation = noop;
    this.dispatchEvent(data);
  }
  focus() {
    // 빈 함수(no-operation)
  }
}

이제 메인 페이지에서 캔버스의 크기와 위치 좌표를 넘겨줘야 한다.
하나 언급하고 싶은 건 예제에서는 캔버스의 크기가 바뀌는 경우만 가정했지, 캔버스가 움직이는 경우는 가정하지 않았다는 점이다. 캔버스가 움직이는 경우를 처리하려면 캔버스가 움직였을 때 sendSize 를 호출하면 된다.

class ElementProxy {
  constructor(element, worker, eventHandlers) {
    this.id = nextProxyId++;
    this.worker = worker;
    const sendEvent = (data) => {
      this.worker.postMessage({
        type: 'event',
        id: this.id,
        data,
      });
    };
 
    // id를 등록합니다.
    worker.postMessage({
      type: 'makeProxy',
      id: this.id,
    });
    sendSize();
    for (const [eventName, handler] of Object.entries(eventHandlers)) {
      element.addEventListener(eventName, function(event) {
        handler(event, sendEvent);
      });
    }
 
    function sendSize() {
      const rect = element.getBoundingClientRect();
      sendEvent({
        type: 'size',
        left: rect.left,
        top: rect.top,
        width: element.clientWidth,
        height: element.clientHeight,
      });
    }
 
    window.addEventListener('resize', sendSize);
  }
}

이제 공통 Three.js 코드에서 state 전역 변수를 쓰지 않으니 삭제한다.

/*
export const state = {
  width: 300,   // 캔버스 기본값
  height: 150,  // 캔버스 기본값
};
*/
 
...
 
function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
//  const width = state.width;
//  const height = state.height;
  const width = inputElement.clientWidth;
  const height = inputElement.clientHeight;
  const needResize = canvas.width !== width || canvas.height !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}
 
function render(time) {
  time *= 0.001;
 
  if (resizeRendererToDisplaySize(renderer)) {
  //  camera.aspect = state.width / state.height;
    camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
    camera.updateProjectionMatrix();
  }
 
  ...

OrbitControls 는 마우스 이벤트를 감지하기 위해 해당 요소의 ownerDocumentpointmovepointup 리스너를 추가한다.

또한 코드는 전역 document 객체를 참조하지만 워커에는 전역 document 객체가 없다.

이 문제는 편법으로 해결할 수 있다. 다시 한 번 워커의 경유 객체를 이용하도록 한다.

function start(data) {
  const proxy = proxyManager.getProxy(data.canvasId);
  proxy.ownerDocument = proxy; // HACK!
  self.document = {} // HACK!
  init({
    canvas: data.canvas,
    inputElement: proxy,
  });
}

이럼 OrbitControls 가 에러를 던지지 않을 것이다.

예제가 복잡해 이해하기 어려울 수 있다. 동작을 요약하면 ElementProxy 가 메인 페이지의 DOM 이벤트를 워커의 ElementPRoxyReceiver 에 넘기고, ElementProxyReceiverHTMLElement 를 가장해 OrbitControls 와 공통 코드에서 쓸 수 있는 대체 DOM 요소로 기능한다.

마지막으로 OffscreenCanvas 를 지원하지 않는 경우의 예외 코드만 수정해주면 끝이다.
간단히 inputElement 에 캔버스 요소 자체를 넘겨주기만 하면 된다.

function startMainPage(canvas) {
//  init({ canvas });
  init({ canvas, inputElement: canvas });
  console.log('using regular canvas');
}

이제 OffscreenCanvas 에서도 OrbitControls 가 잘 작동한다.

profile
Study

0개의 댓글