簡介
本文的目標是要以平滑的影格速率在螢幕上繪製一百萬個動畫字母。新型 GPU 應該就能執行這項工作。每個字母都是由兩個具有紋理的三角形組成,因此每個畫面只會呈現 200 萬個三角形。
如果您從傳統 JavaScript 動畫背景,這些聽起來看起來很瘋狂。每個畫面會更新 200 萬個三角形,這絕對不是您想在 JavaScript 方面做的事。幸好我們擁有 WebGL,可讓我們運用強大的現代 GPU。只要使用現代 GPU 和一些著色器魔術,便很適合使用 200 萬個動畫三角形。
編寫高效率的 WebGL 程式碼
編寫有效的 WebGL 程式碼需要特定思維。使用 WebGL 繪圖的一般方法,是為每個物件設定統一變數、緩衝區和著色器,然後呼叫繪製物件。這種繪圖方式是在繪製少量物件時運作。如要繪製大量物件,建議您將 WebGL 狀態的變更次數降至最低。首先,使用相同的著色器繪製所有物件,這樣您就不必在物件之間變更著色器。對於粒子等簡易物件,您可以將多個物件組合成單一緩衝區,然後使用 JavaScript 編輯。這樣一來,您就只需要重新上傳頂點緩衝區,不必變更每個粒子的著色器製服。
但為了加快速度,您必須將大部分的運算作業推入著色器。這就是我現在要做的事。使用著色器以動畫呈現一百萬個字母。
報導的程式碼使用 Three.js 程式庫,將撰寫 WebGL 程式碼時不必再處理所有繁瑣的樣板。使用 Three.js 即可直接編寫幾行程式碼,而無需編寫 WebGL 狀態設定和錯誤處理等上百行程式碼。您也可以透過 Three.js 輕鬆使用 WebGL 著色器系統。
使用單一繪製呼叫繪製多個物件
以下是一個小型的虛擬程式碼範例,說明如何使用單一繪製呼叫繪製多個物件。傳統的做法是一次繪製一個物件,如下所示:
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);
不過,上述方法需要針對每個物件個別發出繪製呼叫。如要一次繪製多個物件,您可以將物件打包成單一幾何圖形,然後憑一次繪製呼叫即可離開。
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);
瞭解基本概念後,讓我們回到撰寫示範模式,並開始為這些 100 萬個信件加上動畫效果!
設定幾何圖形和紋理
首先,我要在圖片上使用字母點陣圖建立紋理。但我使用的是 2D 畫布產生的紋理含有我想繪製的所有字母。下一步是使用紋理座標工作表建立具有紋理座標的緩衝區。雖然這個字母是設定字母的簡單簡單方法,但由於系統會在紋理座標使用每個頂點使用兩個浮點數,因此較為浪費。較短的方法是將字母索引和邊角索引封裝到一個數字,然後在頂點著色器中轉換回紋理座標,以便給讀者進行學習。
以下說明如何使用 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;
我也會將三角形陣列上傳至 GPU頂點著色器會使用這些頂點在螢幕上放置字母。頂點會設定為文字中的字母位置,所以如果您是按原樣算繪三角形陣列,就會得到文字的基本版面配置轉譯。
建立書籍的幾何圖形:
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 著色器
只要使用簡單的頂點著色器,就能以平面式的方式查看文字沒什麼特別的。執行效果不錯,但如果想製作動畫效果,就一定要使用 JavaScript 製作動畫。JavaScript 對涉及 600 萬個頂點的動畫來說較為緩慢,尤其是當您想在每個頁框執行動畫時。也許有更快的方法。
當然,我們可以使用程序動畫。也就是說,我們會在頂點著色器中執行所有位置和旋轉數學運算。現在我不需要執行任何 JavaScript 即可更新端點的位置。頂點著色器執行速度非常快,我也能獲得順暢的影格速率,就算每個畫面都有一百萬個三角形,也不例外。如要處理個別三角形的問題,我將頂點座標四捨五入,這樣一來,字母四點的全部四個點就可以對應到一個獨特的座標。現在,我利用這個座標,設定相關字母的動畫參數。
如要成功將座標四捨五入,兩個不同字母的座標不得重疊。最簡單的方式就是使用正方形英文字母 Quads,並利用小偏移量分隔字母、右側垂直和上方的線條。舉例來說,你可以將寬度和高度設為 0.5 字母,並對齊整數座標上的字母。現在,將任何字母頂點的座標無條件捨去,您就會取得字母左下角的座標。
為了進一步瞭解動畫頂點著色器,我先透過簡易的毫頂點執行著色器進行操作。這種情況通常發生在您在螢幕上繪製 3D 模型時。模型的頂點是由兩個轉換矩陣轉換,以將每個 3D 頂點投影至 2D 螢幕。每當這些頂點由三個頂點定義的三角形進入可視區域,片段著色器就會處理其所涵蓋的像素進行上色。總之,這是簡單的頂點著色器:
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;
}
現在是動畫頂點著色器基本上,這與簡單的頂點著色器相同,但有細微的改變。它不會只透過轉換矩陣轉換每個頂點,而是應用時間相依動畫轉換。如要讓每個字母的動畫呈現不同的動畫效果,動畫的頂點著色器也會根據字母座標修改動畫。比起簡單的頂點著色器,它看起來會更複雜,因為它也較為複雜。
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;
}
如要使用頂點著色器,我使用 THREE.ShaderMaterial
,一種材質類型,可讓您使用自訂著色器並指定其樣式。以下說明如何在示範中使用 THREE.ShaderMaterial:
// 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;
我會為每個動畫影格更新著色器製度:
// 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());
這樣這就是著色器式的動畫。這看起來有點複雜,但唯一其實是根據目前時間和每個字母的索引的方式來移動字母。如果擔心效能問題,您可以讓這個邏輯在 JavaScript 中執行。然而,在成千上萬個動畫物件中,JavaScript 不再是可行的解決方案。
其餘疑慮
一個問題是,JavaScript 無法得知粒子位置。如果您真的需要知道粒子的位置,可以在 JavaScript 中複製頂點著色器邏輯,並在需要位置時使用網路工作站重新計算頂點位置。如此一來,轉譯執行緒就不必費時計算數學任務,還能以流暢的影格速率繼續動畫。
如需更多可控制的動畫,您可以使用轉譯轉紋理功能,在 JavaScript 提供的兩組位置之間建立動畫。首先,將目前位置算繪到紋理,然後為 JavaScript 所提供個別紋理中定義的位置建立動畫。更棒的是,您可以更新每個頁框提供的一小部分 JavaScript 位置,並繼續使用每個頁框的頂點著色器旋轉位置,以使每個頁框的所有字母動畫化。
另一個疑慮是 256 個字元太少,無法處理非 ASCII 文字。如果將紋理貼圖尺寸推送到 4096x4096,同時將字型大小降至 8px,則可將整個 UCS-2 字元集填滿紋理貼圖。但 8px 字型大小並不容易閱讀。如要放大字型,可以使用多種紋理組合。如需相關範例,請參閱這個 Sprite 地圖集示範。除此之外,建議您只建立文字中使用的字母。
摘要
本文將逐步引導您使用 Three.js 實作以頂點著色器為基礎的動畫示範。這個示範模式會在 2010 年 MacBook Air 上即時以一百萬個字母製作動畫效果。實作方法會將整本書籍納入單一幾何圖形物件,提升繪圖效率。如要找出個別字母的動畫效果,可辨識各個字母所屬的字母,並根據書籍內文中的字母索引為端點加上動畫效果。