Eine Million Buchstaben mit Three.js animieren

Ilmari Heikkinen

Einleitung

Mein Ziel in diesem Artikel ist es, eine Million animierter Buchstaben mit einer flüssigen Framerate auf den Bildschirm zu zeichnen. Mit modernen GPUs sollte diese Aufgabe durchaus möglich sein. Jeder Buchstabe besteht aus zwei strukturierten Dreiecken, es sind also nur zwei Millionen Dreiecke pro Frame.

Wenn Sie bisher einen traditionellen JavaScript-Animationshintergrund hatten, klingt das alles nach Wahnsinn. Zwei Millionen Dreiecke, die jeden Frame aktualisiert haben, würden heute definitiv nichts mit JavaScript machen. Zum Glück gibt es aber WebGL, mit der wir die beeindruckende Leistung moderner GPUs nutzen können. Und mit einer modernen GPU und etwas Shader-Magie sind zwei Millionen animierte Dreiecke durchaus machbar.

Effizienten WebGL-Code schreiben

Für das Schreiben von effizientem WebGL-Code ist eine bestimmte Denkweise erforderlich. Normalerweise richten Sie mit WebGL Ihre Uniformen, Puffer und Shader für jedes Objekt ein, gefolgt von einem Aufruf zum Zeichnen des Objekts. Diese Art des Zeichnens funktioniert beim Zeichnen einer kleinen 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 zunächst alle Objekte mit demselben Shader nacheinander, damit Sie die Shaders zwischen den Objekten nicht wechseln müssen. Bei einfachen Objekten wie Partikeln könnten Sie mehrere Objekte in einem einzigen Puffer bündeln und diesen mithilfe von JavaScript bearbeiten. Auf diese Weise müssten Sie nur den Vertex-Zwischenspeicher neu hochladen, anstatt die Shader-Uniformen für jeden einzelnen Partikel zu ändern.

Damit Sie jedoch wirklich schnell arbeiten können, müssen Sie den größten Teil Ihrer Berechnungen an die Shader weitergeben. Genau das versuche ich hier. Animieren Sie eine Million Buchstaben mithilfe von Shadern.

Im Code des Artikels wird die Three.js-Bibliothek verwendet, die die mühsame Boilerplate zum Schreiben von WebGL-Code entfällt. Mit Three.js müssen Sie nicht mehr hunderte von Zeilen für die Einrichtung von WebGL-Zuständen und die Fehlerbehandlung programmieren, sondern lediglich ein paar Zeilen Code schreiben. Es ist auch einfach, das WebGL-Shader-System von Three.js zu nutzen.

Mehrere Objekte mit einem einzigen Zeichenaufruf zeichnen

Hier ist ein kleines Pseudocode-Beispiel dafür, wie Sie mehrere Objekte mit einem einzigen Draw-Aufruf zeichnen können. Traditionell wird jeweils nur ein Objekt gezeichnet:

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

Die obige Methode erfordert jedoch für jedes Objekt einen separaten "draw"-Aufruf. Um mehrere Objekte auf einmal zu zeichnen, können Sie die Objekte in einer einzigen Geometrie bündeln und mit einem einzigen Draw-Aufruf davonkommen:

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

Nachdem Sie nun die Grundidee kennen, fangen wir wieder an, die Demo zu schreiben und diese Millionen Buchstaben zu animieren.

Geometrie und Texturen einrichten

Im ersten Schritt erstelle ich eine Textur mit den Buchstaben-Bitmaps. Dazu verwende ich den 2D-Canvas. Die resultierende Textur enthält alle Buchstaben, die ich zeichnen möchte. Im nächsten Schritt erstellen Sie einen Puffer mit den Texturkoordinaten für das Buchstaben-Sprite Sheet. Dies ist zwar eine einfache und unkomplizierte Methode, die Buchstaben einzurichten, ist aber etwas Verschwendung, da pro Scheitelpunkt zwei Gleitkommazahlen für die Texturkoordinaten verwendet werden. Ein kürzerer Weg - überlassen Sie dem Lesenden als Übung - wäre, den Buchstabenindex und den Eckenindex in eine Zahl zu packen und diese im Vertex-Shader zurück in Texturkoordinaten zu konvertieren.

So erstelle ich die Buchstabentextur in 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;

Ich lade auch das Dreiecksarray auf die GPU hoch. Diese Eckpunkte werden vom Vertex-Shader verwendet, um die Buchstaben auf dem Bildschirm zu platzieren. Die Eckpunkte werden auf die Buchstabenpositionen im Text gesetzt. Wenn Sie das Dreiecksarray also 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 Texts. Nichts Ausgefallenes. Funktioniert gut, aber wenn ich es animieren möchte, muss ich die Animation in JavaScript erstellen. Und JavaScript ist etwas langsam, um die sechs Millionen Eckpunkte zu animieren, insbesondere wenn Sie dies in jedem Frame tun möchten. Vielleicht gibt es einen schnelleren Weg.

Warum ja, wir können verfahrensbasierte Animationen erstellen? Das bedeutet, dass wir alle unsere Positions- und Rotationsberechnungen im Scheitelpunkt-Shader durchfü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, selbst wenn jeden Frame eine Million Dreiecke einzeln animiert werden. Um die einzelnen Dreiecke zu korrigieren, runde ich die Scheitelpunktkoordinaten ab, sodass alle vier Punkte eines Buchstabenquadrats zu einer einzigen eindeutigen Koordinate zugeordnet sind. Jetzt kann ich anhand dieser Koordinate die Animationsparameter für den betreffenden Buchstaben festlegen.

Damit Koordinaten erfolgreich abgerundet werden können, dürfen sich Koordinaten aus zwei verschiedenen Buchstaben nicht überschneiden. Am einfachsten ist dies, wenn Sie quadratische Buchstaben als Quadrate mit kleinem Abstand verwenden, die den Buchstaben von dem Buchstaben auf der rechten Seite und der Linie darüber trennen. Sie könnten beispielsweise für die Buchstaben eine Breite und Höhe von 0, 5 angeben und die Buchstaben an Ganzzahl-Koordinaten ausrichten. Wenn Sie jetzt die Koordinate eines Scheitelpunkts abrunden, erhalten Sie die Koordinate unten links des Buchstabens.

Durch Abrunden der Scheitelpunktkoordinaten wird die obere linke Ecke eines Buchstabens ermittelt.
Die Koordinaten der Scheitelpunkte abrunden, um die obere linke Ecke eines Buchstabens zu finden.

Um den animierten Vertex-Shader besser zu verstehen, gehe ich zunächst einen einfachen Vertex-Shader durch. Das passiert normalerweise, wenn Sie ein 3D-Modell auf dem Bildschirm zeichnen. Die Eckpunkte des Modells werden durch zwei Transformationsmatrizen transformiert, um jeden 3D-Scheitelpunkt auf den 2D-Bildschirm zu projizieren. Immer wenn ein durch drei dieser Eckpunkte definierte Dreieck im Darstellungsbereich landet, werden die von ihm verdeckten Pixel vom Fragment-Shader zur Einfärbung verarbeitet. Wie dem auch sei, 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 den animierten Vertex-Shader. Im Grunde macht sie die gleiche Funktion wie der einfache Vertex-Shader, nur mit einer kleinen Drehung. Anstatt jeden Scheitelpunkt nur anhand der Transformationsmatrizen zu transformieren, wird auch eine zeitabhängige animierte Transformation angewendet. Um die einzelnen Buchstaben etwas anders zu animieren, ändert der animierte Scheitel-Shader auch die Animation basierend auf den Koordinaten des Buchstabens. Sie sieht dann deutlich komplizierter aus als der einfache Vertex-Shader, weil sie 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;
}

Um den Vertex-Shader zu verwenden, verwende ich einen THREE.ShaderMaterial. Dies ist ein Materialtyp, mit dem Sie benutzerdefinierte Shader verwenden und Uniformen dafür angeben können. So verwende ich DREE.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;

Ich aktualisiere auf jedem Animationsframe die Shader-Uniformen:

// 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 ist die Shar-basierte Animation. Es sieht ziemlich komplex aus, aber der einzige Vorgang besteht darin, die Buchstaben so zu verschieben, dass sie von der aktuellen Uhrzeit und dem Index der einzelnen Buchstaben abhängen. Wenn die Leistung kein Problem darstellt, können Sie diese Logik in JavaScript ausführen. Bei Zehntausenden animierten Objekten ist JavaScript jedoch keine brauchbare Lösung mehr.

Verbleibende Bedenken

Ein Problem besteht nun darin, dass JavaScript die Partikelpositionen nicht kennt. Wenn Sie wirklich wissen müssen, wo sich Ihre Partikel befinden, können Sie die Vertex-Shader-Logik in JavaScript duplizieren und die Scheitelpunktpositionen jedes Mal, wenn Sie die Positionen benötigen, mithilfe eines Web Workers neu berechnen. Auf diese Weise muss der Rendering-Thread nicht auf die Berechnungen warten und Sie können die Animation mit einer gleichmäßigen Framerate fortsetzen.

Um die Animation besser steuern zu können, können Sie die Funktion zum Rendern in Texturen verwenden, um zwischen zwei von JavaScript bereitgestellten Positionssätzen zu animieren. Rendern Sie zuerst die aktuellen Positionen als Textur und animieren Sie sie dann zu Positionen, die in einer separaten von JavaScript bereitgestellten Textur definiert wurden. Das Schöne daran ist, dass Sie einen kleinen Teil der von JavaScript bereitgestellten Positionen pro Frame aktualisieren und dennoch alle Buchstaben in jedem Frame mit dem Scheitel-Shader, um die Positionen anzupassen, weiter animieren können.

Ein weiterer Bedenken ist, dass 256 Zeichen viel zu wenig für Nicht-ASCII-Texte sind. Wenn Sie die Texturkarte auf 4096 × 4096 Pixel verschieben und die Schriftgröße auf 8 Pixel verringern, können Sie den gesamten UCS-2-Zeichensatz in die Texturkarte hineinpassen. Eine Schriftgröße von 8 Pixel ist jedoch nicht gut lesbar. Für eine größere Schrift können Sie mehrere Texturen für Ihre Schriftart verwenden. Ein Beispiel finden Sie in dieser Sprite Atlas-Demo. Außerdem kannst du nur die Buchstaben eingeben, die in deinem Text verwendet werden.

Zusammenfassung

In diesem Artikel wurde die Implementierung einer Demo für eine Vertex-Shader-basierte Animation mit Three.js beschrieben. In der Demo werden auf einem MacBook Air von 2010 eine Million Buchstaben in Echtzeit animiert. Bei der Implementierung wurde ein ganzes Buch in einem einzigen Geometrieobjekt gebündelt, um effizientes Zeichnen zu ermöglichen. Die einzelnen Buchstaben wurden animiert, indem festgestellt wurde, welche Eckpunkte zu welchem Buchstaben gehören, und die Eckpunkte basierend auf dem Index des Buchstabens im Buchtext animiert.

Verweise