[Three.js] 개발 팁

Study·2021년 9월 6일
3

Three.js

목록 보기
4/8
post-thumbnail

불필요한 렌더링 없애기

대부분 렌더링과정을 아래와 같이 재귀적으로 requestAnimationFrame 함수를 사용한다.

function render() {
  ...
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

애니메이션이 지속적이라면 상관없지만, 없다면 불필요한 렌더링이 반복되어 연산 낭비가 일어난다.

처음 한 번만 렌더링하고 변화가 있을 때만 렌더링되는 것이 해결책이다.
여기서 변화는 텍스터나 모델 로딩이 끝났을 때, 외부에서 데이터를 받았을 때, 카메라 조정이나 설정 값이 바뀌는 등이 있다.

변화가 있는 요소가 필요하니 OrbitControls 를 추가한다.

import * as THREE from './resources/three/r132/build/three.module.js';
import { OrbitControls } from './resources/threejs/r132/examples/jsm/controls/OrbitControls.js';
 
...
 
const fov = 75;
const aspect = 2;  // canvas 기본값
const near = 0.1;
const far = 5;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.z = 2;
 
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0, 0);
controls.update();

정육면체에 내이메이션이 없으니 참조할 필요가 없다.

/*
const cubes = [
  makeInstance(geometry, 0x44aa88,  0),
  makeInstance(geometry, 0x8844aa, -2),
  makeInstance(geometry, 0xaa8844,  2),
];
*/
makeInstance(geometry, 0x44aa88,  0);
makeInstance(geometry, 0x8844aa, -2);
makeInstance(geometry, 0xaa8844,  2);

애니메이션과 requestAnimationFrame 관련 코드도 제거한다.

//function render(time) {
//  time *= 0.001;
function render() {
 
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
/* 
  cubes.forEach((cube, ndx) => {
    const speed = 1 + ndx * .1;
    const rot = time * speed;
    cube.rotation.x = rot;
    cube.rotation.y = rot;
  });
*/ 
  renderer.render(scene, camera);
 
//  requestAnimationFrame(render);
}
 
// requestAnimationFrame(render);

그리고 render 함수를 직접 호출한다.

render();

이제 OrbitControls 가 카메라 설정을 바꿀 때마다 직접 render 함수를 호출해야 한다. 복잡할 것 같지만 OrbitControls 에는 change 이벤트가 있다.

controls.addEventListener('change', render);

창 크기가 바뀔 때도 동작을 처리해야 한다.
render 함수를 계속 호출할 때는 해당 동작을 자동으로 처리했지만, render 함수는 수동 호출이니 창의 크기가 바뀔 때 render 함수를 호출하게 한다.

window.addEventListener('resize', render);

이제 불필요한 렌더링을 반복하지 않는다.

OrbitControls 에는 관성 옵션이 있다. enableDamping 속성을 true로 설정하면 동작이 부드러워 진다.

controls.enableDamping = true;

또한 OrbitControls 가 부드러운 동작을 구현할 때 변경된 카메라 값을 계속 넘겨주도록 render 함수 안에 controls.update 메소드를 호출해야 한다.

이렇게 하면 change 이벤트 발생 시 render 함수가 무한정 호출될 것이다. controls 가 change 이벤트를 보내면 render 함수가 호출되고, render 함수는 controls.update 메소드를 호출해 다시 change 이벤트를 보내게 만들 것이다.

requestAnimationFrame 이 직접 render 함수를 호출하게 하면 이 문제를 해결할 수 있다. 너무 많은 프레임을 막기 위해 변수 하나를 두어 요청한 프레임이 없을 경우에만 프레임을 요청하도록 하면 된다.

let renderRequested = false;
 
function render() {
  renderRequested = false;
 
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
 
 controls.update();
  renderer.render(scene, camera);
}
render();
 
function requestRenderIfNotRequested() {
  if (!renderRequested) {
    renderRequested = true;
    requestAnimationFrame(render);
  }
}
 
// controls.addEventListener('change', render);
controls.addEventListener('change', requestRenderIfNotRequested);

창 크기가 변화가 일어나도 requestRenderIfNotRequested 를 호출하도록 한다.

window.addEventListener('resize', render);
window.addEventListener('resize', requestRenderIfNotRequested);

드래그를 이리저리 해보자.

간단한 dat.GUI 를 추가해 반복 렌더링 여부를 제어할 수 있도록 한다.

import * as THREE from './resources/three/r132/build/three.module.js';
import { OrbitControls } from './resources/threejs/r132/examples/jsm/controls/OrbitControls.js';
import { GUI } from '../3rdparty/dat.gui.module.js';

먼저 각 정육면체의 색과 x 축 스케일을 조정하는 GUI 를 추가한다.
이전 글의 ColorGUIHelper 를 가져와 쓰도록 한다.

먼저 GUI 를 생성한다.

const gui = new GUI();

그리고 각 정육면체에 material.color , cube.scale.x 설정을 폴더로 묶어 추가한다.

function makeInstance(geometry, color, x) {
  const material = new THREE.MeshPhongMaterial({color});
 
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
 
  cube.position.x = x;
 
  const folder = gui.addFolder(`Cube${ x }`);
  folder.addColor(new ColorGUIHelper(material, 'color'), 'value')
      .name('color')
      .onChange(requestRenderIfNotRequested);
  folder.add(cube.scale, 'x', .1, 1.5)
      .name('scale x')
      .onChange(requestRenderIfNotRequested);
  folder.open();
 
  return cube;
}

dat.GUI 컨트롤의 onChange 메소드에 콜백 함수를 넘겨 GUI 값이 바뀔 때마다 콜백 함수를 호출한다. 예제의 경우 단순히 requestRenderIfNotRequested 함수를 넘겨주면 된다. 그리고 folder.open 메소드를 호출해 폴더를 열어 둔다.

캔버스 스크린샷

브라우저의 스크린샷은 2가지로 canvas.toDataURLcanvas.toBlob 이 있다.

아래 코드 정도라면 쉽게 스크린샷을 찍을 수 있다.

<canvas id="c"></canvas>
<button id="screenshot" type="button">Save...</button>
const elem = document.querySelector('#screenshot');
elem.addEventListener('click', () => {
  canvas.toBlob((blob) => {
    saveBlob(blob, `screencapture-${ canvas.width }x${ canvas.height }.png`);
  });
});
 
const saveBlob = (function() {
  const a = document.createElement('a');
  document.body.appendChild(a);
  a.style.display = 'none';
  return function saveData(blob, fileName) {
     const url = window.URL.createObjectURL(blob);
     a.href = url;
     a.download = fileName;
     a.click();
  };
}());

하지만 막상 찍으면 다음 결과가 나온다.

이건 성능 관련 문제이다. 브라우저는 화면을 렌더링한 후 WebGL 캔버스의 드로잉 버퍼를 바로 비운다.

이 문제를 해결하려면 캡쳐 직전에 화면 렌더링 함수를 호출해야 한다.

먼저 렌더링 함수를 분리한다.

const state = {
  time: 0,
};
 
// function render(time) {
//   time *= 0.001;
function render() {
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
 
  cubes.forEach((cube, ndx) => {
    const speed = 1 + ndx * .1;
//     const rot = time * speed;
    const rot = state.time * speed;
    cube.rotation.x = rot;
    cube.rotation.y = rot;
  });
 
  renderer.render(scene, camera);
 
//   requestAnimationFrame(render);
}
 
function animate(time) {
  state.time = time * 0.001;
 
  render();
 
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

이제 render 함수는 오직 화면을 렌더링하는 역할만 하기에, 화면을 캡쳐하기 직전에 render 함수를 호출하면 된다.

const elem = document.querySelector('#screenshot');
elem.addEventListener('click', () => {
  render();
  canvas.toBlob((blob) => {
    saveBlob(blob, `screencapture-${ canvas.width }x${ canvas.height }.png`);
  });
});

캔버스 초기화 방지

움직이는 물체를 그린다면 WebGLRender 를 생성할 때 preserveDrawingBuffer: true 를 설정해야 한다. 또한 Three.js 캔버스를 초기화하지 않도록 해주어야 한다.

const canvas = document.querySelector('#c');
// const renderer = new THREE.WebGLRenderer({ canvas });
const renderer = new THREE.WebGLRenderer({
  canvas,
  preserveDrawingBuffer: true,
  alpha: true,
});
renderer.autoClearColor = false;

만약 드로잉을 위한 프로그램을 개발한다면 이 방법은 추천하지 않는다. 해상도 변경될 때마다 브라우저가 캔버스를 초기화하기 때문이다.

키 입력 받기

키보드 이벤트를 받으려면 해당 요소의 tabindex 를 0 이상 값으로 해야 한다.

<canvas tabindex="0"></canvas>

하지만 이 속성은 문제가 생긴다. tabindex 요소는 focus 상태일 때 강조 표시가 적용된다. 이를 해결하기 위해 CSS의 outline 속성을 none 으로 설정해야 한다.

canvas:focus {
  outline: none;
}

간단한 테스트를 위해 캔버스 3개를 만들자.

<canvas id="c1"></canvas>
<canvas id="c2" tabindex="0"></canvas>
<canvas id="c3" tabindex="1"></canvas>

마지막 캔버스에만 CSS를 추가한다.

#c3:focus {
  outline: none;
}

그리고 모든 캔버스에 이벤트 리스너를 추가한다.

document.querySelectorAll('canvas').forEach((canvas) => {
  const ctx = canvas.getContext('2d');
 
  function draw(str) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(str, canvas.width / 2, canvas.height / 2);
  }
  draw(canvas.id);
 
  canvas.addEventListener('focus', () => {
    draw('has focus press a key');
  });
 
  canvas.addEventListener('blur', () => {
    draw('lost focus');
  });
 
  canvas.addEventListener('keydown', (e) => {
    draw(`keyCode: ${e.keyCode}`);
  });
});

첫 번째는 아무 이벤트가 발생하지 않지만 두 번째 이벤트에서 키를 받지만 강조 표시가 나타난다. 대신 세 번째는 두 문제가 발생하지 않는다.

캔버스 투명하게 만들기

캔버스를 투명하게 하려면 WebGLRenderer 를 생성할 때 alpha: true 를 넘겨줘야 한다.

const canvas = document.querySelector('#c');
// const renderer = new THREE.WebGLRenderer({ canvas });
const renderer = new THREE.WebGLRenderer({
  canvas,
  alpha: true,
});

또한 premultiplied 알파를 사용하지 않도록 하게끔 하려면 아래처럼 값을 설정해줘야 한다

const canvas = document.querySelector('#c');
const renderer = new THREE.WebGLRenderer({
  canvas,
  alpha: true,
  premultipliedAlpha: false,
});

Three.js 는 기본적으로 캔버스에는 premultipliedAlpha: true 를 사용하지만 재질에는 false 를 사용한다.

이제 투명한 캔버스를 만들어보자.

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

function makeInstance(geometry, color, x) {
//  const material = new THREE.MeshPhongMaterial({ color });
  const material = new THREE.MeshPhongMaterial({
    color,
    opacity: 0.5,
  });
 
...

여기서 HTML로 텍스트를 추가한다.

<body>
  <canvas id="c"></canvas>
  <div id="content">
    <div>
      <h1>Cubes-R-Us!</h1>
      <p>We make the best cubes!</p>
    </div>
  </div>
</body>

CSS도 하나 추가한다.

body {
    margin: 0;
}
#c {
    width: 100%;
    height: 100%;
    display: block;
    position: fixed;
    left: 0;
    top: 0;
    z-index: 2;
    pointer-events: none;
}
#content {
  font-size: 7vw;
  font-family: sans-serif;
  text-align: center;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

pointer-events:none 은 캔버스가 마우스나 터치에 영향을 받지 않도록 해준다. 아래 텍스트를 바로 선택할 수 있도록 선택한 것이다.

배경에 Three.js 애니메이션 넣기

방법은 2가지가 있다.

  1. 캔버스 요소의 CSS positionfixed어 설정한다.
#c {
    position: fixed;
    left: 0;
    top: 0;
}

이전 예제의 방법과 같이 z-index 를 -1로 설정하면 정육면체들이 텍스트 뒤로 사라질 것이다.

이 방법의 단점은 자바스크립트 코드가 페이지와 통합되어야 한다는 것이다. 특히 복잡한 페이지면 Three.js 렌더링하는 코드가 다른 코드와 충돌하지 않도록 신경써야 한다.

  1. iframe 을 쓴다.

이 방법은 해당 웹 페이지를 iframe만 추가하면 된다.

<iframe id="background" src="threejs-responsive.html">
<div>
  내용 내용 내용 내용
</div>

그 다음 iframe 이 창 전체를 채우도록 하고 z-index 를 이용해 배경으로 지정한다.

#background {
    position: fixed;
    width: 100%;
    height: 100%;
    left: 0;
    top: 0;
    z-index: -1;
    border: none;
    pointer-events: none;
}

profile
Study

0개의 댓글