Cơ Chế Đăng Ký/Tin Nhắn Trong Khung Làm Việc ECS
Trong quá trình áp dụng khung làm việc ECS, chúng tôi nhận ra rằng lý do khái niệm ECS ra đời từ lĩnh vực game là bởi vì các chương trình game thường xử lý định kỳ một loạt đối tượng. Chúng thực hiện các phép tính toán, cập nhật trạng thái của thế giới game từ chu kỳ trước sang chu kỳ tiếp theo. Trong khi đó, các ứng dụng tương tác người-máy truyền thống lại hoạt động theo mô hình phản ứng (reactive): một yêu cầu bên ngoài kích hoạt chuỗi xử lý nghiệp vụ liên quan.
Nếu cố nhồi nhét logic game vào khung làm việc phản ứng, bạn sẽ thấy phải dùng timer để kích hoạt định kỳ. Lúc này, các nghiệp vụ phản ứng lại timer gần như không mang theo trạng thái nào. Việc xử lý từng timer đơn lẻ không thể làm theo kiểu stateless được - bởi chính timer đó đã đại diện cho toàn bộ quá trình cập nhật trạng thái của thế giới game từ chu kỳ trước.
Trong trường hợp này, khung làm việc phản ứng trở nên kém hiệu quả. Ngược lại, nếu khung làm việc hoàn toàn tự lặp định kỳ để cập nhật trạng thái, thì lại không linh hoạt bằng mô hình phản ứng khi xử lý các sự kiện đầu vào bên ngoài.
Với các thao tác đầu vào đơn giản như tay cầm điều khiển, chúng tôi có thể mỗi frame đều cập nhật trạng thái các nút bấm vào thế giới game. Khi đó các hệ thống (System) trong ECS có thể trực tiếp sử dụng trạng thái này như một singleton trong thế giới. Tuy nhiên với các đầu vào phức tạp hơn, cách làm này không còn hiệu quả.
Trong giai đoạn đầu triển khai ECS, chúng tôi cố gắng giữ nguyên tính thuần khiết của mô hình, cố ý tránh dùng cơ chế sự kiện quen thuộc trong các khung làm việc phản ứng. Thay vào đó chỉ thêm một hàng đợi tin nhắn đầu vào bên ngoài. Các sự kiện nội bộ như: tạo/xóa component, hiệu ứng phụ từ việc chuyển trạng thái máy trạng thái (state machine), các sự kiện từ hệ thống vật lý… đều được chuyển thành thay đổi trạng thái. Các hệ thống sẽ xử lý định kỳ các thay đổi này mỗi frame.
Tuy nhiên gần đây tôi nhận ra việc cố ý loại bỏ hoàn toàn hệ thống sự kiện khỏi ECS là không hợp lý. Bản thân logic game là sự kết hợp giữa mô hình cập nhật trạng thái định kỳ và mô hình xử lý nghiệp vụ phản ứng.
Vì vậy tôi quyết định bổ sung một mô-đun đăng ký/tin nhắn hoàn chỉnh cho khung làm việc ECS.
Với việc khung làm việc được viết bằng Lua, mô-đun này có thể linh hoạt hơn nhiều so với các khung tương tự viết bằng C/C++. Mỗi tin nhắn là một bộ key/value được chứa trong bảng Lua. Ví dụ:
|
|
Tất cả tin nhắn được phát qua phương thức world:pub(message)
. Bất kỳ hệ thống nào cũng có thể đăng ký nhận tin nhắn qua mailbox = world:sub(pattern)
. Pattern cũng là bộ key/value. Ví dụ:
{ type = "new" }
: Theo dõi tất cả tin nhắn loại new{ type = "mouse" }
: Theo dõi mọi sự kiện chuột{ type = "mouse", action = "move" }
: Chỉ theo dõi sự kiện di chuyển chuột{ eid = 42 }
: Theo dõi mọi việc xảy ra với entity 42 (thường dùng cho log)
Mỗi hệ thống có thể dùng vòng lặp for msg in mailbox:each() do
để xử lý toàn bộ tin nhắn trong hộp thư, đồng thời xóa chúng khỏi hộp thư.
Phía sau hậu trường:
Điểm phức tạp nhất nằm ở hàm pub
. Hiện tại khi một tin nhắn được phát, nó lập tức được phân phát đến tất cả các hộp thư đã đăng ký trước đó. Với n hộp thư quan tâm đến tin nhắn, mỗi hộp thư là một hàng đợi nên việc lặp lại khá đơn giản.
Cơ chế khớp tin nhắn (message matching) của chúng tôi rất linh hoạt nhưng quy tắc lại đơn giản: Mỗi cặp key/value trong pattern là một điều kiện lọc. Chỉ khi tin nhắn thỏa mãn tất cả điều kiện mới được gửi đến hộp thư tương ứng. Nếu không tối ưu, độ phức tạp thời gian của pub
là O(n*m) với n là độ dài tin nhắn và m là số hộp thư trong hệ thống.
Tuy nhiên việc tối ưu khá dễ dàng bằng cách xây dựng bộ nhớ đệm chỉ mục khi gọi sub
. Ví dụ khi một pattern chứa điều kiện type = "new"
, chúng tôi sẽ tạo bảng chỉ mục type:new
chứa:
- Tập hợp các hộp thư tiềm năng (candidate set) - những hộp thư thỏa mãn điều kiện này
- Tập hợp các hộp thư loại trừ (exclusion set) - những hộp thư không thỏa mãn
Một hộp thư được coi là thỏa mãn nếu:
- Pattern chứa
type = "new"
- Hoặc pattern không có key
type
Ngược lại sẽ bị loại nếu pattern có key type
nhưng giá trị không phải "new"
.
Khi phát tin nhắn, với mỗi điều kiện trong tin nhắn, hệ thống có thể nhanh chóng (O(1)) xác định:
- Những hộp thư nào có thể loại bỏ ngay
- Những hộp thư nào cần kiểm tra thêm
Sau khi xử lý tất cả điều kiện, chỉ những hộp thư còn lại trong candidate set mới được nhận tin nhắn. Để giảm số lần so sánh, hệ thống có thể sắp xếp các điều kiện theo kích thước candidate set từ nhỏ đến lớn, ưu tiên lọc các điều kiện có ít hộp thư quan tâm nhất.
Dù độ phức tạp vẫn là O(n*m), nhưng m lúc này là kích thước candidate set nhỏ nhất thay vì tổng số hộp thư. Trong thực tế, nhiều điều kiện có m=0 vì không có hộp thư nào quan tâm.
Trong trường hợp các điều kiện lọc quá đa dạng dẫn đến bộ nhớ cache chỉ mục chiếm nhiều không gian, chúng tôi có thể dùng cơ chế metatable để hợp nhất các chỉ mục hoặc đánh dấu một số điều kiện không cần cache, thay vào đó dùng phương pháp lặp thông thường.
Ngoài ra, với các tổ hợp điều kiện thường dùng, hệ thống có thể tạo chỉ mục kết hợp (composite index). Đây là hướng phát triển tiềm năng trong tương lai để tiếp tục tối ưu hiệu năng.