Nghiên cứu điển hình – Mê cung Inside World

World Wide Maze là một trò chơi mà bạn sử dụng điện thoại thông minh để điều hướng một quả bóng lăn qua mê cung 3D được tạo từ các trang web để cố gắng đạt được điểm đến.

World Wide Maze

Trò chơi này sử dụng nhiều tính năng HTML5. Ví dụ: sự kiện DeviceOrientation truy xuất dữ liệu độ nghiêng từ điện thoại thông minh, sau đó được gửi đến máy tính qua WebSocket, nơi người chơi tìm đường đi qua không gian 3D do WebGLWeb Worker tạo.

Trong bài viết này, tôi sẽ giải thích chính xác cách sử dụng các tính năng này, quy trình phát triển tổng thể và các điểm chính để tối ưu hoá.

DeviceOrientation

Sự kiện DeviceOrientation (ví dụ) được dùng để truy xuất dữ liệu độ nghiêng từ điện thoại thông minh. Khi addEventListener được sử dụng với sự kiện DeviceOrientation, lệnh gọi lại với đối tượng DeviceOrientationEvent sẽ được gọi dưới dạng đối số theo các khoảng thời gian đều đặn. Bản thân các khoảng thời gian này sẽ khác nhau tuỳ theo thiết bị được sử dụng. Ví dụ: trong iOS + Chrome và iOS + Safari, lệnh gọi lại được gọi khoảng 1/20 giây, trong khi trong Android 4 + Chrome, lệnh gọi lại được gọi khoảng 1/10 giây.

window.addEventListener('deviceorientation', function (e) {
  // do something here..
});

Đối tượng DeviceOrientationEvent chứa dữ liệu độ nghiêng cho mỗi trục X, YZ theo độ (không phải radian) (Đọc thêm trên HTML5Rocks). Tuy nhiên, các giá trị trả về cũng thay đổi tuỳ theo tổ hợp thiết bị và trình duyệt được sử dụng. Phạm vi của các giá trị trả về thực tế được trình bày trong bảng dưới đây:

Hướng của thiết bị.

Các giá trị ở trên cùng được đánh dấu màu xanh dương là những giá trị được xác định trong quy cách của W3C. Những giá trị được đánh dấu màu xanh lục khớp với các thông số kỹ thuật này, còn những giá trị được đánh dấu màu đỏ thì không khớp. Đáng ngạc nhiên là chỉ tổ hợp Android-Firefox trả về các giá trị khớp với thông số kỹ thuật. Tuy nhiên, khi triển khai, bạn nên điều chỉnh để phù hợp với các giá trị xuất hiện thường xuyên. Do đó, World Wide Maze sử dụng các giá trị trả về của iOS làm tiêu chuẩn và điều chỉnh cho phù hợp với các thiết bị Android.

if android and event.gamma > 180 then event.gamma -= 360

Tuy nhiên, tính năng này vẫn chưa hỗ trợ Nexus 10. Mặc dù Nexus 10 trả về cùng một dải giá trị như các thiết bị Android khác, nhưng có một lỗi đảo ngược các giá trị beta và gamma. Vấn đề này đang được giải quyết riêng. (Có thể chế độ mặc định là hướng ngang?)

Như minh hoạ ở trên, ngay cả khi các API liên quan đến thiết bị thực đã đặt thông số kỹ thuật, thì cũng không có gì đảm bảo rằng các giá trị được trả về sẽ khớp với các thông số kỹ thuật đó. Do đó, việc kiểm thử các thiết bị đó trên tất cả thiết bị tiềm năng là rất quan trọng. Điều này cũng có nghĩa là bạn có thể nhập các giá trị không mong muốn, do đó cần phải tạo giải pháp. World Wide Maze nhắc người chơi lần đầu tiên phải hiệu chỉnh thiết bị của họ ở bước 1 của hướng dẫn, nhưng trò chơi sẽ không hiệu chỉnh đúng cách đến vị trí 0 nếu nhận được các giá trị nghiêng không mong muốn. Do đó, tính năng này có giới hạn thời gian nội bộ và nhắc người chơi chuyển sang điều khiển bằng bàn phím nếu không thể hiệu chỉnh trong giới hạn thời gian đó.

WebSocket

Trong World Wide Maze, điện thoại thông minh và máy tính của bạn được kết nối thông qua WebSocket. Chính xác hơn, các thiết bị này được kết nối thông qua một máy chủ chuyển tiếp giữa chúng, tức là điện thoại thông minh đến máy chủ đến máy tính. Điều này là do WebSocket không có khả năng kết nối trực tiếp các trình duyệt với nhau. (Việc sử dụng kênh dữ liệu WebRTC cho phép kết nối ngang hàng và không cần đến máy chủ chuyển tiếp, nhưng tại thời điểm triển khai, phương thức này chỉ có thể dùng được với Chrome Canary và Firefox Nightly.)

Tôi đã chọn triển khai bằng một thư viện có tên Socket.IO (phiên bản 0.9.11). Thư viện này có các tính năng để kết nối lại trong trường hợp hết thời gian chờ kết nối hoặc bị ngắt kết nối. Tôi đã sử dụng Socket.IO cùng với NodeJS, vì sự kết hợp NodeJS + Socket.IO này cho thấy hiệu suất phía máy chủ tốt nhất trong một số thử nghiệm triển khai WebSocket.

Ghép nối theo số

  1. Máy tính của bạn kết nối với máy chủ.
  2. Máy chủ sẽ cung cấp cho máy tính của bạn một số được tạo ngẫu nhiên và ghi nhớ tổ hợp số và máy tính.
  3. Trên thiết bị di động, hãy chỉ định một số và kết nối với máy chủ.
  4. Nếu số được chỉ định giống với số của máy tính đã kết nối, thì thiết bị di động của bạn sẽ được ghép nối với máy tính đó.
  5. Nếu không có máy tính được chỉ định, lỗi sẽ xảy ra.
  6. Khi dữ liệu đến từ thiết bị di động, dữ liệu đó sẽ được gửi đến máy tính đã ghép nối và ngược lại.

Bạn cũng có thể thực hiện kết nối ban đầu từ thiết bị di động. Trong trường hợp đó, các thiết bị chỉ được đảo ngược.

Đồng bộ hoá thẻ

Tính năng Đồng bộ hoá thẻ dành riêng cho Chrome giúp quy trình ghép nối trở nên dễ dàng hơn nữa. Nhờ đó, bạn có thể dễ dàng mở các trang đang mở trên máy tính trên thiết bị di động (và ngược lại). Máy tính lấy số kết nối do máy chủ cấp và thêm số đó vào URL của trang bằng history.replaceState.

history.replaceState(null, null, '/maze/' + connectionNumber)

Nếu bạn bật tính năng Đồng bộ hoá thẻ, URL sẽ được đồng bộ hoá sau vài giây và bạn có thể mở cùng một trang trên thiết bị di động. Thiết bị di động sẽ kiểm tra URL của trang đang mở và nếu có số được thêm vào, thiết bị sẽ bắt đầu kết nối ngay lập tức. Nhờ đó, bạn không cần phải nhập số theo cách thủ công hoặc quét mã QR bằng máy ảnh.

Độ trễ

Vì máy chủ trung gian nằm ở Hoa Kỳ, nên việc truy cập vào máy chủ này từ Nhật Bản sẽ bị trễ khoảng 200 mili giây trước khi dữ liệu độ nghiêng của điện thoại thông minh đến máy tính. Thời gian phản hồi rõ ràng là chậm chạp so với thời gian phản hồi của môi trường cục bộ được sử dụng trong quá trình phát triển, nhưng việc chèn một bộ lọc thông thấp (tôi đã sử dụng EMA) đã cải thiện thời gian phản hồi này ở mức không gây khó chịu. (Trong thực tế, bạn cũng cần có bộ lọc thông thấp cho mục đích trình bày; các giá trị trả về từ cảm biến độ nghiêng bao gồm một lượng nhiễu đáng kể và việc áp dụng các giá trị đó cho màn hình như hiện tại sẽ dẫn đến tình trạng rung lắc nhiều.) Cách này không hoạt động với các bước nhảy, rõ ràng là bị chậm, nhưng không thể làm gì để giải quyết vấn đề này.

Vì dự kiến sẽ gặp vấn đề về độ trễ ngay từ đầu, nên tôi đã cân nhắc việc thiết lập máy chủ chuyển tiếp trên khắp thế giới để máy khách có thể kết nối với máy chủ gần nhất có sẵn (do đó giảm thiểu độ trễ). Tuy nhiên, cuối cùng tôi đã sử dụng Google Compute Engine (GCE). Dịch vụ này chỉ có ở Hoa Kỳ tại thời điểm đó nên tôi không thể làm được việc này.

Vấn đề về thuật toán Nagle

Thuật toán Nagle thường được tích hợp vào các hệ điều hành để giao tiếp hiệu quả bằng cách lưu vào bộ đệm ở cấp TCP, nhưng tôi nhận thấy rằng mình không thể gửi dữ liệu theo thời gian thực khi thuật toán này đang bật. (Cụ thể là khi kết hợp với tính năng xác nhận bị trì hoãn của TCP. Ngay cả khi không có ACK bị trì hoãn, vấn đề tương tự cũng xảy ra nếu ACK bị trì hoãn ở một mức độ nhất định do các yếu tố như máy chủ nằm ở nước ngoài.)

Vấn đề độ trễ Nagle không xảy ra với WebSocket trong Chrome dành cho Android, bao gồm cả tuỳ chọn TCP_NODELAY để tắt Nagle, nhưng vấn đề này đã xảy ra với WebSocket WebKit được dùng trong Chrome dành cho iOS, không bật tuỳ chọn này. (Safari, sử dụng cùng một WebKit, cũng gặp phải vấn đề này. Vấn đề này đã được báo cáo cho Apple thông qua Google và có vẻ như đã được giải quyết trong phiên bản phát triển của WebKit.

Khi vấn đề này xảy ra, dữ liệu độ nghiêng được gửi đi mỗi 100 mili giây sẽ được kết hợp thành các phần chỉ đến được máy tính mỗi 500 mili giây. Trò chơi không thể hoạt động trong những điều kiện này, vì vậy, trò chơi sẽ tránh độ trễ này bằng cách yêu cầu phía máy chủ gửi dữ liệu theo khoảng thời gian ngắn (khoảng 50 mili giây). Tôi tin rằng việc nhận ACK trong khoảng thời gian ngắn sẽ đánh lừa thuật toán Nagle khiến thuật toán này nghĩ rằng có thể gửi dữ liệu.

Thuật toán Nagle 1

Biểu đồ trên cho thấy các khoảng thời gian nhận được dữ liệu thực tế. Biểu thị khoảng thời gian giữa các gói; màu xanh lục biểu thị khoảng thời gian đầu ra và màu đỏ biểu thị khoảng thời gian đầu vào. Tối thiểu là 54 mili giây, tối đa là 158 mili giây và trung bình là gần 100 mili giây. Ở đây, tôi đã sử dụng một chiếc iPhone có máy chủ chuyển tiếp ở Nhật Bản. Cả đầu ra và đầu vào đều ở mức khoảng 100 mili giây và hoạt động trơn tru.

Thuật toán Nagle 2

Ngược lại, biểu đồ này cho thấy kết quả khi sử dụng máy chủ ở Hoa Kỳ. Mặc dù khoảng thời gian đầu ra màu xanh lục giữ nguyên ở mức 100 mili giây, nhưng khoảng thời gian đầu vào dao động trong khoảng từ 0 mili giây đến 500 mili giây, cho biết rằng máy tính đang nhận dữ liệu theo từng phần.

ALT_TEXT_HERE

Cuối cùng, biểu đồ này cho thấy kết quả của việc tránh độ trễ bằng cách yêu cầu máy chủ gửi dữ liệu phần giữ chỗ. Mặc dù không hoạt động tốt như khi sử dụng máy chủ Nhật Bản, nhưng rõ ràng là khoảng thời gian nhập vẫn tương đối ổn định ở mức khoảng 100 mili giây.

Lỗi?

Mặc dù trình duyệt mặc định trong Android 4 (ICS) có WebSocket API, nhưng trình duyệt này không thể kết nối, dẫn đến sự kiện connect_failed Socket.IO. Trong nội bộ, quá trình này sẽ hết thời gian chờ và phía máy chủ cũng không thể xác minh kết nối. (Tôi chưa thử nghiệm điều này chỉ với WebSocket, vì vậy, có thể đây là vấn đề về Socket.IO.)

Mở rộng quy mô máy chủ chuyển tiếp

Vì vai trò của máy chủ trung gian không quá phức tạp, nên việc mở rộng quy mô và tăng số lượng máy chủ sẽ không khó khăn, miễn là bạn đảm bảo rằng cùng một máy tính và thiết bị di động luôn kết nối với cùng một máy chủ.

Vật Lý

Tất cả các chuyển động của quả bóng trong trò chơi (lăn xuống dốc, va chạm với mặt đất, va chạm với tường, thu thập vật phẩm, v.v.) đều được thực hiện bằng trình mô phỏng vật lý 3D. Tôi đã sử dụng Ammo.js – một cổng của công cụ vật lý Bullet được sử dụng rộng rãi vào JavaScript bằng Emscripten – cùng với Physijs để sử dụng công cụ này làm "Trình chạy web".

Trình chạy web

Web Worker là một API để chạy JavaScript trong các luồng riêng biệt. JavaScript được khởi chạy dưới dạng Trình chạy web chạy dưới dạng một luồng riêng biệt với luồng ban đầu gọi luồng đó, vì vậy, các tác vụ nặng có thể được thực hiện trong khi vẫn giữ cho trang có khả năng phản hồi. Physijs sử dụng Web Worker một cách hiệu quả để giúp công cụ vật lý 3D thường xuyên hoạt động trơn tru. World Wide Maze xử lý công cụ vật lý và kết xuất hình ảnh WebGL ở tốc độ khung hình hoàn toàn khác nhau, vì vậy, ngay cả khi tốc độ khung hình giảm trên máy có thông số kỹ thuật thấp do tải kết xuất WebGL nặng, bản thân công cụ vật lý sẽ duy trì ít nhất 60 khung hình/giây và không cản trở các chế độ điều khiển trò chơi.

Khung hình/giây

Hình ảnh này cho thấy tốc độ khung hình thu được trên Lenovo G570. Hộp trên cho biết tốc độ khung hình của WebGL (kết xuất hình ảnh) và hộp dưới cho biết tốc độ khung hình của công cụ vật lý. GPU là một chip Intel HD Graphics 3000 tích hợp, do đó, tốc độ khung hình kết xuất hình ảnh không đạt được 60 khung hình/giây như dự kiến. Tuy nhiên, vì công cụ vật lý đã đạt được tốc độ khung hình dự kiến, nên hiệu suất chơi trò chơi không khác nhiều so với hiệu suất trên máy có thông số kỹ thuật cao.

Vì các luồng có Worker web đang hoạt động không có đối tượng bảng điều khiển, nên dữ liệu phải được gửi đến luồng chính thông qua postMessage để tạo nhật ký gỡ lỗi. Việc sử dụng console4Worker sẽ tạo ra một đối tượng tương đương với bảng điều khiển trong Worker, giúp quá trình gỡ lỗi trở nên dễ dàng hơn đáng kể.

Trình chạy dịch vụ

Các phiên bản Chrome gần đây cho phép bạn đặt điểm ngắt khi khởi chạy Web Worker. Điều này cũng hữu ích cho việc gỡ lỗi. Bạn có thể tìm thấy thông tin này trong bảng "Worker" (Trình chạy) trong Công cụ dành cho nhà phát triển.

Hiệu suất

Các giai đoạn có số lượng đa giác cao đôi khi vượt quá 100.000 đa giác, nhưng hiệu suất không bị ảnh hưởng nhiều ngay cả khi các giai đoạn này được tạo hoàn toàn dưới dạng Physijs.ConcaveMesh (btBvhTriangleMeshShape trong Bullet).

Ban đầu, tốc độ khung hình giảm khi số lượng đối tượng cần phát hiện va chạm tăng lên, nhưng việc loại bỏ quá trình xử lý không cần thiết trong Physijs đã cải thiện hiệu suất. Cải tiến này được thực hiện đối với một fork của Physijs ban đầu.

Đối tượng ma

Các đối tượng có tính năng phát hiện va chạm nhưng không có tác động khi va chạm và do đó không ảnh hưởng đến các đối tượng khác được gọi là "đối tượng ma" trong Bullet. Mặc dù Physijs không hỗ trợ chính thức các đối tượng ma, nhưng bạn có thể tạo các đối tượng đó bằng cách tinh chỉnh các cờ sau khi tạo Physijs.Mesh. World Wide Maze sử dụng các đối tượng ma để phát hiện va chạm của các mục và điểm đến.

hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)

Đối với collision_flags, 1 là CF_STATIC_OBJECT và 4 là CF_NO_CONTACT_RESPONSE. Hãy thử tìm kiếm trên diễn đàn Bullet, Stack Overflow hoặc tài liệu về Bullet để biết thêm thông tin. Vì Physijs là một trình bao bọc cho Ammo.js và Ammo.js về cơ bản giống với Bullet, nên hầu hết những việc có thể làm trong Bullet cũng có thể làm trong Physijs.

Vấn đề về Firefox 18

Bản cập nhật Firefox từ phiên bản 17 lên 18 đã thay đổi cách Web Worker trao đổi dữ liệu và do đó, Physijs đã ngừng hoạt động. Vấn đề này đã được báo cáo trên GitHub và được giải quyết sau vài ngày. Mặc dù hiệu quả của nguồn mở này đã gây ấn tượng với tôi, nhưng sự cố này cũng nhắc tôi về cách World Wide Maze được tạo thành từ một số khung nguồn mở khác nhau. Tôi viết bài này với hy vọng có thể cung cấp một số ý kiến phản hồi.

asm.js

Mặc dù điều này không liên quan trực tiếp đến World Wide Maze, nhưng Ammo.js đã hỗ trợ asm.js mới được Mozilla công bố gần đây (không có gì đáng ngạc nhiên vì asm.js về cơ bản được tạo để tăng tốc JavaScript do Emscripten tạo ra và nhà sáng tạo của Emscripten cũng là nhà sáng tạo của Ammo.js). Nếu Chrome cũng hỗ trợ asm.js, thì tải điện toán của công cụ vật lý sẽ giảm đáng kể. Tốc độ nhanh hơn đáng kể khi thử nghiệm với Firefox Nightly. Có lẽ tốt nhất là bạn nên viết các phần cần tốc độ cao hơn bằng C/C++ rồi chuyển các phần đó sang JavaScript bằng Emscripten?

WebGL

Để triển khai WebGL, tôi đã sử dụng thư viện được phát triển tích cực nhất, three.js (r53). Mặc dù bản sửa đổi 57 đã được phát hành ở các giai đoạn sau của quá trình phát triển, nhưng API đã có những thay đổi lớn, vì vậy, tôi đã sử dụng bản sửa đổi ban đầu để phát hành.

Hiệu ứng ánh sáng

Hiệu ứng phát sáng được thêm vào lõi của quả bóng và vào các mục được triển khai bằng cách sử dụng phiên bản đơn giản của cái gọi là "Phương thức Kawase MGF". Tuy nhiên, trong khi Phương pháp Kawase làm cho tất cả các vùng sáng đều sáng lên, thì World Wide Maze tạo ra các mục tiêu kết xuất riêng biệt cho các vùng cần sáng lên. Lý do là bạn phải sử dụng ảnh chụp màn hình trang web cho hoạ tiết sân khấu và việc chỉ trích xuất tất cả các vùng sáng sẽ khiến toàn bộ trang web sáng lên nếu trang web đó có nền trắng, chẳng hạn. Tôi cũng cân nhắc việc xử lý mọi thứ ở chế độ HDR, nhưng quyết định không làm như vậy lần này vì việc triển khai sẽ khá phức tạp.

Toả sáng

Ở trên cùng bên trái là lượt kết xuất đầu tiên, trong đó các vùng phát sáng được kết xuất riêng biệt rồi áp dụng hiệu ứng làm mờ. Ở dưới cùng bên phải là lượt chạy thứ hai, trong đó kích thước hình ảnh giảm 50% rồi áp dụng hiệu ứng làm mờ. Ở trên cùng bên phải là lần truyền thứ ba, trong đó hình ảnh lại giảm 50% rồi làm mờ. Sau đó, ba hình ảnh này được phủ lên nhau để tạo ra hình ảnh tổng hợp cuối cùng hiển thị ở dưới cùng bên trái. Đối với hiệu ứng làm mờ, tôi đã sử dụng VerticalBlurShaderHorizontalBlurShader, có trong three.js, vì vậy, vẫn có thể tối ưu hoá thêm.

Bóng phản quang

Hình ảnh phản chiếu trên quả bóng dựa trên một mẫu từ three.js. Tất cả các hướng đều được kết xuất từ vị trí của quả bóng và được dùng làm bản đồ môi trường. Bạn cần cập nhật bản đồ môi trường mỗi khi quả bóng di chuyển, nhưng vì việc cập nhật ở tốc độ 60 khung hình/giây là rất tốn kém, nên bạn chỉ cập nhật bản đồ môi trường 3 khung hình/lần. Kết quả không mượt mà như khi cập nhật mọi khung hình, nhưng sự khác biệt hầu như không thể nhận thấy được trừ phi được chỉ ra.

Chương trình đổ bóng, chương trình đổ bóng, chương trình đổ bóng…

WebGL yêu cầu chương trình đổ bóng (chương trình đổ bóng đỉnh, chương trình đổ bóng mảnh) cho tất cả hoạt động kết xuất. Mặc dù chương trình đổ bóng có trong three.js đã cho phép nhiều hiệu ứng, nhưng bạn không thể tránh việc tự viết chương trình đổ bóng để tối ưu hoá và đổ bóng chi tiết hơn. Vì World Wide Maze khiến CPU bận rộn với công cụ vật lý, nên tôi đã cố gắng tận dụng GPU bằng cách viết nhiều nhất có thể bằng ngôn ngữ đổ bóng (GLSL), ngay cả khi việc xử lý CPU (thông qua JavaScript) sẽ dễ dàng hơn. Hiệu ứng sóng biển tự nhiên dựa vào chương trình đổ bóng, cũng như pháo hoa tại các điểm ghi bàn và hiệu ứng lưới được sử dụng khi bóng xuất hiện.

Bóng đổ bóng

Hình ảnh trên là kết quả kiểm thử hiệu ứng lưới được sử dụng khi quả bóng xuất hiện. Hình ảnh bên trái là hình ảnh được sử dụng trong trò chơi, bao gồm 320 đa giác. Hình ở giữa sử dụng khoảng 5.000 đa giác và hình ở bên phải sử dụng khoảng 300.000 đa giác. Ngay cả với nhiều đa giác như vậy, việc xử lý bằng chương trình đổ bóng vẫn có thể duy trì tốc độ khung hình ổn định ở mức 30 khung hình/giây.

Lưới đổ bóng

Các mục nhỏ nằm rải rác trên sân khấu đều được tích hợp vào một lưới và chuyển động riêng lẻ dựa vào chương trình đổ bóng di chuyển từng đầu đa giác. Đây là kết quả của một thử nghiệm để xem hiệu suất có bị ảnh hưởng khi có nhiều đối tượng hay không. Có khoảng 5.000 đối tượng được bố trí ở đây, bao gồm khoảng 20.000 đa giác. Hiệu suất không bị ảnh hưởng chút nào.

poly2tri

Các giai đoạn được hình thành dựa trên thông tin phác thảo nhận được từ máy chủ, sau đó được JavaScript tạo thành đa giác. Bạn sẽ thấy quy trình tam giác hóa (triangulation), một phần quan trọng trong quy trình này, được three.js triển khai không tốt và thường không thành công. Do đó, tôi quyết định tự tích hợp một thư viện tam giác hoá khác có tên là poly2tri. Hóa ra, three.js đã từng thử làm điều tương tự trong quá khứ, vì vậy, tôi đã khắc phục vấn đề này chỉ bằng cách chú thích một phần. Nhờ đó, số lỗi đã giảm đáng kể, cho phép nhiều màn chơi hơn. Lỗi này thỉnh thoảng vẫn xảy ra và vì một số lý do, poly2tri xử lý lỗi bằng cách đưa ra cảnh báo, vì vậy, tôi đã sửa đổi lỗi này để gửi ngoại lệ.

poly2tri

Hình trên cho thấy cách đường viền màu xanh lam được tạo tam giác và các đa giác màu đỏ được tạo.

Bộ lọc không đẳng hướng

Vì bản đồ MIP đẳng hướng chuẩn làm giảm kích thước hình ảnh trên cả trục ngang và dọc, nên việc xem đa giác từ các góc xiên sẽ khiến hoạ tiết ở cuối các màn trong World Wide Maze trông giống như hoạ tiết có độ phân giải thấp, kéo dài theo chiều ngang. Hình ảnh ở trên cùng bên phải trên trang Wikipedia này là một ví dụ điển hình. Trong thực tế, bạn cần có độ phân giải ngang cao hơn. WebGL (OpenGL) sẽ giải quyết vấn đề này bằng cách sử dụng một phương thức có tên là lọc không đẳng hướng. Trong three.js, việc đặt giá trị lớn hơn 1 cho THREE.Texture.anisotropy sẽ bật tính năng lọc không đẳng hướng. Tuy nhiên, tính năng này là một tiện ích và có thể không được tất cả GPU hỗ trợ.

Tối ưu hoá

Như bài viết Các phương pháp hay nhất về WebGL cũng đề cập, cách quan trọng nhất để cải thiện hiệu suất WebGL (OpenGL) là giảm thiểu các lệnh gọi vẽ. Trong quá trình phát triển ban đầu của World Wide Maze, tất cả các hòn đảo, cầu và dải phân cách trong trò chơi đều là các đối tượng riêng biệt. Đôi khi,điều này dẫn đến hơn 2.000 lệnh gọi vẽ, khiến các giai đoạn phức tạp trở nên khó sử dụng. Tuy nhiên, sau khi tôi đóng gói cùng một loại đối tượng vào một lưới, số lệnh gọi vẽ giảm xuống còn khoảng 50, giúp cải thiện đáng kể hiệu suất.

Tôi đã sử dụng tính năng theo dõi của Chrome để tối ưu hoá thêm. Trình phân tích tài nguyên có trong Công cụ dành cho nhà phát triển của Chrome có thể xác định thời gian xử lý phương thức tổng thể ở một mức độ nào đó, nhưng tính năng theo dõi có thể cho bạn biết chính xác thời lượng của từng phần, xuống đến 1/1000 giây. Hãy xem bài viết này để biết thông tin chi tiết về cách sử dụng tính năng theo dõi.

Tối ưu hoá

Trên đây là kết quả theo dõi từ việc tạo bản đồ môi trường cho hình ảnh phản chiếu của quả bóng. Việc chèn console.timeconsole.timeEnd vào các vị trí có vẻ như có liên quan trong three.js sẽ cho chúng ta một biểu đồ như sau. Thời gian trôi từ trái sang phải và mỗi lớp giống như một ngăn xếp lệnh gọi. Việc lồng console.time trong console.time cho phép đo lường thêm. Biểu đồ trên cùng là trước khi tối ưu hoá và biểu đồ dưới cùng là sau khi tối ưu hoá. Như biểu đồ trên cùng cho thấy, updateMatrix (mặc dù từ này bị cắt ngắn) được gọi cho mỗi lần kết xuất từ 0 đến 5 trong quá trình tối ưu hoá trước. Tuy nhiên, tôi đã sửa đổi để chỉ gọi một lần, vì quy trình này chỉ cần thiết khi các đối tượng thay đổi vị trí hoặc hướng.

Bản thân quy trình theo dõi sẽ chiếm tài nguyên, vì vậy, việc chèn console.time quá mức có thể gây ra sự khác biệt đáng kể so với hiệu suất thực tế, khiến bạn khó xác định được các khu vực cần tối ưu hoá.

Bộ điều chỉnh hiệu suất

Do bản chất của Internet, trò chơi có thể được chơi trên các hệ thống có thông số kỹ thuật rất khác nhau. Find Your Way to Oz (Tìm đường đến xứ Oz) được phát hành vào đầu tháng 2, sử dụng một lớp có tên là IFLAutomaticPerformanceAdjust để điều chỉnh hiệu ứng theo sự biến động về tốc độ khung hình, giúp đảm bảo quá trình phát mượt mà. World Wide Maze được xây dựng dựa trên cùng một lớp IFLAutomaticPerformanceAdjust và điều chỉnh các hiệu ứng theo thứ tự sau để giúp quá trình chơi diễn ra mượt mà nhất có thể:

  1. Nếu tốc độ khung hình giảm xuống dưới 45 khung hình/giây, bản đồ môi trường sẽ ngừng cập nhật.
  2. Nếu vẫn dưới 40 khung hình/giây, độ phân giải kết xuất sẽ giảm xuống còn 70% (50% tỷ lệ bề mặt).
  3. Nếu vẫn dưới 40 khung hình/giây, FXAA (chống răng cưa) sẽ bị loại bỏ.
  4. Nếu vẫn dưới 30 khung hình/giây, hiệu ứng ánh sáng sẽ bị loại bỏ.

Rò rỉ bộ nhớ

Việc loại bỏ các đối tượng một cách gọn gàng là một vấn đề rắc rối với three.js. Nhưng việc để các đối tượng này hoạt động một mình rõ ràng sẽ dẫn đến rò rỉ bộ nhớ, vì vậy, tôi đã nghĩ ra phương thức dưới đây. @renderer tham chiếu đến THREE.WebGLRenderer. (Bản sửa đổi mới nhất của three.js sử dụng một phương thức phân bổ khác một chút, vì vậy, phương thức này có thể sẽ không hoạt động với bản sửa đổi đó.)

destructObjects: (object) =>
  switch true
    when object instanceof THREE.Object3D
      @destructObjects(child) for child in object.children
      object.parent?.remove(object)
      object.deallocate()
      object.geometry?.deallocate()
      @renderer.deallocateObject(object)
      object.destruct?(this)

    when object instanceof THREE.Material
      object.deallocate()
      @renderer.deallocateMaterial(object)

    when object instanceof THREE.Texture
      object.deallocate()
      @renderer.deallocateTexture(object)

    when object instanceof THREE.EffectComposer
      @destructObjects(object.copyPass.material)
      object.passes.forEach (pass) =>
        @destructObjects(pass.material) if pass.material
        @renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
        @renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
        @renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2

HTML

Cá nhân tôi cho rằng điều tốt nhất về ứng dụng WebGL là khả năng thiết kế bố cục trang bằng HTML. Việc xây dựng giao diện 2D như điểm số hoặc màn hình văn bản trong Flash hoặc openFrameworks (OpenGL) khá phiền phức. Flash ít nhất cũng có một IDE, nhưng openFrameworks sẽ khó sử dụng nếu bạn chưa quen (bạn có thể sử dụng một công cụ như Cocos2D để dễ dàng hơn). Mặt khác, HTML cho phép kiểm soát chính xác tất cả các khía cạnh thiết kế giao diện người dùng bằng CSS, giống như khi tạo trang web. Mặc dù không thể tạo các hiệu ứng phức tạp như các hạt ngưng tụ thành biểu trưng, nhưng bạn có thể tạo một số hiệu ứng 3D trong phạm vi của các phép biến đổi CSS. Hiệu ứng văn bản "GOAL" (MỤC TIÊU) và "TIME IS UP" (THỜI GIAN HẾT) của World Wide Maze được tạo ảnh động bằng cách sử dụng tỷ lệ trong hiệu ứng Chuyển đổi CSS (triển khai bằng Transit). (Rõ ràng là các hiệu ứng chuyển màu nền sử dụng WebGL.)

Mỗi trang trong trò chơi (tiêu đề, KẾT QUẢ, THẾ XẾP, v.v.) đều có tệp HTML riêng và sau khi các trang này được tải dưới dạng mẫu, $(document.body).append() sẽ được gọi bằng các giá trị thích hợp tại thời điểm thích hợp. Một vấn đề nhỏ là không thể đặt các sự kiện chuột và bàn phím trước khi nối, vì vậy, việc thử el.click (e) -> console.log(e) trước khi nối không hoạt động.

Quốc tế hoá (i18n)

Việc làm việc trong HTML cũng rất thuận tiện cho việc tạo phiên bản tiếng Anh. Tôi đã chọn sử dụng i18next, một thư viện i18n web, cho nhu cầu quốc tế hoá của mình. Tôi có thể sử dụng thư viện này mà không cần sửa đổi.

Chúng tôi đã chỉnh sửa và dịch văn bản trong trò chơi bằng Bảng tính Google Tài liệu. Vì i18next yêu cầu tệp JSON, nên tôi đã xuất các bảng tính sang TSV rồi chuyển đổi các bảng tính đó bằng một trình chuyển đổi tuỳ chỉnh. Tôi đã cập nhật rất nhiều nội dung ngay trước khi phát hành, vì vậy, việc tự động hoá quy trình xuất từ Bảng tính Google Tài liệu sẽ giúp mọi việc trở nên dễ dàng hơn nhiều.

Tính năng dịch tự động của Chrome cũng hoạt động bình thường vì các trang được tạo bằng HTML. Tuy nhiên, đôi khi công cụ này không phát hiện chính xác ngôn ngữ, mà nhầm lẫn với một ngôn ngữ hoàn toàn khác (ví dụ: Tiếng Việt), nên tính năng này hiện đang bị tắt. (Bạn có thể tắt tính năng này bằng thẻ meta.)

RequireJS

Tôi đã chọn RequireJS làm hệ thống mô-đun JavaScript. 10.000 dòng mã nguồn của trò chơi được chia thành khoảng 60 lớp (= tệp coffee) và được biên dịch thành các tệp js riêng lẻ. RequireJS tải các tệp riêng lẻ này theo thứ tự thích hợp dựa trên phần phụ thuộc.

define ->
  class Hoge
    hogeMethod: ->

Bạn có thể sử dụng lớp được xác định ở trên (hoge.coffee) như sau:

define ['hoge'], (Hoge) ->
  class Moge
    constructor: ->
      @hoge = new Hoge()
      @hoge.hogeMethod()

Để hoạt động, bạn phải tải hoge.js trước moge.js và vì "hoge" được chỉ định làm đối số đầu tiên của "define", nên hoge.js luôn được tải trước (được gọi lại sau khi tải xong hoge.js). Cơ chế này được gọi là AMD và bạn có thể sử dụng bất kỳ thư viện bên thứ ba nào cho cùng một loại lệnh gọi lại, miễn là thư viện đó hỗ trợ AMD. Ngay cả những thư viện không hỗ trợ (ví dụ: three.js) cũng sẽ hoạt động tương tự miễn là các phần phụ thuộc được chỉ định trước.

Việc này tương tự như nhập AS3, nên bạn sẽ không thấy lạ. Nếu bạn có nhiều tệp phụ thuộc hơn, đây có thể là giải pháp.

r.js

RequireJS bao gồm một trình tối ưu hoá có tên là r.js. Thao tác này sẽ gói js chính với tất cả các tệp js phụ thuộc vào một tệp, sau đó rút gọn tệp đó bằng UglifyJS (hoặc Trình biên dịch đóng). Điều này làm giảm số lượng tệp và tổng lượng dữ liệu mà trình duyệt cần tải. Tổng kích thước tệp JavaScript cho World Wide Maze là khoảng 2 MB và có thể giảm xuống còn khoảng 1 MB nhờ tính năng tối ưu hoá r.js. Nếu trò chơi có thể được phân phối bằng gzip, thì kích thước này sẽ giảm xuống còn 250 KB. (GAE có vấn đề không cho phép truyền tệp gzip từ 1 MB trở lên, vì vậy, trò chơi hiện được phân phối dưới dạng văn bản thuần tuý 1 MB không nén.)

Trình tạo sân khấu

Dữ liệu giai đoạn được tạo như sau, hoàn toàn trên máy chủ GCE ở Hoa Kỳ:

  1. URL của trang web cần chuyển đổi thành một giai đoạn được gửi qua WebSocket.
  2. PhantomJS chụp ảnh màn hình, đồng thời truy xuất và xuất vị trí thẻ div và img ở định dạng JSON.
  3. Dựa trên ảnh chụp màn hình ở bước 2 và dữ liệu định vị của các phần tử HTML, một chương trình C++ tuỳ chỉnh (OpenCV, Boost) sẽ xoá các khu vực không cần thiết, tạo đảo, kết nối các đảo bằng cầu, tính toán vị trí lan can và mục, đặt điểm đến, v.v. Kết quả được xuất ở định dạng JSON và trả về trình duyệt.

PhantomJS

PhantomJS là một trình duyệt không yêu cầu màn hình. Trình duyệt này có thể tải trang web mà không cần mở cửa sổ, vì vậy, bạn có thể sử dụng trình duyệt này trong các kiểm thử tự động hoặc để chụp ảnh màn hình ở phía máy chủ. Công cụ trình duyệt của trình duyệt này là WebKit, giống với công cụ mà Chrome và Safari sử dụng, vì vậy, bố cục và kết quả thực thi JavaScript của trình duyệt này cũng ít nhiều giống với các trình duyệt tiêu chuẩn.

Với PhantomJS, JavaScript hoặc CoffeeScript được dùng để viết các quy trình mà bạn muốn thực thi. Việc chụp ảnh màn hình rất dễ dàng, như trong mẫu này. Tôi đang làm việc trên một máy chủ Linux (CentOS), vì vậy, tôi cần cài đặt phông chữ để hiển thị tiếng Nhật (M+ FONTS). Ngay cả khi đó, việc kết xuất phông chữ cũng được xử lý theo cách khác so với Windows hoặc Mac OS, vì vậy, cùng một phông chữ có thể trông khác nhau trên các máy khác (mặc dù sự khác biệt là rất nhỏ).

Việc truy xuất vị trí thẻ img và div về cơ bản được xử lý giống như trên các trang chuẩn. Bạn cũng có thể sử dụng jQuery mà không gặp vấn đề gì.

stage_builder

Ban đầu, tôi cân nhắc sử dụng phương pháp dựa trên DOM nhiều hơn để tạo các giai đoạn (tương tự như Firefox 3D Inspector) và thử một số phương pháp như phân tích DOM trong PhantomJS. Tuy nhiên, cuối cùng, tôi đã quyết định sử dụng phương pháp xử lý hình ảnh. Để đạt được mục tiêu này, tôi đã viết một chương trình C++ sử dụng OpenCV và Boost có tên là "stage_builder". Phương thức này thực hiện những việc sau:

  1. Tải ảnh chụp màn hình và(các) tệp JSON.
  2. Chuyển đổi hình ảnh và văn bản thành "đảo".
  3. Tạo cầu để kết nối các đảo.
  4. Loại bỏ các cầu không cần thiết để tạo mê cung.
  5. Đặt các mục có kích thước lớn.
  6. Đặt các vật nhỏ.
  7. Đặt lan can.
  8. Xuất dữ liệu định vị ở định dạng JSON.

Từng bước được trình bày chi tiết bên dưới.

Tải ảnh chụp màn hình và(các) tệp JSON

cv::imread thông thường được dùng để tải ảnh chụp màn hình. Tôi đã thử nghiệm một số thư viện cho tệp JSON, nhưng picojson có vẻ dễ sử dụng nhất.

Chuyển đổi hình ảnh và văn bản thành "đảo"

Bản dựng giai đoạn

Đây là ảnh chụp màn hình phần Tin tức của aid-dcc.com (nhấp để xem kích thước thực tế). Các thành phần hình ảnh và văn bản phải được chuyển đổi thành đảo. Để tách riêng các phần này, chúng ta nên xoá màu nền trắng – nói cách khác là màu phổ biến nhất trong ảnh chụp màn hình. Sau khi hoàn tất, giao diện sẽ có dạng như sau:

Bản dựng giai đoạn

Các phần màu trắng là các đảo tiềm năng.

Văn bản quá mỏng và sắc nét, vì vậy, chúng ta sẽ làm dày văn bản bằng cv::dilate, cv::GaussianBlurcv::threshold. Nội dung hình ảnh cũng bị thiếu, vì vậy, chúng ta sẽ lấp đầy các vùng đó bằng màu trắng, dựa trên dữ liệu thẻ img đầu ra từ PhantomJS. Hình ảnh thu được sẽ có dạng như sau:

Bản dựng giai đoạn

Văn bản hiện tạo thành các cụm phù hợp và mỗi hình ảnh là một hòn đảo thích hợp.

Tạo cầu nối các đảo

Sau khi các hòn đảo đã sẵn sàng, chúng sẽ được kết nối với nhau bằng cầu. Mỗi hòn đảo tìm kiếm các hòn đảo liền kề ở bên trái, bên phải, phía trên và phía dưới, sau đó kết nối một cây cầu với điểm gần nhất của hòn đảo gần nhất, tạo ra kết quả như sau:

Bản dựng giai đoạn

Loại bỏ các cây cầu không cần thiết để tạo mê cung

Nếu giữ lại tất cả cầu, người chơi sẽ dễ dàng di chuyển qua màn chơi này. Vì vậy, bạn phải loại bỏ một số cầu để tạo mê cung. Một hòn đảo (ví dụ: hòn đảo ở trên cùng bên trái) được chọn làm điểm xuất phát và tất cả các cây cầu (ngoại trừ một cây cầu được chọn ngẫu nhiên) nối với hòn đảo đó sẽ bị xoá. Sau đó, hãy làm tương tự cho hòn đảo tiếp theo được kết nối bằng cây cầu còn lại. Khi đường dẫn đi đến một ngõ cụt hoặc dẫn trở lại một hòn đảo đã từng ghé thăm, đường dẫn sẽ quay lại một điểm cho phép truy cập vào một hòn đảo mới. Mê cung sẽ hoàn tất sau khi tất cả các hòn đảo được xử lý theo cách này.

Bản dựng giai đoạn

Đặt các mặt hàng có kích thước lớn

Một hoặc nhiều mục lớn được đặt trên mỗi hòn đảo tuỳ thuộc vào kích thước của hòn đảo, chọn từ các điểm xa nhất từ cạnh của hòn đảo. Mặc dù không rõ ràng lắm, nhưng các điểm này được thể hiện bằng màu đỏ bên dưới:

Bản dựng giai đoạn

Trong số tất cả các điểm có thể có này, điểm ở trên cùng bên trái được đặt làm điểm xuất phát (vòng tròn màu đỏ), điểm ở dưới cùng bên phải được đặt làm đích đến (vòng tròn màu xanh lục) và tối đa 6 điểm còn lại được chọn để đặt mục lớn (vòng tròn màu tím).

Đặt các mặt hàng nhỏ

Bản dựng giai đoạn

Số lượng mục nhỏ phù hợp được đặt dọc theo các đường ở khoảng cách đã đặt từ các cạnh của đảo. Hình ảnh ở trên (không phải từ aid-dcc.com) cho thấy các đường vị trí dự kiến có màu xám, được bù và đặt ở các khoảng thời gian đều đặn từ các cạnh của đảo. Dấu chấm màu đỏ cho biết vị trí đặt các mục nhỏ. Vì hình ảnh này là của một phiên bản đang trong quá trình phát triển, nên các mục được bố trí theo đường thẳng, nhưng phiên bản hoàn thiện sẽ rải các mục một cách không đều đặn hơn một chút ở hai bên đường màu xám.

Đặt lan can

Về cơ bản, các dải phân cách được đặt dọc theo ranh giới bên ngoài của các hòn đảo nhưng phải bị cắt ở cầu để cho phép người dùng truy cập. Thư viện hình học của Boost đã chứng minh là hữu ích cho việc này, đơn giản hoá các phép tính hình học như xác định vị trí dữ liệu ranh giới đảo giao nhau với các đường ở hai bên cầu.

Bản dựng giai đoạn

Các đường màu xanh lục bao quanh các đảo là dải phân cách. Có thể khó thấy trong hình ảnh này, nhưng không có đường màu xanh lục ở vị trí cầu. Đây là hình ảnh cuối cùng dùng để gỡ lỗi, trong đó có tất cả các đối tượng cần xuất ra JSON. Các chấm màu xanh dương nhạt là các mục nhỏ và các chấm màu xám là các điểm khởi động lại được đề xuất. Khi quả bóng rơi xuống đại dương, trò chơi sẽ tiếp tục từ điểm bắt đầu lại gần nhất. Các điểm bắt đầu lại được sắp xếp gần giống như các mục nhỏ, theo chu kỳ đều đặn ở một khoảng cách nhất định tính từ mép đảo.

Xuất dữ liệu định vị ở định dạng JSON

Tôi cũng sử dụng picojson cho đầu ra. Hàm này ghi dữ liệu vào đầu ra tiêu chuẩn, sau đó được phương thức gọi (Node.js) nhận.

Tạo chương trình C++ trên máy Mac để chạy trong Linux

Trò chơi được phát triển trên máy Mac và triển khai trong Linux, nhưng vì OpenCV và Boost tồn tại cho cả hai hệ điều hành, nên việc phát triển không khó khăn sau khi thiết lập môi trường biên dịch. Tôi đã sử dụng Bộ công cụ dòng lệnh trong Xcode để gỡ lỗi bản dựng trên máy Mac, sau đó tạo một tệp cấu hình bằng automake/autoconf để có thể biên dịch bản dựng trong Linux. Sau đó, tôi chỉ cần sử dụng "configure && make" trong Linux để tạo tệp thực thi. Tôi gặp phải một số lỗi dành riêng cho Linux do sự khác biệt về phiên bản trình biên dịch, nhưng có thể dễ dàng giải quyết các lỗi đó bằng gdb.

Kết luận

Bạn có thể tạo một trò chơi như thế này bằng Flash hoặc Unity, mang lại nhiều lợi thế. Tuy nhiên, phiên bản này không yêu cầu trình bổ trợ và các tính năng bố cục của HTML5 + CSS3 đã được chứng minh là cực kỳ mạnh mẽ. Điều quan trọng là bạn phải có công cụ phù hợp cho từng nhiệm vụ. Cá nhân tôi rất ngạc nhiên khi thấy trò chơi này được tạo hoàn toàn bằng HTML5. Mặc dù trò chơi này vẫn còn thiếu sót ở nhiều khía cạnh, nhưng tôi rất mong được xem cách trò chơi này phát triển trong tương lai.