متحرک سازی میلیون ها حرف با استفاده از Three.js

Ilmari Heikkinen

معرفی

هدف من در این مقاله ترسیم یک میلیون حرف متحرک بر روی صفحه نمایش با نرخ فریم صاف است. این کار باید با GPU های مدرن کاملاً امکان پذیر باشد. هر حرف از دو مثلث بافت دار تشکیل شده است، بنابراین ما فقط در مورد دو میلیون مثلث در هر فریم صحبت می کنیم.

اگر از یک پس‌زمینه انیمیشن سنتی جاوا اسکریپت می‌آیید، همه اینها دیوانگی به نظر می‌رسد. دو میلیون مثلث که در هر فریم به روز می شوند قطعاً چیزی نیست که بخواهید امروز با جاوا اسکریپت انجام دهید. اما خوشبختانه ما WebGL را داریم که به ما امکان می دهد از قدرت فوق العاده GPU های مدرن بهره ببریم. و دو میلیون مثلث متحرک با یک GPU مدرن و مقداری جادوی سایه زن کاملاً قابل انجام است.

نوشتن کد WebGL کارآمد

نوشتن کد WebGL کارآمد نیاز به طرز فکر خاصی دارد. روش معمول برای ترسیم با استفاده از WebGL این است که برای هر شی یونیفرم ها، بافرها و سایه بان ها را تنظیم کنید و به دنبال آن یک فراخوان برای ترسیم شی. این روش ترسیم در هنگام کشیدن تعداد کمی از اشیا کار می کند. برای ترسیم تعداد زیادی از اشیا، باید میزان تغییرات حالت WebGL را به حداقل برسانید. برای شروع، تمام اشیاء را با استفاده از یک سایه زن پس از یکدیگر بکشید، به طوری که نیازی به تغییر شیدر بین اشیا نیست. برای اشیاء ساده مانند ذرات، می توانید چندین شی را در یک بافر واحد قرار داده و با استفاده از جاوا اسکریپت آن را ویرایش کنید. به این ترتیب شما فقط باید به جای تغییر یکنواخت سایه زن برای تک تک ذرات، بافر راس را دوباره آپلود کنید.

اما برای اینکه واقعاً سریع پیش بروید، باید بیشتر محاسبات خود را به سایه زن ها فشار دهید. این چیزی است که من در اینجا سعی می کنم انجام دهم. با استفاده از سایه بان ها میلیون ها حرف را متحرک کنید.

کد مقاله از کتابخانه 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;

آرایه مثلثی را هم در GPU آپلود می کنم. این رئوس توسط سایه بان راس برای قرار دادن حروف روی صفحه استفاده می شود. راس ها روی موقعیت حروف در متن تنظیم می شوند تا اگر آرایه مثلثی را همانطور که هست رندر کنید، یک طرح بندی اولیه متن را دریافت کنید.

ایجاد هندسه برای کتاب:

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 برای متحرک سازی حروف

با یک سایه زن رأس ساده، یک نمای مسطح از متن دریافت می کنم. چیز خاصی نیست. خوب اجرا می شود، اما اگر بخواهم آن را متحرک کنم، باید انیمیشن را در جاوا اسکریپت انجام دهم. و جاوا اسکریپت برای متحرک سازی شش میلیون راس درگیر کمی کند است، به خصوص اگر بخواهید آن را روی هر فریم انجام دهید. شاید راه سریع تری وجود داشته باشد.

چرا بله، ما می توانیم انیمیشن رویه ای انجام دهیم. معنی آن این است که ما تمام ریاضیات موقعیت و چرخش خود را در سایه زن راس انجام می دهیم. اکنون برای به روز رسانی موقعیت رئوس نیازی به اجرای هیچ جاوا اسکریپتی نیست. سایه زن راس بسیار سریع اجرا می شود و من حتی با میلیون ها مثلث که به صورت جداگانه در هر فریم متحرک می شوند، نرخ فریم صافی دریافت می کنم. برای پرداختن به مثلث‌های منفرد، مختصات رأس را به سمت پایین گرد می‌کنم تا هر چهار نقطه از یک حرف به یک مختصات منحصربه‌فرد تبدیل شوند. اکنون می توانم از این مختصات برای تنظیم پارامترهای انیمیشن برای حرف مورد نظر استفاده کنم.

برای اینکه بتوانید مختصات را با موفقیت گرد کنید، مختصات از دو حرف مختلف نمی توانند همپوشانی داشته باشند. ساده ترین راه برای انجام این کار، استفاده از حروف چهارگانه با یک افست کوچک است که حرف را از سمت راست و خط بالای آن جدا می کند. به عنوان مثال، می توانید از عرض و ارتفاع 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;
}

و اکنون، سایه زن راس متحرک. اساساً همان کار سایه زن راس ساده را انجام می دهد، اما با یک پیچ کوچک. به جای تبدیل هر راس فقط با ماتریس های تبدیل، یک تبدیل متحرک وابسته به زمان را نیز اعمال می کند. برای اینکه هر حرف کمی متفاوت تر متحرک شود، سایه زن رأس متحرک نیز انیمیشن را بر اساس مختصات حرف تغییر می دهد. به نظر می رسد بسیار پیچیده تر از سایه زن راس ساده است، زیرا، خوب، پیچیده تر است .

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

و شما آن را دارید، انیمیشن مبتنی بر سایه زن. بسیار پیچیده به نظر می رسد، اما تنها کاری که واقعا انجام می دهد این است که حروف را به گونه ای جابجا می کند که به زمان فعلی و شاخص هر حرف بستگی دارد. اگر عملکرد نگران کننده نبود، می توانید این منطق را در جاوا اسکریپت اجرا کنید. با این حال، در ده‌ها هزار شیء متحرک، جاوا اسکریپت به عنوان یک راه‌حل قابل اجرا متوقف می‌شود.

نگرانی های باقی مانده

اکنون یک مشکل این است که جاوا اسکریپت از موقعیت ذرات اطلاعی ندارد. اگر واقعاً نیاز دارید که بدانید ذرات شما کجا هستند، می‌توانید منطق سایه‌زن راس را در جاوا اسکریپت کپی کنید و هر بار که به موقعیت‌ها نیاز داشتید، موقعیت‌های راس را با استفاده از یک وب‌کار دوباره محاسبه کنید. به این ترتیب نخ رندر شما نیازی به انتظار ریاضی ندارد و می توانید انیمیشن را با نرخ فریم صاف ادامه دهید.

برای انیمیشن قابل کنترل تر، می توانید از عملکرد رندر به بافت برای متحرک سازی بین دو مجموعه از موقعیت های ارائه شده توسط جاوا اسکریپت استفاده کنید. ابتدا موقعیت های فعلی را به یک بافت رندر کنید، سپس به سمت موقعیت های تعریف شده در یک بافت جداگانه ارائه شده توسط جاوا اسکریپت متحرک شوید. نکته خوب در مورد این این است که می‌توانید بخش کوچکی از موقعیت‌های ارائه‌شده توسط جاوا اسکریپت را در هر فریم به‌روزرسانی کنید و همچنان به متحرک کردن همه حروف در هر فریم با سایه‌زن رأس در بین موقعیت‌ها ادامه دهید.

نگرانی دیگر این است که 256 کاراکتر برای انجام متون غیر ASCII بسیار کم است. اگر اندازه نقشه بافت را به 4096x4096 فشار دهید در حالی که اندازه فونت را به 8 پیکسل کاهش دهید، می توانید کل مجموعه کاراکترهای UCS-2 را در نقشه بافت قرار دهید. با این حال، اندازه فونت 8 پیکسل چندان قابل خواندن نیست. برای انجام اندازه های بزرگتر فونت، می توانید از چندین بافت برای فونت خود استفاده کنید. برای مثال این نسخه ی نمایشی اطلس sprite را ببینید. چیز دیگری که به شما کمک می کند این است که فقط حروف مورد استفاده در متن خود را ایجاد کنید.

خلاصه

در این مقاله، من شما را با پیاده سازی دمو انیمیشن مبتنی بر سایه زن با استفاده از Three.js آشنا کردم. نسخه ی نمایشی یک میلیون نامه را در زمان واقعی در مک بوک ایر 2010 متحرک می کند. پیاده سازی یک کتاب کامل را در یک شی هندسی واحد برای ترسیم کارآمد قرار داد. حروف جداگانه با تشخیص اینکه کدام رئوس متعلق به کدام حرف است و متحرک سازی رئوس بر اساس نمایه حرف در متن کتاب متحرک شدند.

منابع