אנימציה של מיליון אותיות באמצעות Three.js

Ilmari Heikkinen

מבוא

המטרה שלי במאמר הזה היא לצייר מיליון אותיות מונפשות על המסך בקצב פריימים חלק. המשימה הזו אמורה להיות אפשרית עם יחידות GPU מודרניות. כל אות מורכבת משני משולשים עם מרקם, כך שאנחנו מדברים רק על שני מיליון משולשים לכל מסגרת.

אם הגעת לרקע של אנימציית JavaScript מסורתית, זה נשמע כמו טירוף. שני מיליון משולשים שמעודכנים בכל מסגרת הם לא משהו שהייתם רוצים לעשות היום באמצעות JavaScript. אבל למרבה המזל, יש לנו את WebGL, שמאפשר לנו לנצל את העוצמה המדהימה של יחידות GPU מודרניות. אפשר להשתמש בשני מיליון משולשים מונפשים עם GPU מודרני וקסם כלשהו של ההצללה.

כתיבת קוד WebGL יעיל

כדי לכתוב קוד WebGL יעיל, צריך לפעול לפי כללי החשיבה. הדרך הרגילה לצייר באמצעות WebGL היא להגדיר את המדים, המאגר וההצללה (shader) לכל אובייקט, ולאחר מכן לבצע קריאה לציור של האובייקט. צורת השרטוט הזו פועלת כאשר משרטטים מספר קטן של אובייקטים. כדי לצייר מספר גדול של אובייקטים, עליכם לצמצם את כמות השינויים במצב WebGL. כדי להתחיל, ציירו את כל האובייקטים באמצעות אותו תוכנת הצללה אחד אחרי השני, כדי שלא תצטרכו להחליף את תוכנות הצללה בין האובייקטים. באובייקטים פשוטים כמו חלקיקים, אפשר לקבץ כמה אובייקטים במאגר נתונים זמני אחד ולערוך אותם באמצעות JavaScript. כך תצטרכו להעלות מחדש את המאגר של קודקוד הקוד, במקום להחליף את מדי הצללה (shader) לכל חלקיק בודד.

אבל כדי להתקדם ממש מהר, אתם צריכים להעביר את רוב החישובים שלכם להצללות. זה מה שאני מנסה לעשות כאן. אנימציה של מיליון אותיות באמצעות תוכנות הצללה.

בקוד של המאמר נעשה שימוש בספריית Three.js, שמפשטת את כל תוכן ה-boilerplate המיותר מכתיבת קוד WebGL. במקום לכתוב מאות שורות של הגדרה של מצב WebGL וטיפול בשגיאות, עם Three.js צריך לכתוב רק כמה שורות קוד. אפשר גם להתחבר בקלות למערכת ההצללה של WebGL מתוך Three.js.

ציור מספר אובייקטים באמצעות קריאה אחת לשרטוט

בהמשך מוצגת דוגמה קטנה באמצעות פסאודו-קוד שממחישה איך לצייר אובייקטים מרובים באמצעות קריאה אחת לשרטוט. הדרך המסורתית היא לצייר אובייקט אחד בכל פעם, באופן הבא:

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

אוקיי, עכשיו אחרי שהבנתם את הרעיון הבסיסי, נחזור לכתיבת ההדגמה ונתחיל להנפיש את מיליון האותיות האלה.

הגדרת הגיאומטריה והמרקמים

בשלב הראשון אצור מרקם עם מפות סיביות של אותיות. בשביל זה אני משתמשת בהדפסה על קנבס הדו-ממדי. המרקם שנוצר מכיל את כל האותיות שאני רוצה לצייר. השלב הבא הוא ליצור מאגר עם קואורדינטות המרקם של גיליון ה-Sprite של האותיות. זוהי שיטה קלה ופשוטה להגדרת האותיות, אבל היא קצת בזריזת כי היא משתמשת בשני צפים לכל קודקוד בקואורדינטות של המרקם. דרך קצרה יותר - נותרו כתרגיל לקורא - היא לארוז את אינדקס האות ואת אינדקס הפינה למספר אחד ולהמיר אותם בחזרה לקואורדינטות מרקם בהצללה בקוד הקוד.

כך יוצרים את מרקם האות באמצעות 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. הקודקודים האלה משמשים את תוכנת ההצללה (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++;
  }
}

תוכנת ההצללה (shader) של Vertex לאנימציה של האותיות

באמצעות מקרן קודקוד פשוט, אני מקבל תצוגה שטוחה של הטקסט. שום דבר מיוחד. פועל היטב, אבל אם אני רוצה להנפיש אותו, אני צריך לבצע את האנימציה ב-JavaScript. ו-JavaScript הוא די איטי ליצירת אנימציה של ששת מיליון הקודקודים המעורבים, במיוחד אם רוצים לעשות זאת בכל פריים. אולי יש דרך מהירה יותר.

למה כן, אנחנו יכולים ליצור אנימציה פרוצדורלית. כלומר, אנחנו עושים את כל החישובים המתמטיים של המיקום והסיבוב דרך ההצללה (shader). כעת איני צריך להריץ JavaScript כדי לעדכן את המיקומים של הקודקודים. תוכנת ההצללה (shader) של הקודקוד פועלת מאוד מהר, ואני מקבל קצב פריימים חלק גם כשמיליון משולשים מונפשים בנפרד בכל פריים. כדי לטפל במשולשים הנפרדים, אני מעגלת כלפי מטה את הקואורדינטות של הקודקוד כך שכל ארבע הנקודות בריבוע של אותיות ממופות לקואורדינטה ייחודית אחת. עכשיו אוכל להשתמש בקואורדינטה הזו כדי להגדיר את הפרמטרים של האנימציה עבור האות הרלוונטית.

כדי שניתן יהיה לעגל כלפי מטה קואורדינטות, הקואורדינטות משתי אותיות שונות לא יכולות להיות חופפות. הדרך הקלה ביותר לעשות זאת היא באמצעות אותיות מרובעות, עם קיזוז קטן שמפריד בין האות שמימין לבין הקו שמעליה. לדוגמה, אפשר להשתמש ברוחב ובגובה של 0.5 עבור האותיות, וליישר את האותיות בקואורדינטות עם מספרים שלמים. עכשיו, כשמעגלים למטה את הקואורדינטה של כל קודקוד של אות, מקבלים את הקואורדינטה השמאלית התחתונה של האות.

עיגול קואורדינטות של קודקוד למטה כדי למצוא את הפינה השמאלית העליונה של אות.
עיגול קואורדינטות של קודקוד כלפי מטה כדי למצוא את הפינה השמאלית העליונה של אות.

כדי להבין טוב יותר את תוכנת ההצללה (shader) הקודקודת המונפשת, אעבור קודם על תוכנית ההצללה (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;
}

ועכשיו, לוח ההצללה (shader) המונפש. בגדול, הוא עושה את אותו הדבר כמו תוכנת ההצללה (shader) הפשוטה של הקוד, אבל עם פיתול קטן. במקום לשנות כל קודקוד רק באמצעות מטריצות הטרנספורמציה, המערכת מחילה גם טרנספורמציה מונפשת תלויה בזמן. כדי שהאנימציה של כל אות תהיה שונה מעט, תוכנת ההצללה קודקת המונפשת משנה גם את האנימציה לפי הקואורדינטות של האות. זה ייראה הרבה יותר מורכב מהמשטח פשוט להצללה (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;
}

כדי להשתמש בהצללה (shader) של קודקוד, אני משתמש ב-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 את לוגיקת ההצללה בקוד הקוד ולחשב מחדש את מיקומי הקודקוד באמצעות worker בכל פעם שאתם צריכים את המיקומים. כך שרשור הרינדור לא יצטרך להמתין לחישובים המתמטיים, ותוכלו להמשיך ליצור אנימציה בקצב פריימים חלק.

לאנימציה ניתנת לשליטה רבה יותר, אפשר להשתמש בפונקציונליות של עיבוד למרקם כדי ליצור אנימציה בין שתי קבוצות של מיקומים שמסופקים על ידי JavaScript. תחילה, עבד את המיקומים הנוכחיים למרקם, ולאחר מכן הוסף אנימציה למיקומים המוגדרים במרקם נפרד שמסופק על ידי JavaScript. היתרון הנחמד הוא שאפשר לעדכן חלק קטן מהמיקומים שסופקו על ידי JavaScript בכל פריים, ועדיין להמשיך להנפיש את כל האותיות בכל פריים באמצעות תוכנת ההצללה (shader) של הקודקוד, שמגשרת בין המיקומים.

חשש נוסף הוא ש-256 תווים הם מעט מדי תווים מאשר טקסטים שאינם ASCII. אם דוחפים את גודל מפת המרקם ל-4096x4096 תוך הקטנת גודל הגופן ל-8 פיקסלים, תוכלו להתאים את כל מערכת התווים UCS-2 למפת המרקמים. עם זאת, גודל הגופן של 8 פיקסלים לא מאוד קריא. כדי להגדיל את הגופנים, אתם יכולים להשתמש במספר מרקמים לגופן. כדי לראות דוגמה, אפשר לצפות בהדגמה של אטלס Sprite. דבר נוסף שיעזור הוא ליצור רק את האותיות שבהן נעשה שימוש בטקסט.

סיכום

במאמר הזה הסברתי איך להטמיע הדגמה של אנימציה המבוססת על קודקוד ההצללה (shader) באמצעות Three.js. בהדגמה מוצגת אנימציה של מיליון אותיות בזמן אמת ב-MacBook Air משנת 2010. היישום ארז ספר שלם באובייקט גיאומטריה אחד, כדי לאפשר ציור יעיל. האותיות הנפרדות נוצרו על ידי זיהוי הקודקודים ששייכים לאיזו אות, והוספת אנימציה של הקודקודים בהתאם לאינדקס של האות שבטקסט שבספר.

קובצי עזר