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 quadros suave. Isso é possível com GPUs modernas. Cada letra consiste em dois triângulos com textura, então estamos falando apenas de dois milhões de triângulos por frame.

Se você vem de um plano de fundo tradicional de animação em JavaScript, tudo isso parece loucura. Dois milhões de triângulos atualizados a cada frame definitivamente não é algo que você gostaria de fazer com o JavaScript atualmente. Felizmente, temos o WebGL, que permite aproveitar a incrível potência das GPUs modernas. Com uma GPU moderna e a magia do sombreador, é possível usar dois milhões de triângulos animados.

Como criar um código WebGL eficiente

Escrever um código WebGL eficiente requer uma determinada mentalidade. A maneira comum de desenhar usando o WebGL é configurar seus uniformes, buffers e sombreadores para cada objeto, seguido de 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, é necessário minimizar a quantidade de mudanças de estado do WebGL. Para começar, desenhe todos os objetos usando o mesmo sombreador entre si, para que não seja necessário trocar os sombreadores entre eles. No caso de objetos simples, como partículas, é possível agrupar vários objetos em um único buffer e editá-lo usando JavaScript. Dessa forma, você só precisaria reenviar o buffer de vértice em vez de mudar os uniformes do sombreador para cada partícula.

Mas, para ser realmente rápido, você precisa enviar a maior parte da sua computação aos sombreadores. É o que estou tentando fazer aqui. Anime um milhão de letras com sombreadores.

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

Como desenhar vários objetos usando uma única chamada de desenho

Este é um pequeno exemplo de pseudocódigo que mostra como você pode desenhar vários objetos usando uma única chamada de desenho. A maneira tradicional é desenhar um objeto de cada vez desta forma:

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

Mas o método acima exige uma chamada de desenho separada para cada objeto. Para desenhar vários objetos de uma só vez, você pode agrupar os objetos em uma única geometria e começar com uma única chamada de desenho:

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 aqueles milhões de letras!

Como configurar a geometria e as texturas

Como primeira etapa, vou criar uma textura com os bitmaps de letra. Vou usar 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 letras. Embora esse seja um método fácil e direto para configurar as letras, é um pouco desnecessário, já que usa dois pontos 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 canto em um número e converter isso de volta em coordenadas de textura no sombreador de vértice.

Veja como crio 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 upload da matriz de triângulos para a GPU. Esses vértices são usados pelo sombreador de vértice para colocar as letras na tela. Os vértices são definidos de acordo com as posições das letras no texto. Assim, se você renderizar a matriz de triângulos como está, vai ter uma renderização básica de layout do texto.

Como criar a 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++;
  }
}

Sombreador de vértice para animar as letras

Com um sombreador de vértice simples, tenho uma visualização plana do texto. Nada sofisticado. Ele funciona bem, mas se eu quiser animá-lo, 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 todos os frames. Talvez haja uma maneira mais rápida.

Por que sim, podemos fazer animação processual. Isso significa que fazemos todo o cálculo de posição e rotação no sombreador de vértice. Não é preciso executar nenhum JavaScript para atualizar as posições dos vértices. O sombreador de vértice é executado muito rápido e o frame rate é estável, mesmo com um milhão de triângulos sendo animados individualmente a cada frame. Para lidar com os triângulos individuais, arredondamos as coordenadas dos vértices para que todos os quatro pontos de um quadrado de letra sejam mapeados para uma única coordenada exclusiva. Agora posso usar essa coordenada para definir os parâmetros de animação da letra em questão.

Para arredondar as coordenadas para baixo, as coordenadas de duas letras diferentes não podem se sobrepor. A maneira mais fácil de fazer isso é usando um quadrado de letras quadradas com um pequeno deslocamento separando a letra da letra do lado direito e da linha acima dela. Por exemplo, é possível usar uma largura e altura de 0,5 para as letras e alinhá-las em coordenadas de números inteiros. Agora, ao arredondar para baixo a coordenada de qualquer letra de vértice, você obtém a coordenada inferior esquerda da letra.

Arredondar as coordenadas de vértice para baixo para encontrar o canto superior esquerdo de uma letra.
Arredondar para baixo as coordenadas de vértice para encontrar o canto superior esquerdo de uma letra.

Para entender melhor o sombreador de vértice animado, primeiro vou analisar um sombreador de vértice simples da ferramenta. 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 chega ao interior da janela de visualização, os pixels que ele cobre são processados pelo sombreador de fragmento para colori-los. De qualquer forma, este é o sombreador 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 o mesmo que o sombreador de vértice simples, mas com uma pequena diferença. Em vez de transformar cada vértice apenas pelas matrizes de transformação, ele também aplica uma transformação animada que depende do tempo. Para tornar cada letra um pouco diferente, o sombreador de vértice animado também modifica a animação com base nas coordenadas da letra. Será muito mais complicado do que o sombreador de vértice simples, porque é 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. Veja 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 de 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());

Aí está, animação baseada em sombreador. Parece bastante complexo, mas a única coisa que realmente faz é mover as letras de forma que dependa da hora atual e do índice de cada letra. Se o desempenho não era uma preocupação, é possível executar essa lógica em JavaScript. No entanto, em dezenas de milhares de objetos animados, o JavaScript deixa de ser uma solução viável.

Preocupações restantes

Um problema agora é que o JavaScript não sabe sobre as posições das partículas. Se você realmente precisa saber onde as partículas estão, 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 delas. Dessa forma, a linha de execução de renderização não precisa esperar os cálculos e você pode continuar a animação com um frame rate suave.

Para uma animação mais controlável, você pode usar a funcionalidade de renderização para textura para animar entre dois conjuntos de posições fornecidos pelo JavaScript. Primeiro, renderize as posições atuais para 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 por frame uma pequena fração das posições fornecidas pelo JavaScript 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 são muito poucos para textos não ASCII. Se você aumentar o tamanho do mapa de textura para 4096 x 4096 e reduzir 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 fazer tamanhos de fonte maiores, você pode usar várias texturas na fonte. Confira esta demonstração do sprite atlas para conferir um exemplo. Outra coisa que ajudaria é criar apenas as letras usadas no seu texto.

Resumo

Neste artigo, orientamos você na implementação de 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 2010. A implementação agrupou um livro inteiro em um único objeto de geometria para um desenho eficiente. As letras individuais eram animadas pela descoberta de quais vértices pertencem a cada letra e pela animação deles com base no índice da letra no texto do livro.

Referências