Einführung
In diesem Artikel möchte ich eine Million animierte Buchstaben mit einer flüssigen Framerate auf dem Bildschirm zeichnen. Mit modernen GPUs sollte das problemlos möglich sein. Jeder Buchstabe besteht aus zwei texturierten Dreiecken, also sprechen wir nur von zwei Millionen Dreiecken pro Frame.
Wenn Sie mit traditionellen JavaScript-Animationen vertraut sind, klingt das alles nach Wahnsinn. Zwei Millionen Dreiecke, die jeden Frame aktualisiert werden, sind definitiv keine Aufgabe, die Sie heute mit JavaScript erledigen möchten. Glücklicherweise gibt es WebGL, mit dem wir die enorme Leistung moderner GPUs nutzen können. Und zwei Millionen animierte Dreiecke sind mit einer modernen GPU und ein bisschen Shader-Magie durchaus machbar.
Effizienten WebGL-Code schreiben
Das Erstellen effizienten WebGL-Codes erfordert eine bestimmte Denkweise. Normalerweise werden mit WebGL Uniforms, Buffers und Shader für jedes Objekt eingerichtet, gefolgt von einem Aufruf zum Zeichnen des Objekts. Diese Art des Zeichnens eignet sich für eine kleine Anzahl von Objekten. Wenn Sie eine große Anzahl von Objekten zeichnen möchten, sollten Sie die Anzahl der WebGL-Statusänderungen minimieren. Zeichnen Sie zuerst alle Objekte nacheinander mit demselben Shader, damit Sie den Shader nicht zwischen den Objekten ändern müssen. Bei einfachen Objekten wie Partikeln können Sie mehrere Objekte in einem einzigen Buffer bündeln und mit JavaScript bearbeiten. So müssen Sie nur den Vertex-Puffer noch einmal hochladen, anstatt die Shader-Uniforms für jede einzelne Partikel zu ändern.
Wenn Sie aber wirklich schnell arbeiten möchten, müssen Sie den Großteil Ihrer Berechnungen an die Shader weitergeben. Das versuche ich hier. Mit Shadern eine Million Buchstaben animieren
Im Code des Artikels wird die Three.js-Bibliothek verwendet, die den mühsamen Boilerplate-Code beim Schreiben von WebGL-Code abstrahiert. Anstatt Hunderte von Zeilen für die WebGL-Statuseinrichtung und Fehlerbehandlung schreiben zu müssen, benötigen Sie mit Three.js nur ein paar Codezeilen. Außerdem ist es ganz einfach, das WebGL-Shadersystem von Three.js zu nutzen.
Mehrere Objekte mit einem einzigen Draw-Aufruf zeichnen
Hier ist ein kleines Pseudocode-Beispiel, wie Sie mit einem einzigen Draw-Aufruf mehrere Objekte zeichnen können. Die traditionelle Methode besteht darin, jeweils ein Objekt zu zeichnen:
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);
Bei der oben genannten Methode ist jedoch für jedes Objekt ein separater Draw-Aufruf erforderlich. Wenn Sie mehrere Objekte gleichzeitig zeichnen möchten, können Sie sie in einer einzelnen Geometrie bündeln und mit einem einzigen Draw-Aufruf zeichnen:
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);
In Ordnung, jetzt, da Sie die Grundidee haben, können wir mit dem Schreiben der Demo fortfahren und mit der Animation dieser Millionen von Buchstaben beginnen.
Geometrie und Texturen einrichten
Als ersten Schritt erstelle ich eine Textur mit den Bitmaps der Buchstaben. Ich verwende dazu den 2D-Canvas. Die resultierende Textur enthält alle Buchstaben, die ich zeichnen möchte. Im nächsten Schritt erstellen Sie einen Buffer mit den Texturkoordinaten für das Buchstaben-Spritesheet. Das ist zwar eine einfache und unkomplizierte Methode, um die Buchstaben einzurichten, aber ein bisschen verschwenderisch, da für die Texturkoordinaten zwei Gleitkommazahlen pro Vertex verwendet werden. Eine kürzere Methode, die dem Leser als Übung überlassen wird, besteht darin, den Buchstabenindex und den Eckindex in eine Zahl zu verpacken und diese im Vertex-Shader wieder in Texturkoordinaten umzuwandeln.
So erstelle ich die Textur des Buchstabens mit 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;
Außerdem lade ich das Dreiecksarray auf die GPU hoch. Diese Vertexe werden vom Vertex-Shader verwendet, um die Buchstaben auf dem Bildschirm darzustellen. Die Eckpunkte werden auf die Buchstabenpositionen im Text festgelegt. Wenn Sie das Dreiecksarray unverändert rendern, erhalten Sie ein einfaches Layout-Rendering des Texts.
So erstellen Sie die Geometrie für das Buch:
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 zum Animieren der Buchstaben
Mit einem einfachen Vertex-Shader erhalte ich eine flache Ansicht des Textes. Nichts Ausgefallenes. Er funktioniert gut, aber wenn ich ihn animieren möchte, muss ich die Animation in JavaScript erstellen. Außerdem ist JavaScript für die Animation der sechs Millionen beteiligten Eckpunkte ziemlich langsam, insbesondere wenn Sie dies für jeden Frame tun möchten. Vielleicht gibt es einen schnelleren Weg.
Ja, wir können prozedurale Animationen erstellen. Das bedeutet, dass wir alle Berechnungen für Position und Drehung im Vertex-Shader ausführen. Jetzt muss ich kein JavaScript mehr ausführen, um die Positionen der Eckpunkte zu aktualisieren. Der Vertex-Shader läuft sehr schnell und ich erhalte eine flüssige Framerate, auch wenn jedes Frame eine Million Dreiecke einzeln animiert. Um die einzelnen Dreiecke anzusprechen, runde ich die Koordinaten der Eckpunkte auf, sodass alle vier Punkte eines Buchstabenquadrats einer einzelnen eindeutigen Koordinate zugeordnet werden. Jetzt kann ich mit dieser Koordinate die Animationsparameter für den betreffenden Buchstaben festlegen.
Damit Koordinaten auf die nächste ganze Zahl abgerundet werden können, dürfen sich Koordinaten aus zwei verschiedenen Buchstaben nicht überschneiden. Am einfachsten geht das mit quadratischen Buchstabenquadern mit einem kleinen Versatz, der den Buchstaben von dem auf der rechten Seite und der Zeile darüber trennt. Sie können beispielsweise eine Breite und Höhe von 0, 5 für die Buchstaben verwenden und die Buchstaben an Ganzzahlkoordinaten ausrichten. Wenn Sie nun die Koordinate eines beliebigen Buchstabenknotens aufrunden, erhalten Sie die Koordinate links unten des Buchstabens.
Um den animierten Vertex-Shader besser zu verstehen, werde ich zuerst einen einfachen Vertex-Shader durchgehen. Das ist normalerweise der Fall, wenn Sie ein 3D‑Modell auf dem Bildschirm zeichnen. Die Eckpunkte des Modells werden durch mehrere Transformationsmatrizen transformiert, um jeden 3D-Eckpunkt auf den 2D-Bildschirm zu projizieren. Wenn ein Dreieck, das durch drei dieser Eckpunkte definiert ist, im Viewport landet, werden die von ihm abgedeckten Pixel vom Fragment-Shader verarbeitet, um sie zu färben. Hier ist der einfache 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;
}
Und jetzt der animierte Vertex-Shader. Im Grunde macht er dasselbe wie der einfache Vertex-Shader, aber mit einer kleinen Abwandlung. Anstatt jeden Vertex nur mit den Transformationsmatrizen zu transformieren, wird auch eine zeitabhängige animierte Transformation angewendet. Damit sich jeder Buchstabe etwas anders animiert, ändert der animierte Vertex-Shader die Animation auch anhand der Koordinaten des Buchstabens. Er sieht viel komplizierter aus als der einfache Vertex-Shader, weil er komplizierter ist.
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;
}
Für den Vertex-Shader verwende ich einen THREE.ShaderMaterial
, einen Materialtyp, mit dem Sie benutzerdefinierte Shader verwenden und Uniforms dafür angeben können. So verwende ich THREE.ShaderMaterial in der 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 jedem Animationsframe aktualisiere ich die Shader-Uniforms:
// 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());
Und das war es auch schon – eine shaderbasierte Animation. Es sieht ziemlich komplex aus, aber im Grunde werden die Buchstaben nur so bewegt, dass es von der aktuellen Uhrzeit und dem Index jedes Buchstabens abhängt. Wenn die Leistung keine Rolle spielt, können Sie diese Logik auch in JavaScript ausführen. Bei Zehntausenden von animierten Objekten ist JavaScript jedoch keine praktikable Lösung mehr.
Weitere Fragen
Ein Problem besteht darin, dass JavaScript die Partikelpositionen nicht kennt. Wenn Sie wirklich wissen müssen, wo sich Ihre Partikel befinden, können Sie die Logik des Vertex-Shaders in JavaScript duplizieren und die Vertex-Positionen jedes Mal mit einem Webworker neu berechnen, wenn Sie die Positionen benötigen. So muss der Rendering-Thread nicht auf die Berechnung warten und Sie können die Animation mit einer flüssigen Framerate fortsetzen.
Für eine besser steuerbare Animation können Sie die Render-to-Texture-Funktion verwenden, um zwischen zwei Positionen zu animieren, die von JavaScript bereitgestellt werden. Zuerst werden die aktuellen Positionen in einer Textur gerendert und dann zu Positionen animiert, die in einer separaten Textur definiert sind, die von JavaScript bereitgestellt wird. Das Schöne daran ist, dass Sie pro Frame einen kleinen Teil der von JavaScript bereitgestellten Positionen aktualisieren und trotzdem alle Buchstaben pro Frame mit dem Vertex-Shader animieren können, der die Positionen tweent.
Ein weiteres Problem ist, dass 256 Zeichen viel zu wenig sind, um Nicht-ASCII-Texte zu verarbeiten. Wenn Sie die Größe der Texturkarte auf 4.096 × 4.096 Pixel erhöhen und die Schriftgröße auf 8 Pixel verringern, können Sie den gesamten UCS-2-Zeichensatz in die Texturkarte einpassen. Eine Schriftgröße von 8 px ist jedoch nur schwer lesbar. Für größere Schriftgrößen können Sie mehrere Texturen für die Schrift verwenden. Eine Demo für Sprite-Atlanten finden Sie in diesem Artikel. Außerdem sollten Sie nur die Buchstaben erstellen, die im Text verwendet werden.
Zusammenfassung
In diesem Artikel habe ich Ihnen gezeigt, wie Sie mit Three.js eine vertex-shaderbasierte Animationsdemo implementieren. In der Demo werden eine Million Buchstaben in Echtzeit auf einem MacBook Air aus dem Jahr 2010 animiert. Bei der Implementierung wurde ein ganzes Buch zu einem einzigen geometrischen Objekt zusammengefasst, um die Zeichnung effizient zu gestalten. Die einzelnen Buchstaben wurden animiert, indem ermittelt wurde, welche Eckpunkte zu welchem Buchstaben gehören, und die Eckpunkte dann basierend auf dem Index des Buchstabens im Buchtext animiert wurden.