Animazione di un milione di lettere con Three.js

Ilmari Heikkinen

Introduzione

L'obiettivo di questo articolo è disegnare un milione di lettere animate sullo schermo a una frequenza fotogrammi omogenea. Questa attività dovrebbe essere del tutto possibile con le GPU moderne. Ogni lettera è composta da due triangoli con texture, quindi parliamo solo di due milioni di triangoli per frame.

Se provieni da un'animazione JavaScript tradizionale, tutto è come una follia. Due milioni di triangoli aggiornati ogni frame non sono sicuramente qualcosa che vorresti fare con JavaScript oggi. Per fortuna abbiamo WebGL, che ci permette di sfruttare la straordinaria potenza delle GPU moderne. Due milioni di triangoli animati sono abbastanza fattibili con una GPU moderna e una certa magia.

Scrittura di codice WebGL efficiente

Scrivere codice WebGL efficiente richiede un certo atteggiamento mentale. Il solito modo per disegnare con WebGL è di impostare le uniformi, i buffer e gli mesh per ciascun oggetto, per poi richiamare l'oggetto. Questo metodo di disegno funziona quando si disegna un numero ridotto di oggetti. Per disegnare un numero elevato di oggetti, è necessario ridurre al minimo le variazioni di stato di WebGL. Per iniziare, disegna tutti gli oggetti uno dopo l'altro utilizzando lo stesso ombreggiatore, in modo da non dover cambiare i cursori da un oggetto all'altro. Nel caso di oggetti semplici come le particelle, puoi raggruppare diversi oggetti in un unico buffer e modificarlo utilizzando JavaScript. In questo modo, dovreste solo ricaricare il buffer del vertice invece di cambiare le uniformi dello Shadr per ogni singola particella.

Ma per andare molto velocemente, devi inviare la maggior parte del tuo calcolo agli analisti. È quello che sto cercando di fare. Animazione di un milione di lettere usando gli ombreggiatori.

Il codice dell'articolo utilizza la libreria Three.js, che elimina il noioso boilerplate dalla scrittura del codice WebGL. Invece di dover scrivere centinaia di righe per la configurazione e la gestione degli errori di WebGL, con Three.js ti basterà scrivere un paio di righe di codice. È inoltre facile attingere al sistema di shadowr WebGL di Three.js.

Disegno di più oggetti mediante un'unica chiamata di disegno

Ecco un piccolo esempio di pseudocodice di come potresti disegnare più oggetti utilizzando una singola chiamata di disegno. Il metodo tradizionale consiste nel disegnare un oggetto alla volta nel seguente modo:

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 descritto sopra richiede una chiamata di disegno separata per ciascun oggetto. Per disegnare più oggetti contemporaneamente, puoi raggruppare gli oggetti in un'unica geometria e ottenere l'eliminazione con un'unica chiamata di disegno:

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 avuto l'idea di base, torniamo a scrivere la demo e iniziamo a animare quel milione di lettere.

Impostazione della geometria e delle texture

Come primo passaggio, creerò una texture con sopra una bitmap della lettera. Per farlo userò la tela 2D. La texture risultante contiene tutte le lettere che voglio disegnare. Il passaggio successivo consiste nel creare un buffer con le coordinate della texture nel foglio sprite con le lettere. Sebbene questo sia un metodo semplice e diretto per impostare le lettere, è un po' dispendioso in quanto vengono utilizzati due valori mobili per vertice per le coordinate della texture. Un modo più breve, lasciato come esercizio al lettore, sarebbe comprimere l'indice della lettera e l'indice degli angoli in un numero e convertirlo in coordinate della texture nello strumento di orientamento dei vertici.

Ecco come creare la texture delle lettere 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 triangolare sulla GPU. Questi vertici vengono utilizzati dallo strumento di orientamento per inserire le lettere sullo schermo. I vertici sono impostati in corrispondenza delle posizioni delle lettere nel testo, in modo che, se esegui il rendering dell'array triangolare così com'è, ottieni un rendering del layout di base del testo.

Creazione della 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 animare le lettere

Con un semplice Vertex Shader, ottengo una visuale piatta del testo. Nulla di strano. funziona correttamente, ma se voglio animarla, devo eseguire l'animazione in JavaScript. JavaScript è un po' lento per animare i sei milioni di vertici coinvolti, soprattutto se vuoi farlo su ogni frame. Forse esiste un modo più veloce.

Possiamo creare animazioni procedurali. Ciò significa che eseguiamo tutti i calcoli matematici relativi a posizione e rotazione nello strumento di sincronizzazione dei vertici. Ora non devo eseguire JavaScript per aggiornare le posizioni dei vertici. Lo strumento Vertex Shaper è molto veloce e la frequenza fotogrammi è uniforme, anche con un milione di triangoli animati singolarmente ogni fotogramma. Per risolvere i singoli triangoli, arrotondiamo per difetto le coordinate del vertice in modo che tutti e quattro i punti di una lettera quad mappano a una singola coordinata univoca. Ora posso utilizzare questa coordinata per impostare i parametri di animazione per la lettera in questione.

Per poter arrotondare per difetto le coordinate, le coordinate di due lettere diverse non possono sovrapporsi. Il modo più semplice per riuscirci consiste nell'utilizzare le lettere quadrate con un piccolo offset che separa la lettera da quella sul lato destro e dalla linea sopra. Ad esempio, puoi utilizzare larghezza e altezza pari a 0,5 per le lettere e allineare le lettere su coordinate numeriche. Ora, quando arrotonda per difetto la coordinata del vertice di una lettera, ottieni la coordinata in basso a sinistra della lettera.

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

Per capire meglio il Vertex Shaper animato, per prima cosa esaminerò un semplice vertex smoother. Questo è ciò che accade normalmente quando tracci un modello 3D sullo schermo. I vertici del modello vengono trasformati da un paio di matrici di trasformazione per proiettare ciascun vertice 3D sullo schermo 2D. Ogni volta che un triangolo definito da tre di questi vertici arriva all'interno dell'area visibile, i pixel che copre vengono elaborati dallo strumento di shadowing dei frammenti per colorarli. Comunque, ecco il semplice Vertex Shader:

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 passiamo all'animazione Vertex Shader. In pratica, fa la stessa cosa del semplice vertex smoother, ma con una piccola variazione. Invece di trasformare ogni vertice solo in base alle matrici di trasformazione, viene applicata anche una trasformazione animata dipendente dal tempo. Per fare in modo che ogni lettera si anima in modo leggermente diverso, lo strumento Vertex Shaper animato modifica anche l'animazione in base alle coordinate della lettera. Avrà un aspetto molto più complicato del semplice Vertex Shader, perché è più complicato.

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 usare lo strumento Vertex Shader, uso THREE.ShaderMaterial, un tipo di materiale che consente di utilizzare shard personalizzati e specificare le 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;

A ogni fotogramma dell'animazione aggiorno le uniformi dello Shad:

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

Ed ecco l'animazione basata sullo Shadr. Sembra abbastanza complesso, ma l'unica cosa che fa davvero è spostare le lettere in un modo che dipende dall'ora corrente e dall'indice di ogni lettera. Se le prestazioni non sono un problema, potresti avere questa logica in esecuzione in JavaScript. Tuttavia, con decine di migliaia di oggetti animati, JavaScript smette di essere una soluzione attuabile.

Problemi rimanenti

Un problema ora è che JavaScript non conosce le posizioni delle particelle. Se hai davvero bisogno di sapere dove si trovano le tue particelle, potresti duplicare la logica di vertex Shader 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 dovrà attendere i calcoli matematici e potrai continuare a animare con una frequenza fotogrammi uniforme.

Per un'animazione più controllabile, puoi utilizzare la funzionalità di rendering-texture per animare tra due insiemi di posizioni fornite da JavaScript. Prima di tutto, esegui il rendering delle posizioni correnti in una texture, poi anima le posizioni definite in una texture separata fornita da JavaScript. L'aspetto positivo è che puoi aggiornare una piccola parte delle posizioni fornite da JavaScript per frame e continuare a animare tutte le lettere ogni frame con lo strumento Vertex Shaper che intercambia le posizioni.

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

Riepilogo

In questo articolo ti abbiamo illustrato come implementare una demo di animazione basata su Vertex Shaper utilizzando Three.js. La demo anima un milione di lettere in tempo reale su un MacBook Air 2010. L'implementazione includeva un intero libro in un unico oggetto geometrico per un disegno efficiente. Le singole lettere venivano animate individuando a quali vertici appartengono a quale lettera e animando i vertici in base all'indice della lettera nel testo del libro.

Riferimenti