Tạo ảnh động cho một triệu chữ cái bằng Three.js

Ilmari Heikkinen

Giới thiệu

Mục tiêu của tôi trong bài viết này là vẽ một triệu chữ cái động trên màn hình với tốc độ khung hình mượt mà. Bạn có thể thực hiện nhiệm vụ này với các GPU hiện đại. Mỗi chữ cái bao gồm hai tam giác có hoạ tiết, vì vậy, chúng ta chỉ nói về 2 triệu tam giác trên mỗi khung hình.

Nếu bạn có nền tảng về ảnh động JavaScript truyền thống, thì tất cả những điều này nghe có vẻ điên rồ. Hai triệu tam giác được cập nhật mỗi khung hình chắc chắn không phải là điều bạn muốn làm với JavaScript ngày nay. Nhưng thật may là chúng ta có WebGL, cho phép chúng ta khai thác sức mạnh tuyệt vời của các GPU hiện đại. Và bạn có thể tạo 2 triệu tam giác động bằng một GPU hiện đại và một số hiệu ứng đổ bóng kỳ diệu.

Viết mã WebGL hiệu quả

Để viết mã WebGL hiệu quả, bạn cần có một tư duy nhất định. Cách thông thường để vẽ bằng WebGL là thiết lập các đồng phục, vùng đệm và chương trình đổ bóng cho từng đối tượng, theo sau là lệnh gọi để vẽ đối tượng. Cách vẽ này hoạt động khi vẽ một số lượng nhỏ đối tượng. Để vẽ một số lượng lớn đối tượng, bạn nên giảm thiểu số lượng thay đổi trạng thái WebGL. Để bắt đầu, hãy vẽ tất cả các đối tượng bằng cùng một chương trình đổ bóng để bạn không phải thay đổi chương trình đổ bóng giữa các đối tượng. Đối với các đối tượng đơn giản như hạt, bạn có thể gói một số đối tượng vào một vùng đệm và chỉnh sửa vùng đệm đó bằng JavaScript. Bằng cách đó, bạn chỉ cần tải lại vùng đệm đỉnh thay vì thay đổi chương trình đổ bóng đồng nhất cho từng hạt.

Tuy nhiên, để thực sự nhanh, bạn cần đẩy hầu hết các phép tính vào chương trình đổ bóng. Đó là điều tôi đang cố gắng làm ở đây. Tạo ảnh động cho một triệu chữ cái bằng chương trình đổ bóng.

Mã của bài viết sử dụng thư viện Three.js, giúp tóm tắt tất cả các đoạn mã nguyên mẫu tẻ nhạt khi viết mã WebGL. Thay vì phải viết hàng trăm dòng mã thiết lập trạng thái WebGL và xử lý lỗi, với Three.js, bạn chỉ cần viết một vài dòng mã. Bạn cũng có thể dễ dàng khai thác hệ thống chương trình đổ bóng WebGL từ Three.js.

Vẽ nhiều đối tượng bằng một lệnh gọi vẽ

Dưới đây là một ví dụ nhỏ về mã giả lập về cách bạn có thể vẽ nhiều đối tượng bằng một lệnh gọi vẽ. Cách truyền thống là vẽ từng đối tượng một như sau:

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

Tuy nhiên, phương thức trên yêu cầu một lệnh gọi vẽ riêng cho mỗi đối tượng. Để vẽ nhiều đối tượng cùng một lúc, bạn có thể gói các đối tượng đó vào một hình học duy nhất và chỉ cần một lệnh gọi vẽ:

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

Được rồi, giờ bạn đã nắm được ý tưởng cơ bản, hãy quay lại viết mã minh hoạ và bắt đầu tạo ảnh động cho hàng triệu chữ cái đó!

Thiết lập hình học và hoạ tiết

Bước đầu tiên, tôi sẽ tạo một hoạ tiết có các bitmap chữ cái trên đó. Tôi sẽ sử dụng canvas 2D cho việc này. Kết cấu thu được có tất cả các chữ cái mà tôi muốn vẽ. Bước tiếp theo là tạo một vùng đệm có toạ độ kết cấu đến trang ảnh động chữ cái. Mặc dù đây là phương thức dễ dàng và đơn giản để thiết lập các chữ cái, nhưng phương thức này hơi lãng phí vì sử dụng hai số thực cho mỗi đỉnh cho toạ độ kết cấu. Một cách ngắn gọn hơn (để làm bài tập cho người đọc) là gói chỉ mục chữ cái và chỉ mục góc vào một số rồi chuyển đổi số đó trở lại toạ độ hoạ tiết trong chương trình đổ bóng đỉnh.

Dưới đây là cách tôi tạo hoạ tiết chữ cái bằng 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;

Tôi cũng tải mảng tam giác lên GPU. Các đỉnh này được chương trình đổ bóng đỉnh sử dụng để đặt các chữ cái trên màn hình. Các đỉnh được đặt thành vị trí chữ cái trong văn bản để nếu kết xuất mảng tam giác như hiện có, bạn sẽ nhận được kết xuất bố cục cơ bản của văn bản.

Tạo hình học cho cuốn sách:

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

Chương trình đổ bóng đỉnh để tạo ảnh động cho các chữ cái

Với chương trình đổ bóng đỉnh đơn giản, tôi có được chế độ xem phẳng của văn bản. Không có gì cầu kỳ. Chạy tốt, nhưng nếu muốn tạo ảnh động, tôi cần tạo ảnh động trong JavaScript. Và JavaScript hơi chậm khi tạo ảnh động cho 6 triệu đỉnh liên quan, đặc biệt là nếu bạn muốn tạo ảnh động trên mọi khung hình. Có thể có cách nhanh hơn.

Có, chúng ta có thể tạo ảnh động quy trình. Điều đó có nghĩa là chúng ta thực hiện tất cả phép toán vị trí và xoay trong chương trình đổ bóng đỉnh. Giờ đây, tôi không cần chạy bất kỳ JavaScript nào để cập nhật vị trí của các đỉnh. Chương trình đổ bóng đỉnh chạy rất nhanh và tôi có tốc độ khung hình mượt mà ngay cả khi có một triệu hình tam giác được tạo ảnh động riêng lẻ cho mỗi khung hình. Để xử lý từng tam giác riêng lẻ, tôi làm tròn các toạ độ đỉnh để tất cả 4 điểm của một hình tứ giác chữ cái ánh xạ đến một toạ độ duy nhất. Bây giờ, tôi có thể sử dụng toạ độ này để đặt các tham số ảnh động cho chữ cái có liên quan.

Để có thể làm tròn thành công toạ độ, toạ độ của hai chữ cái khác nhau không được trùng lặp. Cách dễ nhất để thực hiện việc này là sử dụng các khối chữ cái vuông có một độ lệch nhỏ để phân tách chữ cái với chữ cái ở bên phải và dòng ở phía trên. Ví dụ: bạn có thể sử dụng chiều rộng và chiều cao là 0, 5 cho các chữ cái và căn chỉnh các chữ cái trên toạ độ số nguyên. Bây giờ, khi làm tròn xuống toạ độ của bất kỳ đỉnh chữ cái nào, bạn sẽ nhận được toạ độ dưới cùng bên trái của chữ cái đó.

Làm tròn xuống các toạ độ đỉnh để tìm góc trên cùng bên trái của một chữ cái.
Làm tròn tọa độ đỉnh để tìm góc trên cùng bên trái của một chữ cái.

Để hiểu rõ hơn về chương trình đổ bóng đỉnh động, trước tiên, tôi sẽ tìm hiểu một chương trình đổ bóng đỉnh thông thường. Đây là điều thường xảy ra khi bạn vẽ một mô hình 3D lên màn hình. Các đỉnh của mô hình được chuyển đổi bằng một vài ma trận chuyển đổi để chiếu mỗi đỉnh 3D lên màn hình 2D. Bất cứ khi nào một tam giác được xác định bởi 3 đỉnh trong số này nằm trong khung nhìn, các pixel mà tam giác đó bao phủ sẽ được chương trình đổ bóng mảnh xử lý để tô màu. Dù sao, sau đây là chương trình đổ bóng đỉnh đơn giản:

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

Và giờ, trình đổ bóng đỉnh động. Về cơ bản, chương trình đổ bóng này hoạt động giống như chương trình đổ bóng đỉnh đơn giản, nhưng có một chút khác biệt. Thay vì chỉ biến đổi mỗi đỉnh bằng ma trận biến đổi, lớp này cũng áp dụng một phép biến đổi ảnh động phụ thuộc vào thời gian. Để tạo hiệu ứng ảnh động cho từng chữ cái theo cách khác nhau, chương trình đổ bóng đỉnh ảnh động cũng sửa đổi ảnh động dựa trên toạ độ của chữ cái. Mã này sẽ trông phức tạp hơn nhiều so với chương trình đổ bóng đỉnh đơn giản vì chương trình này phức tạp hơn.

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

Để sử dụng chương trình đổ bóng đỉnh, tôi sử dụng THREE.ShaderMaterial, một loại chất liệu cho phép bạn sử dụng chương trình đổ bóng tuỳ chỉnh và chỉ định đồng phục cho các chương trình đổ bóng đó. Sau đây là cách tôi sử dụng THREE.ShaderMaterial trong bản minh hoạ:

// 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;

Trên mỗi khung ảnh động, tôi cập nhật các biến đồng nhất của chương trình đổ bóng:

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

Vậy là bạn đã có ảnh động dựa trên chương trình đổ bóng. Mã này có vẻ khá phức tạp, nhưng thực sự chỉ di chuyển các chữ cái theo cách phụ thuộc vào thời gian hiện tại và chỉ mục của từng chữ cái. Nếu không quan tâm đến hiệu suất, bạn có thể chạy logic này trong JavaScript. Tuy nhiên, khi có hàng chục nghìn đối tượng ảnh động, JavaScript sẽ không còn là giải pháp khả thi.

Các vấn đề còn lại

Hiện tại, JavaScript không biết về vị trí của các hạt. Nếu thực sự cần biết vị trí của các hạt, bạn có thể sao chép logic chương trình đổ bóng đỉnh trong JavaScript và tính toán lại vị trí đỉnh bằng trình chạy web mỗi khi cần vị trí. Bằng cách đó, luồng kết xuất của bạn không phải chờ phép tính và bạn có thể tiếp tục tạo ảnh động ở tốc độ khung hình mượt mà.

Để có ảnh động có thể kiểm soát hơn, bạn có thể sử dụng chức năng kết xuất thành hoạ tiết để tạo ảnh động giữa hai nhóm vị trí do JavaScript cung cấp. Trước tiên, hãy kết xuất các vị trí hiện tại thành một hoạ tiết, sau đó tạo ảnh động hướng tới các vị trí được xác định trong một hoạ tiết riêng biệt do JavaScript cung cấp. Điều hay là bạn có thể cập nhật một phần nhỏ các vị trí do JavaScript cung cấp cho mỗi khung hình và vẫn tiếp tục tạo ảnh động cho tất cả các chữ cái trong mỗi khung hình bằng chương trình đổ bóng đỉnh giữa các vị trí.

Một vấn đề khác là 256 ký tự là quá ít để xử lý văn bản không phải ASCII. Nếu đẩy kích thước bản đồ kết cấu lên 4096x4096 trong khi giảm kích thước phông chữ xuống 8px, bạn có thể vừa khít toàn bộ bộ ký tự UCS-2 vào bản đồ kết cấu. Tuy nhiên, cỡ chữ 8px không dễ đọc. Để tạo cỡ chữ lớn hơn, bạn có thể sử dụng nhiều hoạ tiết cho phông chữ. Hãy xem bản minh hoạ tập hợp các sprite này để biết ví dụ. Một điều khác có thể giúp ích là chỉ tạo các chữ cái được dùng trong văn bản.

Tóm tắt

Trong bài viết này, tôi đã hướng dẫn bạn cách triển khai bản minh hoạ ảnh động dựa trên chương trình đổ bóng đỉnh bằng Three.js. Bản minh hoạ tạo ảnh động cho một triệu chữ cái theo thời gian thực trên MacBook Air 2010. Quá trình triển khai đã gói toàn bộ cuốn sách vào một đối tượng hình học duy nhất để vẽ hiệu quả. Các chữ cái riêng lẻ được tạo ảnh động bằng cách xác định đỉnh nào thuộc về chữ cái nào và tạo ảnh động cho các đỉnh dựa trên chỉ mục của chữ cái trong văn bản sách.

Tài liệu tham khảo