Animer un million de lettres à l'aide de Three.js

Ilmari Heikkinen

Introduction

Dans cet article, je souhaite dessiner un million de lettres animées à l'écran avec une fréquence d'images fluide. Cette tâche devrait être tout à fait possible avec des GPU modernes. Chaque lettre est composée de deux triangles texturés. Nous ne parlons donc que d'environ deux millions de triangles par cadre.

Si vous avez utilisé un arrière-plan d'animation JavaScript traditionnel, tout cela ressemble à de la folie. Vous n'aimeriez certainement pas utiliser deux millions de triangles mis à jour à chaque image avec JavaScript aujourd'hui. Heureusement, nous avons le WebGL, qui nous permet d'exploiter toute la puissance des GPU modernes. L'utilisation de deux millions de triangles animés est tout à fait réalisable avec un GPU moderne et une certaine magie du nuanceur.

Écrire du code WebGL efficace

Écrire du code WebGL efficace nécessite un certain état d'esprit. La méthode habituelle pour dessiner à l'aide de WebGL consiste à configurer les variables uniformes, les tampons et les nuanceurs pour chaque objet, puis à appeler l'objet. Cette méthode fonctionne pour un petit nombre d'objets. Pour dessiner un grand nombre d'objets, vous devez minimiser le nombre de changements d'état WebGL. Pour commencer, dessinez tous les objets les uns après les autres à l'aide du même nuanceur, afin de ne pas avoir à changer de nuanceur entre les objets. Pour les objets simples comme les particules, vous pouvez regrouper plusieurs objets dans un seul tampon et le modifier à l'aide de JavaScript. De cette façon, il vous suffit de réimporter le tampon de sommets au lieu de modifier les variables uniformes du nuanceur pour chaque particule.

Mais pour aller très vite, vous devez transmettre la majeure partie de votre calcul aux nuanceurs. C'est ce que j'essaie de faire ici. Animez un million de lettres à l'aide de nuanceurs.

Le code de l'article utilise la bibliothèque Three.js, ce qui élimine le code récurrent fastidieux lors de l'écriture de code WebGL. Avec Three.js, il vous suffit d'écrire quelques lignes de code au lieu d'avoir à écrire des centaines de lignes de configuration de l'état WebGL et de gestion des erreurs. Il est également facile d'exploiter le système de nuanceurs WebGL de Three.js.

Dessiner plusieurs objets à l'aide d'un seul appel de dessin

Voici un petit exemple de pseudo-code montrant comment dessiner plusieurs objets à l'aide d'un seul appel de dessin. La méthode traditionnelle consiste à dessiner un objet à la fois, comme ceci:

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

Cependant, la méthode ci-dessus nécessite un appel de dessin distinct pour chaque objet. Pour dessiner plusieurs objets à la fois, vous pouvez les regrouper en une seule géométrie et vous en sortir avec un seul appel de dessin:

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

Maintenant que vous avez compris l'idée de base, revenons à la démo et commençons à animer ce million de lettres.

Configurer la géométrie et les textures

Pour commencer, je vais créer une texture avec les bitmaps de lettres. Pour cela, j'utilise le canevas 2D. La texture obtenue contient toutes les lettres que je souhaite dessiner. L'étape suivante consiste à créer un tampon avec les coordonnées de texture sur la feuille de sprites correspondant à la lettre. Bien qu'il s'agisse d'une méthode simple et simple pour configurer les lettres, elle est un peu inefficace, car elle utilise deux floats par sommet pour les coordonnées de la texture. Pour faire plus court, à laisser au lecteur comme exercice, vous pouvez regrouper l'index des lettres et l'index de l'angle en un seul nombre, puis le reconvertir en coordonnées de texture dans le nuanceur de sommets.

Voici comment créer la texture de lettre à l'aide de 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;

J'importe également le tableau de triangles sur le GPU. Le nuanceur de sommets utilise ces sommets pour afficher les lettres à l'écran. Les sommets sont définis sur la position des lettres dans le texte. Ainsi, si vous affichez le tableau de triangles tel quel, vous obtenez un rendu de mise en page de base du texte.

Créer la géométrie du livre:

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

Nuanceur de sommets pour animer les lettres

Avec un nuanceur de sommets simple, j'obtiens une vue plate du texte. Rien d'extraordinaire. L'animation fonctionne bien, mais je dois créer l'animation en JavaScript. De plus, JavaScript est assez lent pour animer les six millions de sommets impliqués, surtout si vous voulez le faire sur chaque frame. Peut-être existe-t-il un moyen plus rapide.

Pourquoi oui, nous pouvons faire une animation procédurale. Cela signifie que nous effectuons tous nos calculs de position et de rotation dans le nuanceur de sommets. Désormais, je n'ai plus besoin d'exécuter JavaScript pour mettre à jour la position des sommets. Le nuanceur de sommets s'exécute très rapidement et j'obtiens une fréquence d'images fluide, même avec un million de triangles animés individuellement à chaque image. Pour dresser la liste des triangles individuels, j'arrondit les coordonnées des sommets de sorte que les quatre points d'un quad de lettres correspondent à une seule coordonnée unique. Je peux maintenant utiliser cette coordonnée pour définir les paramètres d'animation de la lettre en question.

Pour qu'il soit possible d'arrondir des coordonnées inférieures, les coordonnées de deux lettres différentes ne doivent pas se chevaucher. Le moyen le plus simple de le faire est d'utiliser des quadrillages carrés avec un petit décalage séparant la lettre de celle de droite et de la ligne située au-dessus. Par exemple, vous pouvez utiliser une largeur et une hauteur de 0,5 pour les lettres, et aligner les lettres sur des coordonnées entières. Désormais, lorsque vous arrondissez la coordonnée d'un sommet de lettre vers le bas, vous obtenez la coordonnée inférieure gauche de la lettre.

Arrondir les coordonnées des sommets vers le bas pour trouver l&#39;angle supérieur gauche d&#39;une lettre
Nous arrondissons les coordonnées des sommets pour trouver l'angle supérieur gauche d'une lettre.

Pour mieux comprendre le nuanceur de sommets animés, je vais commencer par examiner un nuanceur de sommets simple et ordinaire. C'est ce qui se produit habituellement lorsque vous dessinez un modèle 3D à l'écran. Les sommets du modèle sont transformés par deux matrices de transformation pour projeter chaque sommet 3D sur l'écran 2D. Chaque fois qu'un triangle défini par trois de ces sommets arrive dans la fenêtre d'affichage, les pixels qu'il couvre sont traités par le nuanceur de fragments pour les colorer. Voici le nuanceur de sommets 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;
}

Passons maintenant au nuanceur de sommets animés. Il effectue les mêmes opérations que le nuanceur de sommets simple, mais avec une légère rotation. Au lieu de transformer chaque sommet par les matrices de transformation uniquement, il applique également une transformation animée dépendante du temps. Pour que chaque lettre s'anime un peu différemment, le nuanceur de sommets animés modifie également l'animation en fonction des coordonnées de la lettre. Cet outil semble bien plus complexe que le nuanceur de sommets simple, car il est bien plus complexe.

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

Pour utiliser le nuanceur de sommets, j'utilise un THREE.ShaderMaterial, un type de matériau qui vous permet d'utiliser des nuanceurs personnalisés et de leur spécifier des variables uniformes. Voici comment j'utilise THREE.ShaderMaterial dans la démo:

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

Sur chaque frame d'animation, je mets à jour les variables uniformes du nuanceur:

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

Et voilà, une animation basée sur le nuanceur. Cela semble assez complexe, mais la seule chose qu'il fait réellement est de déplacer les lettres d'une manière qui dépend de l'heure actuelle et de l'index de chaque lettre. Si les performances ne vous préoccupent pas, vous pouvez exécuter cette logique en JavaScript. Toutefois, pour des dizaines de milliers d'objets animés, JavaScript ne constitue plus une solution viable.

Préoccupations restantes

Le premier problème est que JavaScript ne connaît pas les positions des particules. Si vous avez vraiment besoin de savoir où se trouvent vos particules, vous pouvez dupliquer la logique du nuanceur de sommets en JavaScript et recalculer les positions des sommets à l'aide d'un web worker chaque fois que vous en avez besoin. Ainsi, votre thread de rendu n'a pas besoin d'attendre le calcul et vous pouvez continuer l'animation à une fréquence d'images fluide.

Pour une animation plus contrôlable, vous pouvez utiliser la fonctionnalité d'affichage de texture afin d'animer entre deux ensembles de positions fournis par JavaScript. Tout d'abord, affichez les positions actuelles sur une texture, puis effectuez l'animation vers des positions définies dans une texture distincte fournie par JavaScript. L'avantage, c'est que vous pouvez mettre à jour une petite partie des positions fournies par JavaScript par image et continuer à animer toutes les lettres à chaque image, et le nuanceur de sommets interpole les positions.

Une autre préoccupation est que 256 caractères est bien trop peu pour faire des textes non ASCII. Si vous faites passer la taille de la carte de texture à 4 096 x 4 096 tout en réduisant la taille de la police à 8 pixels, vous pouvez adapter l'intégralité du jeu de caractères UCS-2 à la carte de texture. Toutefois, une taille de police de 8 pixels n'est pas très lisible. Pour augmenter la taille des polices, vous pouvez utiliser plusieurs textures. Pour en savoir plus, consultez cette démonstration de l'atlas de sprites. Une autre chose qui pourrait vous aider est de créer uniquement les lettres utilisées dans votre texte.

Résumé

Dans cet article, je vous ai montré comment implémenter une démo d'animation basée sur un nuanceur de sommets à l'aide de Three.js. La démonstration anime un million de lettres en temps réel sur un MacBook Air de 2010. L'implémentation a regroupé un livre entier dans un seul objet de géométrie pour un dessin efficace. Les lettres individuelles ont été animées en identifiant à quel sommet appartiennent à quelle lettre et en animant les sommets en fonction de l'index de la lettre dans le texte du livre.

Références