Những Vấn Đề Hiệu Năng Đã Giải Quyết Trong Quá Trình Phát Triển Gần Đây
Hôm nay là ngày làm việc cuối cùng trước kỳ nghỉ Tết, tôi muốn ghi lại một số công việc đã thực hiện trong thời gian gần đây. Khi sử dụng engine tự phát triển để làm game, chúng tôi đã gặp phải nhiều vấn đề không như kỳ vọng ban đầu. Qua việc phân tích các vấn đề này, chúng tôi đã xem xét lại thiết kế ban đầu và tiến hành cải tiến. Tôi cho rằng việc thiết kế một engine game đa dụng hoàn toàn từ đầu là không khả thi, mà phải gắn liền với dự án thực tế để xây dựng các giải pháp có tính định hướng. Tuy nhiên, đồng thời cũng cần thường xuyên phản tư để tránh tích lũy quá nhiều nợ công nghệ. Trong năm qua, tôi ít trực tiếp tham gia vào việc viết code cho engine mà chủ yếu đóng vai trò giám sát, theo dõi sự tiến hóa của thiết kế và mã nguồn.
Khung sườn chính của engine chúng tôi được xây dựng dựa trên Lua. Nhờ tính linh hoạt của Lua, việc kết nối các module lại với nhau trở nên rất thuận tiện. Tuy nhiên, so với C/C++, Lua lại kém hiệu năng đến hai bậc độ lớn. Đối với mã rendering, nếu đặt toàn bộ API giao tiếp với GPU ở lớp binding của Lua, khi số lượng đối tượng tăng lên, vấn đề hiệu năng sẽ nhanh chóng bộc lộ. Tôi ước tính, trong môi trường di động, ngưỡng “nhiều” này khoảng 10 nghìn đối tượng, còn trên PC có thể chịu được đến 100 nghìn.
Tối ưu hóa dự án Lua cơ bản chỉ có hai hướng: sử dụng công nghệ JIT hoặc chuyển các đoạn nóng sang C. Như Roberto từng nói: “Cuối cùng, cần lưu ý hai lựa chọn này về cơ bản là không tương thích với nhau” - các thao tác cần thực hiện cho hai hướng này thường loại trừ lẫn nhau. Tôi không thích độ phức tạp và tính bất ổn mà JIT mang lại, nên đã chọn hướng thứ hai.
Để giải quyết vấn đề xử lý hàng loạt đối tượng số lượng lớn, năm 2021 tôi đã đưa vào thư viện luaecs. Hiện tại, thư viện này đã trở thành lõi của engine, chuyên xử lý các tác vụ lặp lại trên dữ liệu lớn trong Lua. Nhờ luaecs, chúng tôi có thể dùng Lua để viết quá trình khởi tạo đối tượng phức tạp, còn các tác vụ lặp mỗi frame sẽ được thực hiện bằng C.
Thông thường, nếu số lượng đối tượng render trong scene là 10 nghìn thì vẫn phù hợp với nhiều game. Tuy nhiên, nếu các API đồ họa được đặt ở tầng quá thấp, ngay cả trên thiết bị di động (với chuẩn chip A9 của Apple làm mốc), một scene mượt mà khó có thể đạt đến 10 nghìn đối tượng. Lý do là mỗi đối tượng render cần truyền một loạt tham số đến tầng API đồ họa, thiết lập VB/IB trạng thái render, đặc biệt là các uniform với số lượng khoảng 10 lần gọi API, điều này sẽ tiêu hao một bậc độ lớn hiệu năng. Cuối cùng, số lượng đối tượng mà tầng Lua có thể xử lý mượt chỉ còn khoảng 1 nghìn.
Đây là giới hạn đã được dự báo từ vài năm trước khi xây dựng khung sườn. Tuy nhiên do nguồn lực có hạn, chúng tôi chưa thể giải quyết. Dù sao thì phát triển nhanh bằng Lua vẫn có ưu thế, và khi prototype trên PC thì vấn đề hiệu năng không quá nhạy cảm.
Khoảng tháng 5/2022, khi bắt đầu chuyển đổi phát triển sang nền tảng di động thực tế, vấn đề hiệu năng mới thực sự lộ rõ, lúc đó chúng tôi mới triển khai kế hoạch tái cấu trúc đã trì hoãn nhiều lần.
Trước tiên là di chuyển toàn bộ cấu trúc dữ liệu của hệ thống vật liệu (material) sang C. Tầng Lua chỉ còn phụ trách tạo và sửa material, phần render hoàn toàn xử lý bằng C. Cầu nối trung gian vẫn là luaecs.
Khởi đầu bằng việc sắp xếp lại cấu trúc dữ liệu trong Lua, làm phẳng chúng để dễ dàng chuyển sang C. Sau đó tôi viết phiên bản C cho cấu trúc dữ liệu material, tập trung quản lý các uniform linh hoạt. Đồng thời, dựa trên nhu cầu thực tế, tôi tái cấu trúc lại thư viện toán học. Phiên bản mới vẫn giữ giao diện dễ truy cập từ cả Lua và C. Quản lý vòng đời vẫn để ở Lua, nhưng C đã có thể bỏ qua giao diện Lua để đọc trực tiếp dữ liệu và gọi API đồ họa tương ứng.
Tiếp theo, chúng tôi dùng Lua để viết lại hệ thống áp dụng material. Trong quá trình này, chúng tôi xác định được các điểm nóng thực sự - những hàm cần gọi lặp nhiều lần. Cuối cùng, phần lõi được viết lại hoàn toàn bằng C. Công việc này kéo dài vài tháng, đứt quãng do độ phức tạp trong việc phân rã theo tư duy ECS, sao cho hệ thống lõi ở C chỉ làm những việc đơn giản nhưng khối lượng lớn.
Tháng trước, chúng tôi thực hiện điều chỉnh cuối cùng: tăng tính linh hoạt cho cấu trúc dữ liệu. Bởi chúng tôi nhận ra một đối tượng render có thể cần các material khác nhau trong các quy trình render khác nhau. Ví dụ như render bình thường, render bóng đổ, hay hệ thống chọn điểm. Dù các quy trình này có thể cố định trong engine, nhưng game cụ thể vẫn cần tùy biến đặc biệt như hiệu ứng bán trong suốt, viền sáng, hay các hiệu ứng thị giác kỳ lạ khác, điều này có thể yêu cầu thêm các uniform mới.
Tăng tính linh hoạt cho cấu trúc dữ liệu trong môi trường thuần Lua hay thuần C/C++ không khó. Tuy nhiên, khi cần truy cập từ cả hai phía, độ phức tạp tăng lên khiến tôi phải thận trọng. Trong quá trình phát triển luaecs suốt năm qua, tôi thỉnh thoảng thêm vào các tính năng “hoa mỹ” rồi lại xóa đi, nhằm tránh làm cấu trúc trở nên phức tạp quá mức.
Cuối cùng, tôi nhận ra có thể tận dụng tính động của Lua để giải quyết vấn đề này. Cụ thể, trong quá trình chạy, cấu trúc dữ liệu là cố định - số lượng uniform và tên của chúng đều xác định. Nhưng tại thời điểm khởi động, chúng tôi có thể tận dụng tính động của Lua để lắp ráp chúng. Như vậy, phía C vẫn nhìn thấy một mảng kích thước cố định, nhưng kích thước này không bị “đóng chết” trong code C.
Thực tế, ngay cả khi không tối ưu, 1 nghìn đối tượng render cũng đủ dùng cho nhiều trường hợp. Tuy nhiên, game của chúng tôi lại là ngoại lệ, điều này thúc đẩy tôi phải giải quyết hàng loạt vấn đề hiệu năng này ngay lập tức. Trong tựa game mô phỏng Factorio của chúng tôi, có rất nhiều vật thể nhỏ: những chiếc xe nhỏ chạy trên đường, ngoài thân xe, tôi còn muốn biểu diễn trực tiếp các loại hàng hóa khác nhau trong thùng xe bằng mô hình 3D; hàng hóa trong kho cũng cần hiển thị trực quan trên scene.
Để hợp nhất các đối tượng render giống nhau, chúng tôi mở rộng hệ thống gắn kết (mounting) của engine. Với dự án cụ thể này, xe được xử lý đặc biệt: hàng hóa trong thùng xe không được gắn trực tiếp vào nút xe. Bởi vì xe không chở hàng đều dùng chung một mô hình, trong khi hàng hóa lại đa dạng. Việc