थ्री.js का इस्तेमाल करके लाखों अक्षरों का ऐनिमेशन

Ilmari Heikkinen

परिचय

इस लेख में मेरा लक्ष्य, स्क्रीन पर एक लाख ऐनिमेट किए गए अक्षर, फ़्रेम रेट के साथ दिखाना है. आधुनिक जीपीयू की मदद से, यह काम आसानी से किया जा सकता है. हर अक्षर में दो टेक्सचर वाले ट्राएंगल होते हैं. इसलिए, हर फ़्रेम में सिर्फ़ दो लाख ट्राएंगल होते हैं.

अगर आपने पहले JavaScript ऐनिमेशन का इस्तेमाल किया है, तो आपको यह सब पागलपन लग सकता है. हर फ़्रेम में दो लाख ट्राएंगल अपडेट करना, ऐसा काम नहीं है जिसे आज JavaScript की मदद से किया जा सकता है. हालांकि, हमारे पास WebGL है, जिसकी मदद से हम आधुनिक जीपीयू की बेहतरीन सुविधाओं का इस्तेमाल कर सकते हैं. साथ ही, आधुनिक जीपीयू और कुछ शेडर मैजिक की मदद से, दो लाख ऐनिमेट किए गए ट्राएंगल आसानी से बनाए जा सकते हैं.

बेहतर WebGL कोड लिखना

बेहतर WebGL कोड लिखने के लिए, कुछ खास बातों का ध्यान रखना ज़रूरी है. WebGL का इस्तेमाल करके ड्रॉ करने का सामान्य तरीका यह है कि हर ऑब्जेक्ट के लिए यूनिफ़ॉर्म, बफ़र, और शेडर सेट अप करें. इसके बाद, ऑब्जेक्ट को ड्रॉ करने के लिए कॉल करें. ड्रॉइंग करने का यह तरीका, कम ऑब्जेक्ट ड्रॉ करने पर काम करता है. बड़ी संख्या में ऑब्जेक्ट ड्रॉ करने के लिए, आपको WebGL स्टेटस में होने वाले बदलावों की संख्या कम करनी चाहिए. शुरुआत में, एक ही शेडर का इस्तेमाल करके सभी ऑब्जेक्ट को एक के बाद एक ड्रॉ करें, ताकि आपको ऑब्जेक्ट के बीच शेडर बदलने की ज़रूरत न पड़े. कणों जैसे आसान ऑब्जेक्ट के लिए, कई ऑब्जेक्ट को एक बफ़र में बंडल किया जा सकता है और JavaScript का इस्तेमाल करके उसमें बदलाव किया जा सकता है. इस तरह, आपको हर एक कण के लिए शेडर यूनिफ़ॉर्म बदलने के बजाय, सिर्फ़ वर्टिक्स बफ़र को फिर से अपलोड करना होगा.

हालांकि, ज़्यादा तेज़ी से काम करने के लिए, आपको ज़्यादातर कैलकुलेशन को शेडर पर भेजना होगा. यही कोशिश की जा रही है. शेडर का इस्तेमाल करके, एक लाख अक्षरों को ऐनिमेट करना.

लेख के कोड में Three.js लाइब्रेरी का इस्तेमाल किया गया है. यह लाइब्रेरी, WebGL कोड लिखने से जुड़ी सभी मुश्किल प्रक्रियाओं को आसान बनाती है. WebGL स्टेटस सेटअप और गड़बड़ी को हैंडल करने के लिए, आपको सैकड़ों लाइनें लिखनी पड़ती हैं. इसके बजाय, Three.js की मदद से आपको सिर्फ़ कुछ लाइनें लिखनी पड़ती हैं. 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);

ठीक है, अब आपको बुनियादी जानकारी मिल गई है. अब डेमो लिखने और उन लाखों अक्षरों को ऐनिमेट करने की प्रोसेस शुरू करते हैं!

ज्यामिति और टेक्सचर सेट अप करना

सबसे पहले, मैं अक्षरों के बिटमैप वाला एक टेक्चर बनाऊंगा. इसके लिए, मैं 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;

मैं जीपीयू में ट्राएंगल कलेक्शन भी अपलोड करता/करती हूं. वर्टिक्स शेडर इन वर्टिक्स का इस्तेमाल करके, अक्षरों को स्क्रीन पर दिखाता है. वर्टिसेस को टेक्स्ट में अक्षर की पोज़िशन पर सेट किया जाता है, ताकि त्रिकोण के ऐरे को वैसे ही रेंडर किया जा सके. इससे आपको टेक्स्ट का बुनियादी लेआउट रेंडरिंग मिलती है.

किताब के लिए ज्यामिति बनाना:

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

अक्षरों को ऐनिमेट करने के लिए वर्टिक्स शेडर

किसी साधारण वर्टिक्स शेडर की मदद से, मुझे टेक्स्ट का फ़्लैट व्यू मिलता है. इसमें कोई खास बात नहीं है. यह अच्छी तरह से काम करता है, लेकिन अगर मुझे इसे ऐनिमेट करना है, तो मुझे JavaScript में ऐनिमेशन करना होगा. साथ ही, छह लाख वर्टिसेस को ऐनिमेट करने के लिए JavaScript थोड़ा धीमा है. खास तौर पर, अगर आपको हर फ़्रेम पर ऐसा करना है. शायद कोई तेज़ तरीका हो.

हां, हम प्रोसेस वाला ऐनिमेशन बना सकते हैं. इसका मतलब है कि हम पोज़िशन और रोटेशन का पूरा हिसाब, वर्टिक्स शेडर में लगाते हैं. अब मुझे वर्टिसेस की पोज़िशन अपडेट करने के लिए, कोई JavaScript चलाने की ज़रूरत नहीं है. वर्टिक्स शेडर बहुत तेज़ी से काम करता है. साथ ही, हर फ़्रेम में एक लाख ट्राएंगल को अलग-अलग ऐनिमेट करने पर भी, मुझे फ़्रेम रेट में कोई रुकावट नहीं आती. अलग-अलग त्रिभुजों को हल करने के लिए, मैंने वर्टिक्स के निर्देशांक को राउंड डाउन किया है, ताकि अक्षर वाले क्वाड के सभी चार पॉइंट एक यूनीक निर्देशांक पर मैप हो जाएं. अब इस निर्देशांक का इस्तेमाल करके, उस अक्षर के लिए ऐनिमेशन पैरामीटर सेट किए जा सकते हैं.

निर्देशांक को राउंड डाउन करने के लिए, दो अलग-अलग अक्षरों के निर्देशांक ओवरलैप नहीं होने चाहिए. ऐसा करने का सबसे आसान तरीका, स्क्वेयर लेटर क्वॉड का इस्तेमाल करना है. इसमें अक्षर को दाईं ओर मौजूद अक्षर और उसके ऊपर मौजूद लाइन से अलग करने के लिए, थोड़ा ऑफ़सेट दिया जाता है. उदाहरण के लिए, अक्षरों के लिए 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 से मिली पोज़िशन में से कुछ हिस्से को अपडेट किया जा सकता है. इसके बावजूद, हर फ़्रेम में सभी अक्षरों को ऐनिमेट किया जा सकता है. इसके लिए, पोज़िशन को ट्वीन करने वाले वर्टिक्स शेडर का इस्तेमाल किया जाता है.

एक और समस्या यह है कि गैर-ASCII टेक्स्ट के लिए, 256 वर्ण बहुत कम हैं. अगर फ़ॉन्ट साइज़ को 8 पिक्सल तक कम करते हुए, टेक्सचर मैप का साइज़ 4096x4096 पर सेट किया जाता है, तो पूरे UCS-2 वर्ण सेट को टेक्सचर मैप में फ़िट किया जा सकता है. हालांकि, 8 पिक्सल का फ़ॉन्ट साइज़, पढ़ने में ज़्यादा आसान नहीं होता. फ़ॉन्ट का साइज़ बड़ा करने के लिए, अपने फ़ॉन्ट के लिए एक से ज़्यादा टेक्सचर का इस्तेमाल किया जा सकता है. उदाहरण के लिए, स्प्राइट एटलस का यह डेमो देखें. एक और बात जो मदद करेगी वह यह है कि सिर्फ़ अपने टेक्स्ट में इस्तेमाल किए गए अक्षर बनाएं.

खास जानकारी

इस लेख में, हमने Three.js का इस्तेमाल करके, वर्टिक्स शेडर पर आधारित ऐनिमेशन डेमो लागू करने का तरीका बताया है. इस डेमो में, 2010 के MacBook Air पर रीयल-टाइम में एक लाख अक्षरों को ऐनिमेट किया गया है. बेहतर तरीके से ड्रॉ करने के लिए, लागू करने की प्रोसेस ने पूरी किताब को एक ही ज्यामिति ऑब्जेक्ट में बंडल कर दिया. अलग-अलग अक्षरों को ऐनिमेट करने के लिए, यह पता लगाया गया कि कौनसे वर्टिक्स किस अक्षर से जुड़े हैं. साथ ही, किताब के टेक्स्ट में अक्षर के इंडेक्स के आधार पर, वर्टिक्स को ऐनिमेट किया गया.

रेफ़रंस