로 캔버스, 장면 여러 개 만들기

사람들이 자주 하는 질문 중 하나는 Three.js로 여러 개의 캔버스(canvas)를 렌더링하려면 어떻게 해야 하나요?"입니다. 쇼핑몰 사이트나 3D 도표가 여러 개 있는 웹 페이지를 제작한다고 해봅시다. 얼핏 그리 어려울 건 없어 보입니다. 그냥 도표가 들어갈 곳마다 각각 캔버스를 만들고, 각 캔버스마다 Renderer를 생성하면 되지 않을까요?

하지만 이 방법을 적용하자마자 문제가 생깁니다.

  1. 브라우저의 WebGL 컨텍스트(context)는 제한적이다.

    일반적으로 약 8개가 최대입니다. 9번째 컨텍스트를 만들면 제일 처음에 만들었던 컨텍스트가 사라지죠.

  2. WebGL 자원은 컨텍스트끼리 공유할 수 없다.

    10MB짜리 모델을 캔버스 두 개에서 사용하려면 모델을 각각 총 두 번 로드해야 하고, 원래의 두 배인 20MB의 자원을 사용한다는 의미입니다. 컨텍스트끼리는 어떤 것도 공유할 수 없죠. 또한 초기화도 두 번, 쉐이더 컴파일도 두 번, 같은 동작은 모두 두 번씩 실행해야 합니다. 캔버스의 개수가 많아질수록 성능에 문제가 생기겠죠.

그렇다면 어떻게 해야 할까요?

방법 중 하나는 캔버스 하나로 화면 전체를 채우고, 각 "가상" 캔버스를 대신할 HTML 요소(element)를 두는 겁니다. Renderer는 하나만 만들되 가상 캔버스에 각각 Scene을 만드는 거죠. 그리고 가상 HTML 요소의 좌표를 계산해 요소가 화면에 보인다면 Three.js가 해당 장면(scene)을 가상 요소의 좌표에 맞춰 렌더링하도록 합니다.

이 방법은 캔버스를 하나만 사용하므로 위 1번과 2번 문제 모두 해결할 수 있습니다. 컨텍스트를 하나만 사용하니 WebGL 컨텍스트 제한을 걱정할 일도 없고, 자원을 몇 배씩 더 사용할 일도 없죠.

2개의 장면만 만들어 간단히 테스트를 해보겠습니다. 먼저 HTML을 작성합니다.

<canvas id="c"></canvas>
<p>
  <span id="box" class="diagram left"></span>
  I love boxes. Presents come in boxes.
  When I find a new box I'm always excited to find out what's inside.
</p>
<p>
  <span id="pyramid" class="diagram right"></span>
  When I was a kid I dreamed of going on an expedition inside a pyramid
  and finding a undiscovered tomb full of mummies and treasure.
</p>

다음으로 CSS를 작성합니다.

#c {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: -1;
}
.diagram {
  display: inline-block;
  width: 5em;
  height: 3em;
  border: 1px solid black;
}
.left {
  float: left;
  margin-right: .25em;
}
.right {
  float: right;
  margin-left: .25em;
}

캔버스가 화면 전체를 채우도록 하고 z-index를 -1로 설정해 다른 요소 뒤로 가도록 했습니다. 가상 요소에 컨텐츠가 없어 크기가 0이니 별도의 width와 height도 지정해줬습니다.

이제 각각의 카메라와 조명이 있는 장면 2개를 만듭니다. 하나에는 정육면체, 다른 하나에는 다이아몬드 모양을 넣을 겁니다.

function makeScene(elem) {
  const scene = new THREE.Scene();

  const fov = 45;
  const aspect = 2;  // 캔버스 기본값
  const near = 0.1;
  const far = 5;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.z = 2;
  camera.position.set(0, 1, 2);
  camera.lookAt(0, 0, 0);

  {
    const color = 0xFFFFFF;
    const intensity = 1;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(-1, 2, 4);
    scene.add(light);
  }

  return { scene, camera, elem };
}

function setupScene1() {
  const sceneInfo = makeScene(document.querySelector('#box'));
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshPhongMaterial({color: 'red'});
  const mesh = new THREE.Mesh(geometry, material);
  sceneInfo.scene.add(mesh);
  sceneInfo.mesh = mesh;
  return sceneInfo;
}

function setupScene2() {
  const sceneInfo = makeScene(document.querySelector('#pyramid'));
  const radius = .8;
  const widthSegments = 4;
  const heightSegments = 2;
  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
  const material = new THREE.MeshPhongMaterial({
    color: 'blue',
    flatShading: true,
  });
  const mesh = new THREE.Mesh(geometry, material);
  sceneInfo.scene.add(mesh);
  sceneInfo.mesh = mesh;
  return sceneInfo;
}

const sceneInfo1 = setupScene1();
const sceneInfo2 = setupScene2();

이제 각 요소가 화면에 보일 때만 장면을 렌더링할 함수를 만듭니다. Renderer.setScissorTest를 호출해 가위(scissor) 테스트를 활성화하면 Three.js가 캔버스의 특정 부분만 렌더링하도록 할 수 있습니다. 그리고 Renderer.setScissor로 가위를 설정한 뒤 Renderer.setViewport로 장면의 좌표를 설정합니다.

function renderSceneInfo(sceneInfo) {
  const { scene, camera, elem } = sceneInfo;

  // 해당 요소의 화면 대비 좌표를 가져옵니다
  const { left, right, top, bottom, width, height } =
      elem.getBoundingClientRect();

  const isOffscreen =
      bottom < 0 ||
      top > renderer.domElement.clientHeight ||
      right < 0 ||
      left > renderer.domElement.clientWidth;

  if (isOffscreen) {
    return;
  }

  camera.aspect = width / height;
  camera.updateProjectionMatrix();

  const positiveYUpBottom = canvasRect.height - bottom;
  renderer.setScissor(left, positiveYUpBottom, width, height);
  renderer.setViewport(left, positiveYUpBottom, width, height);

  renderer.render(scene, camera);
}

다음으로 render 함수 안에서 먼저 캔버스 전체를 비운 뒤 각 장면을 렌더링합니다.

function render(time) {
  time *= 0.001;

  resizeRendererToDisplaySize(renderer);

  renderer.setScissorTest(false);
  renderer.clear(true, true);
  renderer.setScissorTest(true);

  sceneInfo1.mesh.rotation.y = time * .1;
  sceneInfo2.mesh.rotation.y = time * .1;

  renderSceneInfo(sceneInfo1);
  renderSceneInfo(sceneInfo2);

  requestAnimationFrame(render);
}

결과를 확인해볼까요?

첫 번째 <span> 요소가 있는 곳에는 빨간 정육면체가, 두 번째 <span> 요소가 있는 곳에는 파란 다이아몬드가 보일 겁니다.

동기화하기

위 코드는 나쁘지 않지만 작은 문제가 있습니다. 복잡한 장면 등 무슨 이유라도 렌더링하는 데 시간이 오래 걸린다면, 장면의 좌표는 페이지의 다른 컨텐츠에 비해 더디게 내려올 겁니다.

각 가상 요소에 테두리를 넣고

.diagram {
  display: inline-block;
  width: 5em;
  height: 3em;
+  border: 1px solid black;
}

각 장면에 배경색도 넣어줍니다.

const scene = new THREE.Scene();
+scene.background = new THREE.Color('red');

그런 다음 빠르게 스크롤을 위아래로 반복해보면 문제가 보일겁니다. 아래는 스크롤 애니메이션 캡쳐본의 속도를 10배 낮춘 예시입니다.

추가로 처리해줘야 할 것이 있긴 하지만, 캔버스의 CSS를 position: fixed에서 position: absolute로 바꿔 문제를 해결할 수 있습니다.

#c {
-  position: fixed;
+  position: absolute;

그리고 페이지 스크롤에 상관 없이 캔버스가 항상 화면의 상단에 위치할 수 있도록 캔버스에 transform 스타일을 지정해줍니다.

function render(time) {
  ...

  const transform = `translateY(${ window.scrollY }px)`;
  renderer.domElement.style.transform = transform;

캔버스에 position: fixed를 적용하면 캔버스는 스크롤의 영향을 받지 않습니다. position: absolute를 적용하면 렌더링하는 데 시간이 걸리더라도 일단 다른 페이지와 같이 스크롤이 되겠죠. 그리고 렌더링하기 전에 캔버스를 다시 움직여 화면 전체에 맞춘 뒤 캔버스를 렌더링하는 겁니다. 이러면 화면의 가장자리에 살짝 렌더링되지 않은 부분이 보일 수는 있어도 나머지 페이지에 있는 요소는 버벅이지 않고 제자리에 있을 겁니다. 아래는 해당 코드를 적용한 화면의 캡쳐본을 아까와 마찬가지로 10배 느리게 만든 것입니다.

확장하기 쉽게 만들기

여러 장면을 구현했으니 이제 이 예제를 좀 더 확장하기 쉽게 만들어보겠습니다.

먼저 기존처럼 캔버스 전체를 렌더링하는 render 함수를 두고, 각 장면에 해당하는 가상 요소, 해당 장면을 렌더링하는 함수로 이루어진 객체의 배열을 만듭니다. render 함수에서 가상 요소가 화면에 보이는지 확인한 뒤, 가상 요소가 화면에 보인다면 상응하는 렌더링 함수를 호출합니다. 이러면 확장성은 물론 각 장면의 렌더링 함수를 작성할 때도 전체를 신경쓸 필요가 없죠.

아래는 전체를 담당하는 render 함수입니다.

const sceneElements = [];
function addScene(elem, fn) {
  sceneElements.push({ elem, fn });
}

function render(time) {
  time *= 0.001;

  resizeRendererToDisplaySize(renderer);

  renderer.setScissorTest(false);
  renderer.setClearColor(clearColor, 0);
  renderer.clear(true, true);
  renderer.setScissorTest(true);

  const transform = `translateY(${ window.scrollY }px)`;
  renderer.domElement.style.transform = transform;

  for (const { elem, fn } of sceneElements) {
    // 해당 요소의 화면 대비 좌표를 가져옵니다
    const rect = elem.getBoundingClientRect();
    const {left, right, top, bottom, width, height} = rect;

    const isOffscreen =
        bottom < 0 ||
        top > renderer.domElement.clientHeight ||
        right < 0 ||
        left > renderer.domElement.clientWidth;

    if (!isOffscreen) {
      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
      renderer.setScissor(left, positiveYUpBottom, width, height);
      renderer.setViewport(left, positiveYUpBottom, width, height);

      fn(time, rect);
    }
  }

  requestAnimationFrame(render);
}

render 함수는 elemfn 속성의 객체로 이루어진 sceneElements 배열을 순회합니다.

그리고 각 요소가 화면에 보이는지 확인하고, 화면에 보인다면 fn에 해당 장면이 들어가야할 사각 좌표와 현재 시간값을 넘겨주어 호출합니다.

이제 각 장면을 만들고 상응하는 요소와 렌더링 함수를 추가합니다.

{
  const elem = document.querySelector('#box');
  const { scene, camera } = makeScene();
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshPhongMaterial({ color: 'red' });
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);
  addScene(elem, (time, rect) => {
    camera.aspect = rect.width / rect.height;
    camera.updateProjectionMatrix();
    mesh.rotation.y = time * .1;
    renderer.render(scene, camera);
  });
}

{
  const elem = document.querySelector('#pyramid');
  const { scene, camera } = makeScene();
  const radius = .8;
  const widthSegments = 4;
  const heightSegments = 2;
  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
  const material = new THREE.MeshPhongMaterial({
    color: 'blue',
    flatShading: true,
  });
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);
  addScene(elem, (time, rect) => {
    camera.aspect = rect.width / rect.height;
    camera.updateProjectionMatrix();
    mesh.rotation.y = time * .1;
    renderer.render(scene, camera);
  });
}

sceneInfo1, sceneInfo2는 더 이상 필요 없으니 제거합니다. 대신 각 mesh의 회전은 해당 장면에서 처리해야 합니다.

HTML Dataset 사용하기

HTML의 dataset을 이용하면 좀 더 확장하기 쉬운 환경을 만들 수 있습니다. id="..." 대신 data-diagram="..."을 이용해 데이터를 직접 HTML 요소에 지정하는 거죠.

<canvas id="c"></canvas>
<p>
-  <span id="box" class="diagram left"></span>
+  <span data-diagram="box" class="left"></span>
  I love boxes. Presents come in boxes.
  When I find a new box I'm always excited to find out what's inside.
</p>
<p>
-  <span id="pyramid" class="diagram left"></span>
+  <span data-diagram="pyramid" class="right"></span>
  When I was a kid I dreamed of going on an expedition inside a pyramid
  and finding a undiscovered tomb full of mummies and treasure.
</p>

요소의 id를 제거했으니 CSS 셀렉터도 다음처럼 바꾸어야 합니다.

-.diagram
+*[data-diagram] {
  display: inline-block;
  width: 5em;
  height: 3em;
}

또한 각 장면을 만드는 코드를 scene initialization functions라는 맵으로 만듭니다. 이 맵은 키값에 대응하는 장면 렌더링 함수를 반환할 겁니다.

const sceneInitFunctionsByName = {
  'box': () => {
    const { scene, camera } = makeScene();
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshPhongMaterial({color: 'red'});
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return (time, rect) => {
      mesh.rotation.y = time * .1;
      camera.aspect = rect.width / rect.height;
      camera.updateProjectionMatrix();
      renderer.render(scene, camera);
    };
  },
  'pyramid': () => {
    const { scene, camera } = makeScene();
    const radius = .8;
    const widthSegments = 4;
    const heightSegments = 2;
    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    const material = new THREE.MeshPhongMaterial({
      color: 'blue',
      flatShading: true,
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return (time, rect) => {
      mesh.rotation.y = time * .1;
      camera.aspect = rect.width / rect.height;
      camera.updateProjectionMatrix();
      renderer.render(scene, camera);
    };
  },
};

그리고 querySelectorAll로 가상 요소를 전부 불러와 해당 요소에 상응하는 렌더링 함수를 실행합니다.

document.querySelectorAll('[data-diagram]').forEach((elem) => {
  const sceneName = elem.dataset.diagram;
  const sceneInitFunction = sceneInitFunctionsByName[sceneName];
  const sceneRenderFunction = sceneInitFunction(elem);
  addScene(elem, sceneRenderFunction);
});

이제 코드를 확장하기가 한결 편해졌습니다.

각 요소에 액션 추가하기

사용자 액션, 예를 들어 TrackballControls를 추가하는 건 아주 간단합니다. 먼저 스크립트를 불러옵니다.

import { TrackballControls } from '/examples/jsm/controls/TrackballControls.js';

그리고 각 장면에 대응하는 요소에 TrackballControls를 추가합니다.

-function makeScene() {
+function makeScene(elem) {
  const scene = new THREE.Scene();

  const fov = 45;
  const aspect = 2;  // 캔버스 기본값
  const near = 0.1;
  const far = 5;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.set(0, 1, 2);
  camera.lookAt(0, 0, 0);
+  scene.add(camera);

+  const controls = new TrackballControls(camera, elem);
+  controls.noZoom = true;
+  controls.noPan = true;

  {
    const color = 0xFFFFFF;
    const intensity = 1;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(-1, 2, 4);
-    scene.add(light);
+    camera.add(light);
  }

-  return { scene, camera };
+ return { scene, camera, controls };
}

위 코드에서는 카메라를 장면에 추가하고, 카메라에 조명을 추가했습니다. 이러면 조명이 카메라를 따라다니겠죠. TrackballControls는 카메라를 조정하기 때문에 이렇게 해야 빛이 계속 우리가 바라보는 방향에서 나갑니다.

또한 컨트롤을 렌더링 함수에서 업데이트해줘야 합니다.

const sceneInitFunctionsByName = {
- 'box': () => {
-    const {scene, camera} = makeScene();
+ 'box': (elem) => {
+    const { scene, camera, controls } = makeScene(elem);
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshPhongMaterial({color: 'red'});
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return (time, rect) => {
      mesh.rotation.y = time * .1;
      camera.aspect = rect.width / rect.height;
      camera.updateProjectionMatrix();
+      controls.handleResize();
+      controls.update();
      renderer.render(scene, camera);
    };
  },
-  'pyramid': () => {
-    const { scene, camera } = makeScene();
+  'pyramid': (elem) => {
+    const { scene, camera, controls } = makeScene(elem);
    const radius = .8;
    const widthSegments = 4;
    const heightSegments = 2;
    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    const material = new THREE.MeshPhongMaterial({
      color: 'blue',
      flatShading: true,
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return (time, rect) => {
      mesh.rotation.y = time * .1;
      camera.aspect = rect.width / rect.height;
      camera.updateProjectionMatrix();
+      controls.handleResize();
+      controls.update();
      renderer.render(scene, camera);
    };
  },
};

이제 각 물체를 자유롭게 회전시킬 수 있습니다.

이 기법은 이 사이트 전체에 사용한 기법입니다. 원시 모델에 관한 글재질에 관한 글에서 다양한 예시를 보여주기 위해 사용했죠.

다른 방법으로는 화면 밖의 캔버스에서 장면을 렌더링해 각 요소에 2D 캔버스 형태로 넘겨주는 방법이 있습니다. 이 방법의 장점은 각 영역을 어떻게 분리할지 고민하지 않아도 된다는 것이죠. 위에서 살펴본 방법은 캔버스를 화면 전체의 배경으로 써야 하지만, 이 방법은 일반 HTML 형태로 사용할 수 있습니다.

하지만 이 방법은 각 영역을 복사하는 것이기에 성능이 더 느립니다. 얼마나 느릴지는 브라우저와 GPU 성능에 따라 다르죠.

바꿔야 하는 건 생각보다 많지 않습니다.

먼저 배경에서 캔버스 요소를 제거합니다.

<body>
-  <canvas id="c"></canvas>
  ...
</body>

CSS도 바꿔줍니다.

-#c {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: block;
-  z-index: -1;
-}
canvas {
  width: 100%;
  height: 100%;
  display: block;
}
*[data-diagram] {
  display: inline-block;
  width: 5em;
  height: 3em;
}

캔버스 요소가 부모에 꽉 차도록 변경했습니다.

이제 자바스크립트를 변경해봅시다. 먼저 캔버스를 참조할 필요가 없으니 대신 캔버스 요소를 새로 만듭니다. 또한 가위 테스트를 처음에 활성화합니다.

function main() {
-  const canvas = document.querySelector('#c');
+  const canvas = document.createElement('canvas');
  const renderer = new THREE.WebGLRenderer({canvas, alpha: true});
+  renderer.setScissorTest(true);

  ...

다음으로 각 장면에 2D 렌더링 컨텍스트를 생성하고 장면에 대응하는 요소에 캔버스를 추가합니다.

const sceneElements = [];
function addScene(elem, fn) {
+  const ctx = document.createElement('canvas').getContext('2d');
+  elem.appendChild(ctx.canvas);
-  sceneElements.push({ elem, fn });
+  sceneElements.push({ elem, ctx, fn });
}

만약 렌더링 시 렌더링용 캔버스의 크기가 장면의 크기보다 작을 경우, 렌더링용 캔버스의 크기를 키웁니다. 또한 2D 캔버스의 크기가 부모 요소와 다르다면 2D 캔버스의 크기를 조정합니다. 마지막으로 가위와 화면을 설정하고, 해당 장면을 렌더링한 뒤, 요소의 캔버스로 렌더링 결과물을 복사합니다.

function render(time) {
  time *= 0.001;

-  resizeRendererToDisplaySize(renderer);
-
-  renderer.setScissorTest(false);
-  renderer.setClearColor(clearColor, 0);
-  renderer.clear(true, true);
-  renderer.setScissorTest(true);
-
-  const transform = `translateY(${ window.scrollY }px)`;
-  renderer.domElement.style.transform = transform;

-  for (const { elem, fn } of sceneElements) {
+  for (const { elem, fn, ctx } of sceneElements) {
    // 해당 요소의 화면 대비 좌표를 가져옵니다
    const rect = elem.getBoundingClientRect();
    const { left, right, top, bottom, width, height } = rect;
+    const rendererCanvas = renderer.domElement;

    const isOffscreen =
        bottom < 0 ||
-        top > renderer.domElement.clientHeight ||
+        top > window.innerHeight ||
        right < 0 ||
-        left > renderer.domElement.clientWidth;
+        left > window.innerWidth;

    if (!isOffscreen) {
-      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
-      renderer.setScissor(left, positiveYUpBottom, width, height);
-      renderer.setViewport(left, positiveYUpBottom, width, height);

+      // 렌더링용 캔버스 크기 조정
+      if (rendererCanvas.width < width || rendererCanvas.height < height) {
+        renderer.setSize(width, height, false);
+      }
+
+      // 2D 캔버스의 크기가 요소의 크기와 같도록 조정
+      if (ctx.canvas.width !== width || ctx.canvas.height !== height) {
+        ctx.canvas.width = width;
+        ctx.canvas.height = height;
+      }
+
+      renderer.setScissor(0, 0, width, height);
+      renderer.setViewport(0, 0, width, height);

      fn(time, rect);

+      // 렌더링된 장면을 2D 캔버스에 복사
+      ctx.globalCompositeOperation = 'copy';
+      ctx.drawImage(
+          rendererCanvas,
+          0, rendererCanvas.height - height, width, height,  // 원본 사각 좌표
+          0, 0, width, height);                              // 결과물 사각 좌표
    }
  }

  requestAnimationFrame(render);
}

결과물은 위와 다르지 않습니다.

이 기법의 다른 장점은 OffscreenCanvas 웹 워커를 이용해 이 기능을 별도 스레드에서 구현할 수 있다는 겁니다. 하지만 아쉽게도 2020년 7월을 기준으로 OffscreenCanvas는 아직 크로미움 기반 브라우저에서만 지원합니다.