Introduction
L'objectif de cet article est de dessiner un million de lettres animées à l'écran à un débit d'images fluide. Cette tâche devrait être tout à fait possible avec les GPU modernes. Chaque lettre est composée de deux triangles texturés. Nous ne parlons donc que de deux millions de triangles par image.
Si vous avez l'habitude de créer des animations JavaScript traditionnelles, tout cela peut sembler fou. Deux millions de triangles mis à jour à chaque frame n'est certainement pas quelque chose que vous aimeriez faire avec JavaScript aujourd'hui. Heureusement, nous avons WebGL, qui nous permet d'exploiter la puissance incroyable des GPU modernes. Deux millions de triangles animés sont tout à fait réalisables avec un GPU moderne et un peu de magie de nuanceur.
Écrire du code WebGL efficace
Écrire du code WebGL efficace nécessite un certain état d'esprit. La méthode habituelle de dessin avec WebGL consiste à configurer vos uniformes, tampons et nuanceurs pour chaque objet, puis à effectuer un appel pour dessiner l'objet. Cette méthode de dessin fonctionne lorsque vous dessinez un petit nombre d'objets. Pour dessiner un grand nombre d'objets, vous devez réduire le nombre de modifications d'état WebGL. Pour commencer, dessinez tous les objets à l'aide du même nuanceur les uns après les autres, afin de ne pas avoir à changer de nuanceur entre les objets. Pour les objets simples tels que les particules, vous pouvez regrouper plusieurs objets dans un seul tampon et le modifier à l'aide de JavaScript. Vous n'aurez ainsi qu'à réimporter le tampon de sommets au lieu de modifier les uniformes du nuanceur pour chaque particule.
Toutefois, pour aller vraiment vite, vous devez transférer la plupart de vos calculs vers les 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, qui élimine tout le code répétitif et ennuyeux de l'écriture de code WebGL. Au lieu d'avoir à écrire des centaines de lignes de configuration de l'état WebGL et de gestion des erreurs, avec Three.js, il vous suffit d'écrire quelques lignes de code. Il est également facile d'accéder au système de nuanceurs WebGL à partir 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 suit:
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);
Toutefois, 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 dans une seule géométrie et vous contenter d'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);
Très bien, maintenant que vous avez compris l'idée de base, revenons à l'écriture de la démonstration et commençons à animer ces millions de lettres.
Configurer la géométrie et les textures
La première étape consiste à créer une texture avec les bitmaps de lettres. Pour ce faire, 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 de la feuille de sprites de lettres. Bien que cette méthode soit simple et directe pour configurer les lettres, elle est un peu coûteuse, car elle utilise deux flottants par sommet pour les coordonnées de texture. Une méthode plus courte (à laisser comme exercice au lecteur) consiste à empaqueter l'index de la lettre et l'index du coin dans un seul nombre, puis à le convertir à nouveau en coordonnées de texture dans le nuanceur de sommet.
Voici comment créer la texture des lettres à 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;
Je mets également en ligne le tableau de triangles sur le GPU. Ces sommets sont utilisés par le nuanceur de sommets pour placer les lettres à l'écran. Les sommets sont définis sur les positions des lettres dans le texte. 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++;
}
}
Un nuanceur de sommet pour animer les lettres
Avec un simple nuanceur de sommet, j'obtiens une vue plate du texte. Rien d'extraordinaire. Il fonctionne correctement, mais si je veux l'animer, je dois le faire en JavaScript. JavaScript est un peu lent pour animer les six millions de sommets impliqués, en particulier si vous souhaitez le faire à chaque frame. Peut-être existe-t-il un moyen plus rapide.
Oui, nous pouvons créer des animations procédurales. Cela signifie que nous effectuons tous nos calculs de position et de rotation dans le nuanceur de sommets. Je n'ai plus besoin d'exécuter de code JavaScript pour mettre à jour les positions des sommets. Le nuanceur de sommets s'exécute très rapidement, et je bénéficie d'un débit d'images fluide, même si un million de triangles sont animés individuellement à chaque image. Pour traiter les triangles individuels, je arrondis les coordonnées des sommets vers le bas afin que les quatre points d'un quadrilatère de lettre soient mappés sur une seule et même coordonnée. Je peux maintenant utiliser cette coordonnée pour définir les paramètres d'animation de la lettre en question.
Pour pouvoir arrondir les coordonnées vers le bas, celles de deux lettres différentes ne doivent pas se chevaucher. Le moyen le plus simple de procéder consiste à utiliser des quads de lettres carrées avec un petit décalage séparant la lettre de celle à sa droite et de la ligne au-dessus. Par exemple, vous pouvez utiliser une largeur et une hauteur de 0,5 pour les lettres et les aligner sur des coordonnées entières. Lorsque vous arrondissez à l'entier inférieur la coordonnée de n'importe quel sommet de lettre, vous obtenez la coordonnée en bas à gauche de la lettre.
Pour mieux comprendre le nuanceur de sommet animé, je vais d'abord passer en revue un nuanceur de sommet standard. C'est ce qui se passe normalement lorsque vous dessinez un modèle 3D à l'écran. Les sommets du modèle sont transformés par un couple de matrices de transformation pour projeter chaque sommet 3D sur l'écran 2D. Chaque fois qu'un triangle défini par trois de ces sommets se trouve dans le viewport, les pixels qu'il recouvre sont traités par le nuanceur de fragments pour être colorés. Voici le nuanceur de vertex 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;
}
Et maintenant, le nuanceur de sommets animé. Fondamentalement, il fait la même chose que le nuanceur de sommet simple, mais avec une petite touche. Au lieu de transformer chaque sommet uniquement à l'aide des matrices de transformation, il applique également une transformation animée dépendant du temps. Pour que chaque lettre s'anime un peu différemment, le nuanceur de sommet animé modifie également l'animation en fonction des coordonnées de la lettre. Il semblera beaucoup plus compliqué que le simple nuanceur de sommets, car il l'est.
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 sommet, j'utilise un THREE.ShaderMaterial
, un type de matériau qui vous permet d'utiliser des nuanceurs personnalisés et de spécifier des uniformes pour eux. Voici comment j'utilise THREE.ShaderMaterial dans la démonstration:
// 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;
À chaque frame d'animation, je mets à jour les 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à, vous avez une animation basée sur des nuanceurs. Cela semble assez complexe, mais la seule chose qu'il fait est de déplacer les lettres en fonction de l'heure actuelle et de l'indice de chaque lettre. Si les performances ne sont pas un problème, vous pouvez exécuter cette logique en JavaScript. Toutefois, lorsque vous dépassez les dizaines de milliers d'objets animés, JavaScript n'est plus une solution viable.
Autres questions
Le problème est maintenant 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 worker Web chaque fois que vous en avez besoin. Ainsi, votre thread de rendu n'a pas besoin d'attendre les calculs et vous pouvez continuer à animer à une fréquence d'images fluide.
Pour une animation plus contrôlée, vous pouvez utiliser la fonctionnalité de rendu en texture pour animer entre deux ensembles de positions fournies par JavaScript. Commencez par afficher les positions actuelles dans une texture, puis animez-les vers les positions définies dans une texture distincte fournie par JavaScript. L'avantage est que vous pouvez mettre à jour une petite fraction des positions fournies par JavaScript par frame tout en continuant à animer toutes les lettres à chaque frame, en interpolant les positions avec le nuanceur de sommets.
Un autre problème est que 256 caractères sont bien trop peu pour les textes non ASCII. Si vous définissez la taille de la carte de texture sur 4 096 x 4 096 tout en réduisant la taille de la police à 8 px, vous pouvez adapter l'ensemble de caractères UCS-2 à la carte de texture. Toutefois, la taille de police de 8 pixels n'est pas très lisible. Pour obtenir des tailles de police plus grandes, vous pouvez utiliser plusieurs textures pour votre police. Pour voir un exemple, consultez cette démonstration d'atlas de sprites. Il est également utile de ne créer que les lettres utilisées dans votre texte.
Résumé
Dans cet article, je vous ai expliqué comment implémenter une démonstration d'animation basée sur un nuanceur de sommet à l'aide de Three.js. La démonstration anime un million de lettres en temps réel sur un MacBook Air 2010. L'implémentation regroupait l'intégralité d'un livre dans un seul objet géométrique pour un dessin efficace. Pour animer les lettres individuelles, nous avons déterminé les sommets qui appartiennent à chaque lettre et les avons animés en fonction de l'indice de la lettre dans le texte du livre.