Một Giải Pháp Nhúng Lua Vào Máy Ảo Mono Trong Unity3D
Dưới đây là một giải pháp nhúng Lua vào máy ảo Mono của Unity3D
Trong cộng đồng phát triển game Unity3D, nhiều dự án không mấy hài lòng với ngôn ngữ lập trình C#. Thay vào đó, họ thường ưu tiên sử dụng Lua - ngôn ngữ được thiết kế để tích hợp vào các hệ thống khác. Trong những năm qua, đã có không ít giải pháp nhúng Lua vào Unity3D được phát triển như UniLua (do bạn A-Nam trong công ty tôi viết bằng C# thuần), ulua, slua, wlua, plua, xlua… thậm chí có thể đã dùng hết cả 26 chữ cái từ A-Z.
Đa số các tác giả của những dự án này đều là những người quen thuộc với tôi. Hiện tại công ty tôi cũng đang sử dụng một giải pháp tương tự do đồng nghiệp tự phát triển. Tuy nhiên, tôi nhận thấy hầu hết các giải pháp này đều quá phức tạp hoặc thiếu sót ở một số chi tiết quan trọng. Điều này khiến tôi luôn có mong muốn tự xây dựng một giải pháp theo ý tưởng riêng.
Về cơ bản, việc giao tiếp giữa Mono và C thông qua P/Invoke không quá phức tạp, nhưng cần đặc biệt lưu ý đến chi phí chuyển đổi dữ liệu (marshaling), đặc biệt là khi truyền các đối tượng qua lại giữa hai hệ thống. Trong khi Lua có sẵn API C để giao tiếp, việc sử dụng đúng cách lại không hề đơn giản. Vấn đề cốt lõi nằm ở cơ chế xử lý ngoại lệ khác biệt giữa hai hệ thống - cần phải đóng gói cẩn thận ranh giới giữa hai ngôn ngữ để tránh để rò rỉ ngoại lệ. Tôi đã từng viết một bài blog năm 2015 thảo luận chi tiết về vấn đề này.
Theo tôi, giải pháp tối ưu để tương tác giữa Mono và Lua cần tuân theo nguyên tắc sau: Khi hai hệ thống cần giao tiếp, bản chất giống như mô hình client-server - gửi một chuỗi dữ liệu đến máy ảo đối phương. Cách tiếp cận này đơn giản hơn nhiều so với việc sử dụng P/Invoke hay các API C của Lua. Mọi giao tiếp giữa hai máy ảo đều có thể coi là một cuộc gọi hàm từ xa. Chỉ cần thỏa thuận rằng phần tử đầu tiên trong chuỗi dữ liệu là một hàm, các phần tử tiếp theo là tham số cần truyền.
Việc tương tác giữa Mono và Lua có thể đơn giản hóa thành việc truyền một chuỗi dữ liệu chứa các kiểu dữ liệu cơ bản (số, chuỗi, boolean) mà cả hai hệ thống đều hiểu, cũng như các đối tượng từ máy ảo này sang máy ảo kia. Chúng ta không cần phải serialize toàn bộ dữ liệu đối tượng, mà chỉ cần gán một ID số cho đối tượng đó. Máy ảo nhận được sẽ lưu trữ ID này và sử dụng nó khi cần thao tác với đối tượng từ xa.
Về mặt kỹ thuật, hàm cần gọi cũng là một đối tượng bản địa. Với Lua, hàm là kiểu dữ liệu hạng nhất (first-class), còn với Mono thì có thể sử dụng Delegate làm cầu nối.
Lấy ví dụ từ Mono gọi sang Lua: Chúng ta sẽ lấy ID của hàm Lua đã biết trước, kết hợp với các tham số, đóng gói vào một struct không cần marshal đặc biệt. Struct này được truyền qua lớp C thông qua P/Invoke, sau đó hàm C sẽ chuyển nội dung struct vào máy ảo Lua. Tại đây, hàm Lua được định nghĩa sẵn sẽ thực hiện việc push hàm vào stack và gọi nó với các tham số tương ứng. Kết quả trả về sẽ được mã hóa thành struct để Mono xử lý.
Việc sử dụng struct trung gian thay vì trực tiếp thao tác máy ảo Lua qua các API C có hai lợi ích chính:
- Tăng tính kết dính (cohesion) của module, giảm sự phụ thuộc (coupling) giữa các thành phần
- Dễ dàng kiểm soát ngoại lệ - toàn bộ quá trình chỉ cần một hàm Lua duy nhất để xử lý, giúp giới hạn phạm vi phát sinh lỗi
Với chiều ngược lại từ Lua gọi sang Mono, cần định nghĩa Delegate và bao bọc các hàm/phương thức C# vào Delegate này. May mắn là C# có cơ chế reflection mạnh mẽ để tự động hóa việc này. Để tối ưu hiệu suất, có thể áp dụng kỹ thuật sinh mã (code generation) cho các lớp cần export. Vì mục tiêu chính là chuyển logic nghiệp vụ sang Lua, nên phần C# thường ổn định sau giai đoạn đầu phát triển, việc đầu tư vào các công cụ tối ưu ở giai đoạn đầu là hoàn toàn hợp lý.
Lưu ý đặc biệt với chuỗi ký tự: Việc truyền chuỗi từ Mono có chi phí cao, nên thiết kế hệ thống để tránh truyền chuỗi khi không cần thiết.
Cuối tuần qua, tôi đã dành trọn một ngày để triển khai ý tưởng này. Mã nguồn đã được chia sẻ trên GitHub, có thể biên dịch trên nền Mono, hiện chưa có tài liệu hướng dẫn nhưng cấu trúc đơn giản, ví dụ sử dụng có trong file test.cs.
Một vấn đề quan trọng cần giải quyết là quản lý tham chiếu chéo giữa các đối tượng ở hai máy ảo. Trước đây tôi đã thảo luận vấn đề này trong dự án xlua.
Khi một đối tượng được truyền sang máy ảo kia, cần giữ một tham chiếu mạnh (strong reference) để tránh bị GC dọn dẹp. Khi không còn sử dụng, cần giải phóng tham chiếu này. Cả Lua và C# đều có cơ chế phát hiện đối tượng không còn được sử dụng: Lua dùng weak table, C# dùng Weak Reference. Ví dụ với Lua, các đối tượng từ xa được lưu trong weak table với ID làm khóa, đồng thời duy trì một tập hợp chứa toàn bộ ID. Định kỳ kiểm tra các ID trong tập hợp mà không còn tồn tại trong weak table - đây chính là các đối tượng không còn được sử dụng.
Mặc dù có thể dùng __gc để theo dõi việc thu dọn đối tượng, nhưng tôi không khuyến khích cách này vì:
- Tăng gánh nặng cho quá trình GC
- Khó kiểm soát thời điểm gọi __gc
- Cuối cùng vẫn phải lưu ID để xử lý, không mang lại lợi ích gì so với phương pháp kiểm tra định kỳ
Vấn đề thực sự phức tạp nằm ở việc xử lý tham chiếu