Quản Lý Vòng Đời Của Entity Trong Hệ Thống ECS
Môi trường trò chơi của chúng ta được xây dựng từ nhiều nút cảnh khác nhau, trong đó mỗi nút cảnh là một thành phần Component của Entity. Những cảnh phức tạp có thể được tạo thành từ các khối dựng sẵn (Prefab) trong trình soạn thảo, giống như việc lắp ráp các mảnh lego đã được thiết kế sẵn. Về thiết kế hệ thống Prefab, chúng tôi đã từng thảo luận chi tiết qua hai bài blog trước đây là “Thiết kế hệ thống Prefab trong engine trò chơi” và “Quản lý Prefab và tập hợp đối tượng”.
Dựa trên kinh nghiệm sử dụng thực tế, gần như tất cả Entity trong trò chơi hiện tại đều được khởi tạo từ các Prefab. Một Prefab có thể tạo ra n Entity, nhưng việc quản lý vòng đời của n Entity này lại gặp không ít thách thức.
Giải pháp trực quan nhất là: mọi Entity đều phải là nút trên cây cảnh (có thành phần Scene), khi xóa một nút cảnh, toàn bộ các nút con cháu cũng sẽ bị xóa theo. Tuy nhiên phương pháp này tồn tại hai vấn đề chính:
-
Xử lý tham chiếu gián tiếp: Khi một Entity bị xóa gián tiếp do tổ tiên nó bị xóa, các tham chiếu đang nắm giữ Entity này phải được xử lý như thế nào? Giải pháp truyền thống là yêu cầu mọi tham chiếu phải là tham chiếu yếu (weak reference) thông qua Entity ID. Mỗi khi giải tham chiếu Entity ID, chúng ta cần kiểm tra tính hợp lệ của nó.
-
Quản lý vòng đời của Entity không thuộc cảnh: Có những Entity không nhất thiết là nút cảnh nhưng vẫn cần cơ chế quản lý vòng đời tự động. Giải pháp truyền thống thường ép buộc mọi thứ phải trở thành nút cảnh hoặc gắn chúng như thành phần Component của một nút cảnh nhất định, nhưng cách làm này dễ dẫn đến việc sử dụng sai lệch bản chất của hệ thống ECS.
Tôi không hài lòng với các giải pháp truyền thống vì quản lý vòng đời và quản lý cảnh vốn là hai vấn đề hoàn toàn khác nhau. Quản lý cảnh nên dùng cấu trúc cây, trong khi quản lý vòng đời phù hợp hơn với cấu trúc tập hợp. Mọi thứ cần được quản lý vòng đời không nhất thiết phải tồn tại trên cây cảnh.
Khả năng thêm Component động cho Entity (hệ thống ECS của chúng tôi cố ý không hỗ trợ) đôi khi chỉ là giải pháp tình thế để ràng buộc vòng đời của các đối tượng không liên quan. Việc cưỡng ép gắn chúng như Component của một nút cảnh nhất định theo tôi là lạm dụng mô hình ECS.
Quay lại với hệ thống Prefab, chúng ta cần nhìn nhận nó như một cơ chế lưu trữ bền vững (persistent) cho tổ hợp Entity, bao gồm dữ liệu của từng Entity và mối quan hệ giữa chúng. Trong quá trình chạy chương trình, chúng ta sẽ tạo ra các phiên bản thực thể (instance) từ các mẫu này.
Nếu quá trình khởi tạo Prefab tạo ra một nhóm n Entity được thiết kế sẵn thì quá trình hủy chúng cũng cần đối xứng - tức là nên hủy toàn bộ nhóm Entity này cùng lúc thay vì chỉ hủy một phần. Trong thực tế, việc hủy từng phần thường xảy ra khi chúng ta “bóc tách” một số Entity để gắn vào cây cảnh hiện có, hoặc cho phép Entity khác gắn vào điểm móc nối của chúng. Hành động đầu tiên sẽ phân tách tập quản lý vòng đời, còn hành động sau lại mở rộng tập quản lý vòng đời.
Gần đây tôi đã重构重构 (cấu trúc lại) tính năng gắn kết trong engine. Thay vì cho phép gắn kết tùy ý giữa các nút cảnh, tôi chuyển sang sử dụng cơ chế tham chiếu yếu, đồng thời giữ nguyên cấu trúc cây cảnh mặc định từ Prefab. Nhờ đó, việc quản lý vòng đời trở nên đơn giản hơn nhiều.
Giải pháp hiện tại hoạt động như sau: Khi khởi tạo Prefab, ngoài việc tạo ra nhóm Entity, hệ thống sẽ trả về một Instance ID dùng để tham chiếu vòng đời của nhóm này. Instance ID chỉ có một chức năng duy nhất là hủy toàn bộ nhóm Entity trong tương lai, không thể dùng cho mục đích khác. Việc điều khiển các Entity cụ thể vẫn phải thông qua Entity ID hoặc chọn lọc các Component phù hợp.
Kết quả là mỗi Entity trong hệ thống có hai ID:
- Entity ID (ID duy nhất cho từng Entity)
- Instance ID (ID nhóm quản lý vòng đời từ Prefab)
Lớp底层 (底层) hệ thống vẫn hỗ trợ hủy Entity qua Entity ID, nhưng lớp trên không được phép dùng chức năng này. Mọi thao tác hủy phải thông qua Instance ID để đảm bảo xóa đồng loạt cả nhóm.
Để hợp nhất hai ID này thành một, chúng tôi áp dụng thủ thuật nhỏ: dùng số 64-bit làm Entity ID, đảm bảo mỗi Entity bị hủy sẽ không bao giờ tái sử dụng ID cũ, nhờ đó có thể duy trì tham chiếu yếu an toàn.
Trong quá trình khởi tạo Prefab, Entity ID được cấp theo cấu trúc 48+16:
- Phần 48-bit đầu tiên là Internal ID tăng đơn điệu mỗi lần khởi tạo Prefab
- Phần 16-bit còn lại đánh số thứ tự Entity trong nhóm khởi tạo
Ví dụ: Nếu một Prefab tạo ra 100K Entity, bắt đầu từ Internal ID = 42:
- Nhóm đầu tiên sử dụng 42 « 16 (64K Entity đầu tiên)
- Nhóm tiếp theo sử dụng 43 « 16 (36K Entity còn lại)
- Instance ID sẽ là 42 « 16 | 2, đánh dấu đây là nhóm gồm 2 tập liên tiếp bắt đầu từ 42
Việc theo dõi Instance ID không cần lưu riêng biệt cho từng Entity, chỉ cần trích xuất 48-bit cao của Entity ID là có thể xác định nhóm quản lý vòng đời tương ứng. Những Entity có cùng giá trị 48-bit cao (42 và 43 trong ví dụ) sẽ thuộc cùng một nhóm quản lý vòng đời.