在 React Native 与 Kotlin Multiplatform 中构建 3D 游戏
原文链接:proandroiddev.com/building-a-…
原文作者:Gabor Berenyi
我如何在 React Native 外壳上做出 3D 棋盘,并把每一条规则都留在 Kotlin 里
Android
iOS
引言
最近我在 Compose Multiplatform 里做了一款 3D 国际象棋:共享 Kotlin 逻辑、藏在窄渲染器接口后面的 3D 棋盘,以及面向 Android、Web、iOS 与桌面的四个原生渲染后端。今天我把同一款游戏在 React Native 上重做一遍,看看什么会变、什么会留下。
简短版的留下的是: 全部象棋逻辑与渲染逻辑仍在 Kotlin 里,约占代码库的 65%,若再加更多象棋功能还可以大幅扩展。变的是: UI层现在是 React Native,以 Three.js 作为标准 Web 渲染器,以 Filament 作为更偏性能的移动端渲染器。
为何核心用 Kotlin,而不是 TypeScript
既然共享逻辑反正会编译成 JavaScript,为什么不直接用 TypeScript 写?因为 Kotlin 的保证会一路活进我们嵌进应用的 JS(IR) 输出——而象棋规则是正确性问题,不是速度问题(这正是为什么只有 Stockfish 走原生)。
有些保证在发出一行 JS 之前就被强制执行:类型系统里的空安全(Piece? ≠ Piece,于是一类 undefined 崩溃变得不可编译),以及对密封类型的穷尽 when(加一个状态,凡是忘了处理它的分支都会编译失败)。另一些则跑在发出的 JS 本身里:data class 的值相等与用于快照和差分棋盘的 copy()、会拆掉整个游戏作用域而不是泄漏孤儿 promise 的协程取消,以及 React Native 桥接处的空检查,让坏数据在边界上大声失败。
TypeScript 留着做 UI 粘合,那是它生态闪光的地方。但规则书是编译器该做强制执行的地方——一路执行到我们实际运行的 JavaScript。
核心想法一样:把 3D 藏在窄契约后面
这款应用的两个版本都立在同一条规则上:
游戏状态保持为普通的 Kotlin 状态,渲染器是对该状态的一种视图——而不是与第一个引擎并行运行的第二个游戏引擎。
渲染器从不校验着法,从不拥有选中的格子,也从不决定什么合法。交给它的是要画的场景、要高亮的格子,以及相机参数。这就是全部关系,也正是它让两个完全不同的渲染引擎能坐在同一套逻辑后面、而任一方都不必学会规则的原因。
这次构建里新的是那套逻辑跑在哪里。在 Compose 版本里,Kotlin 核心在每个平台上原生运行。这里,Kotlin Multiplatform 的 chess-core 模块编译成 JavaScript 库(经 Kotlin/JS),并跑在 React Native 的 JS 运行时里。规则、FEN 处理、UCI 集成、场景数学、相机与拾取仍然全是 Kotlin——只是作为 JS 包交付,而不是原生二进制。
游戏逻辑没有按平台各做一条原生桥接。
规则书是一个 Kotlin 模块,编译一次成 JS,由 iOS、Android、Web 与桌面一并共享。一个标了 @JsExport 的 ChessSession 门面,是 TypeScript 一侧交谈的唯一接缝:订阅场景更新、提交玩家着法、读取当前场景。
两个渲染器,一份场景模型
原应用有四个渲染器,因为当时有四套原生图形栈在运转。把外壳收成 React Native,也把渲染问题一并收拢。
原生平台共享一个渲染器——React Native Filament——于是 iOS 与 Android 现在是同一引擎、同一材质、同一资源,只差 Filament 底下瞄准的 GPU 后端(iOS 上是 Metal,Android 上是 Vulkan 或 GL ES)。Web 与桌面共享 three.js 渲染器,因为桌面不过是 Electron 里的 Web 构建。
两个渲染器都消费同一份 Kotlin Board3DScene。象棋逻辑发出场景;每个后端按其管线偏好去画。任一方后端都不自行做射线投射、动画或任何玩法决策——相机环绕与缩放走 Kotlin 的 OrbitCameraController,点到格子的命中测试走 Kotlin 的 BoardRayPicker。这让 2D 棋盘与两个 3D 后端保持同步,因为它们都在读同一份事实源。
Stockfish,按平台落地
对手是另一块原生代码,而且在每个平台上落法不同:
- Electron 把系统的
stockfish二进制当 Node 子进程,经 UCI 驱动。 - Android 内置
libstockfish.so,经一个小的 Kotlin 模块把它当 UCI 子进程驱动。 - Web 计划用编译到 WebAssembly、跑在 Web Worker 里的
stockfish。 - iOS 计划用原生的 ChessKit Stockfish 模块。
Stockfish 是故意不把一切都留在 JS 运行时里的例外,而推理正好与游戏逻辑相反。走子生成与场景数学很便宜;真正强度的象棋搜索是实打实的计算密集型,原生与解释执行之间的差距巨大。那正是 React Native TurboModule 路径存在的工作负载——在 Android 上,StockfishModule.kt 是编译成 JVM 字节码的 Kotlin,经 UCI 以 ART 上的全速驱动内置引擎。你为每次「给我最佳着法」付一次桥接穿越,换回原生搜索性能。便宜、频繁的逻辑留在 JS,集成容易;昂贵、不频繁的计算走原生,速度值得那次桥接。
收获
这是一次有用的实验,恰恰因为它换了外壳却没换原则。把 Compose 换成 React Native,把同一套 Kotlin 核心以编译后的 JavaScript 交付,承重想法不会动:把规则留在共享 Kotlin,把渲染器放在对国际象棋一无所知的窄契约后面,让每个后端——原生上的 Filament、Web 上的 three.js——都只是同一场景的又一种视图。