Cải Tiến Mô-Đun Toán Học
Tôi đã thiết kế một mô-đun toán học phi truyền thống cho engine 3D đang được phát triển. Khác biệt hoàn toàn với các thư viện toán học cho Lua thông thường sử dụng userdata hoặc bảng để biểu diễn vector/matrix, giải pháp của tôi sử dụng ID số nguyên 64bit để đại diện cho các đối tượng toán học. Quản lý vòng đời các đối tượng này được thực hiện thông qua một “ngăn xếp toán học” mô phỏng, hoàn toàn tách biệt với ngăn xếp hệ thống dùng để lưu trữ biến tạm thời của chương trình. Mọi đối tượng vector/matrix tồn tại trên ngăn xếp toán học sẽ được duy trì trong suốt một chu kỳ cố định (thường là 1 frame render), và được làm mới chủ động mỗi frame thông qua lệnh reset. Thiết kế này giúp giảm tải nhận thức khi sử dụng các đối tượng toán học trong Lua - không cần lo lắng về gánh nặng GC do kết quả trung gian tạo ra, đồng thời chi phí tạo lập các đối tượng mới cực kỳ thấp.
Về mặt tính toán, chúng tôi áp dụng cách tiếp cận độc đáo khi đưa chính các lệnh toán học vào ngăn xếp thay vì thiết kế hàm riêng biệt. Ví dụ phép toán A * B
được viết dưới dạng B A "*"
với chuỗi "*"
đóng vai trò lệnh nhân được đẩy vào ngăn xếp. Trường hợp A * B * C
sẽ thành C B A "**"
(hai lệnh nhân được gộp trong một chuỗi). Nếu muốn đảo ngược kết quả A * B * C
, chỉ cần viết C B A "**i"
với ký tự i
biểu thị phép nghịch đảo.
Thiết kế này hoàn toàn xuất phát từ yêu cầu hiệu năng. Chi phí gọi hàm C từ Lua trong các thao tác nhỏ không thể bỏ qua, do đó cách biểu diễn theo ký pháp nghịch đảo Ba Lan này cho phép gộp nhiều phép tính thành một lần gọi hàm duy nhất. Dù vậy, việc này làm tăng chi phí học tập ban đầu. Tôi đã cân nhắc cải tiến nhưng hiện chưa có thời gian thực hiện. Tuy nhiên, sau giai đoạn làm quen, người dùng vẫn thấy chấp nhận được.
Trong giai đoạn trước, trọng tâm phát triển tập trung vào các module khác. Mô-đun toán học chủ yếu do đồng nghiệp sử dụng và bảo trì, liên tục được bổ sung tính năng mới. Thay đổi lớn nhất là thay thế phiên bản toán học sơ khai ban đầu bằng thư viện chuyên nghiệp GLM, trong khi giữ nguyên kiến trúc bên ngoài.
Gần đây, qua vài tuần sử dụng trực tiếp, tôi nhận thấy một số vấn đề nhỏ và tiếp tục cải tiến:
Cải tiến thứ nhất: Giảm tải ghi nhớ lệnh toán học
Việc dùng ký tự đơn để biểu diễn phép toán tuy tối ưu hiệu năng nhưng gây khó khăn cho người dùng. Ban đầu chúng tôi thử nghiệm đặt tên theo chuẩn camelCase nhưng sau đó chuyển hướng hoàn toàn: Thiết kế toán tử mới dưới dạng hàm Lua, sau đó đẩy vào ngăn xếp toán học. Ví dụ B A "*"
trở thành B A mul
với mul
là hàm Lua. Tuy nhiên, nếu thực hiện trực tiếp sẽ quay lại vấn đề ban đầu về chi phí gọi hàm.
Giải pháp thông minh nằm ở việc giới hạn toán tử chỉ có thể là “light C function” (hàm C không chứa upvalue) với duy nhất một tham số là đối tượng ngăn xếp toán học. Những hàm này được gọi là toán tử FASTMATH. Trong module, chúng tôi bỏ qua lua_call
để gọi trực tiếp hàm C, không tạo frame ngăn xếp Lua mới. Nhờ đó, chi phí bổ sung gần như bằng không. Đặc biệt, các toán tử này vẫn có thể gọi như hàm Lua thông thường để tiện debug.
Cấu trúc hàm FASTMATH được định nghĩa như sau:
|
|
Với quy ước gọi hàm C, nó tương thích với nguyên mẫu Lua:
|
|
Khi gọi nội bộ, chúng tôi truyền trực tiếp đối tượng ngăn xếp toán học. Khi gọi từ Lua, tham số được lấy từ L
(hiệu năng thấp hơn). Việc phân biệt hai cách gọi dựa trên tham số L
- nếu L
là NULL thì sử dụng hai tham số sau, ngược lại là gọi từ Lua.
Cải tiến thứ hai: Tương tác giữa module
Kiến trúc trên của chúng tôi sử dụng Lua, nhưng các module hiệu năng cao như rendering (bgfx), vật lý (Bullet), animation (Ozz) được viết bằng C/C++. Vấn đề then chốt là làm thế nào để các module này trao đổi vector/matrix mà không tạo ra sự phụ thuộc lẫn nhau.
Giải pháp ban đầu là yêu cầu tất cả module sử dụng cùng định dạng vector/matrix, nhưng điều này làm tăng độ kết dính hệ thống. Tôi chọn cách giải quyết khác: thiết kế module toán học độc lập hoàn toàn, sử dụng lightuserdata (con trỏ C) để trao đổi dữ liệu. Ví dụ module rendering yêu cầu đầu vào là con trỏ float*
trỏ đến 16 giá trị float cho ma trận hoặc 4 giá trị cho vector4, hoàn toàn không quan tâm nguồn gốc của con trỏ đó.
Chúng tôi cũng thiết kế toán tử để chuyển đổi giữa ID và con trỏ. Trong quá trình tính toán dùng ID 64bit, khi cần giao tiếp với API rendering thì chuyển sang lightuserdata. Tuy nhiên, việc chuyển đổi này gây ra hai vấn đề:
- Người dùng phải phân biệt khi nào dùng ID, khi nào dùng con trỏ
- Con trỏ không chứa thông tin kiểu, không thể biết đó là matrix, vector hay quaternion
Để giải quyết, chúng tôi xây dựng một lớp trung gian bridge mà không làm tăng chi phí tính toán. Thay vì dùng bảng (table) gây overhead, chúng tôi tận dụng kỹ thuật đặc biệt với các hàm light C function. Lớp bridge này tự động chuyển đổi ID thành con trỏ khi gọi các hàm C/C++. Ví dụ với thư viện bgfx, các hàm như set_uniform
, set_transform
được thay thế bằng phiên bản proxy do bridge tạo ra. Nhờ đó, thư viện bgfx hoàn toàn độc lập với việc sử dụng thư viện toán học cụ thể nào.
Xử lý đầu ra từ module
Với các module như animation cần xuất ra vector/matrix, giải pháp trước đây là dùng userdata để quản lý vòng đời. Nay lớp bridge xử lý vấn đề này bằng cách yêu cầu người gọi cấp phát bộ nhớ đầu ra. Trong hàm proxy, bridge sẽ dự trữ không gian trên ngăn xếp, sau đó đẩy kết quả trực tiếp vào ngăn xếp toán học.
Nếu bạn quan tâm đến thư viện toán học này, có thể tham khảo kho mã nguồn được trích xuất từ dự án engine 3D đang phát triển. Ví dụ sử dụng có thể tìm thấy trong binding Lua cho bgfx. Lưu ý rằng các ví dụ bgfx được dịch trực tiếp