Cải thiện hiệu suất của HTML5 Canvas

Giới thiệu

Canvas HTML5 (bắt đầu từ một thử nghiệm của Apple) là tiêu chuẩn được hỗ trợ rộng rãi nhất cho đồ hoạ ở chế độ tức thì 2D trên web. Nhiều nhà phát triển hiện dựa vào công cụ này cho nhiều dự án đa phương tiện, hình ảnh trực quan và trò chơi. Tuy nhiên, khi các ứng dụng chúng ta xây dựng ngày càng phức tạp, các nhà phát triển vô tình gặp phải rào cản về hiệu suất. Có rất nhiều thông tin rời rạc về cách tối ưu hoá hiệu suất của canvas. Mục đích của bài viết này là hợp nhất một số nội dung này thành một tài nguyên dễ hiểu hơn cho nhà phát triển. Bài viết này bao gồm các phương pháp tối ưu hoá cơ bản áp dụng cho tất cả môi trường đồ hoạ máy tính cũng như các kỹ thuật dành riêng cho canvas có thể thay đổi khi việc triển khai canvas cải thiện. Cụ thể, khi các nhà cung cấp trình duyệt triển khai tính năng tăng tốc GPU canvas, một số kỹ thuật hiệu suất được nêu có thể sẽ ít tác động hơn. Điều này sẽ được lưu ý khi thích hợp. Xin lưu ý rằng bài viết này không đề cập đến việc sử dụng canvas HTML5. Để làm được điều đó, hãy xem các bài viết liên quan đến canvas trên HTML5Rocks, chương này trên trang web Tìm hiểu về HTML5 hoặc hướng dẫn về Canvas MDN.

Kiểm thử hiệu suất

Để giải quyết thế giới canvas HTML5 thay đổi nhanh chóng, các phép kiểm thử JSPerf (jsperf.com) sẽ xác minh rằng mọi phương thức tối ưu hoá được đề xuất vẫn hoạt động. JSPerf là một ứng dụng web cho phép nhà phát triển viết các chương trình kiểm thử hiệu suất JavaScript. Mỗi bài kiểm thử tập trung vào một kết quả mà bạn đang cố gắng đạt được (ví dụ: xoá canvas) và bao gồm nhiều phương pháp để đạt được cùng một kết quả. JSPerf chạy mỗi phương pháp nhiều lần nhất có thể trong một khoảng thời gian ngắn và đưa ra số lần lặp có ý nghĩa thống kê mỗi giây. Điểm số càng cao thì càng tốt! Khách truy cập vào trang kiểm thử hiệu suất JSPerf có thể chạy kiểm thử trên trình duyệt và cho phép JSPerf lưu trữ kết quả kiểm thử đã chuẩn hoá trên Browserscope (browserscope.org). Vì kỹ thuật tối ưu hoá trong bài viết này được hỗ trợ dựa trên kết quả của JavaScripterf, nên bạn có thể quay lại để xem thông tin mới nhất về việc kỹ thuật đó có còn áp dụng hay không. Tôi đã viết một ứng dụng trợ giúp nhỏ hiển thị các kết quả này dưới dạng biểu đồ và nhúng trong bài viết này.

Tất cả kết quả về hiệu suất trong bài viết này đều được phân theo phiên bản trình duyệt. Đây là một hạn chế vì chúng ta không biết trình duyệt đang chạy trên hệ điều hành nào, hoặc quan trọng hơn là liệu canvas HTML5 có được tăng tốc phần cứng khi chạy kiểm thử hiệu suất hay không. Bạn có thể tìm hiểu xem canvas HTML5 của Chrome có được tăng tốc phần cứng hay không bằng cách truy cập vào about:gpu trong thanh địa chỉ.

Kết xuất trước vào canvas ngoài màn hình

Nếu đang vẽ lại các dữ liệu gốc tương tự lên màn hình trên nhiều khung hình, như thường lệ khi viết trò chơi, bạn có thể tăng hiệu suất đáng kể bằng cách kết xuất trước các phần lớn của cảnh. Kết xuất trước có nghĩa là sử dụng một canvas (hoặc các canvas) riêng biệt ngoài màn hình để kết xuất hình ảnh tạm thời, sau đó kết xuất các canvas ngoài màn hình trở lại canvas hiển thị. Ví dụ: giả sử bạn đang vẽ lại Mario chạy ở tốc độ 60 khung hình/giây. Bạn có thể vẽ lại mũ, ria mép và chữ “M” của anh ấy ở mỗi khung hình hoặc kết xuất trước Mario trước khi chạy ảnh động. không kết xuất trước:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

kết xuất trước:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext('2d');
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

Lưu ý việc sử dụng requestAnimationFrame. Việc này sẽ được thảo luận chi tiết hơn trong phần sau.

Kỹ thuật này đặc biệt hiệu quả khi thao tác kết xuất (drawMario trong ví dụ trên) tốn kém. Một ví dụ điển hình về điều này là việc kết xuất văn bản, đây là một thao tác rất tốn kém.

Tuy nhiên, hiệu suất kém của trường hợp kiểm thử "trước khi kết xuất lỏng". Khi kết xuất trước, điều quan trọng là phải đảm bảo rằng canvas tạm thời vừa khít với hình ảnh bạn đang vẽ, nếu không, hiệu suất tăng lên của việc kết xuất ngoài màn hình sẽ bị cân bằng bởi hiệu suất giảm khi sao chép một canvas lớn sang một canvas khác (sẽ thay đổi tuỳ theo kích thước mục tiêu nguồn). Canvas vừa vặn trong thử nghiệm trên chỉ đơn giản là nhỏ hơn:

can2.width = 100;
can2.height = 40;

So với cấu hình lỏng mang lại hiệu suất kém hơn:

can3.width = 300;
can3.height = 100;

Nhóm các lệnh gọi canvas cùng lúc

Vì vẽ là một thao tác tiêu tốn nhiều tài nguyên, nên sẽ hiệu quả hơn nếu bạn tải máy trạng thái vẽ bằng một tập hợp các lệnh dài, sau đó kết xuất tất cả vào bộ đệm video.

Ví dụ: khi vẽ nhiều đường, bạn nên tạo một đường dẫn có tất cả các đường trong đó và vẽ đường dẫn đó bằng một lệnh gọi vẽ duy nhất. Nói cách khác, thay vì vẽ các đường riêng biệt:

for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.beginPath();
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
  context.stroke();
}

Chúng ta có được hiệu suất tốt hơn khi vẽ một đường đa giác:

context.beginPath();
for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
}
context.stroke();

Điều này cũng áp dụng cho thế giới canvas HTML5. Ví dụ: khi vẽ một đường dẫn phức tạp, bạn nên đặt tất cả các điểm vào đường dẫn thay vì kết xuất các đoạn riêng biệt (jsperf).

Tuy nhiên, xin lưu ý rằng với Canvas, có một ngoại lệ quan trọng đối với quy tắc này: nếu các đối tượng gốc liên quan đến việc vẽ đối tượng mong muốn có hộp giới hạn nhỏ (ví dụ: đường kẻ ngang và dọc), thì việc kết xuất riêng các đối tượng đó có thể hiệu quả hơn (jsperf).

Tránh thay đổi trạng thái canvas không cần thiết

Phần tử canvas HTML5 được triển khai trên một máy trạng thái theo dõi các kiểu tô và nét vẽ, cũng như các điểm trước đó tạo nên đường dẫn hiện tại. Khi cố gắng tối ưu hoá hiệu suất đồ hoạ, bạn sẽ muốn chỉ tập trung vào việc kết xuất đồ hoạ. Tuy nhiên, việc thao tác với máy trạng thái cũng có thể gây ra hao tổn hiệu suất. Ví dụ: nếu bạn sử dụng nhiều màu tô để kết xuất một cảnh, thì việc kết xuất theo màu sẽ rẻ hơn so với việc kết xuất theo vị trí trên canvas. Để hiển thị hoa văn kẻ sọc, bạn có thể hiển thị một sọc, thay đổi màu sắc, hiển thị sọc tiếp theo, v.v.:

for (var i = 0; i < STRIPES; i++) {
  context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
  context.fillRect(i * GAP, 0, GAP, 480);
}

Hoặc kết xuất tất cả các sọc lẻ rồi đến tất cả các sọc chẵn:

context.fillStyle = COLOR1;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2) * GAP, 0, GAP, 480);
}
context.fillStyle = COLOR2;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2+1) * GAP, 0, GAP, 480);
}

Đúng như dự kiến, phương pháp xen kẽ sẽ chậm hơn vì việc thay đổi máy trạng thái sẽ tốn kém.

Chỉ hiển thị sự khác biệt trên màn hình, chứ không phải toàn bộ trạng thái mới

Như dự kiến, việc kết xuất ít trên màn hình sẽ rẻ hơn so với việc kết xuất nhiều. Nếu chỉ có sự khác biệt gia tăng giữa các lần vẽ lại, bạn có thể tăng hiệu suất đáng kể chỉ bằng cách vẽ sự khác biệt đó. Nói cách khác, thay vì xoá toàn bộ màn hình trước khi vẽ:

context.fillRect(0, 0, canvas.width, canvas.height);

Theo dõi hộp giới hạn đã vẽ và chỉ xoá hộp giới hạn đó.

context.fillRect(last.x, last.y, last.width, last.height);

Nếu quen thuộc với đồ hoạ máy tính, bạn cũng có thể biết kỹ thuật này là "vùng vẽ lại", trong đó hộp giới hạn đã kết xuất trước đó được lưu, sau đó xoá trên mỗi lần kết xuất. Kỹ thuật này cũng áp dụng cho các ngữ cảnh kết xuất dựa trên pixel, như được minh hoạ trong buổi nói chuyện về trình mô phỏng Nintendo bằng JavaScript này.

Sử dụng nhiều canvas có lớp phủ cho các cảnh phức tạp

Như đã đề cập trước đó, việc vẽ hình ảnh lớn sẽ tốn kém và bạn nên tránh nếu có thể. Ngoài việc sử dụng một canvas khác để kết xuất ngoài màn hình, như minh hoạ trong phần kết xuất trước, chúng ta cũng có thể sử dụng các canvas xếp chồng lên nhau. Bằng cách sử dụng độ trong suốt trong canvas ở nền trước, chúng ta có thể dựa vào GPU để kết hợp các alpha với nhau tại thời điểm kết xuất. Bạn có thể thiết lập như sau, với 2 canvas được bố trí hoàn toàn chồng lên nhau.

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

Lợi thế của việc có hai canvas ở đây là khi vẽ hoặc xoá canvas nền trước, chúng ta không bao giờ sửa đổi nền. Nếu trò chơi hoặc ứng dụng đa phương tiện của bạn có thể được chia thành nền trước và nền sau, hãy cân nhắc kết xuất các thành phần này trên các canvas riêng biệt để tăng hiệu suất đáng kể.

Bạn thường có thể tận dụng khả năng nhận thức không hoàn hảo của con người và chỉ kết xuất nền một lần hoặc ở tốc độ chậm hơn so với nền trước (có thể chiếm phần lớn sự chú ý của người dùng). Ví dụ: bạn có thể kết xuất nền trước mỗi khi kết xuất, nhưng chỉ kết xuất nền sau mỗi khung hình thứ N. Ngoài ra, hãy lưu ý rằng phương pháp này khá tổng quát cho mọi số lượng canvas tổng hợp nếu ứng dụng của bạn hoạt động tốt hơn với loại cấu trúc này.

Tránh shadowBlur

Giống như nhiều môi trường đồ hoạ khác, canvas HTML5 cho phép nhà phát triển làm mờ các đối tượng gốc, nhưng thao tác này có thể rất tốn kém:

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

Biết nhiều cách để xoá canvas

Vì canvas HTML5 là một mô hình vẽ chế độ tức thì, nên cảnh cần được vẽ lại một cách rõ ràng ở mỗi khung hình. Do đó, việc xoá canvas là một thao tác quan trọng về cơ bản đối với các ứng dụng và trò chơi canvas HTML5. Như đã đề cập trong phần Tránh thay đổi trạng thái canvas, việc xoá toàn bộ canvas thường không được mong muốn, nhưng nếu bạn phải thực hiện, có hai lựa chọn: gọi context.clearRect(0, 0, width, height) hoặc sử dụng một kỹ thuật tấn công dành riêng cho canvas để thực hiện: canvas.width = canvas.width;.Tại thời điểm viết, clearRect thường vượt trội hơn chiều rộng của phiên bản đặt lại, nhưng trong một số trường hợp sử dụng lệnh xâm nhập đặt lại canvas.width nhanh hơn đáng kể trong Chrome 14

Hãy cẩn thận với mẹo này vì mẹo này phụ thuộc rất nhiều vào cách triển khai canvas cơ bản và có thể thay đổi rất nhiều. Để biết thêm thông tin, hãy xem bài viết của Simon Sarris về cách xoá canvas.

Tránh toạ độ dấu phẩy động

Canvas HTML5 hỗ trợ tính năng kết xuất pixel phụ và không có cách nào để tắt tính năng này. Nếu bạn vẽ bằng toạ độ không phải là số nguyên, thì canvas sẽ tự động sử dụng tính năng khử răng cưa để cố làm mượt các đường. Dưới đây là hiệu ứng hình ảnh, được lấy từ bài viết này của Seb Lee-Delisle về hiệu suất của canvas có độ phân giải dưới pixel:

Pixel phụ

Nếu ảnh động được làm mượt không phải là hiệu ứng bạn muốn, thì bạn có thể chuyển đổi toạ độ thành số nguyên nhanh hơn nhiều bằng cách sử dụng Math.floor hoặc Math.round (jsperf):

Để chuyển đổi toạ độ dấu phẩy động thành số nguyên, bạn có thể sử dụng một số kỹ thuật thông minh, trong đó kỹ thuật hiệu quả nhất là thêm một nửa vào số mục tiêu, sau đó thực hiện các phép toán bit trên kết quả để loại bỏ phần thập phân.

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

Bạn có thể xem bảng chi tiết hiệu suất đầy đủ tại đây (jsperf).

Xin lưu ý rằng kiểu tối ưu hoá này sẽ không còn quan trọng nữa sau khi các lượt triển khai canvas được tăng tốc GPU, nhờ đó có thể nhanh chóng kết xuất các toạ độ không phải là số nguyên.

Tối ưu hoá ảnh động bằng requestAnimationFrame

API requestAnimationFrame tương đối mới là cách nên dùng để triển khai các ứng dụng tương tác trong trình duyệt. Thay vì lệnh trình duyệt hiển thị ở một tốc độ đánh dấu cố định cụ thể, bạn nên yêu cầu trình duyệt gọi quy trình kết xuất và được gọi khi trình duyệt có sẵn. Một hiệu ứng phụ thú vị là nếu trang không ở nền trước, trình duyệt sẽ đủ thông minh để không hiển thị. Lệnh gọi lại requestAnimationFrame nhắm đến tốc độ gọi lại 60 FPS nhưng không đảm bảo tốc độ đó, vì vậy, bạn cần theo dõi khoảng thời gian đã trôi qua kể từ lần kết xuất gần nhất. Mã có thể có dạng như sau:

var x = 100;
var y = 100;
var lastRender = Date.now();
function render() {
  var delta = Date.now() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

Xin lưu ý rằng cách sử dụng requestAnimationFrame này áp dụng cho canvas cũng như các công nghệ kết xuất khác như WebGL. Tại thời điểm viết bài, API này chỉ có trong Chrome, Safari và Firefox, vì vậy, bạn nên sử dụng trình bổ trợ này.

Hầu hết các hoạt động triển khai canvas trên thiết bị di động đều diễn ra chậm

Hãy cùng thảo luận về thiết bị di động. Tiếc là tại thời điểm viết bài, chỉ có iOS 5.0 beta chạy Safari 5.1 mới triển khai được canvas di động tăng tốc GPU. Nếu không có tính năng tăng tốc GPU, trình duyệt di động thường không có CPU đủ mạnh cho các ứng dụng hiện đại dựa trên canvas. Một số bài kiểm thử JSPerf được mô tả ở trên có hiệu suất kém hơn nhiều trên thiết bị di động so với máy tính, hạn chế đáng kể các loại ứng dụng đa thiết bị mà bạn có thể chạy thành công.

Kết luận

Tóm lại, bài viết này đề cập đến một tập hợp toàn diện các kỹ thuật tối ưu hoá hữu ích giúp bạn phát triển các dự án dựa trên canvas HTML5 có hiệu suất cao. Giờ thì bạn đã học được một số kiến thức mới, hãy bắt đầu tối ưu hoá những nội dung sáng tạo tuyệt vời của mình. Hoặc nếu bạn hiện không có trò chơi hoặc ứng dụng nào để tối ưu hoá, hãy xem Thử nghiệm ChromeCreative JS để tìm nguồn cảm hứng.

Tài liệu tham khảo