Quản Lý Tập Hợp Thực Thể (Entity) Trong Mô Hình ECS
Gần đây mình đang tập trung xây dựng một số công việc nền tảng dựa trên mô hình ECS (Entity-Component-System). Trong quá trình triển khai, mình gặp phải một câu hỏi chưa rõ ràng: Làm thế nào để xử lý một cách hiệu quả các hệ thống (system) liên quan trực tiếp đến tập hợp các thực thể (entity)?
Mình đã nghiên cứu kỹ lưỡng các tài liệu hiện có về ECS. Đa phần tài liệu trên mạng đều được xuất bản từ vài năm trước, thậm chí cách đây hơn chục năm. Hầu hết các nguồn này đều nhấn mạnh vào những nguyên tắc cốt lõi của ECS: Thành phần (component) chỉ chứa dữ liệu thuần túy (không có phương thức), và hệ thống (system) phải là hàm thuần túy (không lưu trạng thái). Từ góc nhìn này, Unity thực chất chỉ là một hệ thống EC (Entity-Component), chứ chưa thực sự tuân thủ mô hình ECS đầy đủ. Do đó, việc tìm kiếm các mẫu thiết kế (design pattern) liên quan đến hệ thống trong Unity có thể không phù hợp.
Mình đã xem lại bài phát biểu tại GDC của Blizzard về kiến trúc trò chơi Overwatch — một ví dụ thành công và mới nhất (tính đến thời điểm này) về áp dụng ECS hiệu quả. Mình tin rằng những câu hỏi cơ bản mình gặp phải đều có câu trả lời trong bài trình bày này.
Thông qua nhiều tài liệu, mình nhận thấy rằng mô hình Entity-Component là một sự phản tư về cách tổ chức dữ liệu trong lập trình hướng đối tượng (OOP) truyền thống. Thay vì kế thừa (inheritance), nó sử dụng cơ chế tổ hợp (composition) — thậm chí là tổ hợp linh hoạt trong thời gian chạy chương trình — để xây dựng các đối tượng. Mặt khác, hệ thống (system) lại là một cách tiếp cận ngược lại với OOP: Thay vì gắn bó một nhóm phương thức với một đối tượng (như OOP chủ trương), hệ thống sẽ xử lý các tập hợp đối tượng dựa trên dữ liệu của chúng.
Lựa chọn ECS thay vì OOP chủ yếu xuất phát từ việc mô hình hướng đối tượng có mức độ liên kết (coupling) quá cao. Cách tiếp cận EC/System giúp giải quyết vấn đề này bằng cách tách rời dữ liệu và xử lý logic.
Việc chia nhỏ các thực thể (entity) thành các đơn vị dữ liệu cơ bản (component) và để các hệ thống tác động trực tiếp lên các tập hợp component thay vì toàn bộ entity giúp giảm độ phức tạp của hệ thống. Ví dụ như trên Wikipedia: Một hệ thống rendering sẽ duyệt qua các entity có component hình ảnh và vật lý để xác định “cách” và “ở đâu” để vẽ. Trong khi đó, hệ thống va chạm lại xử lý các entity chỉ cần component vật lý, không cần quan tâm đến cách vẽ hay logic trò chơi. Một hệ thống khác sẽ quản lý máu (health) của entity, xử lý sự kiện va chạm giữa đạn và quái vật để trừ điểm máu.
Tuy nhiên, trong các engine trò chơi, còn một yêu cầu then chốt liên quan đến hiệu năng: Loại bỏ (culling) các đối tượng không cần xử lý. Hình dung như thế này: Nếu hệ thống rendering cần xử lý một tập hợp nhỏ entity (ví dụ: nằm trong tầm nhìn của camera), thì việc duyệt qua toàn bộ thế giới ảo mỗi frame sẽ gây lãng phí.
Ví dụ thực tế: Giả sử có một hệ thống loại bỏ đối tượng dựa trên vị trí camera. Sau khi hệ thống này chạy xong, các hệ thống khác (như rendering) không nên duyệt toàn bộ thế giới để tìm các component hình ảnh, mà chỉ cần tập trung vào tập hợp đã được lọc.
Nếu tạm thời bỏ qua khác biệt trong cách triển khai giữa mô hình tổ hợp (component-based) và mô hình kế thừa (OOP), ta có thể xem component như một nhãn hiệu (label) dán lên entity. Mô hình ECS thực chất cung cấp khả năng lọc các entity dựa trên nhãn này. Câu hỏi đặt ra là: Liệu có thể thiết kế một cơ chế gắn/tách nhãn hiệu động mà không ảnh hưởng đến hiệu năng? Ví dụ: Bộ culling có thể dán nhãn “cần render” lên entity, và hệ thống rendering chỉ cần xử lý các entity có nhãn đó.
Tuy nhiên, trong bài trình bày về Overwatch, mình không tìm thấy thiết kế tương tự. Trong thực hành cá nhân, ý tưởng dùng nhãn hiệu cũng gặp nhiều vấn đề. Mình đã thử hướng tiếp cận khác.
Theo nghiên cứu, engine của Overwatch sử dụng component đơn thể (singleton) rất nhiều. Các hệ thống liên quan chỉ xử lý component đơn này, đọc hoặc ghi dữ liệu vào đó. Mình cho rằng các hàng đợi sự kiện dùng để giao tiếp giữa hệ thống, hay kết quả của bộ culling, nên được lưu trữ trong singleton. Hệ thống rendering sẽ không duyệt toàn bộ thế giới, mà chỉ duyệt một tập hợp con từ component đơn của bộ culling.
Bộ culling lại phụ thuộc vào trạng thái của các đối tượng. Trong một cảnh game có hàng nghìn entity, việc duyệt lại toàn bộ mỗi frame là không hiệu quả. Giải pháp tối ưu là chỉ cập nhật khi có thay đổi vị trí.
Về vấn đề giao tiếp giữa hệ thống, Wikipedia đề cập đến việc dùng mô hình quan sát (observer pattern) để xử lý các sự kiện ít xảy ra. Thay vì “dò quét” (polling) các cờ hiệu (flag) mỗi frame, hệ thống có thể đăng ký lắng nghe sự kiện và chỉ phản ứng khi sự kiện thực sự xảy ra.
Trong triển khai của mình, mình đã mở rộng framework ECS với cơ chế sau:
- Một hệ thống có thể theo dõi (watch) các sự kiện thay đổi của component.
- Hệ thống chỉ chạy khi component liên quan thay đổi (không phải mỗi frame).
- Việc tạo mới hoặc xóa component tự động kích hoạt sự kiện.
- Framework cũng hỗ trợ gọi thủ công sự kiện thay đổi.
- Trong cùng một frame, một component chỉ kích hoạt sự kiện tối đa một lần.
Đáng chú ý, trong bài phát biểu của Overwatch (ở phút 4), slide trình chiếu có hai phương thức: