Thiết Kế Hệ Thống Hạt Nhân - nói dối e blog

Thiết Kế Hệ Thống Hạt Nhân

Thiết kế hệ thống hạt trong engine

Vài ngày nay tôi đang tái cấu trúc hệ thống hạt trong engine. Trước đây tôi đã làm một phiên bản prototype bằng Lua, giờ đây sẽ triển khai lại bằng C/C++. Hiện tại vẫn đang sử dụng hệ thống hạt dựa trên CPU, sau này nếu cần sẽ phát triển thêm phiên bản dựa trên GPU.

Năm ngoái tôi đã từng viết một bài blog về chủ đề hệ thống hạt. Ý tưởng cơ bản vẫn giữ nguyên, nhưng lần này tôi tập trung vào thiết kế chi tiết cấu trúc dữ liệu với một số điểm mới thú vị đáng để chia sẻ.

Trước tiên, mỗi hạt là một khối dữ liệu tổng hợp chứa nhiều thuộc tính khác nhau. Tôi giới hạn tối đa 64K hạt hoạt động đồng thời, các hạt này có thể lưu trữ trong một vùng nhớ liên tục và dùng ID 16-bit để đánh chỉ số truy xuất.

Khi hiệu ứng hạt phức tạp, sẽ có nhiều thuộc tính thay đổi tự động như vị trí, màu sắc, hướng di chuyển, kích thước, vận tốc, gia tốc… Trong đó gia tốc thực chất là kết quả của các lực tác động. Mỗi hạt có thể chịu tác động của nhiều lực cùng lúc: trọng lực, gió, lực hướng tâm (dùng cho hiệu ứng xoáy).

Có hai phương án thiết kế:

  1. Mỗi bộ phát (emitter) và các hạt do nó tạo ra được quản lý như một đối tượng độc lập
  2. Tất cả các hạt từ mọi bộ phát được quản lý như một tập hợp thống nhất

Tôi chọn phương án thứ hai vì ưu điểm quản lý đơn giản hơn, đặc biệt khi bộ phát có khả năng tạo ra các bộ phát mới (như hiệu ứng pháo hoa hay tia sét). Trong mô hình này, chức năng phát hạt chỉ là một thuộc tính đặc biệt của hạt.

Như vậy, mỗi hạt thực chất là sự tổ hợp của nhiều đặc tính (component). Việc nhìn nhận theo mô hình ECS (Entity Component System) sẽ rất thuận lợi trong việc quản lý:

  1. Dữ liệu hạt là tập hợp các component
  2. Hệ thống hạt được xây dựng từ nhiều phép biến đổi (transform), tương đương với các system trong ECS - mỗi system xử lý một loại component cụ thể
  3. Component có thể chỉ là các tag đánh dấu mà không cần chứa dữ liệu. Ví dụ ngoài các component A, B, C tương ứng với các cấu trúc dữ liệu cụ thể, ta còn có thể tạo tag AB để chỉ các hạt có cả A và B, hoặc tag Ac để chỉ hạt có A nhưng không có C…

Mặc dù các hạt tương ứng với các entity trong ECS, nhưng có điểm khác biệt quan trọng: không cần công khai ID của entity. Điều này xuất phát từ đặc thù của hệ thống hạt - mỗi hạt hoàn toàn tự chủ trong suốt vòng đời của nó, các trạng thái biến đổi đều được xác định từ khi sinh ra. Các hạt không ảnh hưởng lẫn nhau nên khi cập nhật hạt A không cần truy xuất trạng thái của hạt B nào đó. Do đó không cần dùng ID cố định để đánh dấu các hạt cụ thể. Mỗi hạt có thể tính toán độc lập mà không quan trọng thứ tự xử lý.

Từ những đặc điểm này, ta có thể thiết kế cấu trúc dữ liệu tối ưu cả về mặt bộ nhớ và hiệu suất tính toán. Thay vì gom tất cả component của một hạt vào cùng một đối tượng theo cách truyền thống của C++, ta nên nhóm các instance của cùng loại component lại với nhau theo từng loại.

Ví dụ, hầu hết các hạt đều có thuộc tính lifetime kiểu float. Ta có thể lưu tất cả giá trị lifetime của các hạt đang hoạt động vào một mảng float liên tục. Điều này mang lại hai lợi ích lớn:

  1. Các hạt không cần thuộc tính nào thì sẽ không chiếm không gian bộ nhớ cho thuộc tính đó
  2. Thuận tiện cho việc tối ưu bằng SIMD trong các trường hợp đặc biệt (khi chỉ có một system duy nhất xử lý thuộc tính đó)

Hệ thống cần có một module quản lý việc trừ dần lifetime mỗi frame. Module này sẽ duyệt qua toàn bộ component lifetime để giảm giá trị tương ứng. Khó khăn lớn nhất của thiết kế này nằm ở việc các component bị phân tách, nhưng khi xử lý nghiệp vụ lại thường cần kết hợp nhiều component cùng lúc. Ví dụ để cập nhật vị trí từ vận tốc, ta cần tìm cách liên kết giữa component “vận tốc” và “vị trí” của cùng một hạt.

Giải pháp của tôi là dùng ID 16-bit để tạo liên kết nội bộ. Tuy nhiên khác với ECS truyền thống, ID này chỉ sử dụng nội bộ, không công khai cho người dùng. Mỗi component được lưu trong các mảng tuyến tính riêng biệt. Thông qua loại component và chỉ số trong mảng, API có thể tìm ra chỉ số tương ứng của component khác thuộc cùng hạt trong mảng của nó.

Tôi đã xây dựng một bộ quản lý (manager) cho cấu trúc dữ liệu này. Manager này không quản lý trực tiếp dữ liệu mà chỉ điều phối mối quan hệ giữa các thành phần. Các API cốt lõi bao gồm:

bool particlesystem_add(manager, n, components[]) Thêm một hạt mới vào hệ thống với n component. Lưu ý: lúc này chỉ khai báo loại component chứ chưa quan tâm đến dữ liệu cụ thể. Nếu thêm thành công, người dùng cần tự bổ sung dữ liệu tương ứng vào cuối các mảng dữ liệu do mình quản lý.

index particlesystem_component(manager, component, index, sibling_component) Tìm chỉ số của component “sibling_component” tương ứng với cùng hạt tại vị trí index của component đang xét.

particlesystem_remove(manager, component , index) Xóa toàn bộ các component liên quan đến hạt tại vị trí index. Việc xóa chỉ đánh dấu, cần đợi đến giai đoạn sắp xếp lại (arrange) mới thực sự xóa khỏi bộ nhớ.

particlesystem_arrange(manager, n, remap[], state) Dọn dẹp hệ thống, loại bỏ các hạt đã bị đánh dấu xóa. Nếu các mảng component xuất hiện “lỗ hổng” bộ nhớ thì sẽ sắp xếp lại dữ liệu đảm bảo tính liên tục. API này điền thông tin ánh xạ lại vào bảng remap do người dùng cung cấp, cho biết dữ liệu ở vị trí nào đã được chuyển đến đâu. Vì manager không quản lý trực tiếp dữ liệu component nên người dùng phải tự điều chỉnh lại các container dữ liệu của mình dựa trên bảng remap.

Bạn có thể tham khảo chi tiết cách sử dụng trong file test.c, nơi trình bày một ví dụ cực đơn giản với chỉ hai thuộc tính lifetime và value. Hàm update sẽ trừ dần giá trị lifetime, đồng thời giảm giá trị value theo delta khi value khác không.

0%