Pengantar
Sasaran saya dalam artikel ini adalah menggambar satu juta huruf animasi di layar dengan kecepatan frame yang lancar. Tugas ini seharusnya cukup mungkin dilakukan dengan GPU modern. Setiap huruf terdiri dari dua segitiga bertekstur, jadi kita hanya berbicara tentang dua juta segitiga per frame.
Jika Anda berasal dari latar belakang animasi JavaScript tradisional, semua ini terdengar seperti kegilaan. Dua juta segitiga yang diperbarui setiap frame jelas bukan sesuatu yang ingin Anda lakukan dengan JavaScript saat ini. Namun, untungnya kita memiliki WebGL, yang memungkinkan kita memanfaatkan kecanggihan GPU modern. Dan dua juta segitiga animasi cukup dapat dilakukan dengan GPU modern dan beberapa keajaiban shader.
Menulis kode WebGL yang efisien
Menulis kode WebGL yang efisien memerlukan pola pikir tertentu. Cara biasa untuk menggambar menggunakan WebGL adalah menyiapkan seragam, buffer, dan shader untuk setiap objek, diikuti dengan panggilan untuk menggambar objek. Cara menggambar ini berfungsi saat menggambar sejumlah kecil objek. Untuk menggambar objek dalam jumlah besar, Anda harus meminimalkan jumlah perubahan status WebGL. Untuk memulai, gambar semua objek menggunakan shader yang sama satu per satu, sehingga Anda tidak perlu mengubah shader di antara 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 vertex, bukan mengubah seragam shader untuk setiap partikel.
Namun, untuk melakukannya dengan sangat cepat, Anda harus mendorong sebagian besar komputasi ke shader. Itulah yang saya coba lakukan di sini. Menganimasikan jutaan huruf menggunakan shader.
Kode artikel menggunakan library Three.js, yang memisahkan semua boilerplate yang merepotkan dari penulisan kode WebGL. Daripada harus menulis ratusan baris penyiapan status WebGL dan penanganan error, dengan Three.js, Anda hanya perlu menulis beberapa baris kode. Anda juga dapat memanfaatkan sistem shader WebGL dari Three.js dengan mudah.
Menggambar beberapa objek menggunakan satu panggilan gambar
Berikut adalah contoh pseudo-kode kecil tentang cara menggambar beberapa objek menggunakan satu panggilan gambar. Cara tradisionalnya adalah menggambar satu objek pada 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 menggunakan 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 memiliki ide dasar, mari kita kembali menulis demo dan mulai menganimasikan jutaan huruf tersebut.
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 berikutnya adalah membuat buffer dengan koordinat tekstur ke sheet sprite huruf. Meskipun ini adalah metode yang mudah dan sederhana untuk menyiapkan huruf, metode ini agak boros karena menggunakan dua float per vertex untuk koordinat tekstur. Cara yang lebih singkat - yang dibiarkan sebagai latihan bagi pembaca - adalah dengan memaketkan indeks huruf dan indeks sudut menjadi satu angka, lalu mengonversinya kembali menjadi koordinat tekstur di shader vertex.
Berikut cara membuat 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. Vektor ini digunakan oleh vertex shader untuk menempatkan huruf di layar. Vertix ditetapkan ke posisi huruf dalam teks sehingga jika Anda merender array segitiga apa adanya, Anda akan mendapatkan rendering tata letak dasar teks.
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++;
}
}
Vertex shader untuk menganimasikan huruf
Dengan shader vertex sederhana, saya mendapatkan tampilan teks yang datar. Tidak ada yang istimewa. Berjalan dengan baik, tetapi jika ingin menganimasinya, saya harus melakukan animasi di JavaScript. Selain itu, JavaScript agak lambat untuk menganimasikan enam juta vertex yang terlibat, terutama jika Anda ingin melakukannya di setiap frame. Mungkin ada cara yang lebih cepat.
Ya, kita dapat melakukan animasi prosedural. Artinya, kita melakukan semua matematika posisi dan rotasi di shader vertex. Sekarang saya tidak perlu menjalankan JavaScript apa pun untuk memperbarui posisi vertex. Vertex shader berjalan sangat cepat dan saya mendapatkan kecepatan frame yang lancar meskipun dengan satu juta segitiga yang dianimasikan satu per satu setiap frame. Untuk menangani setiap segitiga, saya membulatkan koordinat vertex ke bawah sehingga keempat titik dari empat persegi huruf dipetakan ke satu koordinat unik. Sekarang saya dapat menggunakan koordinat ini untuk menetapkan parameter animasi untuk huruf yang dimaksud.
Agar dapat membulatkan koordinat dengan benar, koordinat dari dua huruf yang berbeda tidak boleh tumpang-tindih. Cara termudah untuk melakukannya adalah dengan menggunakan empat persegi huruf persegi dengan offset kecil yang memisahkan huruf dari huruf di sisi kanannya dan baris di atasnya. Misalnya, Anda dapat menggunakan lebar dan tinggi 0,5 untuk huruf dan meratakan huruf pada koordinat bilangan bulat. Sekarang, saat membulatkan koordinat vertex huruf, Anda akan mendapatkan koordinat kiri bawah huruf.
Untuk lebih memahami vertex shader animasi, saya akan membahas vertex shader biasa yang sederhana terlebih dahulu. Hal ini biasanya terjadi saat Anda menggambar model 3D ke layar. Vertix model ditransformasikan oleh beberapa matriks transformasi untuk memproyeksikan setiap vertex 3D ke layar 2D. Setiap kali segitiga yang ditentukan oleh tiga verteks ini berada di dalam area pandang, piksel yang dicakupnya akan diproses oleh shader fragmen untuk mewarnainya. Ngomong-ngomong, berikut adalah shader vertex 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;
}
Sekarang, shader vertex animasi. Pada dasarnya, shader ini melakukan hal yang sama seperti shader vertex sederhana, tetapi dengan sedikit modifikasi. Alih-alih mengubah setiap vertex hanya dengan matriks transformasi, transformasi ini juga menerapkan transformasi animasi yang bergantung pada waktu. Agar setiap huruf dianimasikan dengan sedikit perbedaan, shader vertex animasi juga mengubah animasi berdasarkan koordinat huruf. Tampilannya akan terlihat jauh lebih rumit daripada shader vertex sederhana karena, shader ini memang 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 vertex, saya menggunakan THREE.ShaderMaterial
, jenis material yang memungkinkan Anda menggunakan shader kustom dan menentukan seragam untuknya. Berikut 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;
Pada setiap frame animasi, saya memperbarui seragam 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());
Dan begitulah, animasi berbasis shader. Tampaknya cukup rumit, tetapi satu-satunya hal yang benar-benar 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, dengan puluhan ribu objek animasi, JavaScript tidak lagi menjadi solusi yang layak.
Masalah lainnya
Satu masalah sekarang adalah JavaScript tidak mengetahui posisi partikel. Jika benar-benar perlu mengetahui posisi partikel, Anda dapat menduplikasi logika shader vertex di JavaScript dan menghitung ulang posisi vertex menggunakan pekerja web setiap kali Anda memerlukan posisi tersebut. Dengan begitu, thread rendering Anda tidak perlu menunggu matematika dan Anda dapat terus menganimasikan dengan kecepatan frame yang lancar.
Untuk animasi yang lebih mudah dikontrol, Anda dapat menggunakan fungsi render-to-texture untuk menganimasikan antara dua kumpulan posisi yang disediakan oleh JavaScript. Pertama, render posisi saat ini ke tekstur, lalu animasi ke posisi yang ditentukan dalam tekstur terpisah yang disediakan oleh JavaScript. Kelebihannya adalah Anda dapat memperbarui sebagian kecil posisi yang disediakan JavaScript per frame dan tetap dapat terus menganimasikan semua huruf setiap frame dengan vertex shader yang melakukan tweening posisi.
Masalah lainnya adalah 256 karakter terlalu sedikit untuk teks non-ASCII. Jika mendorong ukuran peta tekstur ke 4096x4096 sekaligus mengurangi ukuran font menjadi 8 piksel, Anda dapat menyesuaikan seluruh kumpulan karakter UCS-2 ke dalam peta tekstur. Namun, ukuran font 8 piksel tidak terlalu mudah dibaca. Untuk membuat ukuran font yang lebih besar, Anda dapat menggunakan beberapa tekstur untuk font. Lihat demo atlas sprite ini untuk mengetahui contohnya. Hal lain yang akan membantu adalah hanya membuat huruf yang digunakan dalam teks Anda.
Ringkasan
Dalam artikel ini, saya telah memandu Anda dalam menerapkan demo animasi berbasis vertex shader menggunakan Three.js. Demo ini menganimasikan satu juta huruf secara real time di MacBook Air 2010. Implementasi ini memaketkan seluruh buku ke dalam satu objek geometri untuk menggambar secara efisien. Setiap huruf dianimasikan dengan mencari tahu mana vertex yang termasuk dalam huruf mana dan menganimasikan vertex berdasarkan indeks huruf dalam teks buku.