Bọc Lớp Quản Lý Hạt C++ - nói dối e blog

Bọc Lớp Quản Lý Hạt C++

Quản lý hạt trong C++ - Một cách tiếp cận mới

Tiếp nối bài viết trước về thiết kế hệ thống hạt, bài này sẽ trình bày cách tôi đã vật lộn với việc đóng gói hệ thống quản lý hạt bằng C++ trong suốt một đêm dài đầy thử thách.

Sau khi hoàn thành chức năng quản lý hạt bằng C++, tôi không khỏi tự hỏi: Liệu việc đầu tư nhiều thời gian và công sức vào một chức năng nhỏ như vậy có thực sự đáng giá? Việc nhấn mạnh vào an toàn kiểu dữ liệu rõ ràng giúp giảm lỗi trong các đoạn mã liên quan và nâng cao chất lượng code. Tuy nhiên, việc code trở nên phức tạp và khó hiểu hơn lại có thể làm giảm chất lượng tổng thể.

Trước đó, chúng tôi đã xây dựng một hệ thống hạt theo kiến trúc ECS bằng ngôn ngữ C. Bạn có thể tìm thấy mã nguồn tại file psystem_manager.h. Để hiểu rõ hơn về thiết kế, hãy cùng ôn lại một số nguyên tắc chính:

Trong hệ thống hạt này, tôi muốn quản lý các thuộc tính của hạt một cách phân tách. Thay vì sử dụng cách tiếp cận hướng đối tượng truyền thống với cấu trúc dữ liệu chứa tất cả các thuộc tính của hạt (a, b, c…), chúng tôi chọn cách phân loại dữ liệu theo thuộc tính. Điều này có nghĩa là thay vì xử lý nhiều thuộc tính của một đối tượng tại một thời điểm, chúng tôi xử lý nhiều đối tượng có cùng thuộc tính.

Lợi ích của cách tiếp cận này là gì? Khi xử lý một thuộc tính cụ thể, chúng tôi không cần quan tâm đến các thuộc tính khác. Ví dụ:

  • Khi giảm thời gian sống của hạt, chỉ cần quan tâm đến thuộc tính “thời gian sống”
  • Khi xử lý trọng lực, chỉ cần quan tâm đến gia tốc và vận tốc
  • Khi tính toán vị trí, chỉ cần vị trí trước đó và vận tốc tức thời
  • Khi render, hầu như không cần quan tâm đến các thuộc tính vật lý

Việc tổ chức dữ liệu theo thuộc tính mang lại nhiều lợi thế:

  1. Hiệu quả bộ nhớ nhờ tối ưu cache khi xử lý hàng loạt dữ liệu
  2. Không lãng phí bộ nhớ do vấn đề căn chỉnh (alignment)
  3. Dễ dàng xử lý hơn khi các dữ liệu cùng loại có kích thước giống nhau
  4. Phù hợp với xử lý song song do các hạt không ảnh hưởng lẫn nhau

Một điểm quan trọng khác là tính linh hoạt trong việc kết hợp thuộc tính và hành vi. Các hạt khác nhau có thể cần:

  • Thông tin vật lý để tính toán va chạm vật thể cứng
  • Màu sắc hoặc không cần màu sắc
  • Là một mặt phẳng hoặc một mô hình 3D với vật liệu đặc biệt

Trong lập trình hướng đối tượng truyền thống, người ta thường dùng đa hình (virtual function trong C++) hoặc hàng loạt câu lệnh điều kiện. Tuy nhiên, cách tiếp cận này thường dẫn đến nhiều nhánh rẽ phức tạp.

Bằng cách tổ chức dữ liệu theo thành phần (component), chúng tôi có thể:

  • Giảm đáng kể số lượng nhánh điều kiện
  • Dễ dàng kết hợp chức năng tại runtime
  • Tránh tạo ra hàng loạt lớp tĩnh

Tuy nhiên, cách tổ chức này cũng có những thách thức riêng:

  1. Xóa đối tượng: Cần tìm và xóa tất cả các thành phần liên quan
  2. Xử lý liên kết giữa các thành phần: Cần tìm thành phần B của cùng một hạt khi đang xử lý thành phần A

Phương pháp đơn giản nhất là tạo một đối tượng gốc chứa con trỏ đến tất cả các thành phần. Tuy nhiên, cách này có thể gây lãng phí bộ nhớ lớn do các con trỏ tham chiếu lẫn nhau.

Giải pháp của tôi là xây dựng một trình quản lý quan hệ với các đặc điểm nổi bật:

  • Chỉ quản lý mối quan hệ, không quản lý bộ nhớ dữ liệu
  • Dữ liệu bên ngoài được xem như khối bộ nhớ liên tục
  • Không yêu cầu định dạng lưu trữ cụ thể từ bên ngoài
  • Cho phép xóa trong quá trình lặp mà không làm gián đoạn
  • Tối ưu hóa việc dọn dẹp dữ liệu bị xóa với ít di chuyển dữ liệu nhất

Khi chuyển sang C++, chúng tôi cần một tập hợp các container để lưu trữ các thành phần khác nhau. Ban đầu, thiết kế trông như sau:

struct particle_manager *manager; arrayType1 t1; arrayType2 t1; arrayType3 t3; arrayType4 t4; …

Để đơn giản hóa, chúng tôi đã cải tiến thành:

attribute * attribs[MAXCOMPONENTS]; struct particle_manager *manager;

Việc thêm tính năng quản lý dữ liệu vào hệ thống yêu cầu ba chức năng chính:

  1. Thêm hạt mới với các thành phần cụ thể
  2. Xóa các thành phần liên quan khi một thành phần bị xóa
  3. Lặp qua các thành phần cụ thể

Đối với chức năng thứ ba, việc sử dụng std::vector mang lại hiệu quả cao. Tuy nhiên, để đảm bảo an toàn kiểu dữ liệu, C++ với kỹ thuật template là lựa chọn hợp lý. Điều này giúp:

  • Tránh lỗi kiểu tại runtime
  • Tăng tính an toàn khi phát triển
  • Giảm thiểu casting kiểu thủ công

Chúng tôi thiết kế API như sau:

vector<Loại>& particle_system::attrib<Loại>();

Điều này đảm bảo người dùng phải chỉ định đúng kiểu dữ liệu khi truy cập container. Nếu sai kiểu, mã sẽ không biên dịch được thay vì báo lỗi tại runtime.

Về phân loại dữ liệu, chúng tôi chia thành ba nhóm:

  1. Dữ liệu POD (Plain Old Data)
  2. Kiểu dữ liệu nguyên thủy hoặc cấu trúc cơ bản
  3. Đối tượng phức tạp với bảng ảo (virtual table)

Đối với nhóm thứ ba, chúng tôi chọn sử dụng raw pointer thay vì smart pointer để đảm bảo dữ liệu liên tục trong bộ nhớ. Điều này đòi hỏi xử lý cẩn thận hơn trong các thuật toán sắp xếp lại dữ liệu, nhưng lại phù hợp với yêu cầu hiệu năng cao.

Một số thách thức trong quá trình triển khai:

  • Xử lý template specialization cho cả kiểu giá trị và con trỏ
  • Thiết kế giao diện thống nhất cho việc truy cập thành phần liên kết
  • Giữ cho header file gọn nhẹ, giảm thiểu phụ thuộc

Dưới đây là ví dụ về cách sử dụng API:

void particle_system::update_life(float dt) { int index = 0; for (auto &life : attrib()) { printf(“Thời gian sống: %f\n”, life); life -= dt; if (life <= 0) { printf(“XÓA %d\n”, index); remove(index);

0%