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

Ilmari Heikkinen

소개

이 자료의 목표는 부드러운 프레임 속도로 화면에 백만 개의 애니메이션 글자를 그리는 것입니다. 최신 GPU를 사용하면 이 작업이 상당히 가능할 것입니다. 각 문자는 질감이 있는 삼각형 두 개로 구성되어 있으므로 프레임당 2백만 개의 삼각형에 불과합니다.

기존의 JavaScript 애니메이션 배경에서 오셨다면 이 모든 것이 미친 듯 같을 것입니다. 모든 프레임에 업데이트되는 2백만 개의 삼각형은 오늘날 JavaScript로 하고자 하는 작업이 전혀 아닙니다. 하지만 다행히 최신 GPU의 놀라운 성능을 활용할 수 있는 WebGL이 있습니다. 2백만 개의 애니메이션 삼각형은 최신 GPU와 약간의 셰이더 마법으로 꽤 실행할 수 있습니다.

효율적인 WebGL 코드 작성

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

그러나 정말 빠르게 진행하려면 대부분의 계산을 셰이더로 푸시해야 합니다. 이것이 제가 여기서 하려고 하는 것입니다. 셰이더를 사용하여 백만 개의 문자를 애니메이션 처리합니다.

이 문서의 코드는 Three.js 라이브러리를 사용합니다. 이 라이브러리는 WebGL 코드를 작성하는 번거로운 상용구를 모두 추상화합니다. Three.js를 사용하면 수백 줄의 WebGL 상태 설정 및 오류 처리를 작성할 필요가 없으며 코드 몇 줄만 작성하면 됩니다. 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는 관련된 600만 개의 꼭짓점에 애니메이션을 적용하는 데 다소 속도가 느립니다. 특히 모든 프레임에서 애니메이션 작업을 하려는 경우 더욱 그렇습니다. 더 빠른 방법이 있을 수도 있습니다.

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

좌표를 반내림하려면 두 문자의 좌표가 겹치지 않아야 합니다. 가장 쉬운 방법은 문자와 오른쪽의 글자 및 그 위의 줄을 구분하는 작은 오프셋이 있는 사각형의 사각형 문자를 사용하는 것입니다. 예를 들어 문자의 너비와 높이를 0.5로 사용하고 정수 좌표에서 문자를 정렬할 수 있습니다. 이제 문자 꼭짓점의 좌표를 내림하면 문자의 왼쪽 하단 좌표를 얻게 됩니다.

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

애니메이션 꼭짓점 셰이더를 더 잘 이해하기 위해 먼저 간단한 Run-of-the-Mill 꼭짓점 셰이더를 살펴보겠습니다. 화면에 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에서 제공하는 프레임당 위치의 일부를 업데이트하고 계속해서 위치를 트위닝하는 꼭짓점 셰이더를 사용하여 프레임마다 모든 문자를 계속 애니메이션 처리할 수 있다는 것입니다.

또 다른 문제는 ASCII가 아닌 텍스트를 처리하기에는 256자(영문 기준)가 너무 적다는 점입니다. 텍스처 지도 크기를 4096x4096으로 푸시하고 글꼴 크기를 8픽셀로 줄이면 전체 UCS-2 문자 집합을 텍스처 맵에 맞출 수 있습니다. 하지만 8픽셀의 글꼴 크기는 읽기 별로 어렵습니다. 글꼴 크기를 키우려면 글꼴에 여러 텍스처를 사용하면 됩니다. 예는 이 스프라이트 아틀라스 데모를 참고하세요. 또한 텍스트에 사용된 글자만 만드는 것도 도움이 될 수 있습니다.

요약

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

참조