Como animar um milhão de letras usando o Three.js

Ilmari Heikkinen

Introdução

Meu objetivo neste artigo é desenhar um milhão de letras animadas na tela com uma taxa de frames suave. Essa tarefa deve ser bastante possível com GPUs modernas. Cada letra consiste em dois triângulos texturizados, então estamos falando apenas de dois milhões de triângulos por frame.

Se você tem experiência com animações JavaScript tradicionais, tudo isso pode parecer loucura. Dois milhões de triângulos atualizados a cada frame definitivamente não é algo que você gostaria de fazer com o JavaScript hoje. Felizmente, temos o WebGL, que permite aproveitar o incrível poder das GPUs modernas. E dois milhões de triângulos animados são bastante viáveis com uma GPU moderna e um pouco de magia do sombreador.

Como escrever um código WebGL eficiente

Programar um código WebGL eficiente requer uma certa mentalidade. A maneira usual de desenhar usando o WebGL é configurar uniformes, buffers e sombreadores para cada objeto, seguido por uma chamada para desenhar o objeto. Essa forma de desenhar funciona ao desenhar um pequeno número de objetos. Para desenhar um grande número de objetos, minimize a quantidade de mudanças de estado do WebGL. Para começar, desenhe todos os objetos usando o mesmo sombreador um após o outro, para não precisar mudar de sombreador entre os objetos. Para objetos simples, como partículas, é possível agrupar vários objetos em um único buffer e editá-lo usando JavaScript. Dessa forma, você só precisa fazer o upload novamente do buffer de vértices em vez de mudar os uniformes do sombreador para cada partícula.

Mas, para ser muito rápido, você precisa enviar a maior parte da computação para os shaders. É isso que estou tentando fazer aqui. Anime um milhão de letras usando shaders.

O código do artigo usa a biblioteca Three.js, que abstrai todo o boilerplate tedioso da programação de código WebGL. Em vez de escrever centenas de linhas de configuração de estado e tratamento de erros do WebGL, com o Three.js, você só precisa escrever algumas linhas de código. Também é fácil acessar o sistema de sombreador do WebGL usando o Three.js.

Desenhar vários objetos usando uma única chamada de renderização

Confira um exemplo de pseudocódigo de como desenhar vários objetos usando uma única chamada de renderização. A maneira tradicional é desenhar um objeto por vez, como esta:

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

No entanto, o método acima exige uma chamada de renderização separada para cada objeto. Para desenhar vários objetos de uma só vez, você pode agrupar os objetos em uma única geometria e fazer uma única chamada de renderização:

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

Agora que você já tem a ideia básica, vamos voltar a escrever a demonstração e começar a animar milhões de letras.

Configurar a geometria e as texturas

Como primeira etapa, vou criar uma textura com os bitmaps de letra. Estou usando a tela 2D para isso. A textura resultante tem todas as letras que eu quero desenhar. A próxima etapa é criar um buffer com as coordenadas de textura para a folha de sprite de letra. Embora esse seja um método fácil e direto para configurar as letras, ele é um pouco ineficiente, já que usa dois números flutuantes por vértice para as coordenadas de textura. Uma maneira mais curta, deixada como um exercício para o leitor, seria empacotar o índice de letras e o índice de cantos em um número e convertê-lo de volta para coordenadas de textura no sombreador de vértice.

Veja como eu criei a textura da letra usando o 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;

Também faço o upload da matriz de triângulos para a GPU. Esses vértices são usados pelo sombreador de vértices para colocar as letras na tela. Os vértices são definidos como as posições das letras no texto. Assim, se você renderizar a matriz triangular como está, vai receber uma renderização de layout básica do texto.

Criação da geometria do livro:

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

Shader de vértice para animar as letras

Com um sombreador de vértice simples, tenho uma visualização plana do texto. Nada sofisticado. Funciona bem, mas se eu quiser animar, preciso fazer a animação em JavaScript. E o JavaScript é um pouco lento para animar os seis milhões de vértices envolvidos, especialmente se você quiser fazer isso em cada frame. Talvez haja uma maneira mais rápida.

Sim, podemos fazer animações procedurais. Isso significa que fazemos todas as nossas contas de posição e rotação no shader de vértice. Agora não é necessário executar nenhum JavaScript para atualizar as posições dos vértices. O sombreador de vértice é executado muito rápido e eu tenho uma taxa de frames suave, mesmo com um milhão de triângulos animados individualmente em cada frame. Para abordar os triângulos individuais, arredondamos para baixo as coordenadas do vértice para que todos os quatro pontos de um mapa de quadrícula de letra sejam mapeados para uma única coordenada. Agora posso usar essa coordenada para definir os parâmetros de animação da letra em questão.

Para arredondar as coordenadas, as coordenadas de duas letras diferentes não podem se sobrepor. A maneira mais fácil de fazer isso é usar quadrados de letras com um pequeno deslocamento separando a letra da que está no lado direito e da linha acima dela. Por exemplo, você pode usar uma largura e altura de 0,5 para as letras e alinhar as letras em coordenadas inteiras. Agora, ao arredondar para baixo a coordenada de qualquer vértice da letra, você recebe a coordenada inferior esquerda da letra.

Arredondamento para baixo das coordenadas do vértice para encontrar o canto superior esquerdo de uma letra.
Arredondando as coordenadas do vértice para encontrar o canto superior esquerdo de uma letra.

Para entender melhor o sombreador de vértice animado, vou mostrar primeiro um sombreador de vértice comum. Isso é o que normalmente acontece quando você desenha um modelo 3D na tela. Os vértices do modelo são transformados por algumas matrizes de transformação para projetar cada vértice 3D na tela 2D. Sempre que um triângulo definido por três desses vértices cai dentro da viewport, os pixels que ele cobre são processados pelo sombreador de fragmentos para serem coloridos. Confira o shader de vértice simples:

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

Agora, o sombreador de vértice animado. Basicamente, ele faz a mesma coisa que o sombreador de vértice simples, mas com uma pequena diferença. Em vez de transformar cada vértice apenas com as matrizes de transformação, ele também aplica uma transformação animada dependente do tempo. Para fazer com que cada letra seja animada de maneira um pouco diferente, o sombreador de vértice animado também modifica a animação com base nas coordenadas da letra. Ele vai parecer muito mais complicado do que o sombreador de vértice simples porque, bem, ele é mais 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 o sombreador de vértice, uso um THREE.ShaderMaterial, um tipo de material que permite usar sombreadores personalizados e especificar uniformes para eles. Confira como estou usando o THREE.ShaderMaterial na demonstração:

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

Em cada frame da animação, atualizo os uniformes do 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());

Pronto, você tem uma animação baseada em sombreador. Parece muito complexo, mas a única coisa que ele faz é mover as letras de uma maneira que depende do horário atual e do índice de cada letra. Se a performance não for uma preocupação, você poderá executar essa lógica em JavaScript. No entanto, para dezenas de milhares de objetos animados, o JavaScript deixa de ser uma solução viável.

Outras dúvidas

Um problema agora é que o JavaScript não sabe sobre as posições das partículas. Se você realmente precisar saber onde estão as partículas, duplique a lógica do sombreador de vértice em JavaScript e recalcule as posições dos vértices usando um worker da Web sempre que precisar das posições. Dessa forma, a linha de execução de renderização não precisa esperar pela matemática e você pode continuar animando com uma taxa de frames suave.

Para uma animação mais controlável, use a funcionalidade de renderização para textura para animar entre dois conjuntos de posições fornecidos pelo JavaScript. Primeiro, renderize as posições atuais em uma textura e, em seguida, anime em direção às posições definidas em uma textura separada fornecida pelo JavaScript. O bom disso é que você pode atualizar uma pequena fração das posições fornecidas pelo JavaScript por frame e continuar animando todas as letras em cada frame com o sombreador de vértice interpolando as posições.

Outra preocupação é que 256 caracteres é muito pouco para textos não ASCII. Se você aumentar o tamanho do mapa de textura para 4096x4096 e diminuir o tamanho da fonte para 8 px, será possível encaixar todo o conjunto de caracteres UCS-2 no mapa de textura. No entanto, o tamanho da fonte de 8 px não é muito legível. Para criar tamanhos maiores, use várias texturas. Confira este exemplo de demonstração de atlas de sprites. Outra coisa que ajudaria é criar apenas as letras usadas no texto.

Resumo

Neste artigo, mostrei como implementar uma demonstração de animação baseada em sombreador de vértice usando o Three.js. A demonstração anima um milhão de letras em tempo real em um MacBook Air de 2010. A implementação agrupava um livro inteiro em um único objeto de geometria para uma renderização eficiente. As letras individuais foram animadas descobrindo quais vértices pertencem a qual letra e animando os vértices com base no índice da letra no texto do livro.

Referências