Vấn Đề Quản Lý Vòng Đời Tài Nguyên Trong Engine Game
Trong quá trình phát triển engine game gần đây, khi sửa các lỗi liên quan đến module quản lý tài nguyên, tôi đã có một số suy nghĩ mới nhằm đơn giản hóa việc quản lý vòng đời của các đối tượng tài nguyên. Thực tế module này đã trải qua vài lần tái cấu trúc, và tôi muốn hệ thống hóa lại quá trình phát triển của nó để mọi người dễ hình dung.
Giai đoạn đầu - Chiến lược đơn giản “chỉ tạo không hủy”
Ban đầu, chúng tôi hoàn toàn không quan tâm đến vấn đề giải phóng tài nguyên. Mọi tài nguyên đều được giữ nguyên trong bộ nhớ suốt quá trình chạy chương trình. Tuy nhiên, rõ ràng đây chỉ là giải pháp tạm thời phù hợp cho các bản demo đơn giản, không thể áp dụng cho sản phẩm thực tế. Việc này dễ dẫn đến tràn bộ nhớ khi xử lý các tựa game có quy mô lớn với hàng ngàn tài nguyên phức tạp.
Chuyển đổi sang mô hình Garbage Collection (GC) của Lua
Với kiến trúc engine được xây dựng dựa trên Lua - ngôn ngữ có cơ chế GC tự động, phương án đầu tiên được xem xét là tận dụng cơ chế thu gom rác này để quản lý tài nguyên. Tuy nhiên, tôi không thực sự hài lòng với cách tiếp cận “thô” này. Vì hai lý do chính:
- Cơ chế GC của Lua không đảm bảo tính tức thời, dẫn đến việc tài nguyên không được giải phóng kịp thời khi không còn cần thiết.
- Thời điểm kích hoạt GC khó kiểm soát, gây gián đoạn luồng xử lý chính vốn rất nhạy cảm về mặt thời gian, đặc biệt là trong module rendering hình ảnh. Khi CPU phải dành thời gian xử lý thu gom tài nguyên, hiện tượng giật hình rõ ràng có thể được quan sát bằng mắt thường.
Yêu cầu cấp thiết về thiết kế lại module quản lý tài nguyên
Một vấn đề phức tạp hơn phát sinh khi chúng tôi tiến hành thử nghiệm với các scene game quy mô lớn mô phỏng thế giới thực. Lượng tài nguyên đồ sộ đã chạm đến giới hạn nội tại của bgfx - thư viện rendering đa nền tảng. Cụ thể, khi có quá nhiều API gọi đến các tài nguyên (như tạo buffer, texture mới) trong một frame rendering, nó sẽ vượt qua ngưỡng của đường ống thông điệp đa luồng trong bgfx, dẫn đến crash chương trình.
Điều này buộc chúng tôi phải triển khai sớm module tải tài nguyên bất đồng bộ - thành phần then chốt của toàn bộ hệ thống quản lý tài nguyên. Việc này đồng thời mở ra cơ hội để tái thiết kế lại toàn bộ cơ chế quản lý tài nguyên từ đầu.
Giai đoạn thử nghiệm với cơ chế đếm tham chiếu (Reference Counting)
Giải pháp đầu tiên được triển khai là mô hình đếm tham chiếu cơ bản. Mỗi tài nguyên luôn được tham chiếu bởi các Component trong hệ thống ECS, nhờ đó tránh được vòng lặp tham chiếu và đảm bảo việc giải phóng tài nguyên được thực hiện chính xác khi đếm về 0. Các tài nguyên có thể được đưa vào một tập hợp chờ xử lý để một hệ thống chuyên dụng thực hiện giải phóng.
Tuy nhiên, tôi vẫn cảm thấy không hài lòng khi áp dụng cơ chế đếm tham chiếu trong môi trường Lua vốn đã có GC tích hợp sẵn. Hơn nữa, trên các thiết bị di động với giới hạn bộ nhớ nghiêm ngặt, việc quyết định giải phóng tài nguyên chỉ dựa trên việc có còn tham chiếu hay không không phải là cách tối ưu nhất.
Phân loại tài nguyên và chiến lược quản lý tương ứng
Tôi đề xuất chia tài nguyên thành hai nhóm chính để áp dụng chiến lược quản lý phù hợp:
- Tài nguyên có thể tái tải từ IO: Những tài nguyên này có tên duy nhất (thường là đường dẫn file). Ví dụ: texture, model từ file assets. Loại này có thể hủy bỏ bất kỳ lúc nào và tải lại khi cần.
- Tài nguyên sinh ra từ code: Những tài nguyên được tạo lập từ các quá trình xử lý cụ thể, khó tái tạo nếu bị hủy. Ví dụ: texture render từ camera trong scene.
Chiến lược quản lý cho từng loại tài nguyên
-
Với nhóm tài nguyên thứ nhất, việc quản lý vòng đời không nên dựa vào việc còn tham chiếu hay không, mà dựa trên tần suất sử dụng gần đây (LRU - Least Recently Used). Một tài nguyên dù đang được tham chiếu trong ECS nhưng nếu không được sử dụng trong thời gian dài vẫn có thể bị hủy bỏ để giải phóng bộ nhớ. Khi cần thiết, hệ thống sẽ tải lại qua module bất đồng bộ. Một số loại tài nguyên có thể có “phương án thay thế tạm thời” - ví dụ: dùng texture trắng trơn khi texture gốc đang được tải lại.
-
Với nhóm tài nguyên thứ hai, chúng tôi áp dụng nguyên tắc “ưu tiên giữ lại” khi bộ nhớ còn trống. Khi gặp tình trạng khan hiếm tài nguyên, hệ thống mới xem xét hủy bỏ chúng, nhưng chỉ khi có đủ điều kiện cần thiết. Ban đầu, chúng tôi thử nghiệm dùng lại cơ chế đếm tham chiếu, nhưng sau đó loại bỏ vì không thực sự hiệu quả. Thay vào đó, nhờ vào khả năng dễ dàng duyệt qua toàn bộ tài nguyên trong hệ thống ECS, việc kiểm tra xem tài nguyên còn đang được tham chiếu hay không trở nên đơn giản hơn nhiều.
Cải tiến inspired từ thiết kế của ImGUI
Sau một cuộc thảo luận nội bộ, tôi chợt nhớ đến nguyên lý thiết kế của ImGUI - thư viện UI nổi tiếng với triết lý “vẽ lại từ đầu mỗi frame”. Điều này gợi mở một hướng tiếp cận mới cho việc quản lý tài nguyên loại hai:
Thay vì cố gắng lưu trữ trạng thái tài nguyên, tại mỗi frame, hệ thống có thể tạo lại tài nguyên theo nhu cầu. Điều này đảm bảo không vi phạm nguyên tắc thiết kế ECS, đồng thời cho phép tích hợp cơ chế cache kết quả từ frame trước để tối ưu hiệu suất.
Với thiết kế mới này, cả hai nhóm tài nguyên đều có thể bị hủy bỏ bất kỳ lúc nào. Module quản lý tài nguyên chỉ cần áp dụng thuật toán LRU để loại bỏ các tài nguyên vượt quá ngưỡng bộ nhớ cho phép - một giải pháp đơn giản nhưng hiệu quả trong thực tế.