Animowanie miliona liter przy użyciu biblioteki Three.js

Ilmari Heikkinen

Wprowadzenie

Moim celem w tym artykule jest narysowanie na ekranie miliona animowanych liter z płynną częstotliwością klatek. To zadanie powinno być możliwe do wykonania za pomocą nowoczesnych procesorów graficznych. Każda litera składa się z 2 teksturowanych trójkątów, więc mówimy tylko o 2 milionach trójkątów na kadr.

Jeśli zajmujesz się tradycyjną animacją w języku JavaScript, może Ci się to wydawać szaleństwem. Aktualizacja 2 mln trójkątów na każdy kadr to coś, czego z pewnością nie chcesz robić za pomocą JavaScript. Na szczęście mamy WebGL, który pozwala nam korzystać z niesamowitej mocy nowoczesnych kart graficznych. A 2 mln animowanych trójkątów to całkiem wykonalne zadanie przy użyciu nowoczesnego procesora graficznego i odrobiny magii shaderów.

Pisanie wydajnego kodu WebGL

Pisanie wydajnego kodu WebGL wymaga odpowiedniego podejścia. Zwykle rysowanie za pomocą WebGL polega na konfigurowaniu uniformów, buforów i shaderów dla każdego obiektu, a potem na wywołaniu funkcji rysowania obiektu. Ten sposób rysowania działa, gdy rysujesz niewielką liczbę obiektów. Aby narysować dużą liczbę obiektów, należy zminimalizować liczbę zmian stanu WebGL. Na początek narysuj wszystkie obiekty, używając tego samego shadera, aby nie trzeba było zmieniać shaderów między obiektami. W przypadku prostych obiektów, takich jak cząsteczki, możesz umieścić kilka obiektów w jednym buforze i zmodyfikować go za pomocą JavaScriptu. W ten sposób wystarczy przesłać ponownie bufor wierzchołków, zamiast zmieniać shadery dla każdej cząsteczki.

Aby jednak uzyskać naprawdę dużą szybkość, musisz przekazać większość obliczeń do shaderów. Tego właśnie próbuję dokonać. Animuj milion liter za pomocą shaderów.

Kod w tym artykule korzysta z biblioteki Three.js, która abstrahuje wszystkie żmudne elementy szablonowe od pisania kodu WebGL. Zamiast pisać setki linii kodu do konfigurowania stanu WebGL i obsługi błędów, w Three.js wystarczy napisać kilka linii kodu. Możesz też łatwo korzystać z systemu shaderów WebGL z Three.js.

Rysowanie wielu obiektów za pomocą jednego wywołania funkcji draw

Oto krótki przykład pseudokodu, który pokazuje, jak można narysować wiele obiektów za pomocą jednego wywołania metody draw(). Tradycyjnie rysuje się po jednym obiekcie naraz:

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

Jednak metoda opisana powyżej wymaga osobnego wywołania draw dla każdego obiektu. Aby narysować wiele obiektów jednocześnie, możesz umieścić je w jednej geometrii i wystarczy jedno wywołanie funkcji draw:

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

Teraz, gdy znasz już podstawy, wróćmy do pisania wersji demonstracyjnej i zacznij animować te miliony liter.

Konfigurowanie geometrii i tekstur

Najpierw utwórz teksturę z bitmapami liter. Do tego zadania użyję obszaru roboczego 2D. Uzyskana tekstura zawiera wszystkie litery, które chcę narysować. Kolejnym krokiem jest utworzenie bufora z współrzędnymi tekstury do arkusza sprite’ów z literami. Chociaż jest to łatwa i prosta metoda konfigurowania liter, jest ona nieco nieefektywna, ponieważ używa 2 liczb zmiennoprzecinkowych na wierzchołek do określenia współrzędnych tekstury. Krótszą metodą (zostawiliśmy ją jako ćwiczenie dla czytelnika) jest zapakowanie indeksu litery i indeksu narożnika w jedną liczbę i przekształcenie jej z powrotem w współrzędne tekstury w shaderze wierzchołka.

Oto jak tworzymy teksturę litery w 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;

Przesyłam też tablicę trójkątów do GPU. Te wierzchołki są używane przez shader wierzchołkowy do umieszczania liter na ekranie. Wierzchołki są ustawione na pozycje liter w tekście, więc jeśli renderujesz tablicę trójkątów w postaci, otrzymujesz podstawowy układ renderowania tekstu.

Tworzenie geometrii fotoksiążki:

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

Shader wierzchołkowy do animowania liter

Przy użyciu prostego shadera wierzchołkowego tekst jest wyświetlany płasko. Nic specjalnego. Działa dobrze, ale jeśli chcę go animować, muszę użyć do tego JavaScriptu. Ponadto JavaScript jest dość powolny, jeśli chodzi o animowanie 6 mln wierzchołków, zwłaszcza jeśli chcesz to robić w każdej klatce. Może istnieje szybszy sposób.

Oczywiście, możemy użyć animacji proceduralnej. Oznacza to, że wszystkie obliczenia dotyczące pozycji i obrotu wykonujemy w shaderze wierzchołka. Teraz nie muszę uruchamiać żadnego kodu JavaScript, aby zaktualizować pozycje wierzchołków. Shader wierzchołkowy działa bardzo szybko i udaje mi się uzyskać płynną liczbę klatek na sekundę nawet przy milionie trójkątów animowanych indywidualnie w każdej klatce. Aby wskazać poszczególne trójkąty, zaokrąglam w dół współrzędne wierzchołków, tak aby wszystkie 4 punkty kwadratu literowego były mapowane na jedną unikalną współrzędną. Teraz mogę użyć tej współrzędnej, aby ustawić parametry animacji dla danej litery.

Aby można było zaokrąglić współrzędne w dół, współrzędne z 2 różnych liter nie mogą się nakładać. Najłatwiej jest użyć kwadratowych bloków liter z niewielkim przesunięciem litery od tej po prawej stronie i od linii nad nią. Możesz na przykład użyć szerokości i wysokości równej 0, 5 dla liter i wyrównać je do współrzędnych całkowitych. Gdy zaokrąglisz w dół współrzędne wierzchołka litery, otrzymasz współrzędne lewego dołu litery.

Zaokrąglenie w dół współrzędnych wierzchołka, aby znaleźć lewy górny róg litery.
Zaokrąglanie współrzędnych wierzchołka w dół, aby znaleźć lewy górny róg litery.

Aby lepiej zrozumieć animowany shader wierzchołkowy, najpierw omówię prosty, standardowy shader wierzchołkowy. Zwykle tak się dzieje, gdy rysujesz model 3D na ekranie. Wierzchołki modelu są przekształcane za pomocą pary macierzy przekształceń, aby rzutować każdy wierzchołek 3D na ekran 2D. Gdy trójkąt zdefiniowany przez 3 takie wierzchołki znajdzie się w widoku, piksele, które pokrywa, są przetwarzane przez fragment shadera w celu nadania im koloru. Oto prosty shader wierzchołkowy:

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

A teraz animowany shader wierzchołkowy. Zasadniczo działa on tak samo jak prosty shader wierzchołkowy, ale z niewielką różnicą. Zamiast przekształcać każdy wierzchołek tylko za pomocą macierzy przekształcenia, stosuje też animację zależną od czasu. Aby każda litera animowała się nieco inaczej, animowany shader wierzchołka modyfikuje animację na podstawie współrzędnych litery. Będzie wyglądać znacznie bardziej skomplikowanie niż prosty shader wierzchołkowy, ponieważ jest bardziej skomplikowany.

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

Aby użyć shadera wierzchołka, używam THREE.ShaderMaterial, czyli typu materiału, który umożliwia korzystanie z niestandardowych shaderów i określanie ich uniformów. W demo używam biblioteki THREE.ShaderMaterial w taki sposób:

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

W każdym ujęciu animacji aktualizuję uniformy shadera:

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

I to jest właśnie animacja na podstawie shadera. Wygląda to dość skomplikowanie, ale jedyne, co robi, to przemieszczanie liter w sposób zależny od bieżącej godziny i indeksu każdej litery. Jeśli nie zależy Ci na wydajności, możesz użyć tej logiki w JavaScript. Jednak w przypadku dziesiątek tysięcy animowanych obiektów JavaScript przestaje być odpowiednim rozwiązaniem.

Pozostałe problemy

Jednym z problemów jest to, że JavaScript nie zna pozycji cząstek. Jeśli naprawdę musisz wiedzieć, gdzie znajdują się cząsteczki, możesz zduplikować logikę shadera wierzchołka w JavaScriptie i zawsze, gdy potrzebujesz pozycji wierzchołka, ponownie obliczać ich pozycje za pomocą web workera. Dzięki temu wątki renderowania nie muszą czekać na obliczenia, a Ty możesz kontynuować animację z płynną liczbą klatek.

Aby uzyskać większą kontrolę nad animacją, możesz użyć funkcji renderowania do tekstury, aby animować między 2 zestawami pozycji dostarczonych przez JavaScript. Najpierw wyrenderuj bieżące pozycje w teksturze, a potem animuj je w kierunku pozycji zdefiniowanych w oddzielnej teksturze dostarczonej przez JavaScript. W tym przypadku możesz aktualizować niewielką część pozycji dostarczanych przez JavaScript w każdej klatce i nadal animować wszystkie litery w każdej klatce za pomocą shadera wierzchołkowego, który tworzy płynne przejścia między pozycjami.

Kolejnym problemem jest to, że 256 znaków to za mało na teksty niebędące w alfabecie ASCII. Jeśli zwiększysz rozmiar mapy tekstur do 4096 x 4096, a zmniejszysz rozmiar czcionki do 8 pikseli, możesz umieścić na mapie tekstur cały zestaw znaków UCS-2. Rozmiar czcionki 8 pikseli jest jednak mało czytelny. Aby uzyskać większe rozmiary czcionek, możesz użyć kilku tekstur. Przykładem jest ta prezentacja atlasu sprite’ów. Innym sposobem jest utworzenie tylko tych liter, które występują w Tekście.

Podsumowanie

W tym artykule opisaliśmy implementację animacji opartej na shaderze wierzchołkowym za pomocą Three.js. Demonstracja animuje milion liter w czasie rzeczywistym na MacBooku Air z 2010 roku. W ramach implementacji całą książkę zapakowano w jeden obiekt geometryczny, aby umożliwić wydajne rysowanie. Poszczególne litery zostały animowane przez określenie, które wierzchołki należą do której litery, i animowanie wierzchołków na podstawie indeksu litery w tekście książki.

Pliki referencyjne