Tái Cấu Trúc Thư Viện Toán Học
Chúng tôi đã tiến hành tái cấu trúc thư viện toán học được sử dụng trong engine sau một thời gian dài vá víu. Quá trình này tích hợp nhiều ý tưởng đã tích lũy qua nhiều năm phát triển. Gần đây, khi tối ưu hiệu năng engine, chúng tôi đã chuyển đổi một số hệ thống tốn nhiều tài nguyên sang viết bằng C/C++ và nhân cơ hội này hoàn thiện kiến trúc thư viện toán học để hỗ trợ tốt hơn cho cả Lua API lẫn C API.
Lần cập nhật đáng kể cuối cùng cho thư viện toán học đã cách đây 3 năm. Trải nghiệm trong thời gian qua cho thấy: mặc dù việc sử dụng DSL dựa trên ngăn xếp có thể giảm nhẹ chi phí giao tiếp giữa Lua và C, nhưng lại gây bất tiện trong thực hành. Đa số trường hợp, lập trình viên vẫn thiên về các giao diện hàm truyền thống - mỗi phép toán gọi một hàm riêng biệt. Các phép tính phức tạp hoàn toàn có thể xử lý tập trung trong các module C độc lập. Kết hợp với hệ thống ECS, chúng tôi có thể xử lý hàng loạt các phép toán toán học đồng nhất với khối lượng lớn ngay tại phía C.
Chúng tôi đã từ bỏ thiết kế DSL ban đầu và chỉ giữ lại những tính năng toán học thiết yếu. Trong đợt tái cấu trúc này, tôi quyết định loại bỏ hẳn các thành phần lỗi thời và thiết kế lại cấu trúc dữ liệu nền tảng để phù hợp hơn với các đặc tính cốt lõi.
Tính năng cốt lõi của thư viện là tất cả các đối tượng toán học (bao gồm ma trận, vector, quaternion) đều là các giá trị không thể thay đổi (immutable) với giao diện đồng nhất. Tôi biểu diễn chúng dưới dạng ID 64-bit để thuận tiện cho việc binding với Lua (hoặc ngôn ngữ khác).
Khi binding với Lua, ID của đối tượng toán học được biểu diễn dưới dạng lightuserdata thay vì userdata “nặng ký” như các thư viện thông thường. Nhờ đó, chi phí sử dụng gần như không khác biệt so với các biến số cơ bản.
Ngay cả khi sử dụng trực tiếp từ C/C++, ID 64-bit vẫn ưu việt hơn các cấu trúc dữ liệu phức tạp hoặc smart pointer. Tại phía C/C++, các đối tượng “nặng” như ma trận cũng được xử lý như kiểu giá trị nguyên thủy.
Thách thức lớn nhất nằm ở việc quản lý vòng đời đối tượng. Tôi giả định phần lớn các đối tượng toán học thường được dùng xong rồi bỏ, chỉ đối tượng cuối cùng trong chuỗi xử lý mới được truyền cho module khác. Các hệ thống thứ ba (vật lý, animation, rendering) thường có giải pháp riêng nên không phụ thuộc vào việc duy trì vòng đời các đối tượng toán học.
Do đó, thư viện mặc định phân bổ không gian lưu trữ toán học trên một vùng nhớ tạm thời cố định. Phiên bản mới nhất sử dụng tối đa 256 trang bộ nhớ, mỗi trang chứa 1024 vector4, cho phép lưu trữ đồng thời đến 64K ma trận hoặc 256K vector4. Bộ nhớ tạm thời được xóa sạch giữa các frame rendering, và trong một frame, các đối tượng không bị xoá.
Việc phân bổ đối tượng tạm thời sử dụng kỹ thuật bump allocator đơn giản nhất. Tạo mới chỉ cần một phép cộng, hiệu quả ngang với phân bổ trên stack, nhờ đó không cần phân biệt giữa stack và heap nữa.
Đối với các đối tượng cần tham chiếu dài hạn, có thể sao chép dữ liệu từ vùng tạm thời sang vùng lưu trữ vĩnh viễn. Khác với phiên bản cũ, vùng vĩnh viễn áp dụng đếm tham chiếu một phần để giảm chi phí tham chiếu lặp lại.
Giao diện tương ứng là mark() và unmark(). Nếu muốn giữ tham chiếu lâu dài, gọi mark(id) để tạo ID vĩnh viễn mới, và unmark(id) khi không dùng nữa. Khi truyền tham chiếu, nếu bên nhận cũng muốn giữ, cần gọi mark() lần nữa. Hệ thống có thể chọn cách tăng đếm tham chiếu nội bộ hoặc tạo bản sao mới.
Vì mọi ID đều bất biến, việc chia sẻ vùng nhớ hay tạo bản sao đều mang lại hiệu quả như nhau với người dùng.
Unmark() không tương đương delete(), vì nó không thu hồi bộ nhớ ngay lập tức. ID bị unmark vẫn có hiệu lực đến cuối frame, do đó không cần cố ý gọi mark() khi truyền tham chiếu (đây là điểm khác biệt so với cơ chế smart pointer truyền thống).
Trong đợt tái cấu trúc này, tôi bổ sung hai tính năng mới quan trọng dựa trên kinh nghiệm thực tế:
-
Thêm kiểu NULL: Cho phép truyền đối tượng NULL tại mọi vị trí yêu cầu toán học, giúp giao diện linh hoạt hơn. Trong cấu trúc dữ liệu vĩnh viễn, NULL có thể dùng làm giá trị mặc định để tối ưu hóa. Ví dụ, giao diện kết hợp SRT thành ma trận sẽ cho phép các tham số S/R/T là NULL để giảm tính toán.
-
Hỗ trợ mảng: Chính xác hơn, mọi đối tượng đều là mảng với độ dài mặc định là 1. Ma trận mảng có thể dùng như ma trận đơn (phần tử đầu), hoặc dùng hàm index() nhẹ để trích xuất phần tử cụ thể.
Tính năng này giúp biểu diễn dễ dàng các đối tượng như AABB (2 vector), frustum (6 vector)… và thiết kế C API có thể trả về nhiều kết quả cùng lúc thông qua mảng.
Tôi hoàn thành công việc tái cấu trúc trong 3 ngày. Phiên bản hiện tại có thể xem tại . Thư viện Lua là phần chính được sử dụng, nhưng module con mathid có thể gọi trực tiếp từ C/C++.