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

Giới thiệu

Canvas HTML5, ban đầu được 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. Hiện nay, nhiều nhà phát triển sử dụng nền tảng 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 tôi xây dựng có độ phức tạp ngày càng tăng, các nhà phát triển đã vô tình chạm đến tường hiệu suất. Có rất nhiều hiểu biết về việc tối ưu hoá hiệu suất của canvas. Bài viết này nhằm 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 thức tối ưu hoá cơ bản áp dụng cho mọi 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 quá trình triển khai canvas được 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 đã nêu được thảo luận có thể sẽ ít tác động hơn. Điều này sẽ được ghi chú 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 HTML5 hoặc hướng dẫn MDN Canvas.

Kiểm thử hiệu suất

Để giải quyết thế giới canvas HTML5 luôn thay đổi nhanh chóng, các thử nghiệm của JSPerf (jsperf.com) xác minh rằng mọi phương thức tối ưu hoá được đề xuất vẫn hoạt động. Jetpackerf 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 cho JavaScript. Mỗi lượt 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ả. Jetpackerf chạy từng 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 lại có ý nghĩa thống kê mỗi giây. Điểm số cao hơn luôn tốt hơn! Khách truy cập vào trang kiểm tra hiệu suất DEXerf có thể chạy quy trình kiểm thử trên trình duyệt của họ và cho phép DEXerf lưu trữ kết quả thử nghiệm được chuẩn hoá trên Browserscope (browserscope.org). Vì các kỹ thuật tối ưu hoá trong bài viết này được hỗ trợ bởi một kết quả Jetpackerf, nên bạn có thể quay lại để xem thông tin mới nhất về việc liệu 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à được nhúng xuyên suốt bài viết này.

Tất cả các kết quả về hiệu suất trong bài viết này đều dựa trên phiên bản trình duyệt. Đây hoá ra là một hạn chế, vì chúng tôi không biết trình duyệt đang chạy trên hệ điều hành nào, hoặc thậm chí quan trọng hơn là liệu canvas HTML5 có được tăng tốc phần cứng khi chạy thử nghiệm 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 trên canvas ngoài màn hình

Nếu đang vẽ lại các ảnh 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 lớn 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 nghĩa là sử dụng một canvas (hoặ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 lại các canvas ngoài màn hình trên khung hiển thị hiển thị. Ví dụ: giả sử bạn đang vẽ lại Mario đang 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 tại 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);
}

Hãy lưu ý việc sử dụng requestAnimationFrame. Điều này sẽ được thảo luận chi tiết hơn ở phần sau.

Kỹ thuật này đặc biệt hiệu quả khi thao tác kết xuất (trong ví dụ trên là drawMario) tốn kém. Một ví dụ điển hình là hiển thị 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ử "được kết xuất trước" lỏng lẻo. Khi kết xuất trước, bạn phải đảm bảo canvas tạm thời vừa khít với hình ảnh đang vẽ, nếu không thì mức tăng hiệu suất của việc kết xuất ngoài màn hình sẽ được cân nhắc bởi mức giảm hiệu suất của việc sao chép một canvas lớn sang một canvas khác (thay đổi tuỳ theo kích thước đích của nguồn). Một canvas nhỏ gọn trong kiểm thử ở trên chỉ đơn giản là nhỏ hơn:

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

So với chiến dịch lỏng lẻo mang lại hiệu suất kém hơn:

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

Thực hiện nhiều lệnh gọi canvas cùng lúc

Vì thao tác vẽ là một thao tác tốn kém, 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 lệnh dài, sau đó để máy kết xuất tất cả vào vùng đệm video.

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

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 hình nhiều đường:

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ì hiển thị các phân đoạn riêng biệt (jsperf).

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

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

Phần tử canvas HTML5 được triển khai trên máy trạng thái theo dõi các nội dung như kiểu tô màu nền 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. Chẳng hạn, nếu bạn sử dụng nhiều màu nền để kết xuất một cảnh, cách này sẽ rẻ hơn khi kết xuất theo màu thay vì theo vị trí trên canvas. Để kết xuất mẫu hình sọc, bạn có thể kết xuất một sọc, thay đổi màu sắc, kết xuất 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 hiển thị tất cả các sọc lẻ, sau đó là 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);
}

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

Chỉ hiển thị các điểm khác biệt của màn hình, không hiển thị toàn bộ trạng thái mới

Như mọi người mong đợi, việc kết xuất ít hơn trên màn hình sẽ rẻ hơn so với việc kết xuất nhiều hơn. Nếu chỉ có sự khác biệt gia tăng giữa các lần vẽ lại, thì bạn có thể tăng đáng kể hiệu suất bằng cách chỉ vẽ lại 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 được vẽ và chỉ để rõ điều đó.

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ẽ lại khu vực", nơi hộp giới hạn đã kết xuất trước đó được lưu, sau đó được xoá trong 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 bài nói chuyện về trình mô phỏng Ninja của JavaScript.

Sử dụng nhiều lớp canvas 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 chi phí và nên tránh nếu có thể. Ngoài việc sử dụng một canvas khác để kết xuất hình ảnh 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 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 đặt ở vị trí tuyệt đối, lần lượt 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ế so với việc chỉ có một canvas ở đây là khi chúng ta vẽ hoặc xoá canvas ở nền trước, chúng ta sẽ không bao giờ sửa đổi nền. Nếu ứng dụng 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 việc kết xuất chúng trên các canvas riêng biệt để tăng hiệu suất đáng kể.

Thường thì bạn có thể tận dụng nhận thức chưa hoàn hảo của con người để 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 (việc này có khả năng chiếm hầu hết 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 thứ N. Ngoài ra, xin lưu ý rằng phương pháp này sẽ khái quát hoá hiệu quả cho số lượng canvas kết hợp bất kỳ 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 làm mờ bóng

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ờ dữ liệu 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ô hình vẽ ở chế độ tức thì, nên bạn cần vẽ lại cảnh rõ ràng ở mỗi khung hình. Do đó, việc xoá canvas là một thao tác cơ bản quan trọng đố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 xóa toàn bộ canvas thường không được mong muốn, nhưng nếu bạn phải làm điều này, có hai tùy chọn: gọi context.clearRect(0, 0, width, height) hoặc sử dụng một tấn công dành riêng cho canvas để thực hiện việc này: canvas.width = canvas.width;.Tại thời điểm viết, clearRect thường hoạt động tốt hơn phiên bản đặt lại chiều rộng, nhưng trong một số trường hợp sử dụng cách đặ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 việc 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 hiển thị 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ì tính năng này sẽ tự động sử dụng tính năng khử răng cưa để làm mịn các đường nét. Dưới đây là hiệu ứng hình ảnh được lấy từ bài viết về hiệu suất ảnh in trên vải canvas có pixel phụ này của Seb Lee-Del Chính:

Pixel phụ

Nếu sprite được làm mượt không phải là hiệu ứng mà bạn muốn, thì việc chuyển đổi toạ độ thành số nguyên có thể 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, cách hiệu quả nhất trong đó có thể thêm một nửa vào số mục tiêu, sau đó thực hiện các thao tác bit trên kết quả để loại bỏ phần phân số.

// 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 toàn bộ thông tin phân tích về hiệu suất tại đây (jsperf).

Lưu ý rằng kiểu tối ưu hoá này sẽ không còn quan trọng khi quá trình 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

Bạn nên sử dụng API requestAnimationFrame tương đối mới để triển khai các ứng dụng tương tác trong trình duyệt. Thay vì ra lệnh cho trình duyệt kết xuất ở một tốc độ kim đánh dấu nhịp độ khung hình cố định cụ thể, bạn hãy lịch sự yêu cầu trình duyệt gọi quy trình kết xuất của bạn và được gọi khi trình duyệt hoạt động. Là một hiệu ứng phụ tuyệt vời, nếu trang không ở nền trước, trình duyệt đủ thông minh để không hiển thị. Lệnh gọi lại requestAnimationFrame hướng đến tốc độ gọi lại 60 FPS, nhưng không đảm bảo. Vì vậy, bạn cần theo dõi thời lượng đã trôi qua kể từ lần kết xuất gần đây nhất. Mã này 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();

Lưu ý rằng việc 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, API này chỉ có trong Chrome, Safari và Firefox, vì vậy, bạn nên sử dụng phần đệm này.

Hầu hết quá trình triển khai canvas trên thiết bị di động đều bị chậm

Hãy cùng thảo luận về thiết bị di động. Rất tiếc, tại thời điểm viết bài này, chỉ có iOS 5.0 (thử nghiệm) chạy Safari 5.1 mới có tính năng triển khai canvas trên thiết bị 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 cho thiết bị di động thường sẽ không có CPU đủ mạnh cho các ứng dụng dựa trên canvas hiện đại. Một số kiểm thử DEXerf được mô tả ở trên hoạt động kém hơn trên thiết bị di động so với máy tính, điều này hạn chế đáng kể các loại ứng dụng trên nhiều thiết bị mà bạn có thể mong đợi 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 sẽ giúp bạn phát triển các dự án dựa trên canvas HTML5 hiệu quả. Giờ bạn đã học được những điều mới ở đây, hãy tiếp tục và tối ưu hoá những tác phẩm tuyệt vời của mình. Hoặc, nếu bạn hiện chưa có trò chơi hoặc ứng dụng để tối ưu hoá, hãy xem Thử nghiệm ChromeCreative JS để lấy cảm hứng.

Tài liệu tham khảo