Animowanie miliona liter przy użyciu biblioteki Three.js

Ilmari Heikkinen

Wstęp

Moim celem w tym artykule jest narysowanie miliona animowanych liter na ekranie z płynną liczbą klatek. To powinno być w ogóle możliwe przy współczesnych procesorach graficznych. Każda litera składa się z 2 teksturowanych trójkątów, więc mówimy o 2 milionach trójkątów na klatkę.

Jeśli używasz tradycyjnego tła animacji JavaScript, to wszystko brzmi jak szaleństwo. W przypadku JavaScriptu nie da się obecnie korzystać z dwóch milionów trójkątów w każdej klatce. Na szczęście mamy WebGL, który pozwala nam wykorzystać niesamowitą moc nowoczesnych GPU. Przy użyciu nowoczesnego GPU i magii cieniowania da się to zrobić 2 miliony animowanych trójkątów.

Napisanie wydajnego kodu WebGL

Tworzenie skutecznego kodu WebGL wymaga określonego nastawienia. Typowym sposobem rysowania za pomocą WebGL jest skonfigurowanie mundurków, buforów i narzędzi do cieniowania dla każdego obiektu, a następnie wywołanie funkcji rysowania. Ten sposób rysowania działa podczas rysowania niewielkiej liczby obiektów. Aby narysować dużą liczbę obiektów, należy zminimalizować liczbę zmian stanu WebGL. Na początek narysuj po kolei wszystkie obiekty przy użyciu tego samego programu do cieniowania, aby nie trzeba było ich zmieniać. W przypadku prostych obiektów, takich jak cząstki, możesz połączyć kilka obiektów w jeden bufor i edytować go za pomocą JavaScriptu. Umożliwi to ponowne przesłanie bufora wierzchołków zamiast zmiany uniformów dla każdej cząstki.

Jednak aby przyspieszyć działanie, musisz przenieść większość mocy obliczeniowej do programów do cieniowania. To właśnie spróbuję zrobić. Animuj milion liter za pomocą cieniowania.

W kodzie tego artykułu wykorzystano bibliotekę Three.js, która pozwala uniknąć żmudnego pisania kodu WebGL. Zamiast pisać setki wierszy konfiguracji stanu WebGL i obsługi błędów w tagu Three.js, wystarczy napisać kilka linijek kodu. Można też łatwo skorzystać z systemu cieniowania WebGL z poziomu Three.js.

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

Oto mały, pseudokodowy przykład, jak można narysować wiele obiektów przy użyciu jednego wywołania rysowania. Tradycyjnie rysuj po jednym obiekcie naraz w ten sposób:

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

Powyższa metoda wymaga jednak osobnego wywołania rysowania dla każdego obiektu. Aby narysować wiele obiektów naraz, możesz je zgrupować w jedną geometrię, dzięki czemu da się tego uniknąć jednym wywołaniem rysowania:

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

Skoro znasz już podstawy, możemy wrócić do pisania wersji demonstracyjnej i tworzenia animacji miliona liter.

Ustawianie geometrii i tekstur

Najpierw utworzę teksturę z mapami bitowymi liter. Do tego celu używam kanwy 2D. Otrzymana tekstura zawiera wszystkie litery, które chcę narysować. Następnym krokiem jest utworzenie bufora ze współrzędnymi tekstury dla arkusza sprite z literami. Ta prosta i prosta metoda ustawiania liter jest trochę marna, ponieważ do określenia współrzędnych tekstury używa się dwóch liczb zmiennoprzecinkowych na każdy wierzchołek. Krótszym sposobem, pozostawionym czytelnikom jako ćwiczenie, jest zapakowanie indeksu liter i indeksu narożników w jedną liczbę oraz przekształcenie go z powrotem na współrzędne tekstury w cieniowaniu wierzchołków.

Aby utworzyć teksturę liter za pomocą 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ątną do GPU. Te wierzchołki są używane przez cieniowanie wierzchołków do umieszczania liter na ekranie. Wierzchory są ustawione zgodnie z pozycjami liter w tekście, więc jeśli renderujesz tablicę trójkątną w niezmienionej postaci, tekst jest renderowany w podstawowym układzie.

Tworzenie geometrii książ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++;
  }
}

Program do cieniowania Vertex do animowania liter

Prosty cienier wierzchołków daje płaski widok tekstu. Nic wyrafinowanego. Działa poprawnie, ale jeśli chcę ją animować, muszę zrobić to w JavaScript. Poza tym JavaScript działa wolno w przypadku animowania tych 6 milionów wierzchołków, zwłaszcza jeśli chcesz to zrobić na każdej klatce. Może istnieje szybszy sposób.

Cóż, możemy tworzyć animacje proceduralne. Oznacza to, że wszystkie obliczenia pozycji i obrotu wykonujemy w cieniowaniu wierzchołków. Teraz nie muszę uruchamiać żadnego JavaScriptu, aby zaktualizować pozycje wierzchołków. Oprogramowanie do cieniowania wierzchołków działa bardzo szybko i uzyskuje płynną liczbę klatek, nawet gdy każda klatka jest animowana z osobna milionem trójkątów. Aby odnieść się do poszczególnych trójkątów, zaokrąglam w dół współrzędne wierzchołków, tak aby wszystkie 4 punkty czworokąta przypisano do jednej unikalnej współrzędnych. Mogę teraz użyć tych współrzędnych, by ustawić parametry animacji dla danej litery.

Aby skutecznie zaokrąglać współrzędne, współrzędne 2 różnych liter nie mogą się na siebie nakładać. Najłatwiejszym sposobem jest użycie kwadratowych liter czworokątnych z małym przesunięciem, które oddziela literę od litery po prawej stronie i od linii nad nią. Możesz na przykład użyć szerokości i wysokości liter równych 0, 5 i wyrównać litery we współrzędnych całkowitych. Teraz, gdy zaokrąglisz w dół współrzędną dowolnego wierzchołka litery, otrzymasz współrzędną tej litery w lewym dolnym rogu.

Zaokrąglanie współrzędnych wierzchołków w dół w celu znalezienia lewego górnego rogu litery.
Zaokrąglaj współrzędne wierzchołków w dół, aby znaleźć lewy górny róg litery.

Aby lepiej zrozumieć animowany program do cieniowania wierzchołków, przejdę najpierw do prostego, działającego jak maszyna cieniowania wierzchołków. Dzieje się tak zwykle podczas rysowania modelu 3D na ekranie. Wierki modelu są przekształcane przez kilka macierzy przekształcenia w celu rzutu każdego z nich na ekran 2D. Gdy trójkąt zdefiniowany przez trzy z tych wierzchołków znajdzie się w widocznym obszarze, piksele, które obejmują, są przetwarzane przez cienia fragmentów w celu ich pokolorowania. Tak czy inaczej, oto prosty cieniowanie wierzchołków:

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 cieniowanie wierzchołków. Działa tak samo jak prosty cieniowanie wierzchołków, ale z niewielką nutą. Zamiast przekształcać każdy wierzchołek tylko za pomocą macierzy przekształcenia, zastosowano także animowane przekształcenie czasowe zależne od czasu. Aby każda litera była animowana nieco inaczej, animowany cieninik wierzchołków modyfikuje również animację na podstawie współrzędnych litery. Może się to wydawać o wiele bardziej skomplikowane niż zwykły cieniowanie wierzchołków, bo 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żywać cieniowania wierzchołków, używam THREE.ShaderMaterial – typu materiału, który umożliwia stosowanie niestandardowych cieniowań i określanie dla nich uniformów. Oto jak używam THREE.ShaderMaterial w wersji demonstracyjnej:

// 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żdej klatce animacji aktualizujem uniformy cieniowania:

// 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 gotowe – animacja oparta na cieniowaniu. Wygląda na dość skomplikowany, ale tak naprawdę działa tylko poprzez przesuwanie liter w taki sposób, który zależy od aktualnej godziny i indeksu poszczególnych liter. Jeśli wydajność nie jest problemem, ta logika może działać w JavaScript. Jednak w przypadku dziesiątek tysięcy animowanych obiektów JavaScript przestaje być dobrym rozwiązaniem.

Pozostałe potencjalne problemy

Jednym z problemów jest to, że JavaScript nie wie o położeniu cząstek. Jeśli naprawdę chcesz wiedzieć, gdzie są cząstki, możesz powielić logikę cieniowania w JavaScript i ponownie obliczyć pozycje wierzchołków, korzystając z narzędzia internetowego za każdym razem, gdy ich potrzebujesz. Dzięki temu wątek renderowania nie musi czekać na obliczenia i można kontynuować animację z płynną liczbą klatek.

Aby uzyskać większą kontrolę nad animacją, możesz użyć funkcji renderowania na teksturę, aby animować obiekty między dwoma zestawami pozycji dostarczanych przez JavaScript. Najpierw renderuj aktualne pozycje tekstury, a następnie animuj je w kierunku pozycji zdefiniowanych w osobnej teksturze dostępnej w JavaScript. Zaletą jest to, że możesz zaktualizować niewielki ułamek pozycji podanych przez JavaScript na klatkę, nadal animować wszystkie litery w każdej klatce, używając cieniowania wierzchołków, które zmieniają pozycje.

Innym problemem jest fakt, że 256 znaków to znacznie za mało, aby wprowadzać teksty spoza zestawu ASCII. Po zwiększeniu rozmiaru mapy tekstury do 4096 x 4096 przy jednoczesnym zmniejszeniu rozmiaru czcionki do 8 pikseli można zmieścić na niej cały zestaw znaków UCS-2. Jednak 8-pikselowa czcionka jest nieczytelna. Aby zwiększyć rozmiar czcionki, możesz użyć wielu tekstur. Przykład znajdziesz w tej prezentacji atlasu sprite. Pomocne może być też utworzenie tylko liter używanych w tekście.

Podsumowanie

W tym artykule pokazaliśmy Ci, jak wdrożyć wersję demonstracyjną animacji opartą na wierzchołku do cieniowania za pomocą kodu Three.js. Ta wersja demonstracyjna animuje w czasie rzeczywistym milion liter na MacBooku Air z 2010 roku. Wdrożenie zebrało całą książkę w jeden obiekt geometryczny, aby umożliwić wydajne rysowanie. Poszczególne litery były animowane przez odnalezienie wierzchołków, do których należą poszczególne litery, oraz animowanie wierzchołków na podstawie indeksu litery w tekście książki.

Źródła