Vấn Đề Thực Thể Chứa Nhiều Thành Phần Cùng Kiểu - nói dối e blog

Vấn Đề Thực Thể Chứa Nhiều Thành Phần Cùng Kiểu

Trong mô hình ECS, một thực thể (Entity) có thể chứa nhiều thành phần (Component) cùng kiểu hay không? Trong Unity, câu trả lời là có. Hệ thống của chúng tôi từ đầu cũng cho phép điều này.

Tuy nhiên, một vấn đề nảy sinh: làm thế nào để truy cập các Component cùng kiểu trong Lua? Nếu có nhiều Component giống nhau, cách tự nhiên nhất là lưu chúng trong một mảng. Nhưng phần lớn trường hợp, việc cứ phải thêm chỉ số [1] hay [0] mỗi lần truy cập lại gây phiền phức. Nếu chỉ khi có nhiều Component mới dùng mảng, còn đơn lẻ thì không, điều này lại tạo ra gánh nặng tư duy do phải xử lý hai loại kiểu dữ liệu khác nhau.

Chúng tôi từng tìm giải pháp tận dụng đặc tính của Lua bằng cách đóng gói cả Component và mảng vào một bảng (table). Khi có nhiều Component, mảng sẽ tự động được đặt trong bảng của Component đầu tiên. Tuy nhiên, sau một thời gian sử dụng, mẹo lập trình này dần trở nên “bẩn” và khó bảo trì. Đến khi chuyển sang viết luaecs bằng C, tính năng này đã bị loại bỏ hoàn toàn.

ECS không phải là “thuốc tiên”. Nếu cần tập hợp nhiều Component giống nhau, hãy sử dụng cấu trúc dữ liệu phụ trợ hoặc chia sẻ giữa nhiều “thế giới” (world). Đây là bài học thực tế của chúng tôi. Vấn đề này từng được thảo luận trong issue 9 của luaecs năm ngoái.

Gần đây, chúng tôi lại gặp tình huống tương tự ở module render. Vấn đề cốt lõi nằm ở chỗ: Dù ECS cung cấp mô hình dữ liệu hiệu quả thay thế OOP, nhưng nó cũng giới hạn tính linh hoạt trong thiết kế cấu trúc dữ liệu. Làm thế nào để cân bằng giữa hiệu suất và khả năng mở rộng?

Bài toán render: Khi hiệu suất gặp linh hoạt

Thông thường, để tạo Component linh hoạt trong luaecs, chỉ cần khai báo bảng Lua với cấu trúc tự do. Tuy nhiên, truy cập bảng Lua từ phía C sẽ gây sụt giảm hiệu suất nghiêm trọng, đặc biệt với module render yêu cầu tốc độ cao.

Giải pháp của chúng tôi là định nghĩa sẵn các cấu trúc C cho Component render. Điều này giúp hệ thống xử lý native từ C không bị overhead, trong khi phía Lua chỉ cần thao tác với cấu trúc thông qua lớp gián tiếp. Vì Lua chỉ dùng để khởi tạo hoặc thực hiện các tác vụ ít tần suất, giải pháp này hoàn toàn chấp nhận được. Kết quả là module render dựa trên Lua có thể đạt tới 90% hiệu suất của bản C++ gốc.

Bài toán vật liệu: Nhiều vật liệu cho một đối tượng

Một vấn đề cụ thể: vật thể render cần nhiều vật liệu (material) khác nhau. Ví dụ, vật liệu dùng cho render bình thường khác với vật liệu cho đổ bóng. Chúng tôi giải quyết bằng cách định nghĩa mảng vật liệu trực tiếp trong cấu trúc C. Tuy nhiên, điều này dẫn đến giới hạn cứng về số lượng vật liệu mỗi đối tượng có thể chứa.

Với dự án C++, chúng tôi dùng mảng động linh hoạt. Nhưng trong môi trường lai (C + Lua), chúng tôi chọn phương pháp thứ ba: cấu trúc dữ liệu được khởi tạo động ở giai đoạn đầu chương trình, sau đó cố định trong runtime. Độ dài mảng vật liệu sẽ được xác định khi load plugin tùy chỉnh, giúp cân bằng giữa linh hoạt và hiệu suất.

Giải pháp tổng quát cho dữ liệu linh hoạt

Nếu cần xử lý dữ liệu động từ phía C, chúng tôi đề xuất mô hình quản lý ID gián tiếp. Luaecs chỉ lưu ID tham chiếu, trong khi module C++ quản lý dữ liệu thực tế. Quá trình dọn dẹp đối tượng không dùng sẽ dựa trên việc so sánh các tập hợp ID định kỳ.

Từ bài toán logic game: Ghép nối Assembly Machine

Trong game dạng Factorio của chúng tôi, mỗi máy móc được tạo từ nhiều Component. Ví dụ, trạm sạc xe điện cần:

  • Bộ tiêu thụ năng lượng
  • Máy lắp ráp
  • Kho chứa

Chúng tôi muốn xây dựng trạm sạc với các tính năng đặc biệt:

  1. Giới hạn hiệu suất sạc tối đa
  2. Chức năng tích năng lượng khi rảnh
  3. Vẫn hoạt động khi mất điện

Thay vì viết Component mới, chúng tôi đề xuất phương án ghép nối hai Assembly Machine:

  • Máy 1: Tiêu thụ xe rỗng để tạo ra xe đầy điện
  • Máy 2: Sản xuất “pin ảo” từ năng lượng lưới

Khi xe vào trạm:

  1. Năng lượng dư chuyển thành pin ảo
  2. Xe rỗng trở thành nguyên liệu
  3. Quá trình lắp ráp diễn ra theo chu kỳ cố định

Pin ảo là vật liệu nội bộ, người chơi không trực tiếp quan sát. Hai máy sản xuất pin với tốc độ khác nhau:

  • Máy nhanh: Tiêu thụ năng lượng, tốc độ cao
  • Máy chậm: Không tiêu thụ năng lượng, dự phòng khi mất điện

Bài học từ cộng đồng Factorio

Các mod nổi tiếng trong Factorio đã áp dụng nguyên tắc ghép nối tương tự:

  • Mod tàu chở dầu: Tự động đóng thùng/chuyển dầu thông qua các thiết bị lắp ráp
  • Mod điện mặt trời: Kết hợp nhiều máy để tối ưu hiệu suất khi lưới điện đầy

Hướng phát triển cho luaecs

Hiện tại luaecs chưa hỗ trợ nhiều Component cùng kiểu trên một Entity. Tuy nhiên, về mặt lý thuyết, việc này hoàn toàn khả thi:

  • Về cấu trúc dữ liệu: Chỉ cần cho phép nhiều Component cùng ID
  • Về hiệu suất: Tìm kiếm nhị phân O(log N) cho Component cùng Entity
  • Về API Lua: Thiết kế lại giao diện select để xử lý trường hợp nhiều Component

Một số thay đổi tiềm năng:

  1. Khi chọn Component A làm khóa chính, mỗi Component A sẽ tạo một lần lặp độc lập
  2. Với Component phụ, mặc định chỉ trả về phần tử đầu tiên, nhưng cho phép trả về mảng nếu cần

Việc cải tiến này đòi hỏi nhiều thay đổi sâu rộng trong luaecs. Hiện tại, chúng tôi vẫn đang cân nhắc kỹ lưỡng trước khi triển khai.

0%