إضافة حركة لمليون حرف باستخدام 3.js

مقدمة

هدفي في هذه المقالة هو رسم مليون حرف متحرك على الشاشة بمعدّل عرض سلس للإطارات. من المفترض أن تكون هذه المهمة ممكنة تمامًا باستخدام وحدات معالجة الرسومات الحديثة. يتألف كل حرف من مثلثَين مسطّحَين، لذا نتحدث فقط عن مليونَي مثلث في كل إطار.

إذا كنت لديك خبرة في إنشاء صور متحركة باستخدام JavaScript التقليدية، قد يبدو لك هذا الأمر غريبًا. إنّ تعديل مليونَي مثلث في كل لقطة ليس بالأمر الذي تريد تنفيذه باستخدام JavaScript اليوم. لحسن الحظ، لدينا WebGL، الذي يتيح لنا الاستفادة من إمكانات وحدات معالجة الرسومات الحديثة الرائعة. ويمكن إنشاء مليونَي مثلث متحرّك باستخدام وحدة معالجة رسومات حديثة وبعض التأثيرات السحرية.

كتابة رمز WebGL فعّال

تتطلّب كتابة رمز WebGL فعّال طريقة تفكير معيّنة. إنّ الطريقة المعتادة للرسم باستخدام WebGL هي إعداد العناصر الثابتة ووحدات التخزين المؤقت وبرامج التظليل لكل عنصر، متبوعة باستدعاء لرسم العنصر. تعمل طريقة الرسم هذه عند رسم عدد صغير من الأجسام. لرسم عدد كبير من الكائنات، عليك تقليل عدد تغييرات حالة WebGL. في البداية، ارسم جميع الأجسام باستخدام تأثير التمويه نفسه بعد بعضها، لكي لا تضطر إلى تغيير تأثيرات التمويه بين الأجسام. بالنسبة إلى الأجسام البسيطة، مثل الجسيمات، يمكنك تجميع عدة أجسام في ذاكرة تخزين مؤقت واحدة وتعديلها باستخدام JavaScript. بهذه الطريقة، لن يكون عليك سوى إعادة تحميل مخزن رؤوس المثلثات بدلاً من تغيير قيم متغيرات وحدة الإضاءة لكل جسيم فردي.

ولكن لتحقيق سرعات عالية، عليك توجيه معظم العمليات الحسابية إلى وحدات التظليل. هذا ما أحاول فعله هنا. إضافة حركة إلى مليون حرف باستخدام تأثيرات التظليل

يستخدِم رمز المقالة مكتبة Three.js التي تزيل كل المحتوى المتكرّر المملّ من كتابة رمز 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);

حسنًا، بعد أن تعرّفت على الفكرة الأساسية، لنعود إلى كتابة العرض التجريبي ونبدأ بإضافة الحركة إلى هذه المليون حرف.

إعداد الأشكال الهندسية والقوام

في الخطوة الأولى، سأنشئ نسيجًا يتضمّن ملفات بتر في شكل أحرف. أستخدم لوحة الرسم ثنائية الأبعاد لهذا الغرض. يحتوي النسيج الناتج على كل الأحرف التي أريد رسمها. الخطوة التالية هي إنشاء ذاكرة تخزين مؤقت تتضمّن إحداثيات النسيج في لوحة صور متحركة للحروف. على الرغم من أنّ هذه الطريقة سهلة ومباشرة لإعداد الأحرف، إلا أنّها تُعدّ مضيعةً بعض الشيء لأنّها تستخدِم عددًا من القيم العشرية لكلّ رأس من رؤوس المضلّعات لتنسيقات النسيج. هناك طريقة أقصر - تركناها كتمرين للقارئ - وهي تجميع فهرس الحرف فهرس الزاوية في رقم واحد وتحويله مرة أخرى إلى إحداثيات النسيج في برنامج تظليل رؤوس المضلّعات.

في ما يلي كيفية إنشاء نسيج الحرف باستخدام 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;

وأحمّل أيضًا صفيف المثلثات إلى وحدة معالجة الرسومات. يستخدم برنامج 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++;
  }
}

برنامج تشفير قمة لتحريك الحروف

باستخدام برنامج بسيط لظلال الرأس، يمكنني الحصول على عرض مسطّح للنص. لا شيء مميّز. يعمل بشكل جيد، ولكن إذا أردت إضافة رسوم متحركة إليه، يجب إنشاء الرسوم المتحركة باستخدام JavaScript. وتكون JavaScript بطيئة نوعًا ما في إضافة الحركة إلى النقاط الستة مليون نقطة المعنيّة، خاصةً إذا كنت تريد إجراء ذلك في كل لقطة. ربما تتوفّر طريقة أسرع.

نعم، يمكننا إنشاء رسوم متحركة آلية. وهذا يعني أنّنا نُجري جميع العمليات الحسابية المتعلّقة بالموضع والدوران في برنامج تظليل رؤوس المضلّعات. الآن، لا أحتاج إلى تشغيل أي JavaScript لتعديل مواضع الرؤوس. يتم تشغيل برنامج تظليل رؤوس المضلّعات بسرعة كبيرة، وأحصل على معدّل عرض سلس حتى مع مليون مثلث يتم تحريكه بشكلٍ فردي في كل إطار. لمعالجة المثلثات الفردية، أُقرّب إحداثيات الرأس إلى أقرب عدد صحيح كي يتم ربط جميع النقاط الأربعة للحرف الرباعي بإحداثية فريدة واحدة. يمكنني الآن استخدام هذا الإحداثي لضبط مَعلمات الحركة للحرف المعنيّ.

لكي تتمكّن من تقريب الإحداثيات بنجاح، لا يمكن أن تتداخل إحداثيات من حرفَين مختلفَين. إنّ أسهل طريقة لتنفيذ ذلك هي استخدام مربّعات من الأحرف المربّعة مع مسافة صغيرة تفصل الحرف عن الحرف على جانبه الأيمن والخط فوقه. على سبيل المثال، يمكنك استخدام عرض وارتفاع 0.5 للأحرف ومحاذاة الأحرف على إحداثيات صحيحة. الآن عند تقريب إحداثيات أيّ رأس حرف إلى الأسفل، ستحصل على إحداثيات أسفل يمين الحرف.

تقريب إحداثيات رؤوس الحرف لأدناه للعثور على أعلى يمين الحرف
تقريب إحداثيات الرأس لأدناه للعثور على أعلى يمين الحرف

لفهم مخطّط تظليل رؤوس المضلّعات المتحركة بشكل أفضل، سأشرح أولاً مخطّط تظليل رؤوس مضلّعات عاديًا. هذا ما يحدث عادةً عند رسم نموذج ثلاثي الأبعاد على الشاشة. يتم تحويل رؤوس النموذج من خلال مجموعتَين من مصفوفات التحويل لعرض كل رأس ثلاثي الأبعاد على الشاشة ثنائية الأبعاد. عندما يقع مثلث محدّد بثلاثة من هذه الرؤوس داخل مساحة العرض، يعالج برنامج 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 للرأس المتحرك أيضًا الحركة استنادًا إلى إحداثيات الحرف. سيبدو الأمر أكثر تعقيدًا بكثير من برنامج تشفير قمة بسيط، لأنّه هو أكثر تعقيدًا.

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 وإعادة احتساب مواضع رؤوس المضلّعات باستخدام Web Worker في كل مرة تحتاج فيها إلى المواضع. بهذه الطريقة، لن ينتظر خيط المعالجة الحسابية لإجراء العمليات الحسابية، ويمكنك مواصلة إنشاء الصور المتحركة بمعدّل عرض سلس للإطارات.

للحصول على تأثير متحرك يمكن التحكّم فيه بشكل أكبر، يمكنك استخدام وظيفة التحويل إلى نسيج لإنشاء تأثير متحرك بين مجموعتَين من مواضع الصور التي يوفّرها JavaScript. أولاً، يمكنك عرض المواضع الحالية في نسيج، ثمّ إنشاء مؤثرات متحركة نحو المواضع المحدّدة في نسيج منفصل يوفّره JavaScript. والميزة في ذلك هي أنّه يمكنك تعديل جزء صغير من مواضع JavaScript المقدَّمة لكل إطار مع مواصلة إضافة الحركة إلى كل الأحرف في كل إطار باستخدام أداة ربط النقاط لتغيير مواضعها.

هناك قلق آخر وهو أنّ 256 حرفًا قليل جدًا لاستخدام النصوص غير المستندة إلى ASCII. إذا كبّرت حجم خريطة النسيج إلى 4096x4096 مع تقليل حجم الخط إلى 8 بكسل، يمكنك ملاءمة مجموعة أحرف UCS-2 بالكامل في خريطة النسيج. ومع ذلك، لا يمكن قراءة حجم الخط الذي يبلغ 8 بكسل بسهولة. لإنشاء أحجام خط أكبر، يمكنك استخدام مواد متعددة لخطك. يمكنك الاطّلاع على العرض التوضيحي لجدول مرجعي للصور المتحركة للحصول على مثال. من الأمور الأخرى التي قد تساعدك إنشاء الأحرف المستخدَمة في النص فقط.

ملخّص

في هذه المقالة، شرحنا لك كيفية تنفيذ عرض توضيحي للصور المتحركة المستندة إلى برنامج vertex shader باستخدام Three.js. يعرض الإصدار التجريبي مليون حرف متحرّك في الوقت الفعلي على جهاز MacBook Air من طراز 2010. ودمجت عملية التنفيذ كتابًا بأكمله في جسم هندسي واحد للرسم بكفاءة. تم إنشاء صور متحركة للأحرف الفردية من خلال تحديد الرؤوس التي تنتمي إلى الحرف وإضافة الحركة إليها استنادًا إلى فهرس الحرف في نص الكتاب.

المراجع