Hành Trình Tiến Hóa Của Mô-Đun IO Trong Động Cơ Game
Mô-đun IO của động cơ game chúng tôi đã trải qua nhiều lần cải tiến và thay đổi trong suốt quá trình phát triển. Phiên bản đầu tiên ra đời từ hơn bốn năm trước, nhưng đến nay vẫn chưa có bản thiết kế cuối cùng. Một phần nguyên nhân xuất phát từ yêu cầu kỹ thuật đặc biệt: động cơ game có khả năng tự cập nhật qua mạng. Điều này khiến việc cập nhật chính động cơ phụ thuộc vào mô-đun IO, bao gồm cả việc cập nhật chính mô-đun IO đó. Đặc biệt hơn, động cơ còn xây dựng trên nền tảng khung Lua đa luồng ltask, trong khi bản thân ltask lại cần mô-đun IO để khởi động. Những yếu tố thiết kế này ra đời sau khi mô-đun IO được xây dựng, dẫn đến việc phải tái cấu trúc nhiều lần.
Ban đầu, chúng tôi dành riêng một luồng xử lý cho IO, đảm nhiệm việc giao tiếp với máy chủ tệp tin và thực hiện các thao tác đọc/ghi. Tuy nhiên khi tích hợp ltask, chúng tôi phải đầu tư nhiều công sức để chuyển đổi mô-đun IO từ dạng luồng độc lập thành một dịch vụ đặc biệt trong hệ sinh thái ltask. Dịch vụ này có điểm đặc biệt: nó còn phải đảm nhiệm việc tải chính ltask. Nói cách khác, tiền thân của nó là một luồng hệ điều hành thuần túy, sau đó mới chuyển đổi thành dịch vụ độc quyền của ltask.
Gần đây, tôi nhận ra rằng hành trình phát triển này có thể đã đưa vào những độ phức tạp không cần thiết. Việc tập trung toàn bộ yêu cầu IO vào một luồng/dịch vụ riêng biệt có thể là thiết kế quá mức cần thiết. Có lẽ chúng ta nên đơn giản hóa bằng cách duy trì một vài trạng thái toàn cục, điều này sẽ giúp giảm đáng kể độ phức tạp trong thiết kế.
Một thách thức quan trọng khác là xử lý các thư viện bên thứ ba yêu cầu đăng ký callback IO. Khi cần đọc/ghi tệp tin, chúng sẽ gọi các giao diện do khung ứng dụng cung cấp. Các mô-đun này đều giả định thao tác tệp tin là đồng bộ, trong khi mô-đun IO của chúng tôi bản chất lại cung cấp giao diện bất đồng bộ. Đối với các mô-đun được viết bằng Lua, sự khác biệt giữa đồng bộ và bất đồng bộ không đáng kể. Tuy nhiên khi callback xảy ra ở phía C, việc chuyển đổi từ bất đồng bộ sang đồng bộ trở nên cực kỳ khó khăn vì không thể yield luồng trực tiếp từ mã C. Đây chính là động lực thúc đẩy việc chúng tôi phát triển tính năng chạy máy ảo Lua đa luồng theo mô hình tuần tự. Tuy nhiên sau khi tính năng này hoàn thành, chúng tôi lại quyết định không sử dụng mà thay vào đó là cải tiến thiết kế mô-đun IO.
Khi phân tích nhu cầu thực tế của động cơ game, chúng tôi nhận ra rằng việc tải đồng bộ theo yêu cầu không thường xuyên xảy ra. Lý do là vì nó chắc chắn sẽ gây chặn luồng xử lý. Ngay cả trong game 30fps, độ trễ vượt quá 33ms cũng tạo cảm giác giật lag rõ rệt cho người chơi. Trong khi đó, việc đọc tệp tin hay tải từ mạng về thường mất nhiều thời gian hơn con số này.
Theo kinh nghiệm nhiều năm qua, ứng dụng lớn nhất của việc tải theo yêu cầu lại nằm ở giai đoạn phát triển. Nó giúp các lập trình viên tiết kiệm thời gian chờ đợi khi khởi động lại game, dễ dàng tái hiện lỗi và cải thiện trải nghiệm lập trình. Trong khi đó, việc gián đoạn trải nghiệm game do IO lại là vấn đề thứ yếu hơn.
Vì vậy, chúng tôi quyết định cung cấp đồng thời hai chế độ hoạt động và cho phép chuyển đổi linh hoạt giữa chúng.
Một vấn đề nan giải khác là quản lý vòng đời tài nguyên. Nếu giao từng tệp tin tài nguyên cho tầng trên quản lý, gần như không thể thực hiện quản lý tinh tế. Có hai lựa chọn: hoặc chấp nhận độ trễ khi tải khi cần thiết và giải phóng ngay khi không dùng nữa, hoặc tải toàn bộ tài nguyên ngay từ đầu kèm theo màn hình loading. Trong cả hai trường hợp, tầng trên đều không đầu tư nhiều vào quản lý chi tiết.
Để giải quyết điều này, chúng tôi giới thiệu khái niệm “asset bundle” - mượn từ Unity, các động cơ khác cũng có khái niệm tương tự. Bundle là tập hợp các tệp tài nguyên. Trong giai đoạn phát triển, mỗi tệp trong bundle có thể được gắn chính sách quản lý riêng: có thể yêu cầu tải ngay khi khởi tạo, hoặc tải theo yêu cầu. Tùy theo đặc điểm động cơ, việc tải theo yêu cầu có thể chia thành tải từ mạng hoặc tải vào bộ nhớ.
Khác với Unity, chúng tôi không đóng gói tệp theo bundle (hay nói cách khác, bundle không phải là đơn vị đóng gói). Bundle chỉ là tập hợp logic mô tả các tệp được tham chiếu và cách quản lý chúng. Các bundle khác nhau có thể tham chiếu đến cùng một tệp nhưng với chính sách quản lý khác nhau.
Tầng logic game phía trên không cần quản lý từng tệp cụ thể, mà chỉ cần quản lý theo bundle: mở hoặc đóng một bundle cụ thể. Khi tham chiếu tài nguyên, không cần chỉ định bundle chứa nó. Toàn bộ tệp trong các bundle vẫn được ánh xạ vào một cây thư mục chung. Do đó, tham số đầu vào của API mở tệp vẫn là đường dẫn tệp thông thường chứ không phải tổ hợp “bundle + đường dẫn”. Tuy nhiên, nếu một bundle chưa được mở tại dịch vụ cụ thể, các tệp trong bundle đó sẽ không thể truy cập được.
Trong phiên bản hiện tại, tôi thiết kế cây thư mục là tài nguyên toàn cục được chia sẻ, thay vì truyền qua lại giữa các dịch vụ. Điều này giúp tích hợp thư viện bên ngoài dễ dàng hơn và cải thiện hiệu năng. Tuy nhiên, mỗi dịch vụ lại quản lý bundle riêng biệt, tạo ra sự cách ly nhất định giúp phát hiện lỗi trong logic quản lý tài nguyên.
Một số tài nguyên đặc biệt được quản lý không thông qua tệp tin. Ví dụ như texture được quản lý thông qua handle. Điều này cho phép chúng tôi thay thế bằng texture tạm thời khi chưa tải xong. Từ góc độ nghiệp vụ, texture luôn có thể tải đồng bộ, dù cơ chế nội tại là bất đồng bộ (