Mô Hình Xử Lý Theo Tiếp Cận ECS - nói dối e blog

Mô Hình Xử Lý Theo Tiếp Cận ECS

Gần đây, tôi đã thực hiện buổi chia sẻ kéo dài 2 tiếng tại công ty, trình bày về những suy nghĩ và kinh nghiệm áp dụng mô hình ECS trong vài năm gần đây. Chủ đề tôi chọn không phải là “ECS” mà là một khái niệm rộng hơn mang tên “Thiết kế định hướng dữ liệu”. Lý do là bởi tôi muốn tránh bị gò bó vào các thuật ngữ cụ thể như Entity Component System. Theo Wikipedia, DOD (Data-Oriented Design) ra đời từ nhu cầu tối ưu hiệu năng trong phát triển game, tập trung vào cách tổ chức dữ liệu trong bộ nhớ. So với cách bố trí mặc định của các ngôn ngữ lập trình hướng đối tượng như C++, mô hình DOD/ECS tối ưu hơn trong việc tận dụng CPU Cache nhờ cách sắp xếp dữ liệu thông minh, từ đó mang lại hiệu suất vượt trội.

Tuy nhiên, nếu không sử dụng các ngôn ngữ cho phép kiểm soát trực tiếp layout bộ nhớ như C/C++, lợi ích của ECS sẽ không đáng kể. Theo góc nhìn của tôi, giá trị cốt lõi của ECS không chỉ nằm ở hiệu năng, mà ở khả năng giải kết hợp dữ liệu theo mô-đun. Khi dữ liệu được tổ chức theo cách này, người phát triển sẽ dễ dàng xây dựng các module có độ kết dính cao hơn.

Kinh nghiệm của tôi cho thấy, để tận dụng tối đa ECS, cần thay đổi cách nhìn nhận vấn đề: thay vì tiếp cận theo logic nghiệp vụ truyền thống, hãy suy nghĩ dựa trên cách tổ chức dữ liệu và tuân thủ các mẫu xử lý đặc thù. Nếu chỉ đơn giản thay thế con trỏ object bằng entity ID mà vẫn giữ tư duy OOP, bạn sẽ gặp nhiều trở ngại. ECS thay đổi cách xử lý dữ liệu - những thao tác O(1) trong OOP có thể trở thành O(n), ngược lại các xử lý phức tạp lại trở nên đơn giản hơn. Để tận dụng lợi ích (tăng hiệu năng, giảm kết hợp), chúng ta phải chấp nhận một số giới hạn nhất định.

Trong mô hình ECS, tôi hình dung toàn bộ dữ liệu như một bảng 2D thưa. Mỗi hàng đại diện cho một Entity, mỗi cột là một loại Component. Các hàng có thể chứa nhiều ô trống (không phải Entity nào cũng có đầy đủ Component), tương tự với các cột. Thao tác cơ bản nhất là duyệt dữ liệu: duyệt dọc để truy cập các Component cùng loại, hoặc duyệt ngang để lấy các Component cùng hàng (cùng Entity). Đặc biệt, kiểu dữ liệu đóng vai trò then chốt - bạn luôn có thể tìm được dữ liệu dựa trên loại Component. Điều này khiến mô hình Singleton truyền thống trở nên thừa thãi: một Singleton đơn giản là một cột chỉ chứa duy nhất một phần tử, có thể truy cập O(1).

Trong mô hình này, việc tham chiếu đến một Entity cụ thể (hàng cụ thể) trở nên khó khăn hơn, trong khi các cột (loại Component) thường cố định và dễ dàng truy cập. Việc lặp qua một cột trở nên rất hiệu quả. Chúng ta thậm chí có thể tạo các bộ lọc để chọn tập hợp Entity đáp ứng điều kiện nhất định - ví dụ như các Entity chứa đồng thời Component A và B, hay thậm chí là A = 42. Đây là cách tiếp cận tương tự như thiết kế cơ sở dữ liệu, và thực tế Wikipedia cũng nhận định: “ECS có nhiều điểm tương đồng với thiết kế cơ sở dữ liệu, có thể coi là một cơ sở dữ liệu trong bộ nhớ”.

Khác với nhiều framework ECS hiện có, tôi áp dụng thêm nhiều giới hạn nghiêm ngặt nhằm đơn giản hóa triển khai và định hướng người dùng đến các thuật toán tối ưu hơn:

  1. Entity ID ẩn danh: Mỗi Entity có ID nội bộ, nhưng không được phép truy cập trực tiếp ID này để tham chiếu Entity.
  2. Cấu trúc cố định: Khi tạo Entity phải xác định đầy đủ Component, không cho phép thêm/bớt Component trong quá trình chạy (trừ các trường hợp đặc biệt).
  3. Tag đặc biệt: Component loại Tag không chứa dữ liệu, chỉ dùng để đánh dấu Entity ảnh hưởng đến kết quả truy vấn. Tag có thể bật/tắt động (vi phạm quy tắc 2).
  4. Component tạm thời: Cho phép thêm Component tạm thời trong quá trình xử lý (vi phạm quy tắc 2), nhưng phải tuân thủ nguyên tắc: chỉ thêm theo thứ tự duyệt và xóa sau khi xử lý xong.
  5. Thứ tự ổn định: Thứ tự duyệt Component luôn giữ nguyên theo thứ tự tạo Entity.
  6. Sắp xếp tùy chỉnh: Có thể tạo Tag “sorted tag” để duyệt theo thứ tự mong muốn, nhưng phải tự cập nhật và không áp dụng được với Component tạm thời.

Việc xóa Entity trở nên đơn giản hơn: chỉ cần gắn Tag “Removed” và xóa vật lý ở cuối chu kỳ cập nhật do giới hạn bộ nhớ. Nếu không có giới hạn này, chúng ta chỉ cần loại trừ Entity có Tag Removed khi truy vấn. Tương tự, cơ chế RAII trong C++ cũng không còn cần thiết - có thể tập trung hủy các Component bị đánh dấu ở cuối pipeline, giúp xử lý an toàn và hiệu quả hơn.

Khi tạo Entity mới, thay vì kích hoạt ngay lập tức, nên tạo Entity với Component khởi tạo và đợi đến chu kỳ tiếp theo để xử lý. Trong mô hình này, không có cách trực tiếp tham chiếu đến Entity cụ thể. Nếu cần xử lý một Entity đặc biệt, có hai cách:

  • Gắn Tag đặc biệt và duyệt theo Tag đó.
  • Thêm Component chứa ID, sau đó so sánh ID khi duyệt (thao tác O(n)).

Vấn đề tham chiếu chéo giữa các dữ liệu (ví dụ như đồ thị kề trong engine 3D) được giải quyết bằng Component đặc biệt. Những Component này tồn tại độc lập, không nằm cùng hàng với các Component khác. Chúng không bị xóa mà được tái sử dụng, với vị trí ổn định trong cột. Hai Tag “Live” và “Dead” được dùng để quản lý trạng thái, cho phép truy cập O(1) thông qua số hàng trong cột. Người dùng tự quản lý việc tham chiếu/giải tham chiếu bằng kỹ thuật đếm

0%