מבוא
המטרה שלי במאמר הזה היא לצייר מיליון אותיות מונפשות על המסך בקצב פריימים חלק. אפשר לבצע את המשימה הזו בקלות יחסית באמצעות מעבדי GPU מודרניים. כל אות מורכבת משני משולשים עם טקסטורה, כך שמדובר רק בשני מיליון משולשים לכל פריים.
אם אתם מגיעים מרקע של אנימציות JavaScript מסורתיות, כל זה נשמע כמו טירוף. עדכון של שני מיליון משולשים בכל פריים הוא בהחלט לא משהו שתרצו לעשות עם JavaScript היום. אבל למזלנו יש לנו את WebGL, שמאפשר לנו לנצל את העוצמה המדהימה של מעבדי GPU מודרניים. ואפשר להציג בקלות שני מיליון משולשים מונפשים באמצעות מעבד גרפי (GPU) מודרני וכמה קסמים של שגיאות (shaders).
כתיבת קוד WebGL יעיל
כדי לכתוב קוד WebGL יעיל, צריך גישה מסוימת. הדרך הרגילה לציור באמצעות WebGL היא להגדיר את המדים, המאגרים והשידרוגים לכל אובייקט, ולאחר מכן לבצע קריאה לציור האובייקט. דרך השרטוט הזו מתאימה לשרטוט מספר קטן של אובייקטים. כדי לצייר מספר גדול של אובייקטים, צריך לצמצם את כמות השינויים בסטטוס של WebGL. כדי להתחיל, מציירים את כל האובייקטים בזה אחר זה באמצעות אותו שדרוג (shader), כדי שלא תצטרכו לשנות את השדרוגים בין אובייקטים. באובייקטים פשוטים כמו חלקיקים, אפשר לארוז כמה אובייקטים במאגר אחד ולערוך אותו באמצעות JavaScript. כך תצטרכו להעלות מחדש רק את מאגר הנקודות במקום לשנות את המדים של שפת התכנות (shader) לכל חלקיק בנפרד.
אבל כדי להגיע למהירות גבוהה מאוד, צריך להעביר את רוב החישובים לשכבת השיזוע (shader). זה מה שאני מנסה לעשות כאן. אנימציה של מיליון אותיות באמצעות שיבוטים.
הקוד במאמר משתמש בספרייה Three.js, שמאפשרת להימנע מכתיבה של קוד WebGL סטנדרטי ומייגע. במקום לכתוב מאות שורות של הגדרת מצב WebGL וטיפול בשגיאות, ב-Three.js צריך לכתוב רק כמה שורות קוד. קל גם להשתמש במערכת ה-shader של WebGL מ-Three.js.
ציור של כמה אובייקטים באמצעות קריאה אחת לציור
הנה דוגמה קטנה לפסאודו-קוד שמראה איך אפשר לצייר כמה אובייקטים באמצעות קריאה אחת ל-draw. הדרך המסורתית היא לצייר אובייקט אחד בכל פעם, כך:
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);
עם זאת, בשיטה שלמעלה נדרשת קריאה נפרדת ל-draw לכל אובייקט. כדי לצייר כמה אובייקטים בבת אחת, אפשר לארוז את האובייקטים בגיאומטריה אחת ולבצע קריאה אחת ל-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);
עכשיו, אחרי שהבנתם את הרעיון הבסיסי, נמשיך לכתוב את הדמו ולהוסיף אנימציה למיליון האותיות האלה!
הגדרת הגיאומטריה והטקסטורות
בשלב הראשון, אצור טקסטורה עם הביטים של האותיות. אני משתמש בבד הציור 2D בשביל זה. המרקם שנוצר מכיל את כל האותיות שרציתי לצייר. השלב הבא הוא ליצור מאגר עם קואורדינטות המרקם של גיליון ה-sprite של האות. זוהי שיטה פשוטה וקלה להגדרת האותיות, אבל היא קצת בזבזנית כי היא משתמשת בשני מספרי float לכל קודקוד עבור קואורדינטות המרקם. דרך קצרה יותר – שמיועדת לתרגיל של הקורא – היא לארוז את מדד האות ואת מדד הפינה במספר אחד ולהמיר אותו בחזרה לקואורדינטות של המרקם ב-vertex shader.
כך יצרתי את המרקם של האות באמצעות 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. הקוד של vertex shader משתמש בקודקודים האלה כדי להציב את האותיות במסך. הקודקודים מוגדרים למיקומי האותיות בטקסט, כך שאם תבצעו עיבוד (רנדור) של מערך המשולשים כפי שהוא, תקבלו עיבוד של הפריסה הבסיסית של הטקסט.
יצירת הגיאומטריה של האלבום:
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 ליצירת אנימציה של האותיות
בעזרת פונקציית vertex shader פשוטה, אני מקבל תצוגה רגילה של הטקסט. פשוט. הקוד פועל בצורה תקינה, אבל אם רוצים להוסיף לו אנימציה, צריך ליצור את האנימציה ב-JavaScript. בנוסף, JavaScript איטית יחסית ליצירת אנימציה של ששת מיליון הנקודות המעורבות, במיוחד אם רוצים לעשות זאת בכל פריים. אולי יש דרך מהירה יותר.
כן, אנחנו יכולים ליצור אנימציה פרוצדוראלית. המשמעות היא שאנחנו מבצעים את כל החישובים של המיקום והרוטציה ב-vertex shader. עכשיו אין צורך להריץ שום JavaScript כדי לעדכן את המיקומים של הנקודות. ה-vertex shader פועל מהר מאוד, ויש לי קצב פריימים חלק גם כשמיליון משולשים מונפשים בנפרד בכל פריים. כדי לטפל במשולשים הנפרדים, אני גורם לכך שקואורדינטות הנקודות העליונות של המשולשים יהיו עגולות כלפי מטה, כך שכל ארבע הנקודות של ריבוע האות ימופו לקוואורדינטה ייחודית אחת. עכשיו אפשר להשתמש בקואורדינטה הזו כדי להגדיר את פרמטרים האנימציה של האות הרלוונטית.
כדי שאפשר יהיה לחשב את הערך הקרוב ביותר לשבר שלם, הקואורדינטות משתי אותיות שונות לא יכולות להיות חופפות. הדרך הקלה ביותר לעשות זאת היא להשתמש בקובע של 4 אותיות ריבועיות עם היסט קטן שמפריד בין האות לבין האות שמשמאל לה ולקו שמעליה. לדוגמה, אפשר להשתמש ברוחב ובגובה של 0.5 עבור האותיות וליישר את האותיות לפי קואורדינטות שלמים. עכשיו, כשמגלגלים למטה את הקואורדינטה של כל קודקוד של אות, מקבלים את הקואורדינטה השמאלית התחתונה של האות.
כדי להבין טוב יותר את ה-vertex shader האנימציה, אתחיל בהסבר על vertex shader פשוט רגיל. זה מה שקורה בדרך כלל כשמציירים מודל תלת-ממדי במסך. הקודקודים של המודל עוברים טרנספורמציה באמצעות כמה מטריצות טרנספורמציה כדי להקרין כל קודקוד תלת-ממדי על המסך הדו-ממדי. בכל פעם שמשולש שמוגדר על ידי שלושה מהקודקודים האלה נמצא בתוך אזור התצוגה, הפיקסלים שהוא מכסה עוברים עיבוד על ידי שובר הפירגמנטים כדי לצבוע אותם. בכל מקרה, זהו שדה ה-vertex shader הפשוט:
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;
}
עכשיו, שידור הקוד של צומת האנימציה. בעיקרון, הוא עושה את אותו הדבר כמו עיבוד קודקודים פשוט, אבל עם טוויסט קטן. במקום להמיר כל קודקוד רק באמצעות מטריצות הטרנספורמציה, המערכת מחילה גם טרנספורמציה מונפשת שתלויה בזמן. כדי שכל אות תזוז בצורה קצת שונה, ה-vertex shader האנימציה משנה גם את האנימציה על סמך הקואורדינטות של האות. הוא נראה הרבה יותר מורכב מאשר שדה הצבעים הפשוט של הנקודות, כי הוא כן מורכב יותר.
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;
}
כדי להשתמש ב-vertex shader, משתמשים ב-THREE.ShaderMaterial
, סוג חומר שמאפשר להשתמש ב-shaders מותאמים אישית ולציין להם מדים. כך משתמשים ב-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;
בכל פריים של האנימציה, מעדכנים את המדים של שפת התכנות לשיפור הביצועים (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());
זהו, זהו, אנימציה מבוססת-שידרוג. הפונקציה נראית מורכבת למדי, אבל כל מה שהיא עושה הוא להזיז את האותיות באופן שמשתנה בהתאם לשעה הנוכחית ולמספר של כל אות. אם הביצועים לא חשובים לכם, תוכלו להריץ את הלוגיקה הזו ב-JavaScript. עם זאת, כשמדובר בעשרות אלפי אובייקטים מונפשים, JavaScript כבר לא נחשב לפתרון ריאלי.
שאלות נוספות
עכשיו יש בעיה אחת: JavaScript לא יודעת על מיקומי החלקיקים. אם אתם באמת צריכים לדעת איפה נמצאות החלקיקים, תוכלו להכפיל את הלוגיקה של שדה הצבעים של הנקודות (vertex shader) ב-JavaScript ולחשב מחדש את מיקומי הנקודות באמצעות web worker בכל פעם שצריך את המיקומים. כך חוט העיבוד לא צריך להמתין לחישוב, ותוכלו להמשיך ליצור אנימציה בקצב פריימים חלק.
כדי לשלוט טוב יותר באנימציה, אפשר להשתמש בפונקציה של עיבוד (render) ליצירת טקסטורה כדי ליצור אנימציה בין שתי קבוצות של מיקומים שסופקו על ידי JavaScript. קודם, מבצעים עיבוד (render) של המיקומים הנוכחיים למרקם, ואז יוצרים אנימציה לעבר מיקומים שמוגדרים במרקם נפרד שסופק על ידי JavaScript. היתרון של השיטה הזו הוא שאפשר לעדכן חלק קטן מהמיקומים שסופקו על ידי JavaScript בכל פריים, ועדיין להמשיך ליצור אנימציה לכל האותיות בכל פריים באמצעות טריין (tween) של המיקומים על ידי שדה הקודקודים.
בעיה נוספת היא ש-256 תווים הם מעט מדי כדי להכיל טקסטים שאינם תווי ASCII. אם מגדילים את גודל מפת הטקסטורה ל-4096x4096 ומקטינים את גודל הגופן ל-8px, אפשר להתאים את כל קבוצת התווים UCS-2 למפת הטקסטורה. עם זאת, גודל גופן של 8px לא נוח לקריאה. כדי ליצור גודל גופן גדול יותר, אפשר להשתמש בכמה טקסטורות לגופן. בדמו הזה של מפה של סמלי ספריי (sprite) אפשר לראות דוגמה. דבר נוסף שיעזור הוא ליצור רק את האותיות שבהן אתם משתמשים בטקסט.
סיכום
במאמר הזה הנחתי אתכם ליישם הדגמה של אנימציה מבוססת-vertex shader באמצעות Three.js. בהדגמה מוצגת אנימציה של מיליון אותיות בזמן אמת ב-MacBook Air מ-2010. כדי לאפשר ציור יעיל, ההטמעה קבצה ספר שלם באובייקט גיאומטרי אחד. כדי ליצור אנימציה של האותיות הבודדות, המערכת זיהתה אילו קודקודים שייכים לאותות השונים, והפעילה אנימציה של הקודקודים על סמך האינדקס של האות בטקסט של הספר.