Giảm Thiểu Việc Render Lại Các Pixel Không Thay Đổi Giữa Các Khung Hình
Vào thứ Sáu tuần trước, tôi đã có buổi chia sẻ kỹ thuật nội bộ tại công ty về động cơ game tự phát triển trong 5 năm qua, cùng với tựa game mới nhất được xây dựng trên nền tảng này trong năm vừa qua. Buổi chia sẻ thu hút sự tham gia của hơn 100 bạn đồng nghiệp và nhận được phản hồi tích cực. Do có quá nhiều nội dung muốn trình bày trong thời gian giới hạn, nhiều chi tiết thú vị đã chỉ được đề cập qua loa.
Tôi nhấn mạnh rằng động cơ của chúng tôi được thiết kế tối ưu hóa cho thiết bị di động, do đó trọng tâm không nằm ở việc tăng tốc độ render từng khung hình đơn lẻ, mà là giảm tổng lượng tính toán trong khoảng thời gian dài ở mức khung hình ổn định, từ đó tiết kiệm năng lượng cho thiết bị. Trong phần trình bày, tôi đã đưa ra một số ví dụ cụ thể về các giải pháp đã triển khai cũng như những ý tưởng đang trong giai đoạn nghiên cứu.
Một vấn đề quan trọng tôi nêu ra là: Nếu một pixel đã được render ở khung hình trước, liệu chúng ta có thể xác định được pixel đó có cần render lại ở khung hình hiện tại hay không? Việc giảm thiểu render trùng lặp sẽ giúp tiết kiệm năng lượng đáng kể. Để phương pháp này hiệu quả, chi phí kiểm tra trạng thái pixel phải thấp hơn ít nhất một bậc độ lớn so với việc render trực tiếp. Đây là lý do các động cơ thương mại hiện tại chưa khai thác triệt để hướng tối ưu này - họ chưa đặt tiêu chí tiết kiệm năng lượng cho thiết bị di động lên hàng đầu. Hơn nữa, việc xác định các pixel không cần render lại đòi hỏi phải có sự phối hợp chặt chẽ từ kiến trúc động cơ.
Dù không trình bày chi tiết trong buổi chia sẻ vì đây vẫn là ý tưởng đang trong giai đoạn nghiên cứu, tôi đã hứa sẽ viết bài blog phân tích sâu hơn, và đây chính là bài viết đó. Hiện tại, hiệu năng động cơ đã được tối ưu ở mức chấp nhận được, ưu tiên hàng đầu là hoàn thiện sản phẩm game. Đối với nhóm phát triển chỉ 3-4 người, việc hoãn lại các công việc ít quan trọng là điều tất yếu.
Đặc biệt, dù động cơ được viết bằng Lua, điểm nghẽn hiệu năng hiện tại lại nằm ở GPU chứ không phải CPU. Một minh chứng rõ ràng là việc kích hoạt quy trình PreZ - trước tiên ghi thông tin hình học vào GPU để giảm bớt tính toán shader pixel trùng lặp - đã mang lại cải thiện hiệu năng rõ rệt.
PreZ là kỹ thuật tối ưu phổ biến: Chúng ta đầu tiên render tất cả đối tượng vào Z-Buffer để xác định giá trị độ sâu của từng pixel. Trong quá trình render tiếp theo, với các đối tượng không trong suốt, ta so sánh giá trị Z để quyết định có bỏ qua shader pixel hay không. Tuy nhiên, tôi tự hỏi: Liệu có phương pháp hiệu quả nào để tạo ra bản đồ mặt nạ xác định các pixel không thay đổi giữa các khung hình không?
Hãy tưởng tượng trong nhiều trường hợp, điều kiện ánh sáng và vị trí camera không thay đổi (trừ khi chơi game ở chế độ FPS). Ngay cả trong chế độ FPS, camera cũng không di chuyển liên tục từng khung hình. Điều này có nghĩa là tỷ lệ pixel không thay đổi giữa các khung hình là rất lớn. Nếu tạo được bản đồ mặt nạ hiệu quả, chúng ta có thể tiết kiệm hàng loạt phép tính shader pixel trong thời gian dài.
Làm thế nào để tạo bản đồ mặt nạ hiệu quả, trong đó giá trị 1 đại diện cho pixel cần render và 0 là pixel có thể giữ nguyên? Bản đồ này không cần độ chính xác tuyệt đối - việc đánh dấu sai một pixel cần giữ thành cần render là chấp nhận được, nhưng ngược lại sẽ gây lỗi hiển thị. Giải pháp đơn giản là không xóa backbuffer sau mỗi khung hình, đồng thời áp dụng bản đồ mặt nạ này cho mọi lệnh render (tương tự cách sử dụng Z-Buffer từ PreZ).
Để đơn giản hóa vấn đề, tạm thời bỏ qua yếu tố bóng đổ. Mỗi lệnh render đều trực tiếp thay đổi không gian màn hình. Một pixel có thể bị thay đổi nhiều lần bởi các lệnh render khác nhau. Tương tự như PreZ giúp loại bỏ các render thừa, chúng ta có thể tạo bản đồ mặt nạ ngay đầu mỗi khung hình bằng cách phân loại lệnh render thành hai nhóm: đỏ (lệnh mới chưa xuất hiện ở khung trước) và đen (lệnh đã xuất hiện ở khung trước). Với thiết kế hợp lý, động cơ có thể dễ dàng xác định sự thay đổi của tham số lệnh render.
Chúng ta có thể sử dụng buffer đếm số lần render: Với lệnh đen, tăng giá trị pixel tương ứng lên 1; với lệnh đỏ, tăng một giá trị cực lớn. Cuối cùng so sánh buffer này với buffer khung trước để xác định các pixel cần render. Kỹ thuật tương tự đã được tôi áp dụng thành công trong động cơ 2D Feng Hun cách đây hơn 20 năm, giúp cải thiện hiệu năng đáng kể so với các động cơ cùng thời.
Không chỉ dừng lại ở GPU, chúng ta có thể áp dụng nguyên tắc này ở cấp độ CPU để loại bỏ các lệnh render thừa. Giải pháp là tạo buffer lưới thô bằng cách thu nhỏ backbuffer (ví dụ tỷ lệ 64:1), sau đó tính toán hình chữ nhật bao (AABB) của từng mesh trên buffer này. Với cách tiếp cận tương tự, các ô lưới không thay đổi giữa các khung hình có thể bị loại bỏ, tạo ra bản đồ mặt nạ ở mức độ thô hơn nhưng hiệu quả hơn.
Vậy còn bóng đổ thì sao? Tôi đề xuất giải pháp tạo bản đồ bóng đổ độc lập cho từng đối tượng nhận bóng, thay vì render toàn bộ cảnh vào một bản đồ bóng đổ lớn như truyền thống. Dù ban đầu có vẻ tốn kém, nhưng thực tế cho thấy với các cảnh có phân bố vật thể đồng đều (như game góc nhìn thứ ba), mỗi vật thể chỉ nhận bóng từ vài vật thể xung quanh. Thông tin về các vật thể gây bóng đổ thường thay đổi rất ít giữa các khung hình, do đó quá trình lọc không cần thực hiện lại hoàn toàn mỗi khung hình. Nhờ đó, chi phí tạo n bản đồ bóng đổ có thể giảm từ O(n*m) xuống còn O(n log m).
Nếu kết hợp thêm cơ chế cache giữa các khung hình, nhiều bản đồ bóng đổ (chỉ là một phần của vật liệu) thậm chí không cần render lại ở khung kế tiếp, miễn là các vật thể gây bóng không thay đổi. Về tổng thể, giải pháp này có tiềm năng tiết kiệm năng lượng tốt hơn phương pháp truyền thống.