Introducción
Mi objetivo en este artículo es dibujar un millón de letras animadas en la pantalla con una velocidad de fotogramas fluida. Esta tarea debería ser bastante posible con las GPUs modernas. Cada letra consta de dos triángulos con textura, por lo que solo estamos hablando de dos millones de triángulos por fotograma.
Si tienes experiencia en animaciones tradicionales de JavaScript, todo esto puede parecer una locura. Dos millones de triángulos actualizados en cada fotograma no es algo que te gustaría hacer con JavaScript en la actualidad. Pero, por fortuna, tenemos WebGL, que nos permite aprovechar la increíble potencia de las GPUs modernas. Y dos millones de triángulos animados son bastante factibles con una GPU moderna y un poco de magia de sombreador.
Cómo escribir código WebGL eficiente
Para escribir código WebGL eficiente, se requiere una mentalidad determinada. La forma habitual de dibujar con WebGL es configurar tus uniformes, búferes y sombreadores para cada objeto, seguidos 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 empezar, dibuja todos los objetos con el mismo sombreador uno tras otro, de modo que no tengas que cambiar los sombreadores entre los objetos. En el caso de los objetos simples, como las partículas, puedes agrupar varios objetos en un solo búfer y editarlo con 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.
Sin embargo, para que sea 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 todo el código estándar tedioso de la escritura de código WebGL. En lugar de tener que escribir cientos de líneas de configuración de estado y manejo de errores de WebGL, con Three.js solo debes escribir un par de líneas de código. También es fácil aprovechar el sistema de sombreadores de WebGL desde Three.js.
Cómo dibujar varios objetos con una sola llamada de dibujo
Este es un pequeño ejemplo de pseudocódigo de cómo puedes dibujar varios objetos con una sola llamada de dibujo. La forma tradicional es 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 realizar 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);
Muy bien, 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
Como primer paso, crearé una textura con los mapas de bits de las letras. Para ello, 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 textura de la hoja de sprites de letras. Si bien este es un método sencillo y directo para configurar las letras, es un poco ineficiente, ya que usa dos números de punto flotante por vértice para las coordenadas de textura. Una forma más corta, que dejamos como ejercicio para el lector, sería empaquetar el índice de letras y el índice de esquinas en un número y volver a convertirlo en coordenadas de textura en el sombreador de vértices.
A continuación, te explico cómo compilo la textura de la letra 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 del texto para que, si renderizas el array de triángulos tal como está, obtengas una renderización de diseño básico del texto.
Creación de la geometría 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++;
}
}
Vertex shader para animar las letras
Con un sombreador de vértices simple, obtengo una vista plana del texto. Nada sofisticado. Funciona bien, pero si quiero animarlo, debo hacerlo en JavaScript. Además, JavaScript es un poco lento para animar los seis millones de vértices involucrados, especialmente si quieres hacerlo en cada fotograma. Quizás haya una forma más rápida.
Por supuesto, podemos hacer animaciones procedimentales. Esto significa que hacemos todas las operaciones matemáticas de posición y rotación en el sombreador de vértices. Ahora no necesito ejecutar ningún código 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 animados de forma individual en cada fotograma. Para abordar los triángulos individuales, redondeo hacia abajo las coordenadas del vértice de modo que los cuatro puntos de un cuádruple de letras se asignen a una sola coordenada única. Ahora puedo usar esta coordenada para establecer los parámetros de animación de la letra en cuestión.
Para poder redondear correctamente las coordenadas, las coordenadas de dos letras diferentes no se pueden superponer. La forma más sencilla de hacerlo es usar grupos de cuatro letras cuadradas con un pequeño desplazamiento que separe la letra de la que está a su derecha y de la línea que está encima. Por ejemplo, puedes usar un ancho y una altura de 0.5 para las letras y alinearlas en 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.
Para comprender mejor el sombreador de vértices animado, primero analizaré un sombreador de vértices simple y corriente. Esto es lo que suele suceder cuando dibujas un modelo 3D en la pantalla. Los vértices del modelo se transforman con un par de matrices de transformación para proyectar cada vértice 3D en la pantalla 2D. Cada vez que un triángulo definido por tres de estos vértices se encuentra dentro del viewport, el sombreador de fragmentos procesa los píxeles que cubre para colorearlos. De cualquier manera, 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 con las matrices de transformación, también aplica una transformación animada dependiente del tiempo. Para que cada letra se anime de forma un poco diferente, el sombreador de vértices animado también modifica la animación en función de las coordenadas de la letra. Se verá mucho más complicado que el sombreador de vértices simple porque, bueno, 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, uso un THREE.ShaderMaterial
, un tipo de material que te permite usar sombreadores personalizados y especificar uniformes para ellos. A continuación, te indico cómo uso 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 la 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());
Y ahí lo tienes, la animación basada en sombreadores. Parece bastante complejo, pero lo único que hace es mover las letras de una manera que depende de la hora actual y del índice de cada letra. Si el rendimiento no fuera una preocupación, podrías ejecutar esta lógica en JavaScript. Sin embargo, con decenas de miles de objetos animados, JavaScript deja de ser una solución viable.
Inquietudes restantes
Ahora, el problema es que JavaScript no conoce las posiciones de las partículas. Si realmente necesitas saber dónde están tus 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 las necesites. De esta manera, tu subproceso de renderización no tiene que esperar a que se realicen los cálculos y puedes seguir animando a una velocidad de fotogramas fluida.
Para obtener una animación más controlable, puedes usar la función renderización en textura para animar entre dos conjuntos de posiciones que proporciona JavaScript. Primero, renderiza las posiciones actuales en una textura y, luego, anima hacia posiciones definidas en una textura independiente 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 que interpolan las posiciones.
Otra preocupación es que 256 caracteres son muy pocos para escribir textos que no sean ASCII. Si aumentas el tamaño del mapa de texturas a 4,096 x 4,096 y reduces el tamaño de la fuente a 8 px, puedes ajustar todo el conjunto de caracteres UCS-2 en el mapa de texturas. Sin embargo, el tamaño de fuente de 8 px no es muy legible. Para crear tamaños de fuente más grandes, puedes usar varias texturas para la fuente. Consulta esta demostración de atlas de sprites para ver un ejemplo. Otra cosa que podría ayudar es crear solo las letras que se usan en el texto.
Resumen
En este artículo, te expliqué cómo implementar una demostración de animación basada en un sombreador de vértices con Three.js. La demostración anima un millón de letras en tiempo real en una MacBook Air de 2010. La implementación empaquetó un libro completo en un solo objeto de geometría para dibujar de manera eficiente. Para animar las letras individuales, se determinó qué vértices pertenecen a cada letra y se animaron según el índice de la letra en el texto del libro.