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

Ilmari Heikkinen

शुरुआती जानकारी

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

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

कुशल WebGL कोड लिखना

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

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

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

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

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

हां, हम प्रोसीज़रल ऐनिमेशन कर सकते हैं. इसका मतलब यह है कि हम अपनी सारी पोज़िशन और रोटेशन का गणित वर्टेक्स शेडर में करते हैं. अब मुझे वर्टेक्स की पोज़िशन अपडेट करने के लिए, कोई 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 वर्ण बहुत कम हैं. अगर टेक्सचर मैप के साइज़ को 4096x4096 तक कम करने के साथ-साथ फ़ॉन्ट साइज़ को 8 पिक्सल तक कम करते हैं, तो टेक्सचर मैप में सेट किए गए पूरे UCS-2 वर्ण को फ़िट किया जा सकता है. हालांकि, 8px का फ़ॉन्ट साइज़ ज़्यादा पढ़ा नहीं जा सकता. फ़ॉन्ट का साइज़ बड़ा करने के लिए, एक से ज़्यादा टेक्सचर का इस्तेमाल किया जा सकता है. उदाहरण के लिए, स्प्राइट एटलस डेमो देखें. एक अन्य तरीका, जिससे आपको अपने टेक्स्ट में इस्तेमाल किए गए अक्षर बनाने में मदद मिलेगी.

खास जानकारी

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

रेफ़रंस