Animazione di un milione di lettere con Three.js

Ilmari Heikkinen

Introduzione

Il mio obiettivo in questo articolo è disegnare un milione di lettere animate sullo schermo a una frequenza fotogrammi uniforme. Questa operazione dovrebbe essere abbastanza possibile con le GPU moderne. Ogni lettera è composta da due triangoli con texture, quindi parliamo solo di due milioni di triangoli per frame.

Se hai esperienza con le animazioni JavaScript tradizionali, tutto questo può sembrare folle. Due milioni di triangoli aggiornati ogni frame non sono certo qualcosa che vorresti fare con JavaScript oggi. Fortunatamente, però, abbiamo WebGL, che ci consente di sfruttare la straordinaria potenza delle GPU moderne. E due milioni di triangoli animati sono abbastanza fattibili con una GPU moderna e un po' di magia degli shader.

Scrivere codice WebGL efficiente

Scrivere codice WebGL efficiente richiede una certa mentalità. Il modo usuale per disegnare utilizzando WebGL è configurare uniformi, buffer e shader per ogni oggetto, seguiti da una chiamata per disegnare l'oggetto. Questo metodo di disegno funziona quando si disegnano pochi oggetti. Per disegnare un numero elevato di oggetti, devi ridurre al minimo la quantità di modifiche dello stato WebGL. Per iniziare, disegna tutti gli oggetti uno dopo l'altro utilizzando lo stesso shader, in modo da non dover cambiare shader tra un oggetto e l'altro. Per oggetti semplici come le particelle, puoi raggruppare più oggetti in un unico buffer e modificarlo utilizzando JavaScript. In questo modo, dovrai solo ricaricare il buffer di vertici anziché modificare le uniformi dello shader per ogni singola particella.

Ma per andare davvero veloce, devi spostare la maggior parte dei calcoli sugli shader. È quello che sto cercando di fare qui. Animare un milione di lettere utilizzando gli shader.

Il codice dell'articolo utilizza la libreria Three.js, che esegue l'astrazione di tutto il codice boilerplate necessario per scrivere codice WebGL. Invece di dover scrivere centinaia di righe di configurazione dello stato e gestione degli errori di WebGL, con Three.js devi scrivere solo un paio di righe di codice. È inoltre facile accedere al sistema di shader WebGL da Three.js.

Disegno di più oggetti utilizzando una singola chiamata draw

Ecco un piccolo esempio di pseudocodice su come disegnare più oggetti utilizzando una singola chiamata draw. Il metodo tradizionale consiste nel disegnare un oggetto alla volta, come mostrato di seguito:

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

Tuttavia, il metodo riportato sopra richiede una chiamata draw separata per ogni oggetto. Per disegnare più oggetti contemporaneamente, puoi raggrupparli in un'unica geometria e ottenerne un risultato con una singola chiamata draw:

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

Bene, ora che hai capito l'idea di base, torniamo a scrivere la demo e iniziamo ad animare questo milione di lettere.

Configurazione della geometria e delle texture

Come primo passaggio, creerò una trama con le bitmap delle lettere. Per questo utilizzo la tela 2D. La trama risultante contiene tutte le lettere che voglio disegnare. Il passaggio successivo consiste nel creare un buffer con le coordinate della trama per lo sprite sheet delle lettere. Sebbene questo sia un metodo semplice e diretto per configurare le lettere, è un po' dispendioso in quanto utilizza due valori float per vertice per le coordinate texture. Un modo più breve, da lasciare come esercizio al lettore, consiste nel combinare l'indice della lettera e l'indice dell'angolo in un unico numero e convertirlo di nuovo in coordinate texture nello shader vertex.

Ecco come creo la trama della lettera utilizzando 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;

Carico anche l'array di triangoli sulla GPU. Questi vertici vengono utilizzati dallo shader per mettere le lettere sullo schermo. I vertici sono impostati sulle posizioni delle lettere nel testo in modo che, se rendi l'array di triangoli così com'è, ottieni un rendering di base del layout del testo.

Crea la geometria 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 per l'animazione delle lettere

Con un semplice shader per vertici, ottengo una visualizzazione piatta del testo. Nulla di strano. Funziona bene, ma se voglio animarlo, devo farlo in JavaScript. Inoltre, JavaScript è un po' lento per animare i sei milioni di vertici coinvolti, soprattutto se vuoi farlo in ogni frame. Forse c'è un modo più veloce.

Sì, possiamo fare animazioni procedurali. Ciò significa che eseguiamo tutti i calcoli relativi a posizione e rotazione nello shader vertex. Ora non devo più eseguire JavaScript per aggiornare le posizioni dei vertici. Lo shader vertex funziona molto velocemente e ottengo una frequenza fotogrammi fluida anche con un milione di triangoli animati singolarmente in ogni frame. Per gestire i singoli triangoli, arrotondo per difetto le coordinate dei vertici in modo che tutti e quattro i punti di un quadrato di lettere vengano mappati a una singola coordinata univoca. Ora posso utilizzare questa coordinata per impostare i parametri di animazione per la lettera in questione.

Per poter arrotondare le coordinate per difetto, le coordinate di due lettere diverse non possono sovrapporsi. Il modo più semplice per farlo è utilizzare quadretti di lettere quadrati con un piccolo offset che separa la lettera da quella sul lato destro e dalla riga sopra. Ad esempio, puoi utilizzare una larghezza e un'altezza di 0,5 per le lettere e allinearle alle coordinate intere. Ora, quando arrotondi per difetto la coordinata di un vertice della lettera, ottieni la coordinata in basso a sinistra della lettera.

Arrotondamento per difetto delle coordinate dei vertici per trovare l&#39;angolo in alto a sinistra di una lettera.
Arrotondamento per difetto delle coordinate dei vertici per trovare l'angolo in alto a sinistra di una lettera.

Per comprendere meglio lo shader vertex animato, esaminerò prima un semplice shader vertex standard. Questo è ciò che accade normalmente quando disegni un modello 3D sullo schermo. I vertici del modello vengono trasformati da un paio di matrici di trasformazione per proiettare ogni vertice 3D sullo schermo 2D. Ogni volta che un triangolo definito da tre di questi vertici si trova all'interno dell'area visibile, i pixel che copre vengono elaborati dallo shader di frammento per colorarli. Ad ogni modo, ecco il semplice shader vertex:

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

E ora, lo shader vertex animato. Fondamentalmente, fa la stessa cosa del semplice shader vertex, ma con un piccolo cambiamento. Invece di trasformare ogni vertice solo con le matrici di trasformazione, applica anche una trasformazione animata dipendente dal tempo. Per animare ogni lettera in modo leggermente diverso, lo shader vertex animato modifica anche l'animazione in base alle coordinate della lettera. Sembra molto più complicato del semplice shader vertex perché, in effetti, lo è.

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

Per utilizzare lo shader vertex, utilizzo un THREE.ShaderMaterial, un tipo di materiale che ti consente di utilizzare shader personalizzati e di specificare le relative uniformi. Ecco come utilizzo THREE.ShaderMaterial nella demo:

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

In ogni frame dell'animazione, aggiorna le uniformi dello shader:

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

Ecco fatto, animazione basata su shader. Sembra piuttosto complesso, ma l'unica cosa che fa è spostare le lettere in base all'ora corrente e all'indice di ogni lettera. Se il rendimento non è un problema, puoi eseguire questa logica in JavaScript. Tuttavia, con decine di migliaia di oggetti animati, JavaScript non è più una soluzione praticabile.

Problemi rimanenti

Un problema ora è che JavaScript non conosce le posizioni delle particelle. Se hai davvero bisogno di sapere dove si trovano le particelle, puoi duplicare la logica dello shader vertex in JavaScript e ricalcolare le posizioni dei vertici utilizzando un web worker ogni volta che ne hai bisogno. In questo modo, il thread di rendering non deve attendere i calcoli e puoi continuare a creare l'animazione a una frequenza fotogrammi uniforme.

Per un'animazione più controllabile, puoi utilizzare la funzionalità di rendering in texture per animare tra due insiemi di posizioni forniti da JavaScript. Innanzitutto, esegui il rendering delle posizioni attuali in una texture, quindi animale verso le posizioni definite in una texture separata fornita da JavaScript. Il vantaggio è che puoi aggiornare una piccola parte delle posizioni fornite da JavaScript per frame e continuare ad animare tutte le lettere ogni frame con il tweening delle posizioni dello shader di vertice.

Un altro problema è che 256 caratteri sono troppo pochi per i testi non ASCII. Se imposti le dimensioni della mappa di texture su 4096 x 4096 e riduci le dimensioni del carattere a 8 px, puoi adattare l'intero set di caratteri UCS-2 alla mappa di texture. Tuttavia, la dimensione dei caratteri di 8 px non è molto leggibile. Per utilizzare caratteri di dimensioni maggiori, puoi usare più texture per il carattere. Per un esempio, consulta questa demo dell'atlante di sprite. Un'altra cosa che potrebbe essere utile è creare solo le lettere utilizzate nel testo.

Riepilogo

In questo articolo, ti ho guidato nell'implementazione di una demo di animazione basata su shader vertex utilizzando Three.js. La demo anima un milione di lettere in tempo reale su un MacBook Air del 2010. L'implementazione ha raggruppato un intero libro in un unico oggetto geometrico per un disegno efficiente. Le singole lettere sono state animate individuando i vertici che appartengono a ciascuna lettera e animandoli in base all'indice della lettera nel testo del libro.

Riferimenti