Cubacadabra và tham vọng viết một lần, chạy mọi nơi bằng Rust

Công nghệ10 tháng 9, 2026·10 phút đọc

Dự án Cubacadabra đang chuyển dần logic ứng dụng từ Swift, Kotlin và JavaScript sang các crate Rust dùng chung, nhằm giảm trùng lặp mã nguồn trên iOS, Android và web. Bài viết phân tích cách kiến trúc shared core này xử lý phiên chơi nhiều người, hiển thị đồ họa qua wgpu và cả hệ thống xác thực tài nguyên như mũ, tóc cho nhân vật.

Cubacadabra và tham vọng viết một lần, chạy mọi nơi bằng Rust

Cubacadabra và tham vọng viết một lần, chạy mọi nơi bằng Rust

Khi phát triển một sản phẩm cho cả iOS, Android và web, lập trình viên thường phải viết lại cùng một tính năng ba lần bằng Swift, Kotlin và JavaScript. Dự án Cubacadabra đang thử nghiệm một hướng đi khác: giữ các lớp giao diện native mỏng nhất có thể và đẩy toàn bộ logic di động vào Rust.

Bài viết gốc của tác giả Andrew Arrow mô tả chi tiết hành trình này, từ những quyết định kiến trúc ban đầu cho đến các bài học cụ thể về quản lý socket, hiển thị đồ họa và xác thực tài nguyên 3D.

Vấn đề: viết ba lần, sửa ba lần

Nếu từng phát hành một sản phẩm trên iOS, Android và web, bạn sẽ quen với quy trình quen thuộc. Tính năng được xây bằng Swift, rồi bằng Kotlin, rồi bằng JavaScript. Mỗi phiên bản có phần xác thực, gọi mạng, trạng thái tải và xử lý lỗi riêng.

Kiểm thử trên nhiều nền tảngKiểm thử trên nhiều nền tảng

Vấn đề nảy sinh khi tính năng thay đổi. iOS học cách phục hồi sau một lần lưu thất bại, Android nhận bản vá muộn hơn một tuần, còn trình duyệt xử lý lỗi theo cách khác. Không ai cố tình thiết kế ba hành vi khác nhau — chúng cứ tích tụ lại trong quá trình phát triển bình thường.

Triết lý DRY (Don't Repeat Yourself) thường được áp dụng cho mã nguồn, nhưng tác giả cho rằng nó cần được áp dụng cho cả các quyết định mà ứng dụng đưa ra.

Lớp vỏ mỏng, lõi Rust dùng chung

Ý tưởng cốt lõi là biến các ứng dụng khách thành những lớp vỏ mỏng bao quanh Rust. Chỉ giữ lượng Swift, Kotlin và JavaScript tối thiểu cần thiết cho điều khiển native, dịch vụ thiết bị và tích hợp trình duyệt. Phần logic di động được chuyển vào các crate Rust dùng chung.

Một trường tên người dùng vẫn có thể là SwiftUI TextField, Compose TextField hoặc thẻ HTML input. Mỗi thành phần chuyển tiếp thay đổi tới Rust và hiển thị trạng thái trả về. Rust quyết định nút Lưu có được bật hay không, gọi yêu cầu nào và ý nghĩa phản hồi ra sao.

Cách tiếp cận này không phải không có tiền lệ. Dự án Litter dùng giao diện iOS và Android native trên lõi Rust nắm giữ trạng thái phiên, luồng dữ liệu và hành vi kết nối lại, thông qua UniFFI. Mozilla cũng từng chuyển sang các thành phần Rust dùng chung vì việc duy trì nhiều phiên bản đồng bộ riêng biệt gây khó bảo trì và phát sinh lỗi.

Nút Lưu cũng có quan điểm riêng

Một điểm đáng chú ý là một triển khai không có nghĩa là một crate khổng lồ. Cubacadabra tách thành:

  • cubacadabra-client — phiên chơi nhiều người, giao tiếp với engine
  • cubacadabra-app — hành vi di động ngoài phần chơi game
  • engine — mô phỏng và hiển thị

Xét tình huống thực tế: người dùng lưu tên "Dragon_7", tiếp tục gõ, rồi đăng xuất trước khi phản hồi về. Phản hồi cũ có thể ghi đè bản nháp mới hơn hoặc cập nhật nhầm cho tài khoản vừa đăng nhập. Đây chính là loại quy tắc mà tác giả muốn chỉ sửa một lần.

Trong mô hình này, máy chủ gửi sự kiện UsernameChangedSaveUsername. Rust xác thực bản nháp và phát ra một hiệu ứng HTTP kèm ID, mã tài khoản, phương thức, đường dẫn và nội dung. Máy chủ cung cấp thông tin xác thực, thực hiện yêu cầu và trả về trạng thái thô. Rust chấp nhận hoặc từ chối phản hồi trước khi bất kỳ màn hình nào cập nhật hồ sơ.

Việc thay thế phiên sẽ vô hiệu hóa các công việc đang chờ. ID hiệu ứng không được tái sử dụng trong mô hình, nên phản hồi muộn không thể hoàn tất thao tác của tài khoản mới.

Giao diện ABI và bài toán con trỏ

Các ràng buộc kỹ thuật ở đây khá cụ thể. ABI ứng dụng native chỉ có bảy hàm: tạo, hủy, gửi JSON, lấy snapshot JSON, thăm dò hiệu ứng JSON, đọc con trỏ đầu ra và độ dài.

Handle là đơn luồng. Dữ liệu đầu ra thuộc về Rust và phải được sao chép trước lần thay đổi tiếp theo. Swift sao chép vào Data, Android dùng một bộ chuyển đổi mảng byte JNI nhỏ. Cả hai đều kiểm tra phiên bản giao thức snapshot — một liên kết không tương thích sẽ thất bại thay vì âm thầm tạo ra bộ quy tắc tài khoản thứ hai.

Trên trình duyệt có một cái bẫy khác. WebClientWebRenderer đến từ cùng một module WASM được sinh ra, nên handle engine trỏ tới cùng vùng nhớ tuyến tính ở cả hai phía. Hai module được biên dịch từ cùng mã nguồn sẽ không có con trỏ tương đương. Runtime ứng dụng là một module WASM riêng, cho phép trang tài khoản chỉnh sửa tên người dùng mà không cần tải renderer.

Hai người cùng chạm vào một thứ

Phần thú vị nhất có lẽ là cách xử lý xung đột trong chế độ nhiều người. Hãy tưởng tượng hai người bạn cùng đứng ở một trạm kiểm soát tiếp sức. Cả hai đều đọc sequence 7, cả hai đều cố gắng tiến vòng chơi.

Backend JavaScript dùng Cloudflare Durable Object cho mỗi thế giới, với API trạng thái lưu giữ chấp nhận so sánh-và-đặt kèm sequence mà client đã quan sát. Một cập nhật tiến sequence lên, cập nhật còn lại nhận về trạng thái hiện tại kèm cờ xung đột.

SDK Luau dùng chung giữ các ý định đang chờ, tái cơ sở hóa chúng trên phản hồi đó và thử lại. Trong trò Signal Run, reducer trả về nil nếu ai đó đã chiếm nút đó rồi.

Tuy nhiên, tác giả nhấn mạnh một giới hạn quan trọng:

Việc sắp xếp đúng thứ tự hai tuyên bố không làm cho tuyên bố nào trở thành đúng. Các trò chơi cạnh tranh sẽ cần xác thực quy tắc phía máy chủ.

Phần vẽ nên chú rồng

Hiển thị đồ họa đi qua wgpu: Metal trên iOS, backend Vulkan/GLES trên Android và các đường dẫn WebGPU/WebGL cho trình duyệt. Điều này chia sẻ được phần lớn mã renderer, nhưng vòng đời surface, đầu vào, phát âm thanh và việc hệ điều hành ngắt ngang vẫn thuộc về từng máy chủ.

Hiển thị đồ họa trên nền tảng di độngHiển thị đồ họa trên nền tảng di động

Renderer nhân vật dùng danh mục lưới có chỉ mục và gộp các instance theo lưới và vật liệu. Nó chọn giữa ba mức chi tiết LOD dựa trên chiều cao chiếu, với ranh giới danh nghĩa ở 180 và 70 pixel. Bố cục instance là 128 byte, và một hệ xương cố định 15 khớp điều khiển nhân vật.

Có những giải pháp rất đặc thù trong đó. Tay áo hoodie uốn quanh khuỷu tay ngay trong vertex shader, mang dữ liệu trục-góc trong các làn dự phòng của hàng pháp tuyến. Quần áo khác dùng gắn kết cứng. Tóc có chuyển động lò xo có giới hạn trong khi phần chụp vẫn dính vào đầu.

Ngân sách nhân vật bao gồm 50 nhân vật chỉ dành cho renderer32 MiB bộ đệm lưới/instance. Đây là giới hạn trong triển khai, không phải tuyên bố rằng mọi điện thoại đều giữ được một tốc độ khung hình nhất định.

Chiếc mũ có 248 hình tam giác

Quay lại với chiếc mũ chóp — biểu tượng khởi nguồn của tên dự án. Có một bản xuất Blender trong nhật ký công việc morph với một nút tên Cylinder và 248 hình tam giác. Trình xác thực đã từ chối nó.

Lý do rất rõ ràng: một chiếc mũ hoàn toàn hợp lý nhưng thiếu ba nút LOD riêng biệt mà sidecar yêu cầu. Đây là kết quả sớm hữu ích hơn nhiều so với một dấu kiểm xanh chỉ có nghĩa là chúng ta mở được file.

Hệ thống nhân vật hiện tại mã hóa thân và trang phục trong các enum Rust, và renderer chuẩn bị các tổ hợp của chúng. Điều này giúp bản mẫu chạy được, nhưng trở nên lúng túng khi thêm một chiếc mũ lại đòi hỏi thay đổi mã engine.

Crate cubacadabra-morphs mới nắm giữ ID, schema danh mục, trang bị và các quyết định tương thích. Bộ giải quyết của nó kiểm tra các base được hỗ trợ, tương thích rig, cấu hình vừa vặn chung, ô bị chiếm, xung đột và các khả năng yêu cầu như mesh.rigid.v1, rồi đưa ra chẩn đoán xác định.

Trình kiểm tra GLB xác minh magic glTF, phiên bản 2, độ dài file khai báo và ranh giới chunk. Nó đọc các nút JSON, lưới và số lượng accessor, sau đó so sánh số tam giác dẫn xuất với sidecar. Thư viện từ chối nguồn trên 64 MiB và sidecar trên 256 KiB.

Chẩn đoán xác định một mã lỗi và đường dẫn trường, để người thiết kế có thứ hữu ích hơn câu "nhập thất bại".

Mục tiêu cuối cùng rất rõ ràng: thêm chiếc mũ thứ hai mà không cần mở một file mã nguồn Rust nào.

Chừa chỗ cho chính trò chơi

Ý tưởng tương tự áp dụng cho các trò chơi. Một dự án nguồn có manifest.json, src/main.luau và thư mục assets. Các công cụ Python mở rộng include tường minh và lắp ráp gói di động.

Bản dựng native thực thi Luau qua mlua với Luau đi kèm; bản WASM dùng runtime thuần Rust luaur-rt. Chúng cung cấp cùng một API trò chơi, dù hai triển khai runtime vẫn cần kiểm tra tương đương. HUD trò chơi cũng có thể nằm trong Luau, với Rust đảm nhiệm bố cục vùng an toàn, kiểm thử điểm chạm và lớp phủ wgpu.

Chạy trò chơi trên thiết bịChạy trò chơi trên thiết bị

Hãy đọc reducer của Signal Run, hoặc ví dụ Stormline Outpost với nhiên liệu đèn hiệu và nguồn tiếp tế vô tuyến. Những quy tắc đó thuộc về trò chơi. Tác giả mong muốn ai đó có thể thay đổi cơn bão, biến trạm tiếp sức thành thứ lố bịch, hoặc đội mũ lên chú rồng mà không cần phối hợp ba bản vá client và một bản phát hành backend.

Góc nhìn cho lập trình viên Việt Nam

Với các nhóm phát triển ứng dụng đa nền tảng tại Việt Nam, bài học từ Cubacadabra khá thiết thực. Nhiều startup trong nước đang duy trì song song ứng dụng React Native hoặc Flutter cho giao diện và các module native riêng cho từng nền tảng. Việc đẩy logic nghiệp vụ vào một lõi dùng chung — dù bằng Rust hay ngôn ngữ khác — có thể giảm đáng kể chi phí bảo trì khi đội ngũ còn nhỏ.

Tuy nhiên, cần cân nhắc kỹ chi phí ban đầu. Như tác giả thừa nhận, một lập trình viên di động cấp cao hoàn toàn có lý khi hỏi tại sao một trường tên người dùng lại cần Rust, ABI C, JNI và WASM. Với ứng dụng nhỏ chỉ có vài biểu mẫu, việc lặp lại mã nguồn có thể rẻ hơn.

Điểm mấu chốt là liệu hành vi phức tạp có nằm ở một chỗ và các bộ điều hợp máy chủ có giữ được kích thước nhỏ hay không. Dữ liệu từ dự án cho thấy tín hiệu tích cực: một commit tích hợp iOS thêm 106 dòng và xóa 269 dòng, đơn giản hóa cả màn hình tên người dùng lẫn cầu nối của nó.

Như lá bùa abracadabra cổ xưa loại bỏ từng chữ cái cho đến khi gần như chẳng còn gì, mục tiêu của Cubacadabra là giản lược xuống chỉ còn phần Swift, Kotlin và JavaScript thực sự cần phải khác biệt.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗