Cómo animar un millón de letras con Three.js

Ilmari Heikkinen

Introducción

Mi objetivo en este artículo es dibujar un millón de letras animadas en la pantalla a una velocidad de fotogramas fluida. Esta tarea debería ser bastante posible con las GPU modernas. Cada letra consta de dos triángulos con textura, por lo que solo nos referimos a dos millones de triángulos por marco.

Si vienes de una animación de fondo tradicional de JavaScript, todo esto parece una locura. Dos millones de triángulos actualizados en cada fotograma no es algo que te gustaría hacer hoy con JavaScript. Por suerte, tenemos WebGL, que nos permite aprovechar la increíble potencia de las GPU modernas. Y dos millones de triángulos animados es bastante factible con una GPU moderna y algo de magia de sombreadores.

Escribir código WebGL eficiente

Escribir código WebGL eficiente requiere cierta mentalidad. La forma habitual de dibujar con WebGL es configurar los uniformes, los búferes y los sombreadores para cada objeto, seguido de una llamada para dibujar el objeto. Esta forma de dibujar funciona cuando se dibuja una pequeña cantidad de objetos. Para dibujar una gran cantidad de objetos, debes minimizar la cantidad de cambios de estado de WebGL. Para comenzar, dibuja todos los objetos con el mismo sombreador uno tras otro, de modo que no tengas que cambiar los sombreadores entre los objetos. Para objetos simples, como partículas, puedes agrupar varios objetos en un solo búfer y editarlo usando JavaScript. De esa manera, solo tendrías que volver a subir el búfer de vértices en lugar de cambiar los uniformes del sombreador para cada partícula.

Pero para avanzar muy rápido, debes enviar la mayor parte de tu procesamiento a los sombreadores. Eso es lo que estoy tratando de hacer aquí. Anima un millón de letras con sombreadores.

El código del artículo usa la biblioteca Three.js, que abstrae la tediosa tarea de escribir código de WebGL. En lugar de tener que escribir cientos de líneas de configuración de estado de WebGL y manejo de errores, con Three.js solo debes escribir un par de líneas de código. También es fácil acceder al sistema de sombreadores WebGL desde Three.js.

Cómo dibujar varios objetos usando una sola llamada de dibujo

Este es un pequeño ejemplo de un seudocódigo de cómo puedes dibujar varios objetos usando una sola llamada de dibujo. La forma tradicional consiste en dibujar un objeto a la vez de la siguiente manera:

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);

Sin embargo, el método anterior requiere una llamada de dibujo independiente para cada objeto. Para dibujar varios objetos a la vez, puedes agruparlos en una sola geometría y lograr una sola llamada de dibujo:

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);

Ahora que tienes la idea básica, volvamos a escribir la demostración y comencemos a animar esos millones de letras.

Cómo configurar la geometría y las texturas

En primer lugar, crearé una textura con los mapas de bits de letras. Para esto, uso el lienzo 2D. La textura resultante tiene todas las letras que quiero dibujar. El siguiente paso es crear un búfer con las coordenadas de texturas de la hoja de objeto de la letra. Si bien este es un método sencillo y directo para configurar las letras, es un poco desperdiciado, ya que usa dos números de punto flotante por vértice para las coordenadas de textura. Una forma más corta (que se deja como ejercicio para el lector) sería empaquetar el índice de las letras y el de las esquinas en un número y volver a convertirlo en coordenadas de texturas en el sombreador de vértices.

Así es como creo la textura de las letras con Canvas 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;

También subo el array de triángulos a la GPU. El sombreador de vértices usa estos vértices para colocar las letras en la pantalla. Los vértices se establecen en las posiciones de las letras en el texto, de modo que, si renderizas el array de triángulos tal como está, obtendrás una renderización de diseño básica del texto.

Crear las formas geométricas del libro:

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++;
  }
}

Sombreador de vértices para animar las letras

Con un sombreador de vértices simple, obtengo una vista plana del texto. Nada sofisticado. Se ejecuta bien, pero si quiero animarlo, debo hacer la animación en JavaScript. JavaScript es un poco lento en la animación de los seis millones de vértices involucrados, especialmente si se quiere hacerlo en cada fotograma. Quizás haya una manera más rápida.

¿Por qué sí, podemos hacer animación de procedimiento? Eso significa que realizamos todos los cálculos de posición y rotación en el sombreador de vértices. Ahora no necesito ejecutar JavaScript para actualizar las posiciones de los vértices. El sombreador de vértices se ejecuta muy rápido y obtengo una velocidad de fotogramas fluida incluso con un millón de triángulos que se animan de forma individual en cada fotograma. Para abordar los triángulos individuales, redondeo las coordenadas de vértices de modo que los cuatro puntos de un cuadrante de letras se asignen a una única coordenada única. Ahora, puedo usar esta coordenada para configurar los parámetros de animación de la letra en cuestión.

Para poder redondear correctamente las coordenadas hacia abajo, no se pueden superponer las coordenadas de dos letras diferentes. La forma más fácil de hacerlo es usar cuadrantes de letras cuadradas con un pequeño desplazamiento que separe la letra de la que está a su derecha y la línea que está por encima de ella. Por ejemplo, podrías usar un ancho y una altura de 0.5 para las letras y alinearlas con coordenadas de números enteros. Ahora, cuando redondeas hacia abajo la coordenada de cualquier vértice de la letra, obtienes la coordenada inferior izquierda de la letra.

Redondear coordenadas de vértices hacia abajo para encontrar la esquina superior izquierda de una letra.
Se redondean las coordenadas de vértices hacia abajo para encontrar la esquina superior izquierda de una letra.

Para comprender mejor el sombreador de vértices animado, primero voy a pasar por un sombreador de vértices sencillo y rápido. Esto es lo que suele suceder cuando dibujas un modelo 3D en la pantalla. Un par de matrices de transformación transforman los vértices del modelo para proyectar cada vértice 3D en la pantalla 2D. Cuando un triángulo definido por tres de estos vértices llega al viewport, el sombreador de fragmentos procesa los píxeles que cubre para colorearlos. De todos modos, este es el sombreador de vértices simple:

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;
}

Y ahora, el sombreador de vértices animado. Básicamente, hace lo mismo que el sombreador de vértices simple, pero con un pequeño giro. En lugar de transformar cada vértice solo por las matrices de transformación, también aplica una transformación animada dependiente del tiempo. Para que cada letra se anime de forma diferente, el sombreador de vértices animado también modifica la animación según las coordenadas de la letra. Se verá mucho más complicado que el sombreador de vértices simple porque es más complicado.

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;
}

Para usar el sombreador de vértices, utilizo un THREE.ShaderMaterial, un tipo de material que te permite usar sombreadores personalizados y especificar uniformes para ellos. Así es como estoy usando THREE.ShaderMaterial en la demostración:

// 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;

En cada fotograma de animación, actualizo los uniformes del sombreador:

// 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());

Ya está. Animación basada en sombreadores. Parece bastante complejo, pero lo único que hace realmente es mover las letras de una manera que depende de la hora actual y del índice de cada letra. Si el rendimiento no fuera un problema, puedes tener esta lógica ejecutándose en JavaScript. No obstante, con decenas de miles de objetos animados, JavaScript deja de ser una solución viable.

Inquietudes restantes

Un problema ahora es que JavaScript no conoce las posiciones de las partículas. Si realmente necesitas saber dónde están las partículas, puedes duplicar la lógica del sombreador de vértices en JavaScript y volver a calcular las posiciones de los vértices con un trabajador web cada vez que necesites las posiciones. De esa manera, el subproceso de renderización no tendrá que esperar los cálculos y podrás seguir realizando animaciones a una velocidad de fotogramas fluida.

Para lograr una animación más controlable, puedes usar la funcionalidad de renderización a textura para animar entre dos conjuntos de posiciones proporcionados por JavaScript. Primero, renderiza las posiciones actuales en una textura y luego anima las posiciones definidas en una textura separada que proporciona JavaScript. Lo bueno de esto es que puedes actualizar una pequeña fracción de las posiciones proporcionadas por JavaScript por fotograma y seguir animando todas las letras en cada fotograma con el sombreador de vértices interpolando las posiciones.

Otra preocupación es que 256 caracteres son muy pocos como para crear textos que no sean ASCII. Si aplicas el tamaño del mapa de texturas a 4096 x 4096 y lo disminuyes a 8 px, puedes introducir el conjunto de caracteres UCS-2 completo en el mapa de texturas. Sin embargo, el tamaño de fuente de 8 px no es muy legible. Para tamaños de fuente más grandes, puedes usar varias texturas para la fuente. Consulta esta demostración del atlas de objetos para ver un ejemplo. Otro aspecto útil es crear solo las letras que se usan en el texto.

Resumen

En este artículo, te explicamos cómo implementar una demostración de animación basada en sombreadores de vértices con Three.js. La demostración anima un millón de letras en tiempo real en una MacBook Air 2010. La implementación reunió un libro completo en un solo objeto de geometría para generar un dibujo eficiente. Para animar las letras individuales, se descubriera qué vértices pertenecen a qué letra y se animaron los vértices en función del índice de la letra en el texto del libro.

Referencias