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 ở tốc độ khung hình mượt mà. Tác vụ này hoàn toàn khả thi với GPU hiện đại. Mỗi chữ cái bao gồm hai hình tam giác có hoạ tiết, vì vậy chúng ta chỉ nói về 2 triệu hình tam giác cho mỗi khung.

Nếu bạn đang sử dụng nền ảnh động JavaScript truyền thống thì tất cả điều này nghe có vẻ điên rồ. Hai triệu tam giác cập nhật từng khung chắc chắn không phải là điều bạn muốn thực hiện với JavaScript hiện nay. Nhưng rất may là chúng tôi đã có WebGL, cho phép chúng tôi khai thác sức mạnh tuyệt vời của các GPU hiện đại. Hơn nữa, bạn có thể sử dụng 2 triệu hình tam giác động với GPU hiện đại và phép thuật của chương trình đổ bóng.

Viết mã WebGL hiệu quả

Viết mã WebGL hiệu quả đòi hỏi tư duy nhất định. Cách vẽ thông thường bằng WebGL là thiết lập đồng phục, vùng đệm và chương trình đổ bóng cho từng đối tượng, sau đó là lệnh gọi để vẽ đối tượng. Cách vẽ này hoạt động khi bạn vẽ một số ít đối tượng. Để vẽ số lượng lớn đối tượng, bạn nên giảm thiểu lượng thay đổi trạng thái WebGL. Để bắt đầu, hãy vẽ tất cả đối tượng bằng cùng một chương trình đổ bóng sau nhau để 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 nhiều đối tượng vào một vùng đệm và chỉnh sửa đối tượng đó bằng JavaScript. Bằng cách đó, bạn chỉ phải tải lại vùng đệm đỉnh lên thay vì thay đổi đồng nhất của chương trình đổ bóng cho từng hạt riêng lẻ.

Nhưng để thực hiện nhanh, bạn cần đẩy hầu hết phép tính sang chương trình đổ bóng. Đó là điều tôi đang cố gắng thực hiện ở đây. Tạo ảnh động cho 1 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 loại bỏ tất cả 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 để 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 truy cập vào hệ thống đổ bóng WebGL từ Three.js.

Vẽ nhiều đối tượng bằng một hàm gọi vẽ duy nhất

Dưới đây là một ví dụ nhỏ về mã giả về cách bạn có thể vẽ nhiều đối tượng bằng một lệnh gọi vẽ duy nhất. 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 từng đối tượng. Để vẽ nhiều đối tượng cùng một lúc, bạn có thể nhóm các đối tượng thành một hình duy nhất và thoát bằng một lệnh gọi vẽ duy nhất:

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 bản 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 hoạ tiết với các bitmap chữ cái trên đó. Tôi đang sử dụng canvas 2D cho việc này. Hoạ tiết thu được có tất cả các chữ cái tôi muốn vẽ. Bước tiếp theo là tạo vùng đệm có toạ độ hoạ tiết cho bảng sprite dạng chữ. Mặc dù đây là phương pháp dễ dàng và đơn giản để thiết lập các chữ cái, nhưng hơi lãng phí vì sử dụng hai dấu phẩy động trên mỗi đỉnh cho toạ độ hoạ tiết. Cách ngắn hơn – bên trái như một bài tập cho người đọc – là gộp chỉ mục chữ cái và chỉ mục góc thành một số và chuyển 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. Những đỉnh này được chương trình đổ bóng đỉnh để đưa các chữ cái lên màn hình. Các đỉnh được đặt thành các vị trí chữ cái trong văn bản để nếu hiển thị mảng tam giác nguyên trạng, bạn sẽ có được hình ảnh kết xuất bố cục cơ bản của văn bản.

Tạo hình học cho 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 Vertex để tạo hiệu ứng cho chữ cái

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

Lý do là gì, chúng ta có thể tạo ảnh động theo quy trình. Điều đó có nghĩa là chúng ta sẽ thực hiện tất cả các phép toán xác định vị trí và xoay trong chương trình đổ bóng đỉnh. Hiện tại, tôi không cần phải chạy JavaScript để 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ó được tốc độ khung hình mượt mà ngay cả với hàng triệu hình tam giác được tạo hiệu ứng động cho mỗi khung hình. Để giải quyết từng tam giác, tôi làm tròn toạ độ đỉnh xuống để tất cả bốn điểm của một tứ giác ánh xạ thành một toạ độ duy nhất. Bây giờ, tôi có thể sử dụng toạ độ này để đặt tham số ảnh động cho chữ cái được đề cập.

Để có thể làm tròn toạ độ xuống thành công, toạ độ từ 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 ô chữ cái vuông với một khoảng dời nhỏ, phân cách giữa 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. Giờ đây, khi làm tròn toạ độ của một đỉnh chữ cái bất kỳ, bạn sẽ nhận được toạ độ dưới cùng bên trái của chữ cái.

Làm tròn toạ độ đỉnh xuống để tìm góc trên cùng bên trái của một chữ cái.
Làm tròn các toạ độ đỉnh xuống để 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 ảnh động, trước tiên, tôi sẽ giới thiệu về chương trình đổ bóng đỉnh đơn giản. Đâ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 biến đổi bởi một vài ma trận biến đổi để chiếu từng đỉ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 ba trong số các đỉnh này dừng lại bên trong khung nhìn, các pixel mà nó bao phủ sẽ được chương trình đổ bóng mảnh xử lý để tô màu chúng. Dù sao thì sau đây là một 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;
}

Còn bây giờ là chương trình đổ bóng đỉnh ảnh động. Về cơ bản, giao diện này hoạt động tương tự như chương trình đổ bóng đỉnh đơn giản, nhưng có một chút thay đổi nhỏ. Thay vì biến đổi từng đỉnh chỉ bằng các ma trận biến đổi, phép biến đổi ảnh động cũng áp dụng phép biến đổi ảnh động phụ thuộc vào thời gian. Để làm cho mỗi chữ cái có hiệu ứng động khác nhau một chút, chương trình đổ bóng đỉnh động cũng sửa đổi ảnh động dựa trên toạ độ của chữ cái đó. Công cụ này có vẻ sẽ 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 (vertex), tôi sử dụng THREE.ShaderMaterial, một loại material cho phép bạn sử dụng chương trình đổ bóng tuỳ chỉnh và chỉ định trạng thái đồng nhất cho các chương trình đó. Đâ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 đồng nhất cho 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à ảnh động dựa trên chương trình đổ bóng. Ứng dụng này trông khá phức tạp, nhưng điều duy nhất bạn thực sự làm là 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, với hàng chục nghìn đối tượng ảnh động, JavaScript không còn là một giải pháp khả thi nữa.

Mối lo ngại còn lại

Hiện tại có một vấn đề là JavaScript không biết vị trí phần tử. Nếu thực sự cần biết các phần tử của mình ở đâu, 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 cách sử dụng trình thực thi web mỗi khi cần vị trí. Nhờ vậy, luồng kết xuất của bạn không phải đợi tính toán và bạn có thể tiếp tục tạo ảnh động với tốc độ khung hình mượt mà.

Để tạo ảnh động dễ kiểm soát hơn, bạn có thể sử dụng chức năng kết xuất thành kết cấu để tạo hiệu ứng động giữa hai tập hợp 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 vào một hoạ tiết, sau đó tạo ảnh động về 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 tuyệt vời ở đây là bạn có thể cập nhật một phần nhỏ các vị trí do JavaScript cung cấp trên mỗi khung và vẫn tiếp tục tạo ảnh động cho tất cả các chữ cái trong mỗi khung bằng chương trình đổ bóng đỉnh tweening các vị trí.

Một mối lo ngại khác là 256 ký tự là quá ít để tạo các văn bản không phải ASCII. Nếu đẩy kích thước bản đồ hoạ tiết lên 4096x4096 trong khi giảm kích thước phông chữ xuống 8px, bạn có thể điều chỉnh toàn bộ bộ ký tự UCS-2 vào bản đồ hoạ tiết. Tuy nhiên, cỡ chữ 8px không dễ đọc. Để sử dụng cỡ chữ lớn hơn, bạn có thể sử dụng nhiều hoạ tiết cho phông chữ. Xem bản minh hoạ tập bản đồ sprite này để biết ví dụ. Một cách khác sẽ hữu ích là chỉ tạo các chữ cái được sử 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 hàng triệu chữ cái theo thời gian thực trên MacBook Air 2010. Việc triển khai đã nhóm toàn bộ một cuốn sách thành một đối tượng hình học duy nhất để vẽ một cách hiệu quả. Từng chữ cái riêng lẻ được tạo ảnh động bằng cách tìm ra đỉnh 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 nội dung sách.

Tài liệu tham khảo