Menganimasikan satu juta huruf menggunakan Three.js

Ilmari Heikkinen

Pengantar

Tujuan saya dalam artikel ini adalah menggambar satu juta huruf animasi di layar pada kecepatan frame yang lancar. Tugas ini seharusnya dapat dilakukan dengan GPU modern. Setiap huruf terdiri dari dua segitiga bertekstur. Jadi, kita hanya berbicara tentang dua juta segitiga per bingkai.

Jika Anda menggunakan latar belakang animasi JavaScript tradisional, semua ini terdengar seperti kegilaan. Dua juta segitiga memperbarui setiap bingkai bukanlah sesuatu yang ingin Anda lakukan dengan JavaScript saat ini. Namun untungnya kita memiliki WebGL, yang memungkinkan kita memanfaatkan kecanggihan GPU modern yang luar biasa. Selain itu, dua juta segitiga animasi cukup bisa dilakukan dengan GPU modern dan keajaiban shader.

Menulis kode WebGL yang efisien

Menulis kode WebGL yang efisien memerlukan pola pikir tertentu. Cara umum untuk menggambar menggunakan WebGL adalah dengan menyiapkan uniform, buffer dan shader Anda untuk setiap objek, diikuti dengan panggilan untuk menggambar objek. Cara menggambar ini berfungsi saat menggambar objek dalam jumlah kecil. Untuk menggambar objek dalam jumlah besar, Anda harus meminimalkan jumlah perubahan status WebGL. Untuk memulainya, gambar semua objek menggunakan shader yang sama, sehingga Anda tidak perlu mengubah shader antar objek. Untuk objek sederhana seperti partikel, Anda dapat memaketkan beberapa objek ke dalam satu buffer dan mengeditnya menggunakan JavaScript. Dengan begitu, Anda hanya perlu mengupload ulang buffer verteks, bukan mengubah seragam shader untuk setiap partikel.

Namun, agar dapat bekerja dengan sangat cepat, Anda perlu mendorong sebagian besar komputasi ke shader. Itulah yang saya coba lakukan di sini. Menganimasikan satu juta huruf menggunakan shader.

Kode artikel menggunakan library Three.js, yang memisahkan semua boilerplate yang membosankan dari penulisan kode WebGL. Daripada harus menulis ratusan baris penyiapan status WebGL dan penanganan error, Anda hanya perlu menulis beberapa baris kode dengan Three.js. Sangat mudah untuk memanfaatkan sistem shader WebGL dari Three.js.

Menggambar beberapa objek menggunakan satu panggilan gambar

Berikut adalah contoh kode pseudo kecil tentang cara menggambar beberapa objek menggunakan satu panggilan gambar. Cara tradisionalnya adalah menggambar satu objek dalam satu waktu seperti ini:

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

Namun, metode di atas memerlukan panggilan gambar terpisah untuk setiap objek. Untuk menggambar beberapa objek sekaligus, Anda dapat memaketkan objek ke dalam satu geometri dan keluar dengan satu panggilan gambar:

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

Baiklah, setelah Anda mendapatkan ide dasarnya, mari kita kembali menulis demo dan mulai menganimasikan jutaan huruf itu.

Menyiapkan geometri dan tekstur

Sebagai langkah pertama, saya akan membuat tekstur dengan bitmap huruf di atasnya. Saya menggunakan kanvas 2D untuk ini. Tekstur yang dihasilkan memiliki semua huruf yang ingin saya gambar. Langkah selanjutnya adalah membuat buffer dengan koordinat tekstur pada huruf sprite sheet. Meskipun mudah dan sederhana untuk menyiapkan huruf, metode ini sedikit boros karena menggunakan dua float per verteks untuk koordinat tekstur. Cara yang lebih singkat - yang dilakukan untuk melatih pembaca - adalah dengan memaketkan indeks huruf dan indeks sudut menjadi satu angka dan mengubahnya kembali menjadi koordinat tekstur dalam shader verteks.

Berikut adalah cara saya membangun tekstur huruf menggunakan 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;

Saya juga mengupload array segitiga ke GPU. Verteks ini digunakan oleh shader verteks untuk menempatkan huruf di layar. Verteks disetel ke posisi huruf dalam teks sehingga jika Anda merender array segitiga apa adanya, Anda akan mendapatkan rendering tata letak dasar dari teks tersebut.

Membuat geometri untuk buku:

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 verteks untuk menganimasikan huruf

Dengan shader verteks sederhana, saya mendapatkan tampilan datar dari teks. Tidak ada yang mewah. Berjalan dengan baik, tetapi jika ingin menganimasikannya, saya harus melakukan animasinya dengan JavaScript. JavaScript agak lambat untuk menganimasikan enam juta verteks yang terlibat, terutama jika Anda ingin melakukannya di setiap bingkai. Mungkin ada cara yang lebih cepat.

Mengapa ya, kita bisa melakukan animasi sesuai prosedur. Artinya, kita melakukan semua perhitungan posisi dan rotasi dalam shader verteks. Sekarang saya tidak perlu menjalankan JavaScript untuk memperbarui posisi verteks. Shader verteks berjalan sangat cepat dan saya mendapatkan kecepatan frame yang lancar bahkan dengan satu juta segitiga dianimasikan satu per satu setiap frame. Untuk menangani setiap segitiga, saya membulatkan ke bawah koordinat verteks sehingga keempat titik huruf segi empat dipetakan ke satu koordinat yang unik. Sekarang saya dapat menggunakan koordinat ini untuk mengatur parameter animasi untuk huruf yang dimaksud.

Agar berhasil membulatkan koordinat ke bawah, koordinat dari dua huruf yang berbeda tidak boleh tumpang tindih. Cara termudah untuk melakukannya adalah dengan menggunakan segi empat huruf persegi dengan offset kecil yang memisahkan huruf dari huruf di sisi kanan dan garis di atasnya. Misalnya, Anda dapat menggunakan lebar dan tinggi 0,5 untuk huruf dan menyelaraskan huruf pada koordinat bilangan bulat. Sekarang, saat Anda membulatkan koordinat verteks huruf ke bawah, Anda akan mendapatkan koordinat kiri bawah huruf.

Membulatkan koordinat verteks untuk menemukan sudut kiri atas huruf.
Membulatkan koordinat verteks ke bawah untuk menemukan sudut kiri atas huruf.

Untuk lebih memahami shader verteks animasi, saya akan membahas shader verteks sederhana terlebih dahulu. Inilah yang biasanya terjadi saat Anda menggambar model 3D ke layar. Verteks model diubah oleh beberapa matriks transformasi untuk memproyeksikan setiap verteks 3D ke layar 2D. Setiap kali segitiga yang didefinisikan oleh tiga verteks ini mendarat di dalam area pandang, piksel yang dicakup akan diproses oleh shader fragmen untuk mewarnainya. Bagaimanapun, berikut ini adalah shader verteks sederhana:

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

Dan sekarang, shader verteks animasi. Pada dasarnya, ia melakukan hal yang sama seperti shader verteks sederhana, tetapi dengan sedikit sentuhan. Alih-alih mengubah setiap verteks hanya dengan matriks transformasi, fitur ini juga menerapkan transformasi animasi yang bergantung pada waktu. Agar setiap huruf dianimasikan dengan sedikit berbeda, shader verteks animasi juga mengubah animasi berdasarkan koordinat huruf. Ini akan terlihat jauh lebih rumit daripada shader verteks sederhana karena, yah, ini lebih rumit.

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

Untuk menggunakan shader verteks, saya menggunakan THREE.ShaderMaterial, jenis material yang memungkinkan Anda menggunakan shader kustom dan menentukan seragam untuk shader tersebut. Inilah cara saya menggunakan THREE.ShaderMaterial dalam 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;

Di setiap frame animasi, saya memperbarui uniform shader:

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

Nah, animasi berbasis shader. Terlihat cukup rumit, tetapi satu-satunya yang dilakukan adalah memindahkan huruf dengan cara yang bergantung pada waktu saat ini dan indeks setiap huruf. Jika performa tidak menjadi masalah, Anda dapat menjalankan logika ini di JavaScript. Namun, pada puluhan ribu objek animasi, JavaScript berhenti menjadi solusi yang tepat.

Masalah lainnya

Salah satu masalahnya adalah JavaScript tidak mengetahui posisi partikel. Jika benar-benar perlu mengetahui lokasi partikel, Anda dapat menduplikasi logika shader verteks di JavaScript dan menghitung ulang posisi verteks menggunakan pekerja web setiap kali Anda memerlukan posisi. Dengan begitu, thread rendering tidak perlu menunggu perhitungan dan Anda dapat terus menganimasikan pada kecepatan frame yang lancar.

Untuk animasi yang lebih mudah dikontrol, Anda bisa menggunakan fungsi render-to-texture untuk menganimasikan antara dua set posisi yang disediakan oleh JavaScript. Pertama, render posisi saat ini ke tekstur, lalu animasikan ke posisi yang ditentukan dalam tekstur terpisah yang disediakan oleh JavaScript. Keuntungannya, Anda bisa memperbarui sebagian kecil posisi yang disediakan JavaScript per frame dan tetap terus menganimasikan semua huruf di setiap frame dengan shader verteks yang melakukan tweening posisi.

Kekhawatiran lainnya adalah 256 karakter terlalu sedikit untuk teks non-ASCII. Jika mendorong ukuran peta tekstur ke 4096x4096 sekaligus mengurangi ukuran font menjadi 8 px, Anda dapat memasukkan seluruh set karakter UCS-2 ke dalam peta tekstur. Namun, ukuran font 8px tidak terlalu mudah dibaca. Untuk melakukan ukuran font yang lebih besar, Anda dapat menggunakan beberapa tekstur untuk font Anda. Lihat demo sprite atlas ini sebagai contoh. Hal lain yang akan membantu adalah membuat hanya huruf yang digunakan dalam teks.

Ringkasan

Dalam artikel ini, saya akan memandu Anda dalam menerapkan demo animasi berbasis shader verteks menggunakan Three.js. Demo menganimasikan satu juta huruf secara waktu nyata pada MacBook Air 2010. Implementasi ini memaketkan seluruh buku ke dalam satu objek geometri untuk menggambar secara efisien. Masing-masing huruf dianimasikan dengan mencari tahu verteks mana yang termasuk dalam huruf yang mana dan menganimasikan verteks berdasarkan indeks huruf dalam teks buku.

Referensi