【翻译】在 Compose Multiplatform 中构建 3D 游戏

0 阅读11分钟

在 Compose Multiplatform 中构建 3D 游戏

原文链接:proandroiddev.com/building-a-…

原文作者:Gabor Berenyi

我如何用纯 Kotlin(外加通往 Stockfish 与 Filament 的少量 Swift 与 C++ 桥接)让 3D 国际象棋跑在 Android、iOS、桌面与 Web 上

Web

Web

Mobile

Mobile

引言

这是我此前文章 在 React Native 与 Kotlin Multiplatform 中构建 3D 游戏 的姊妹篇。那一篇在共享 Kotlin 象棋核心之上套了 React Native 外壳,其中近一半本可以用 Kotlin 写的代码,却落成了 TypeScript(前端 UI、3D 渲染与桥接)。这一篇保留同一核心,把外壳换成 Compose Multiplatform——而且结果是,连每个渲染器也一并换成了原生实现。有意思的是那些没有变的东西。

有一条规则塑造了其余一切——与 React Native 那套构建所遵循的是同一条:

游戏状态保持为普通的 Compose/Kotlin 状态,渲染器只是对该状态的一种视图——而不是与第一个引擎并行运行的第二个游戏引擎。

如果你读过上一篇,这里点头跳过即可。本文其余部分探讨:当外壳是 Compose、渲染器是原生时,这条规则要付出什么、又能换来什么。我们会看各目标如何托管 GPU 表面、如何接线输入、如何管理生命周期,以及当设备完全无法渲染 3D 时如何回退。

核心想法:把 3D 藏在小接口后面

这里的一切都挂在 commonMain 里一个很小的接口上:

interface Chess3DBoardRenderer {
    fun attach(surface: Chess3DSurface)
    fun detach()
    fun updatePosition(fen: String)
    fun updatePosition(fen: String, transition: Board3DTransition?) = updatePosition(fen)
    fun onUserInteraction(event: Board3DInput)
    fun dispose()
    fun setSelectedSquare(square: BoardSquare?) {}
}

要紧的是它不知道什么。它不校验着法,不拥有选中的格子,也从不碰 Compose 手势。你交给它一串 FEN、可选的过渡、要高亮的格子,以及相机更新。这就是全部关系。

渲染器经可空工厂(Board3DSupport)到达,而不是走 Kotlin 的 expect/actual 机制。原因是:「这个平台能编译出一个渲染器」与「这个平台在运行时真的会把一个渲染器交给你」是两种不同的断言。初始化可能失败。在测试里,我想要一个假实现。允许返回 null 的工厂,把上述所有情况收成一次检查:我们拿到渲染器了吗?没有的话,应用回退到 2D 棋盘。

在代码里,该工厂也是 suspend 的,因此渲染器可以在把自己交回去之前,经 Res.readBytes() 加载其 glTF 模型:

/** 当不支持 3D 或初始化失败时返回 null -> UI 回退到 2D。
 * 做成 suspend,以便实现方能经 Res.readBytes() 加载 glTF 资源。 */
fun interface Chess3DRendererFactory {
    suspend fun create(): Chess3DBoardRenderer?
}

/** 在平台入口注入,镜像 ChessEngine 的注入方式。 */
@Immutable
class Board3DSupport(
    val rendererFactory: Chess3DRendererFactory,
    val surfaceContent: @Composable (renderer: Chess3DBoardRenderer, modifier: Modifier) -> Unit,
)

若你来自 React Native 那一版,这就是那道移动过的接缝。在那里,整个核心坐在单个 @JsExportChessSession 门面后面,因为 TypeScript 需要一个定义清晰的对象来跨越语言边界调用。在 Compose 下,应用的大部分没有语言边界——是 Kotlin 调用 Kotlin——于是接缝收缩成这样:一个普通接口加上一个可空工厂,在平台入口注入。同一想法(渲染器对国际象棋一无所知),仪式更少。

一个共享宿主可组合项 Board3D 扛起重活:构建渲染器,推入 FEN,映射成与渲染器无关的场景,把场景差分变成过渡,拥有环绕相机,把点击转换成棋盘格子,并在棋盘离开屏幕时释放 GPU 资源。3D 渲染器不只是几个可组合项——它持有原生表面,还可能跑着渲染循环。若生命周期不是契约的一部分,之后就会以内存泄漏的形式回来找你。

一份契约,多种渲染器

这里是让我惊讶的部分——而且惊讶了两次。从 React Native 那套构建到这一套,每个渲染器都换过:桌面从 Electron 里的 Three.js 换成无头原生 Filament,Web 从 Three.js 换成 Filament/Wasm,两个移动端目标从 React Native Filament 换成 SceneView 与 Metal 原生 Filament。四次换装。仅此就足以让它们之上的契约——接口、FEN、场景、相机、拾取器——纹丝不动。随后渲染器又进一步汇合:桌面、iOS 与 Web 现在通过同一共享的 FilamentEncodedChessRenderer 生命周期驱动同一 Filament 渲染器,而 Android 的 SceneView 本身就是 Filament 包装——于是起初四套不同的图形栈,如今变成同一引擎的四种宿主方式。契约仍然没动。这就是本文一整段里的全部论点:它从未依赖渲染器彼此不同,因此在它们变得相同之后依然成立。其余都是细节。

共享的是接缝,不是管道。在三个存活表面目标上,上层透明的 Compose 层把控件与输入留在 Compose;桌面则通过把画完的位图交给 Compose 得到同样结果。它们的差异仅在于各自如何托管 GPU 表面:

桌面 —— 经无头 C++/JNI 桥接的 Filament。 它离屏渲染,再把帧作为普通 ImageBitmap 交给 Compose。回读拷贝并非免费,但换来的是棋盘成为常规 Compose 内容——于是对话框、滚动与测试都能直接工作。

Web —— 编译到 WebAssembly 的 Filament。 渲染器画进位于 Compose 自身表面之后的 canvas。相机与每帧场景经 @JsFun 互操作跨越 Kotlin↔JS 边界——同一契约,无 DOM,无第二套框架。

iOS —— 同一 Filament,Metal 原生。 渲染器是一小段 Swift/Obj-C++ 构建,恰如 Stockfish 引擎那样从 Swift 应用目标注入——因此 Kotlin 框架对 Filament 完全没有编译期依赖。Compose 托管所得视图,共享的相机/拾取管线驱动它。

Android —— SceneView,Google 面向 Compose 友好的 Filament 包装。 SceneView 拥有表面与场景,「渲染器」对象其实是同一契约背后的可观察状态持有者。

FEN:游戏状态与渲染状态之间的边界

状态边界是 FEN,即棋局局面的标准单行编码——与 React Native 那套构建所用的是同一边界,理由也相同:应用已经用 FEN 与 Stockfish 对话,它捕获局面与轮到谁走,并阻止渲染器去读可变 UI 状态。共享映射器把 FEN 转成棋子种类、颜色、格子、世界坐标与朝向——足以画棋盘并对两个局面做差分,且刻意不足以下棋。渲染器不能自行做出玩法决策。

点按走子:在共享代码里做射线拾取

很诱人的做法是让每个渲染器自己做命中测试。我没有——也用不着,因为 OrbitCameraControllerBoardRayPicker 就是 React Native 那套构建里同一份共享 Kotlin。它们从未触碰过渲染器,因此换掉全部四个渲染器后,它们字节级未动。因为宿主拥有相机,一次点击只是一对屏幕坐标,共享数学把它变成棋盘格子。从那里起就是应用已在使用的同一套 2D 回调:点棋子选中它,点合法目标格走子。非法着法仍然非法,因为仍是同一套规则代码决定什么合法。渲染器永远不知道点了什么——它们只看到 setSelectedSquare 与新的 FEN。

场景差分:不必让每个渲染器都实现动画

FEN 是状态边界,但把一个 FEN 换成另一个是瞬移——棋子径直跳到新格子。若要动画,宿主对两个由 FEN 派生的场景做差分,并发出带类型的过渡:普通 MoveCapture(被吃子淡出或下沉)、Promotion(在着法结束时吸附到新棋子种类),或当变化无法安全差分时的 Reset。易位不是独立变体——它是一个携带 secondary MoveMove,好让车与王一同移动。渲染器的 updatePosition(fen, transition) 同时收到目的地与编排。

接口默认实现——updatePosition(fen, transition) = updatePosition(fen)——意味着不关心动画的渲染器只需吸附到结果。又因为差分器在不确定时回退到 Reset,差分永远可以安全调用,从不是正确性风险。新渲染器可以先以吸附出货,稍后再获得动画,全部落在同一契约下。

过渡实际跑在哪里。 双参数的 updatePosition 只是入口。动画本身住在一处——commonMain 里与后端无关的 Board3DAnimationDriver——而不是复制进每个渲染器。驱动器持有静止场景,加上任何进行中的着法或选中状态,并跑一个按帧预算推进的协程循环。每一拍它经单一回调 render: (Board3DScene) -> Unit 发出当前时刻的插值场景。每个后端只提供该回调,并按其管线偏好绘制场景:

class Board3DAnimationDriver(
    private val scope: CoroutineScope,
    private val frameBudgetMs: Long = 16L,
    private val render: (Board3DScene) -> Unit,
) { … }

于是每个后端否则会重造的部分——走子弧线、选中弹跳、帧节奏,以及一旦没有东西在动画就立刻停掉循环——恰好只存在于一处。一个只会画静态场景的后端,白捡整套动画系统。

把它当成它大体上就是的 Compose 特性来测

最有用的测试是假渲染器。因为接口是普通 Kotlin,测试可以断言完整生命周期——attach、update、高亮、相机、detach、dispose——而无需启动 GPU。真实 GPU 路径仍有两类检查:确认它们能跑且不崩溃的冒烟测试,以及一张由人审看以确认观感仍正确的保存截图。但行为的大头,是在测试下的普通 Kotlin 与 Compose。

「那为什么不干脆用 Unity?」

决定性的问题是谁宿主谁。3D 是应用里的一个特性,还是应用只是围着 3D 世界的薄壳?国际象棋是第一种:Android 上的 Filament 渲染器是 64 行状态持有者加 237 行 SceneView 可组合项——约占 Android Kotlin 代码库的 6%(我们还能再加很多象棋功能,让这个比例更小)。其余 94% 是普通应用:菜单、对话框、设置、着法列表、引擎集成。「画一块棋盘」只是一小片,而选对的库(此处是 SceneView)让它保持那样。Unity 把这个比例倒过来——它的运行时变成宿主,你的应用围着它变成薄壳——于是你在那 6% 够不到的地方处处付账:原生对话框、无障碍、文本输入、每个平台数十兆字节的运行时、每次系统调用都要一个 C# 到原生的插件,以及缓慢的 Web 构建。而你付出这一切,对一块这么简单的棋盘却换不来任何视觉收益——这里的观感由资源与光照决定,而不是由绘制它们的引擎决定。

收获

那篇 React Native 文章以一个论断收尾:换掉外壳,承重想法不会动。本文就是收据。跨越 React Native 外壳与 Compose 外壳——渲染器从 Three.js 换成原生 Filament,又从 React Native Filament 换成原生 Filament——同一套 Kotlin 核心驱动了两边。每个渲染器都变过;四个里有三个随后汇合到单一 Filament 生命周期。契约两者都没做——它们分歧时它没动,它们汇合时它也没有溶解。

「共享 UI」从来不会意味着一个渲染器——有趣的是,我其实大体上还是得到了一个。即便如此,「共享」止于契约:同一 Filament 以四种完全不同的方式被宿主——桌面上的无头 C++ 回读、iOS 上 Swift 背后的 CAMetalLayer、Web 上 Wasm 下的 WebGL canvas、Android 上的 SceneView——落在各平台交给它的任意 GPU API 之上。Compose Multiplatform 很好地共享了应用模型、结构与交互逻辑;它从来不会把那四种集成折成一种。因此共享应当共享的部分(状态、场景快照、着法路由、相机、拾取、过渡、生命周期契约、假渲染器测试),隔离不应当共享的部分(表面创建、资源加载、管线搭建、绘制循环)——即便它们底下碰巧是同一引擎。

那道切分在四个目标上交付了可玩的 3D 棋盘,而无需把应用分叉四次。把规则留在共享 Kotlin,把渲染器放在窄契约后面,让 3D 成为同一游戏的又一种视图。这就是我要带进下一个应用的部分。

仓库:github.com/ber4444/com…