Cubacadabra 背后的魔法

Hacker News Top 产品

摘要

Cubacadabra 利用共享的 Rust 库,统一了 iOS、Android 和 Web 平台上的应用逻辑,从而减少代码重复并简化多平台游戏或应用的维护。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/09/12 20:32

# cubacadabra 背后的魔法 来源:https://andrewarrow.dev/2026/moon/2/day/19/the-magic-behind-cubacadabra/ ## 01 / 让代码消失 在abracadabra成为你对着高顶礼帽念叨的咒语之前,它曾是人们挂在脖子上的护身符。在古罗马的《治疗之书》(Liber Medicinalis,https://artsandculture.google.com/asset/quintus-serenus-liber-medicinalis/fAGBmLhWW99n0g)中,作者将其归于昆图斯·塞雷努斯·萨莫尼库斯,其中记载的处方是:反复书写这个词,每次去掉一个字母,并将最终结果作为护身符佩戴以对抗发烧。这是一种相当字面意义上的让问题变小的方法。 如果你曾经同时发布过某样东西的 iOS、Android 和 Web 版本,你一定熟悉这套常规流程:用 Swift 构建一次功能,再用 Kotlin 构建一次,最后用 JavaScript 再构建一次。每个版本都有自己独立的验证、请求、加载状态和错误处理。它们与同一个后端通信,因此我们称之为一个产品的三个客户端。但我们实际上把产品逻辑写了三遍。 然后功能需要变更。iOS 版学会了如何从保存失败中恢复。Android 版一周后才得到修复。浏览器版本对同一错误的处理方式又不同。没有人一开始就打算设计三种不同的行为,它们只是在大家进行常规开发的过程中逐渐累积出来的。 我希望 cubacadabra 的客户端能变成围绕 Rust 的薄壳层。保留最少的 Swift、Kotlin 和 JavaScript,仅用于处理原生控件、设备服务和浏览器集成。将可移植的应用逻辑移入共享的 Rust crate。这包括那些无聊的账户页面以及游戏引擎。DRY(不要重复自己)原则应当适用于应用程序所做出的所有决策。 用户名输入框仍然可以是 SwiftUI 的文本字段、Compose 的文本字段或 HTML 的 input 元素。每个输入框都将编辑操作转发给 Rust,并显示最终状态。Rust 决定“保存”按钮是否启用、发起哪个请求以及如何解析其响应。改变规则只需要修改一处实现。这三个界面可以继续保持各自平台上的原生外观。 这已经有很好的先例。Litter(https://github.com/0xSero/litter)拥有原生的 iOS 和 Android 界面,其背后是一个拥有会话状态、流媒体和重连行为的 Rust 核心,通过 UniFFI 绑定暴露出来。Mozilla 的共享 Rust 组件(https://firefox-source-docs.mozilla.org/rust-components/developing-rust-components/index.html)源于为 Firefox 的桌面端和移动端维护独立的同步实现。原因听起来很熟悉:重复的逻辑难以维护,不同实现之间的差异会导致 bug。 ## 02 / 所有这些都值得为一个保存按钮吗? 我在一个深入的架构讨论(https://chatgpt.com/share/6aa2eb74-0490-83e8-aba5-57d9ad42b994)和 iOS 开发笔记(https://github.com/cubacadabra/ios_app/tree/main/docs)中详细阐述了这一点。一位资深移动开发者可能会合理地质问,为什么一个用户名输入框需要 Rust、C ABI、JNI 和 WASM。独立的原生实现在各自的 IDE 中更容易调试。绑定增加了构建工作和生命周期 bug。对于一个只有几个表单的小应用来说,复制代码可能成本更低。 对于 cubacadabra,我确信共享核心是值得的。Rust 已经运行着引擎和桌面版 Studio,浏览器也通过 WASM 运行着它。随着平台的发展,加入一个游戏将涉及内容兼容性、家长权限、订阅访问、屏蔽玩家以及中途断线恢复等问题。我希望只处理一次这些交互逻辑。我不希望每一个新规则都变成三个实现之间的协调难题。 这项投资已经开始产生回报。一次 iOS 集成提交(https://github.com/cubacadabra/ios_app/commit/bbde24eb3edb07cb2f580dc2bd17e8ef15cc32d3)增加了 106 行代码,删除了 269 行,简化了用户名界面及其桥接层。首次 Web 集成代码量增长是因为需要搭建基础设施。未来的功能应该能重用这些机制。真正的考验在于,那些复杂的行为是否集中在一处,而它们的宿主适配器是否保持小巧。 ## 03 / 保存按钮有自己的主见 单一实现并不意味着一个巨大的 crate。cubacadabra-client(https://github.com/cubacadabra/rust/commit/205e3c81b3f5eb69786eb17f3f228ef6cbda6efd)负责引擎层面的多人游戏会话。cubacadabra-app(https://github.com/cubacadabra/rust/commit/1221dcd28f6526e086e7721a97c8f22dc735e5b0)负责游戏玩法之外的便携行为。引擎负责模拟和渲染。用户名保存功能属于应用 crate,这样所有三个消费端客户端都可以使用它。 假设你保存了 "Dragon_7",继续输入,然后在收到响应前登出。旧的响应可能会覆盖你更新的草稿,或者更新下一个登录的用户。这正是我希望只修复一次的那种规则。 宿主分发 `UsernameChanged` 和 `SaveUsername` 指令。Rust 验证草稿,并发出一个包含 ID、账户 ID、方法、路径和正文的 HTTP 效果指令。宿主提供凭证、执行请求,并返回原始状态码和正文。Rust 在任何界面更新个人资料之前,接受或拒绝这个响应。HTTP 执行器保持原生;请求的含义保持共享。 替换会话会作废未完成的工作。效果 ID 在模型内不会被复用,因此迟到的响应无法完成一个新账户的操作。在保存期间编辑会保留更新的草稿。这些决策现在有了单一的实现和一套共享的契约用例。 > 我希望只处理一次这些交互逻辑。 头像保存、目录分页以及乐观更新的屏蔽/取消屏蔽现在遵循相同的模式。举报功能仍然由宿主处理。在手机端,`AppViewModel` 与 `GameViewModel` 并存,因此重启游戏不会取消账户保存操作。运行时契约(https://github.com/cubacadabra/rust/blob/main/docs/app-runtime.md)记录了这一边界及其测试。设备集成仍需检查;产品规则可以独立于界面进行演练。 ## 04 / 谁拥有那个指针? 我们的绑定与 Litter 的不同:cubacadabra 目前在移动端使用一个小型的 C 接口,在浏览器端使用 wasm-bindgen。应用 crate 需要 Serde 和 serde_json,没有渲染器或异步 HTTP 运行时。这使得共享的应用行为无需启动游戏即可使用。 原生应用 ABI(https://github.com/cubacadabra/rust/blob/main/include/cubacadabra_app.h)定义了七个函数:创建、销毁、分发 JSON、获取快照 JSON、轮询效果 JSON,以及读取输出指针和长度。句柄是单线程的。输出属于 Rust,必须在下一次修改之前复制。Swift 将其复制到 `Data` 中;Android 有一个小型的 JNI 字节数组适配器。两者都会检查快照协议版本。不兼容的绑定会失败,而不是悄悄启用第二套账户规则。 cubacadabra 在一部 iPhone 17 上运行,玩家处于一个明亮的游戏世界中。 * iPhone / 原生客户端:宿主提供设备表面;共享运行时提供世界、控件和玩家状态。* 这里有序列化和复制。对于账户编辑和目录页面,我喜欢能够直接读取契约。Studio 通过普通的 Rust 方法和枚举调用游戏客户端。原生游戏客户端暴露一个借用的引擎指针,用于现有的输入和渲染 API;销毁客户端会使该指针失效。当下一个调用者是 C 语言时,Rust 的所有权规则仍然需要一个诚实的描述。 浏览器还有另一个容易掉入的陷阱。`WebClient` 和 `WebRenderer` 来自同一个生成的 WASM 模块,因此引擎句柄在两侧指向相同的线性内存。即使从相同源码编译的两个模块,也不会使它们的指针可以互换应用。应用运行时是一个独立的 WASM 模块,这允许账户页面编辑用户名而无需加载渲染器。它没有引擎指针可以共享。 ## 05 / 两个人碰了同一样东西 客户端提取消除了另一种重复。每个宿主之前都在翻译 socket 消息、维护远程玩家、路由世界变更以及处理 Luau 网络发送队列。在浏览器的“crate 2nd”提交(https://github.com/cubacadabra/web/commit/60c11f85d198080a59ee491f7ea95ffd3f2f0b82)中,减少了 244 行代码,增加了 74 行。这只是一个宿主的差异,包括其适配器。有用的部分在于拥有一个需要修复的会话实现。 cubacadabra Studio 测试工作区显示一个活跃的玩家会话和三个会话预览面板。 * Studio / 测试工作区:多人界面使得“一个客户端能工作”和“会话表现正常”之间的差距无法忽视。* 宿主将 socket 文本传递给 `ClientSession::receive_text`,并在引擎步进前后轮询操作。Rust 发出 `SetWorld` 和 `SendText`。宿主仍然拥有认证、重连退避和移动发送节流。一个启动实例可以保持引擎的逻辑世界为 "arena",同时将 socket 路由到 "arena:7"。Rust 还跟踪远程身份和代际,过滤被忽略的账户,并在变更时提交带版本的玩家名单。 现在假设两个朋友在同一个中继检查点。两人都读到序列 7。两人都试图推进回合。JavaScript 后端使用 Cloudflare Durable Object 作为世界实例,其保留状态 API 接受一个包含客户端观察到的序列的 compare-and-set 操作。一次更新推进了序列。另一次更新会返回当前状态和一个冲突标志。 Sally 站在 cubacadabra 平台跳跃游戏中的一个彩色峡谷检查点。 * Signal Run / 检查点:共享会话确保即使两个玩家同时到达相同状态,世界也能继续前进。* 共享的 Luau SDK 保存待处理的意图,根据该响应进行 rebase,并重试。在 Signal Run 中,如果其他人已经捕获了该节点,reducer 会返回 nil。重复的意图可以消失。保留的通道也传递给新玩家,服务器派生的 `ageMs` 允许一个短暂的计时器在不依赖手机自身时间的情况下恢复。 这足以协调协作状态。但它并不能证明玩家获得了分数。客户端仍然负责提出负载内容,而后端并不理解游戏的 reducer。网络契约(https://github.com/cubacadabra/rust/blob/main/docs/network-runtime.md)明确指出了这一限制。竞技游戏需要服务器端的规则验证。正确地排序两个声明并不等于使任何一个声明变为真实。 ## 06 / 画龙的那部分 渲染通过 wgpu 进行:iOS 上使用 Metal,Android 上配置 Vulkan/GLES 后端,浏览器上使用 WebGPU/WebGL 路径。这共享了大量的渲染器代码。Surface 生命周期、输入、音频播放以及操作系统的中断仍然属于每个宿主。Android 的 surface 消失是一个相当有效的提醒,表明引擎并非应用程序的全部。 Sally 的头像站在一个明亮的 cubacadabra 游戏世界中,远处有另一位玩家。 * 共享渲染器 / 实时客户端:同一个角色面向的运行时可以出现在浏览器中、手机上或桌面工具内部。* 角色渲染器使用索引的模型目录,并按网格和材质对实例进行批处理。它使用投影高度在三个 LOD 之间进行选择,名义边界为 180 和 70 像素。实例布局为 128 字节。固定的 15 关节层次结构驱动着角色。这些都是可以在代码中找到的具体约束,你可以在为某人头顶添加另一个装饰物之前进行推理。 cubacadabra 在一部 iPad Air 上运行,玩家站在 Signal Run 关卡中。 * iPad / 原生宿主:渲染器是共享的;设备仍然为游戏提供自己的 surface、控件和中断。* 其中有一些非常精妙的解决方案。人物的卫衣袖子在顶点着色器中弯曲,包裹着手肘,将轴角数据存储在法线行的空闲通道中。其他服装使用刚性附着物。头发有受限的弹簧运动,同时其帽状部分固定在头上。这类工作隐藏在“让人物挥手而不让衣服散开”这个看似简单的需求背后。 角色预算(https://github.com/cubacadabra/rust/blob/main/docs/character_runtime.md)包括 50 个仅渲染的角色和 32 MiB 的网格/实例缓冲区。这些是实现中的限制,并非声称每部手机都能维持特定的帧率。网络花名册有其自己更小的上限。在将任何一个数字转化为性能承诺之前,我需要物理设备的帧时间数据。 ## 07 / 那顶帽子有 248 个三角形 这把我们带回了那顶高顶礼帽。在变形工作日志中有一个 Blender 导出文件,其中一个节点名为 `Cylinder`,包含 248 个三角形。验证器拒绝了它。一顶完全可以理解的帽子,只是缺少了附属文件所要求的三个独立的 LOD 节点。这比仅仅表示“我们成功打开了一个文件”的绿色对勾要实用得多。 cubacadabra Studio 变形检查器显示一顶有 248 个三角形且缺少 LOD 映射的高顶礼帽 GLB 文件。 * Studio / 变形预览:验证器可以让不完整的资产在到达设备之前变得清晰可读。* 现有的角色系统将身体和服装编码在 Rust 枚举中,渲染器负责准备它们的组合。这可以驱动原型开发。但当添加一顶帽子意味着要修改引擎代码,或者当独立的发型和服装成倍增加组合时,事情就变得尴尬了。我希望艺术家的第二顶帽子只是一个内容变更。 新的 `cubacadabra-morphs` crate 负责 ID、目录模式、装束和兼容性决策。它的解析器检查支持的基座、装备兼容性、通用适配配置、占用槽位、冲突以及必需的能力(如 `mesh.rigid.v1`)。它生成确定的诊断信息。Studio 可以在不初始化 GPU 的情况下使用它。运行时采用仍处于早期阶段;现有的外观和渲染路径尚未全部迁移过来。 Studio 创作 crate(https://github.com/cubacadabra/studio/tree/main/crates/morph_authoring)处理源边界。一个 GLB 导出文件包含一个可读的 `.morph.json` 附属文件,声明资产、几何路径、命名的近/中/远节点、三角形数量,以及附着到一个语义关节(如“头部”)的刚性附着物。附着变换会被检查,包括归一化的四元数。声明的数量必须满足 `near >= mid >= far > 0`。将一个节点名称复制到所有三个插槽会导致验证失败。 GLB 检查器检查 `glTF` 魔法数、版本 2、声明的文件长度和块边界。它读取 JSON 节点、网格和访问器计数,然后将推导出的三角形数量与附属文件进行比较。它理解三角形、三角形条带和三角形扇面用于此计算。该库拒绝超过 64 MiB 的源文件和超过 256 KiB 的附属文件。诊断信息标识了代码和字段路径,因此艺术家能得到比“导入失败”更有用的信息。 你现在就可以对附属文件和 GLB 文件运行 `morph_validate`。它仍然会在解码顶点缓冲区或编译运行时包之前停止。Studio 的变形标签页有一个可搜索的目录和检查器;导入、重新导入以及完整的渲染创作循环尚未完成。元数据检查并不能证明网格能够正确绘制。 计划路径(https://github.com/cubacadabra/rust/blob/main/docs/morph_plan.md)是:Blender 导出,Studio 验证和编译,然后生成一个有界的 `.morphpack` 由共享渲染器消费。导入机制保留在 Studio 中。手机端接收运行时资产。使用现有能力的新几何体应该是数据;新的变形或材质行为可能需要引擎改动。我感兴趣的里程碑是,在不打开 Rust 源文件的情况下添加第二顶帽子。 ## 08 / 为游戏留出空间 同样的理念也适用于游戏本身。一个源项目包含 `manifest.json`、`src/main.luau` 和资源。Python 工具展开显式包含并组装成

相似文章