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

مقدمة

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

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

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

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

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

يستخدم رمز المقالة مكتبة Three.js، التي تزيل جميع النصوص النموذجية المملة من كتابة رموز WebGL. فبدلاً من كتابة مئات السطور لإعداد حالة WebGL ومعالجة الأخطاء، ما عليك سوى كتابة 3.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;

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

إنشاء الأشكال الهندسية للكتاب:

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

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

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

تقريب إحداثيات الرأس لأسفل للعثور على الزاوية العلوية اليسرى للحرف.
تقريب إحداثيات الرأس لأسفل للعثور على الزاوية العلوية اليسرى للحرف.

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

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 المتاحة لكل إطار مع الاستمرار في تحريك جميع الحروف في كل إطار باستخدام أداة تظليل الرأس التي تعمل على زيادة الموضعين.

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

ملخّص

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

المراجع