Khung ECS Trong Lua - nói dối e blog

Khung ECS Trong Lua

Trước đây tôi đã viết một bài luận về việc áp dụng mô hình ECS trong game “Overwatch”. Gần đây tôi muốn thử nghiệm xây dựng một khung ECS đơn giản bằng Lua, nên đã dành thời gian nghiên cứu kỹ hơn.

Qua quá trình phân tích, tôi nhận ra rằng: Khái niệm ECS thực chất không hề mới mẻ, mà xuất phát từ nhu cầu phát triển gắn liền với đặc điểm của từng ngôn ngữ lập trình. ECS được khởi xướng trong ngành công nghiệp game, hầu hết các framework liên quan đều được xây dựng trên nền tảng C++. Đây là một sự phản tư sâu sắc về mô hình hướng đối tượng truyền thống của C++. Thay vì dùng kế thừa, ECS xây dựng đối tượng dựa trên tổ hợp các thành phần (Component). Chính mô hình đối tượng này mới là cốt lõi của triết lý thiết kế ECS. Khi rời khỏi môi trường mô hình đối tượng của C++, ECS không còn là ý tưởng độc đáo nữa.

Quan điểm này của tôi không phải là mới. Trang Wikipedia về ECS cũng nêu rõ:

Trong bài thuyết trình tại GDC, Scott Bilas đã so sánh hệ thống hướng đối tượng của C++ với hệ thống Component tùy chỉnh mới của ông. Điều này hoàn toàn phù hợp với cách sử dụng truyền thống của thuật ngữ này trong ngành kỹ thuật hệ thống nói chung, ví dụ như hệ thống đối tượng Common Lisp hay hệ thống kiểu dữ liệu. Do đó, việc đặt “Hệ thống (System)” làm yếu tố trung tâm là một quan điểm cá nhân. Nhìn chung, ECS là sự kết hợp chủ quan giữa những ý tưởng đã được xác lập vững chắc trong khoa học máy tính và lý thuyết ngôn ngữ lập trình. Chẳng hạn, các Component có thể được xem như kỹ thuật mixin trong nhiều ngôn ngữ lập trình. Hoặc ngược lại, Component chỉ là trường hợp đặc biệt của kỹ thuật ủy quyền (delegation) và giao thức meta-đối tượng tổng quát hơn. Nói cách khác, bất kỳ hệ thống Component hoàn chỉnh nào cũng có thể biểu diễn bằng cách kết hợp mẫu thiết kế (template) và mô hình đồng cảm (empathy) trong tầm nhìn của Orlando Treaty về lập trình hướng đối tượng.

Không đi sâu vào lý thuyết, nếu muốn triển khai thực tế trên nền Lua, chúng ta có thể làm gì?
Tôi cho rằng cần tập trung vào ba khía cạnh sau:

Thứ nhất: Tăng cường hệ thống kiểu dữ liệu cho Lua
Lua vốn có tính động cao, cho phép dễ dàng tập hợp các Component khác nhau vào cùng một bảng (table) để tạo thành Entity. Tuy nhiên, khi số lượng Component tăng lên, việc quản lý thông qua nhiều bảng riêng lẻ sẽ gây tốn kém bộ nhớ. Khác với C++, nơi tổ chức dữ liệu bằng struct gần như không tốn chi phí, chúng ta nên tối ưu bằng cách phẳng hóa (flatten) dữ liệu Component vào cùng một bảng, miễn là các khóa (key) không bị trùng lặp. Điều này đòi hỏi thêm thông tin kiểu dữ liệu để dễ dàng trích xuất Component từ Entity trong quá trình chạy chương trình. Ngoài ra, đối với thiết kế kết hợp C/Lua, một số Component có thể được biểu diễn dưới dạng userdata.

Thứ hai: Tối ưu hóa thao tác duyệt dữ liệu
Chức năng cốt lõi của các System trong ECS là duyệt và xử lý các Component cùng loại. Đây là nơi cần phân biệt rõ ràng giữa Component viết bằng C (C Component) và Lua (Lua Component). Để tăng hiệu suất, có thể tập hợp các C Component cùng loại vào một khối bộ nhớ liên tục, sau đó chỉ giữ một lightuserdata trỏ đến khối này trong bảng Entity. Việc duy trì cache danh sách Entity cần xử lý cũng rất quan trọng. Với Lua, việc tạo cache rất trực quan: chỉ cần duyệt một lần để xây dựng tập hợp ban đầu, sau đó theo dõi sự thay đổi để cập nhật cache hiệu quả.

Thứ ba: Thiết kế hệ thống kiểu động cho Entity
Mỗi Component được gán một ID kiểu dữ liệu duy nhất 16-bit. Khi một Entity được tạo ra bằng cách kết hợp nhiều Component, các ID kiểu này sẽ được sắp xếp tăng dần thành một chuỗi ký tự đại diện cho kiểu động của Entity. Trong môi trường Lua, tôi thiết kế một hệ thống cache chuyển đổi chuỗi này thành các đối tượng kiểu dễ sử dụng, phục vụ cho việc lọc Entity và trích xuất Component. Phiên bản xử lý chuỗi kiểu này được tôi viết bằng C để tối ưu hiệu suất, vì mã Lua thuần sẽ chạy chậm hơn đáng kể ở công đoạn này.

Về cấu trúc, đa số Component nên được phẳng hóa vào bảng Entity, nhưng tôi cũng thiết kế cơ chế đăng ký kiểu Component. Khi tạo mới Component, lập trình viên có thể định nghĩa hàm dựng (constructor) và hàm hủy (destructor) riêng, hữu ích khi cần liên kết với cấu trúc C hoặc quản lý các bảng phụ.

Về việc duyệt tập hợp Entity, tôi xây dựng một hệ thống iterator dựa trên weak table để quản lý cache. Lần đầu duyệt, hệ thống sẽ tạo một tập hợp chứa các ID Entity thỏa điều kiện. Sau đó, bộ cache này sẽ tự động cập nhật khi có Component mới được tạo ra, đảm bảo kết quả luôn cập nhật. Cả phiên bản Lua và C của iterator đều được tôi triển khai. Đặc biệt, phiên bản C có hiệu suất gần tương đương với các container gốc của C/C++, mang lại hiệu quả cao trong các vòng lặp xử lý liên tục.

Đối với việc quản lý Entity và Component, tôi đã hoàn thiện phần lớn chức năng cần thiết. Tuy nhiên, đối với các System, tôi vẫn đang cân nhắc nên thiết kế ra sao. Có lẽ tôi sẽ tiếp tục hoàn thiện sau khi có yêu cầu sử dụng thực tế.

Hiện tại tôi đã hoàn thành phiên bản thử nghiệm đầu tiên. Dù có thể hiện thực hóa hoàn toàn bằng Lua thuần, tôi特意 xây dựng hai hàm chính bằng C để tăng tốc độ xử lý - kết quả cho thấy hiệu năng cải thiện rõ rệt.

Về thiết kế API, tất cả Entity đều được xác định bằng ID số duy nhất thay

0%