Three.js를 사용하여 백만 개의 문자 애니메이션화

Ilmari Heikkinen

소개

이 도움말의 목표는 화면에 100만 개의 애니메이션 글자를 부드럽게 프레임 속도로 그리는 것입니다. 이 작업은 최신 GPU를 사용하면 충분히 가능합니다. 각 문자는 텍스처가 적용된 두 개의 삼각형으로 구성되므로 프레임당 200만 개의 삼각형만 사용됩니다.

기존의 JavaScript 애니메이션 배경을 가지고 있다면 이 모든 것이 미친 짓처럼 들릴 수 있습니다. 프레임마다 200만 개의 삼각형을 업데이트하는 것은 오늘날 JavaScript로 하기에 적합하지 않습니다. 다행히 최신 GPU의 강력한 성능을 활용할 수 있는 WebGL이 있습니다. 최신 GPU와 셰이더 마법을 사용하면 애니메이션이 적용된 삼각형 200만 개는 충분히 처리할 수 있습니다.

효율적인 WebGL 코드 작성

효율적인 WebGL 코드를 작성하려면 특정 사고방식이 필요합니다. WebGL을 사용하여 그리는 일반적인 방법은 각 객체의 유니폼, 버퍼, 셰이더를 설정한 다음 객체를 그리는 호출을 실행하는 것입니다. 이 그리기 방식은 소수의 객체를 그릴 때 유용합니다. 많은 수의 객체를 그리려면 WebGL 상태 변경의 양을 최소화해야 합니다. 먼저 객체 간에 셰이더를 변경할 필요가 없도록 동일한 셰이더를 사용하여 모든 객체를 차례로 그립니다. 입자와 같은 간단한 객체의 경우 여러 객체를 단일 버퍼로 번들로 묶고 JavaScript를 사용하여 수정할 수 있습니다. 이렇게 하면 모든 개별 입자의 셰이더 유니폼을 변경하는 대신 정점 버퍼만 다시 업로드하면 됩니다.

하지만 속도를 높이려면 대부분의 계산을 셰이더로 푸시해야 합니다. 제가 여기서 하고자 하는 일입니다. 셰이더를 사용하여 100만 개의 문자에 애니메이션을 적용합니다.

이 도움말의 코드는 WebGL 코드를 작성할 때의 모든 지루한 상용구를 추상화하는 Three.js 라이브러리를 사용합니다. WebGL 상태 설정 및 오류 처리를 수백 줄 작성하는 대신 Three.js를 사용하면 코드 몇 줄만 작성하면 됩니다. Three.js에서 WebGL 셰이더 시스템을 쉽게 활용할 수도 있습니다.

단일 그리기 호출을 사용하여 여러 객체 그리기

다음은 단일 그리기 호출을 사용하여 여러 객체를 그리는 방법을 보여주는 작은 슈도코드 예입니다. 기존 방식은 다음과 같이 한 번에 하나의 객체를 그리는 것입니다.

for (var i=0; i<objects.length; i++) {
  // each added object requires a separate WebGL draw call
  scene.add(createNewObject(objects[i]));
}
renderer.render(scene, camera);

하지만 위의 방법은 객체마다 별도의 그리기 호출이 필요합니다. 여러 객체를 한 번에 그리려면 객체를 단일 도형으로 번들로 묶고 단일 그리기 호출로 처리하면 됩니다.

var geo = new THREE.Geometry();
for (var i=0; i<objects.length; i++) {
  // bundle the objects into a single geometry
  // so that they can be drawn with a single draw call
  addObjectToGeometry(geo, objects[i]);
}
// GOOD! Only one object to add to the scene!
scene.add(new THREE.Mesh(geo, material));
renderer.render(scene, camera);

이제 기본적인 개념을 알게 되었으니 데모 작성으로 돌아가 수백만 개의 글자에 애니메이션을 적용해 보겠습니다.

도형 및 텍스처 설정

첫 번째 단계로 문자 비트맵이 있는 텍스처를 만들겠습니다. 여기서는 2D 캔버스를 사용합니다. 결과 텍스처에는 그리려는 모든 문자가 있습니다. 다음 단계는 문자 스프라이트 시트에 대한 텍스처 좌표가 포함된 버퍼를 만드는 것입니다. 이 방법은 글자를 설정하는 간단하고 직관적인 방법이지만, 텍스처 좌표에 정점당 두 개의 부동 소수점을 사용하기 때문에 약간 낭비가 됩니다. 더 짧은 방법은 문자 색인과 모서리 색인을 하나의 숫자로 묶은 다음 정점 셰이더에서 텍스처 좌표로 다시 변환하는 것입니다(독자에게 연습문제로 남겨 둡니다).

다음은 캔버스 2D를 사용하여 문자 텍스처를 빌드하는 방법입니다.

var fontSize = 16;

// The square letter texture will have 16 * 16 = 256 letters, enough for all 8-bit characters.
var lettersPerSide = 16;

var c = document.createElement('canvas');
c.width = c.height = fontSize*lettersPerSide;
var ctx = c.getContext('2d');
ctx.font = fontSize+'px Monospace';

// This is a magic number for aligning the letters on rows. YMMV.
var yOffset = -0.25;

// Draw all the letters to the canvas.
for (var i=0,y=0; y<lettersPerSide; y++) {
  for (var x=0; x<lettersPerSide; x++,i++) {
    var ch = String.fromCharCode(i);
    ctx.fillText(ch, x*fontSize, yOffset*fontSize+(y+1)*fontSize);
  }
}

// Create a texture from the letter canvas.
var tex = new THREE.Texture(c);
// Tell Three.js not to flip the texture.
tex.flipY = false;
// And tell Three.js that it needs to update the texture.
tex.needsUpdate = true;

또한 GPU에 삼각형 배열을 업로드합니다. 이러한 정점은 정점 셰이더에서 문자를 화면에 배치하는 데 사용됩니다. 정점은 텍스트의 문자 위치로 설정되므로 삼각형 배열을 있는 그대로 렌더링하면 텍스트의 기본 레이아웃 렌더링이 됩니다.

사진첩의 도형을 만듭니다.

var geo = new THREE.Geometry();

var i=0, x=0, line=0;
for (i=0; i<BOOK.length; i++) {
  var code = BOOK.charCodeAt(i); // This is the character code for the current letter.
  if (code > lettersPerSide * lettersPerSide) {
    code = 0; // Clamp character codes to letter map size.
  }
  var cx = code % lettersPerSide; // Cx is the x-index of the letter in the map.
  var cy = Math.floor(code / lettersPerSide); // Cy is the y-index of the letter in the map.

  // Add letter vertices to the geometry.
  var v,t;
  geo.vertices.push(
    new THREE.Vector3( x*1.1+0.05, line*1.1+0.05, 0 ),
    new THREE.Vector3( x*1.1+1.05, line*1.1+0.05, 0 ),
    new THREE.Vector3( x*1.1+1.05, line*1.1+1.05, 0 ),
    new THREE.Vector3( x*1.1+0.05, line*1.1+1.05, 0 )
  );
  // Create faces for the letter.
  var face = new THREE.Face3(i*4+0, i*4+1, i*4+2);
  geo.faces.push(face);
  face = new THREE.Face3(i*4+0, i*4+2, i*4+3);
  geo.faces.push(face);

  // Compute texture coordinates for the letters.
  var tx = cx/lettersPerSide, 
      ty = cy/lettersPerSide,
      off = 1/lettersPerSide;
  var sz = lettersPerSide*fontSize;
  geo.faceVertexUvs[0].push([
    new THREE.Vector2( tx, ty+off ),
    new THREE.Vector2( tx+off, ty+off ),
    new THREE.Vector2( tx+off, ty )
  ]);
  geo.faceVertexUvs[0].push([
    new THREE.Vector2( tx, ty+off ),
    new THREE.Vector2( tx+off, ty ),
    new THREE.Vector2( tx, ty )
  ]);

  // On newline, move to the line below and move the cursor to the start of the line.
  // Otherwise move the cursor to the right.
  if (code == 10) {
    line--;
    x=0;
  } else {
    x++;
  }
}

문자 애니메이션을 위한 정점 셰이더

간단한 정점 셰이더를 사용하면 텍스트의 평면 뷰를 가져올 수 있습니다. 전혀 복잡할 게 없지. 잘 실행되지만 애니메이션을 적용하려면 JavaScript에서 애니메이션을 실행해야 합니다. 또한 JavaScript는 관련된 6백만 개의 정점을 애니메이션 처리하기에 다소 느립니다. 특히 모든 프레임에서 애니메이션 처리를 수행하려는 경우 더욱 그렇습니다. 더 빠른 방법이 있을 수도 있습니다.

예, 절차적 애니메이션을 실행할 수 있습니다. 즉, 꼭짓점 셰이더에서 모든 위치 및 회전 수학을 실행합니다. 이제 정점의 위치를 업데이트하기 위해 JavaScript를 실행할 필요가 없습니다. 꼭짓점 셰이더가 매우 빠르게 실행되며 프레임마다 100만 개의 삼각형이 개별적으로 애니메이션되더라도 부드러운 프레임 속도를 얻을 수 있습니다. 개별 삼각형을 처리하기 위해 꼭짓점 좌표를 반올림하여 문자 쿼드의 네 지점이 모두 고유한 단일 좌표에 매핑되도록 합니다. 이제 이 좌표를 사용하여 해당 문자의 애니메이션 매개변수를 설정할 수 있습니다.

좌표를 내림 처리하려면 두 개의 서로 다른 문자의 좌표가 겹치면 안 됩니다. 이를 실행하는 가장 쉬운 방법은 글자를 오른쪽의 글자와 위의 선과 구분하는 작은 오프셋이 있는 정사각형 글자 쿼드를 사용하는 것입니다. 예를 들어 문자의 너비와 높이를 0.5로 사용하고 정수 좌표에 문자를 정렬할 수 있습니다. 이제 문자 정점의 좌표를 내림차기하면 문자의 왼쪽 하단 좌표가 나옵니다.

꼭짓점 좌표를 반올림하여 문자의 왼쪽 상단을 찾습니다.
꼭짓점 좌표를 내림하여 문자의 왼쪽 상단 모서리를 찾습니다.

애니메이션된 정점 셰이더를 더 잘 이해하기 위해 먼저 일반적인 간단한 정점 셰이더를 살펴보겠습니다. 이는 일반적으로 화면에 3D 모델을 그릴 때 발생합니다. 모델의 정점은 몇 개의 변환 행렬에 의해 변환되어 각 3D 정점을 2D 화면에 투사합니다. 이러한 꼭짓점 3개로 정의된 삼각형이 뷰포트 내에 있을 때마다 삼각형이 덮는 픽셀이 프래그먼트 셰이더에 의해 처리되어 색상이 지정됩니다. 다음은 간단한 정점 셰이더입니다.

varying float vUv;

void main() {
  // modelViewMatrix, position and projectionMatrix are magical
  // attributes that Three.js defines for us.

  // Transform current vertex by the modelViewMatrix
  // (bundled model world position & camera world position matrix).
  vec4 mvPosition = modelViewMatrix * position;

  // Project camera-space vertex to screen coordinates
  // using the camera's projection matrix.
  vec4 p = projectionMatrix * mvPosition;

  // uv is another magical attribute from Three.js.
  // We're passing it to the fragment shader unchanged.
  vUv = uv;

  gl_Position = p;
}

이제 애니메이션이 적용된 꼭짓점 셰이더를 살펴보겠습니다. 기본적으로 간단한 버텍스 셰이더와 동일한 작업을 수행하지만 약간의 차이점이 있습니다. 각 정점을 변환 행렬로만 변환하는 대신 시간에 종속된 애니메이션 변환도 적용합니다. 각 문자의 애니메이션을 약간 다르게 만들기 위해 애니메이션된 정점 셰이더는 문자의 좌표를 기반으로 애니메이션을 수정합니다. 단순한 정점 셰이더보다 훨씬 더 복잡해 보일 것입니다. 실제로 더 복잡하기 때문입니다.

uniform float uTime;
uniform float uEffectAmount;

varying float vZ;
varying vec2 vUv;

// rotateAngleAxisMatrix returns the mat3 rotation matrix
// for given angle and axis.
mat3 rotateAngleAxisMatrix(float angle, vec3 axis) {
  float c = cos(angle);
  float s = sin(angle);
  float t = 1.0 - c;
  axis = normalize(axis);
  float x = axis.x, y = axis.y, z = axis.z;
  return mat3(
    t*x*x + c,    t*x*y + s*z,  t*x*z - s*y,
    t*x*y - s*z,  t*y*y + c,    t*y*z + s*x,
    t*x*z + s*y,  t*y*z - s*x,  t*z*z + c
  );
}

// rotateAngleAxis rotates a vec3 over the given axis by the given angle and
// returns the rotated vector.
vec3 rotateAngleAxis(float angle, vec3 axis, vec3 v) {
  return rotateAngleAxisMatrix(angle, axis) * v;
}

void main() {
  // Compute the index of the letter (assuming 80-character max line length).
  float idx = floor(position.y/1.1)*80.0 + floor(position.x/1.1);

  // Round down the vertex coords to find the bottom-left corner point of the letter.
  vec3 corner = vec3(floor(position.x/1.1)*1.1, floor(position.y/1.1)*1.1, 0.0);

  // Find the midpoint of the letter.
  vec3 mid = corner + vec3(0.5, 0.5, 0.0);

  // Rotate the letter around its midpoint by an angle and axis dependent on
  // the letter's index and the current time.
  vec3 rpos = rotateAngleAxis(idx+uTime,
    vec3(mod(idx,16.0), -8.0+mod(idx,15.0), 1.0), position - mid) + mid;

  // uEffectAmount controls the amount of animation applied to the letter.
  // uEffectAmount ranges from 0.0 to 1.0.
  float effectAmount = uEffectAmount;

  vec4 fpos = vec4( mix(position,rpos,effectAmount), 1.0 );
  fpos.x += -35.0;

  // Apply spinning motion to individual letters.
  fpos.z += ((sin(idx+uTime*2.0)))*4.2*effectAmount;
  fpos.y += ((cos(idx+uTime*2.0)))*4.2*effectAmount;

  vec4 mvPosition = modelViewMatrix * fpos;

  // Apply wavy motion to the entire text.
  mvPosition.y += 10.0*sin(uTime*0.5+mvPosition.x/25.0)*effectAmount;
  mvPosition.x -= 10.0*cos(uTime*0.5+mvPosition.y/25.0)*effectAmount;

  vec4 p = projectionMatrix * mvPosition;

  // Pass texture coordinates and the vertex z-coordinate to the fragment shader.
  vUv = uv;
  vZ = p.z;

  // Send the final vertex position to WebGL.
  gl_Position = p;
}

버텍스 셰이더를 사용하려면 맞춤 셰이더를 사용하고 셰이더의 유니폼을 지정할 수 있는 재료 유형인 THREE.ShaderMaterial를 사용합니다. 다음은 데모에서 THREE.ShaderMaterial을 사용하는 방법입니다.

// First, set up uniforms for the shader.
var uniforms = {

  // map contains the letter map texture.
  map: { type: "t", value: 1, texture: tex },

  // uTime is the urrent time.
  uTime: { type: "f", value: 1.0 },

  // uEffectAmount controls the amount of animation applied to the letters.
  uEffectAmount: { type: "f", value: 0.0 }
};

// Next, set up the THREE.ShaderMaterial.
var shaderMaterial = new THREE.ShaderMaterial({
  uniforms: uniforms,

  // I have my shaders inside HTML elements like
  // <script id="vertex" type="text/x-glsl-vert">... shaderSource ... <script>

  // The below gets the contents of the vertex shader script element.
  vertexShader: document.querySelector('#vertex').textContent,

  // The fragment shader is a bit special as well, drawing a rotating
  // rainbow gradient.
  fragmentShader: document.querySelector('#fragment').textContent
});

// I set depthTest to false so that the letters don't occlude each other.
shaderMaterial.depthTest = false;

모든 애니메이션 프레임에서 셰이더 유니폼을 업데이트합니다.

// I'm controlling the uniforms through a proxy control object.
// The reason I'm using a proxy control object is to
// have different value ranges for the controls and the uniforms.
var controller = {
  effectAmount: 0
};

// I'm using <a href="http://code.google.com/p/dat-gui/">DAT.GUI</a> to do a quick & easy GUI for the demo.
var gui = new dat.GUI();
gui.add(controller, 'effectAmount', 0, 100);

var animate = function(t) {
  uniforms.uTime.value += 0.05;
  uniforms.uEffectAmount.value = controller.effectAmount/100;
  bookModel.position.y += 0.03;

  renderer.render(scene, camera);
  requestAnimationFrame(animate, renderer.domElement);
};
animate(Date.now());

이제 셰이더 기반 애니메이션을 완성했습니다. 꽤 복잡해 보이지만 실제로 하는 일은 현재 시간과 각 문자의 색인에 따라 문자를 움직이는 것뿐입니다. 성능이 문제가 되지 않는다면 이 로직을 JavaScript에서 실행할 수 있습니다. 그러나 애니메이션이 적용된 객체가 수만 개가 되면 JavaScript는 더 이상 실행 가능한 솔루션이 아닙니다.

남은 우려사항

이제 JavaScript가 입자 위치를 알지 못하는 문제가 있습니다. 입자의 위치를 정말로 알아야 하는 경우 JavaScript에서 정점 셰이더 로직을 복제하고 위치가 필요할 때마다 웹 워커를 사용하여 정점 위치를 다시 계산할 수 있습니다. 이렇게 하면 렌더링 스레드가 수학을 기다릴 필요가 없으며 원활한 프레임 속도로 애니메이션을 계속할 수 있습니다.

더 제어 가능한 애니메이션을 위해 텍스처에 렌더링 기능을 사용하여 JavaScript에서 제공하는 두 위치 집합 간에 애니메이션을 적용할 수 있습니다. 먼저 현재 위치를 텍스처로 렌더링한 다음 JavaScript에서 제공하는 별도의 텍스처에 정의된 위치를 향해 애니메이션을 적용합니다. 이 방법의 좋은 점은 프레임당 JavaScript에서 제공하는 위치의 일부만 업데이트하고도 정점 셰이더로 위치를 트윈하여 프레임마다 모든 글자를 계속 애니메이션할 수 있다는 것입니다.

또 다른 문제는 256자가 ASCII가 아닌 텍스트를 처리하기에는 너무 적다는 점입니다. 텍스처 맵 크기를 4,096x4,096으로 설정하고 글꼴 크기를 8px로 줄이면 전체 UCS-2 문자 집합을 텍스처 맵에 맞출 수 있습니다. 하지만 8px 글꼴 크기는 가독성이 좋지 않습니다. 글꼴 크기를 더 크게 하려면 글꼴에 여러 텍스처를 사용할 수 있습니다. 예를 보려면 이 스프라이트 아틀라스 데모를 참고하세요. 텍스트에 사용된 문자만 만드는 것도 도움이 됩니다.

요약

이 도움말에서는 Three.js를 사용하여 정점 셰이더 기반 애니메이션 데모를 구현하는 방법을 설명했습니다. 이 데모에서는 2010년 MacBook Air에서 100만 개의 글자를 실시간으로 애니메이션 처리합니다. 이 구현은 효율적인 그리기를 위해 전체 책을 단일 도형 객체로 번들로 묶었습니다. 개별 문자는 어떤 정점이 어떤 문자에 속하는지 파악하고 책 텍스트의 문자 색인을 기반으로 정점을 애니메이션 처리하여 애니메이션 처리되었습니다.

참조