Swordreforge's blog

Bevy 网页端(wasm32-unknown-unknown)移植复盘

阅读 49评论 0

把 Rust 游戏移植到网页端:一份踩坑复盘

项目:bevy-vn-engine(水仙10周年版本) 范围:源码中所有针对网页端(wasm32)的特殊处理 结论先说:移植的核心不是改渲染代码,而是把六类平台差异收敛掉。


一句话原则

引擎 crate 保持平台无关(零 cfg),平台差异收敛到 trait;example 层用 target 条件 feature + main.rs wasm 分支;纯浏览器问题(autoplay、touch、SRI、预压缩)在 HTML / nginx 层兜底。

六类平台差异:

差异收敛位置手段
文件系统storage.rsAppStorage trait + 两个 impl
时间save / uiweb-time 替代 std::time
线程Cargo.tomlwasm 段不写 multi_threaded
资产枚举script/asset.rsScriptManifest 清单替代 load_folder
音频启动index.html手势解锁 AudioContext
输入模型index.html触摸重派发为鼠标事件

构建层:三个必改项

1. wasm 没有线程 —— 开 multi_threaded 直接编译失败或 panic,必改。

2. WebGPU + WebGL2 双开,且必须手动并集:

backends: Some(Backends::BROWSER_WEBGPU | Backends::GL)

Bevy 默认只给 BROWSER_WEBGPU,不加 GL 会导致 fallback 后找不到 WebGL2 适配器,报 Unable to find a GPU。

3. 反例:别设 WebGL2 priority WgpuSettings::default() 的 limits 会按默认 priority 展开为 Limits::default()(compute workgroups = 65535),而 WebGL2 adapter 上限是 0 → LimitsExceeded。保持默认 Functionality 即可。

其他坑:getrandom 要显式开 wasm_js、wasm-bindgen CLI 版本必须和 Cargo.lock 严格一致、CARGO_TARGET_DIR 别放 tmpfs(会 SIGBUS)。


资产管线:load_folder 在 wasm 上是废的

Bevy 0.19 的 HttpWasmAssetReader::read_directory 无条件返回空流 —— wasm 上 load_folder("scripts") 加载 0 个脚本。

解法:引入 ScriptManifest 清单(一行一个文件名),原生和 wasm 走同一条路径,行为统一。

顺带解决连接池耗尽:SCRIPT_LOADS_PER_FRAME = 16,每帧最多发 16 个请求,避免单帧上百 HTTP 触发 ERR_INSUFFICIENT_RESOURCES。

还有 .meta 检查:wasm 上必须 AssetMetaCheck::Never。两个原因 —— ① 避免每个资产一次 404;② itch.io 等平台用 403 代替 404,Bevy 会当致命错误,整个资产加载直接挂掉。


平台抽象:唯一的 #[cfg] 收敛点

AppStorage trait:

pub trait AppStorage: Send + Sync {
    fn read(&self, path: &str) -> Result<Option<String>, String>; // None = 不存在
    fn write(&self, path: &str, data: &str) -> Result<(), String>;
    fn remove(&self, path: &str) -> Result<(), String>;
}
  • 原生 → FsStorage(std::fs)
  • wasm → LocalStorage(web-sys,key 前缀 bevy_vn:)

#[cfg(target_arch = "wasm32")] 只出现在 storage.rs 一个文件里,引擎其余部分零平台分支。存档、设置都拿 &dyn AppStorage。

时间同理:std::time::SystemTime 在 wasm 上不可用,统一换 web_time。


🌐 浏览器交互层:手写 JS 兜底

这部分是 Rust/winit 触达不到的浏览器策略问题,只能在 HTML 层解决。

① WebAudio autoplay 解锁 bevy/cpal 在 App 启动(无手势)就创建 AudioContext,浏览器置为 suspended 且永不 resume → 游戏无声。方案:monkey-patch AudioContext 构造函数,首次 pointerdown/keydown/touchend 时 resume()(Phaser/Unity 同款)。

② 音频质量参数注入 cpal 不传 latencyHint,浏览器默认 'interactive'(~256 帧缓冲),主线程下会 underrun → 爆音。注入 { latencyHint: 'playback', sampleRate: 44100 }。

③ 触摸 → 鼠标重派发 winit web 后端只监听 Pointer Events,pointerType === "touch" 在设备仿真下不可靠。方案:捕获阶段拦截所有非 mouse 的 pointer 事件,重派发为 pointerType:"mouse" 的合成事件。双指规则 —— 第一指 = 左键,第二指 = 右键(打开菜单)。

④ 首次手势全屏 检测 display-mode: standalone/fullscreen,已独立则跳过;否则首次手势 requestFullscreen({ navigationUI:'hide' })。监听器必须注册在捕获阶段,早于触摸转换脚本(后者同阶段 stopPropagation),否则永远不触发。

⑤ Service Worker 立即注册 不挂 load 事件 —— wasm 下载慢,load 会延迟数分钟。二次访问秒开 + 离线,仅 https/localhost 生效。


部署:几个致命的坑

坑后果
用 try_files $uri.br 手动分发把压缩字节当普通文件返回,页面损坏
wasm 以 text/plain 返回浏览器直接拒绝
子路径用 aliasalias + try_files 双重拼接路径,全 404
重编译后忘更新 SRI浏览器整包拒绝 + 白屏

正确姿势:brotli_static on + gzip_static on 让 nginx 协商;显式声明 application/wasm;子路径用 root + location 前缀。

SRI 与 wasm/js 内容强绑定,每次重编译必须重算更新。


排错速查

症状根因
完全无声autoplay policy
音频爆音latencyHint: interactive underrun
白屏 + Failed to fetch ... integritySRI 未更新
itch.io 上整体加载失败缺 .meta 返回 403 被当致命错误
Unable to find a GPU忘并集 Backends::GL
LimitsExceeded设了 WebGL2 priority
脚本加载 0 个wasm 上 load_folder 返回空流
rust-lld SIGBUS目标目录在 tmpfs,配额不足
触摸/仿真点击无效winit touch 链路不可靠
移动端存档丢失std::fs 在 wasm 不可用

总结:移植 Web 端最难的从来不是渲染,而是那些"只有浏览器才有的规矩"。把它们全部推到边界层,引擎核心就能保持干净。

评论

还没有评论,来抢沙发。