Những Cải Tiến Gần Đây Về ECS
Trong thời gian qua, chúng tôi đã thực hiện một loạt tối ưu hóa cho hệ thống engine 3D. Hệ thống quản lý đối tượng render của engine được xây dựng dựa trên kiến trúc ECS, toàn bộ engine đều được thiết kế và phát triển bằng Lua. Điều này cho phép dữ liệu trong hệ thống render có thể được đọc/ghi dễ dàng thông qua Lua. Tuy nhiên, khi số lượng đối tượng cần render tăng lên, hiệu năng của vòng lặp render viết bằng Lua bắt đầu bộc lộ những điểm yếu.
Để giải quyết vấn đề này, chúng tôi đã sớm phát triển công cụ luaecs. Công cụ này cho phép lưu trữ dữ liệu trong cấu trúc C, đồng thời cung cấp giao diện truy cập từ Lua. Nhờ đó, giai đoạn đầu có thể tận dụng tốc độ phát triển nhanh của Lua, sau đó tái cấu trúc các vòng lặp cốt lõi bằng C để tối ưu hiệu năng. Vào đầu năm nay, chúng tôi đã hoàn thành việc viết lại toàn bộ hệ thống render core bằng C, cơ bản giải quyết được vấn đề hiệu năng.
Trong quá trình phân tích hiệu năng (profiling) gần đây, chúng tôi tiếp tục phát hiện một điểm nghẽn mới: Trong các cảnh game thông thường, mặc dù tồn tại hàng loạt đối tượng, nhưng tỷ lệ đối tượng thực sự cần render trong khung hình lại rất nhỏ. Trước đây, chúng tôi đã từng tối ưu vấn đề này bằng cách bổ sung tính năng phân nhóm (grouping) vào khung làm việc ECS.
Việc phân nhóm hợp lý cho phép đánh dấu nhanh các đối tượng cần render sau khi cắt tỉa khung hình (frustum culling), từ đó lọc nhanh các đối tượng ECS theo tag. Quá trình lọc này được thực hiện trong môi trường C, giúp tăng tốc đáng kể thao tác ECS từ Lua. Tuy nhiên, khi chuyển vòng lặp cốt lõi sang C, chúng tôi nhận ra vẫn còn không gian để cải tiến.
Nguyên nhân nằm ở việc: Khi số lượng đối tượng ECS quản lý lớn hơn nhiều so với số lượng đối tượng cần render, các đối tượng này được lưu trữ rời rạc trong bộ nhớ. Thời gian tìm kiếm mỗi thành phần (component) có độ phức tạp O(log n), dẫn đến độ phức tạp tổng thể của vòng lặp là O(n log n). Mặc dù luaecs đã áp dụng cơ chế tối ưu đơn giản (ghi nhớ vị trí tìm kiếm cuối cùng của component để tham chiếu cho lần sau), nhưng hiệu quả tăng tốc vẫn còn hạn chế khi duyệt tuần tự qua các tag rời rạc.
Mục tiêu tối ưu lần này là: Trong quá trình vòng lặp render core duyệt qua các component liên quan đến Entity trong khung hình, toàn bộ quá trình cần đạt độ phức tạp O(log N) trong đa số trường hợp. Để thực hiện điều này, chúng tôi đã áp dụng phương pháp “dùng không gian đổi lấy thời gian”.
Hãy tưởng tượng cách giải quyết trong một hệ thống không dùng ECS? Thông thường sẽ cần xây dựng một container tuyến tính để lưu trữ tham chiếu đến các đối tượng khả kiến. Trong khung làm việc ECS, do các đối tượng được cấu thành động từ nhiều component với mối quan hệ phức tạp, việc xử lý theo đơn vị component nhỏ nhất đòi hỏi một container hiệu quả chứa hàng loạt component (ước tính quy mô lên đến hàng chục nghìn). Mỗi lần truy cập container này đều phải đảm bảo hiệu năng cao.
Rõ ràng container này chính là một dạng cache, chịu ảnh hưởng từ sự thay đổi khung hình và trạng thái sống/chết của đối tượng. Vấn đề khó khăn nhất nằm ở việc xử lý cache失效 (cache invalidation). Nếu áp dụng giải pháp con trỏ thông minh truyền thống, hiệu năng sẽ bị ảnh hưởng nghiêm trọng. Trong khung làm việc ECS, việc sử dụng ID là giải pháp tối ưu hơn.
Ban đầu chúng tôi cho rằng việc dùng tìm kiếm nhị phân trên ID có thứ tự sẽ không gây vấn đề lớn, nhưng qua thực tế profiling, vẫn còn không gian cải tiến. Tôi đã bổ sung thêm một cơ chế cache chỉ số: Trong quá trình duyệt, hệ thống ghi nhớ vị trí cụ thể của từng component để tham chiếu cho lần duyệt tiếp theo. Cache vị trí này không cần đồng bộ hoàn toàn với trạng thái thực tế - mỗi lần tìm kiếm đều sẽ kiểm tra lại, nếu sai sẽ cập nhật. Kết quả thử nghiệm cho thấy hiệu năng tăng đáng kể. Trong các cảnh test quy mô lớn với số lượng đối tượng khổng lồ trên iPhone 8, thời gian CPU tiêu tốn cho vòng lặp render core mỗi khung hình có thể kiểm soát dưới 10ms, đáp ứng hoàn toàn yêu cầu hiệu năng ban đầu.
Vấn đề tiếp theo là: Nên đặt đối tượng cache này ở đâu? Việc sử dụng biến toàn cục rõ ràng không phù hợp, dù hiện tại chúng tôi chỉ có một ecs world duy nhất. Hơn nữa, biến toàn cục vốn đã là một anti-pattern trong lập trình. Giải pháp tối ưu là gắn cache với world. Tuy nhiên, nếu thêm trực tiếp vào đối tượng world theo cách truyền thống cũng sẽ tạo ra một anti-pattern khác, vì cache này chỉ liên quan đến hệ thống render, không phải là thành phần hạ tầng của khung làm việc ECS.
Một giải pháp khác là đặt cache như một component độc lập (singleton) trong world. Tuy nhiên tồn tại hai vấn đề:
- Hiện tại số lượng kiểu component độc lập trong ECS có hạn. Nếu nhiều hệ thống cùng yêu cầu kiểu component riêng, sẽ nhanh chóng cạn kiệt tài nguyên kiểu dữ liệu.
- Các kiểu component mang tính loại trừ lẫn nhau. Nếu nhiều hệ thống cùng yêu cầu kiểu component riêng, cần phải khai báo tập trung để phân chia rõ ràng. Điều này khiến các chi tiết triển khai nội bộ của hệ thống render phải phơi bày ra ngoài, vi phạm nguyên tắc đóng gói.
Khi suy nghĩ về giải pháp, tôi nhớ đến mô hình biến toàn cục trong C++. Khác với C, biến toàn cục C++ phức tạp hơn nhiều do quá trình khởi tạo và hủy đối tượng, đòi hỏi linker phải làm thêm nhiều công việc phối hợp giữa các module độc lập.
Nếu chúng ta cần không phải một biến toàn cục, mà là một đối tượng duy nhất gắn với từng world cụ thể thì sao? Chúng tôi đã thiết kế một vùng “biến toàn cục” cho world, tồn tại dưới dạng một singleton component. Vùng này là một mảng con trỏ đối tượng, mỗi đối tượng toàn cục được cấp một chỉ số độc nhất. Việc phân bổ chỉ số này do một bộ phân phối ID tự tăng quản lý - bộ phân phối này chỉ cấp ID duy nhất cho các module sử dụng, không quản lý trực tiếp đối tượng. Nhờ đó, mỗi world có thể có một khe