引言
Defold 的 HTML5 导出不是「把游戏翻译成 JavaScript」,而是把整个引擎编译成 WebAssembly,再用一层薄薄的 JavaScript 胶水把浏览器 API 接到引擎上。这意味着两件事:好处是跨平台行为高度一致(同一套 Lua 逻辑、同一套渲染管线);代价是包体和内存都比原生大,而且浏览器的资源加载、音频、输入都与原生不同。
本文按「导出 → 理解架构 → 优化包体 → 优化运行 → 集成平台」的顺序展开:先看构建流程与产物结构,再讲 WASM 主循环与 JS 桥的工作方式,然后是归档(Archive)加载与自定义加载页、纹理压缩与资源裁剪、WebGL 与内存的运行时调优,最后是 JS 互操作、音频解锁与离线缓存的工程实践。
前置:跨平台发布 、性能优化 。WebAssembly 的底层机制见 C++ 与 Emscripten 、Web 性能与核心指标 。
1. 导出流程与产物结构
命令行构建(CI 里最常用):
# 用 bob.jar 构建 HTML5(无编辑器环境)
java -jar bob.jar --platform js-web \
--bundle-output build/web \
--variant release \
--archive \
--texture-compression true \
resolve build bundle
# 本地起个静态服务器预览
python3 -m http.server 8080 --directory build/web
产物结构(build/web):
index.html 入口页(含加载页 DOM 与引导脚本)
dmloader.js 加载器:拉归档、初始化引擎、挂载画布
<name>.wasm 引擎本体(WebAssembly)
<name>.js 引擎胶水(Emscripten 生成)
<name>.archive 打包后的游戏资源(--archive 时生成)
<name>.arcd 归档索引(资源清单 + 偏移)
variant 的选择:
| variant | 优化 | 体积 | 用途 |
|---|---|---|---|
debug | 无 | 最大 | 本地开发、断点 |
release | 压缩 + 死代码消除 | 较小 | 正式发布 |
headless | 无渲染 | — | 服务端逻辑测试 |
关键点:--archive 把上千个小资源打成一个 .archive,浏览器只需一次请求;不开归档时每个 .png/.ttx 都是一次 HTTP 请求,加载时间会成倍恶化。
心智:HTML5 导出 = WASM 引擎 + JS 胶水 + 归档资源——
--archive是必开项,否则几百个小文件会拖垮首屏。
2. WASM 主循环与 JS 桥
引擎在浏览器里的运行方式:
浏览器
├── <canvas> 渲染目标(WebGL/WebGL2 上下文)
├── Emscripten runtime 内存、文件系统(MEMFS)、事件循环
└── <name>.wasm 引擎(渲染/物理/脚本 VM)
↑ dmloader.js 负责:拉归档 → 写入 MEMFS → 调 _main()
主循环:引擎用 requestAnimationFrame(rAF)驱动,每帧回调进 WASM 执行 update/render:
rAF 回调 → 引擎 update(dt) → 渲染到 canvas → 回到浏览器合成
浏览器渲染节奏与原生最大的三个差异:
1. rAF 由浏览器调度,切到后台标签页会降频甚至暂停
2. WebGL 上下文可能被系统回收(context lost),必须处理恢复
3. 音频上下文初始为 suspended,需要用户手势才能 resume
上下文丢失的处理(移动端切后台最常见):
-- 监听应用生命周期,切后台时暂停
function init(self)
msg.post("#", "acquire_input_focus")
end
function on_message(self, message_id, message, sender)
if message_id == hash("window_resized") then
-- 处理画布尺寸变化
end
end
工程做法:
- 页面隐藏(visibilitychange)时暂停游戏逻辑,回来时恢复
- WebGL 上下文丢失时,资源需重新上传(引擎内部处理,但要及时停更)
- 不要在隐藏期间跑计时器累积——回来会「时间跳跃」
心智:WASM 引擎靠 rAF 驱动、资源走 MEMFS——切后台会降频、WebGL 上下文可能丢、音频要手势解锁,这三件事是 Web 与原生最大的行为差。
存档在 Web 上的落点:引擎内部的文件系统是内存文件系统(MEMFS),刷新页面即丢。真正的持久化要靠 IDBFS 或直接走浏览器的 localStorage/IndexedDB:
-- sys.save / sys.load 在 Web 上写入的是浏览器持久层(引擎已接好 IDBFS)
sys.save("save1", { level = 3, gold = 1200 })
local data = sys.load("save1")
-- 也可以直接借道页面 JS,把存档放 localStorage(便于跨标签页共享)
function save_to_localstorage(self)
if html5 and html5.run then
local json = require("json").encode(self.save)
html5.run("localStorage.setItem('save1', '" .. json .. "')")
end
end
存档三选一:
sys.save/sys.load 引擎统一 API,Web 上落 IDBFS(推荐)
localStorage 同步、容量小(约 5MB)、跨标签页可见
IndexedDB 异步、容量大、适合大存档
3. 归档加载与自定义加载页
默认加载页是一个进度条,实际项目要自定义(品牌、错误处理、进度文案):
<!-- index.html 里替换默认加载页 -->
<div id="loading">
<img src="logo.png" alt="logo">
<div id="bar"><div id="fill"></div></div>
<p id="tip">加载中…</p>
</div>
<script>
// dmloader.js 暴露的进度回调
Module = {
onProgress: function (loaded, total) {
const pct = total ? (loaded / total * 100) : 0;
document.getElementById('fill').style.width = pct + '%';
document.getElementById('tip').textContent = '加载中 ' + pct.toFixed(0) + '%';
},
onGameLoaded: function () {
document.getElementById('loading').style.display = 'none';
},
onError: function (err) {
document.getElementById('tip').textContent = '加载失败,请刷新重试';
console.error(err);
}
};
</script>
分阶段加载策略:把「首屏必需」和「后续资源」拆开,先让玩家进游戏再后台补:
阶段一(阻塞加载):加载页 + 主菜单资源 + 引擎
阶段二(异步): 关卡资源、音效、大图,进游戏后按需加载
-- 运行时按需加载(HTML5 同样支持 live update 的资源热更)
msg.post("/loader", "load", { resource = "/levels/level_02.collectionc" })
归档配置注意:
- 归档在 game.project → Bundle → Archive 里勾选
- 「Exclude」列表可剔除开发用资源(测试关卡、调试音效)
- 归档不可压缩(已压)——服务器别再对 .archive 做 gzip,浪费 CPU
心智:自定义加载页靠 dmloader 的 onProgress/onGameLoaded 钩子;资源按「首屏必需」与「后续按需」两阶段拆——归档是单次请求的关键,但别对大归档再叠 gzip。
4. 包体优化:纹理与资源裁剪
HTML5 的包体直接决定首屏时间,三个抓手:
4.1 纹理压缩
Web 端纹理格式选择:
- 桌面浏览器:ASTC / DXT(取决于扩展支持)
- 移动浏览器:ASTC / ETC2
- 兜底:未压缩 RGBA(体积最大)
game.project → Graphics → Texture Compression
Texture compression format = Enabled
按平台配置目标格式,HTML5 通常输出「多格式 + 运行时探测」
| 格式 | 每像素位数 | 相对 RGBA | 支持度 |
|---|---|---|---|
| RGBA8888 | 32 | 1x | 全平台 |
| RGB565 | 16 | 0.5x | 全平台 |
| ETC2 | 4 | 0.125x | 现代移动 |
| ASTC | 2~8 | 0.06~0.25x | 现代移动/桌面 |
4.2 资源裁剪
必查项:
- 未使用的图集帧、音效、字体是否被打包
- 大图是否用了合适的分辨率(2K 贴图在手机上是浪费)
- 是否误把「编辑器用」资源(.png 源图)打进包
- 音频:短音效用 ogg(体积小),长音乐考虑流式
# 看归档里到底装了什么(bob 的 --dump 或直接查 arcd)
java -jar bob.jar --platform js-web --archive --dump build/web
4.3 代码裁剪
-- 条件编译:把调试专用逻辑排除在 release 之外
if not release then
-- 仅开发环境:可视化碰撞体、打印日志
end
裁剪清单:
✓ 关闭 release 的 profiler(game.project → Profiler → 关)
✓ 移除只用于调试的 collection proxy
✓ Lua 模块按需 require,别在 main 里 require 全部
✓ 关闭未用的扩展(每个原生扩展都会增加 wasm 体积)
心智:包体三刀——纹理压缩(ASTC/ETC2 省 8 倍)、资源裁剪(删未用与大图)、代码裁剪(去调试与未用扩展);HTML5 上「少 1MB」就是「快几百毫秒」。
服务器侧的配合(包体优化的一半在传输):
- .wasm 用 application/wasm MIME 类型(否则不能流式编译)
- index.html / dmloader.js 开 gzip 或 brotli
- .archive 已经压缩过,别再叠 gzip
- 静态资源设长缓存(Cache-Control: max-age=31536000),文件名带哈希
- 启用 HTTP/2 或 HTTP/3,减少多请求的队头阻塞
# nginx 片段
types { application/wasm wasm; }
location ~ \.wasm$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location ~ \.archive$ { gzip off; add_header Cache-Control "public, max-age=31536000"; }
gzip on;
gzip_types text/html application/javascript text/css;
5. 运行时性能:WebGL 与内存
Web 端的性能瓶颈与原生不同:JS/WASM 边界、GC、WebGL 状态切换更贵。
帧预算:
目标 60 FPS → 每帧 16.6ms
目标 30 FPS → 每帧 33.3ms
Web 上建议按 30 FPS 设计,60 FPS 留给轻量场景
三条 Web 专属优化:
1. 减少 draw call:同图集的精灵合并、避免频繁切材质
2. 减少 WASM↔JS 往返:批量调用,别在每帧里逐对象调 JS
3. 控制内存:浏览器标签页有内存上限,超了直接崩(尤其 iOS Safari)
-- 减少 draw call:同图集精灵靠「渲染顺序 + 同材质」合批
-- 反例:每个敌人换一张独立纹理 → 每个都是一次 draw call
-- 正例:所有敌人共用一张图集,靠 sprite 换帧区分外观
sprite.play_flipbook("#sprite", hash("enemy_" .. self.type))
内存控制清单:
- 贴图是最耗内存的:一张 2048×2048 RGBA = 16MB
- iOS Safari 单标签页内存通常 200~400MB 就危险
- 用完的图集要释放:collection proxy unload 会连带释放其资源
- 音效解码后常驻内存,长音乐用流式播放
-- 关卡切换时卸载上一个集合,释放其独占资源
msg.post("#level_proxy", "unload")
性能剖析:
浏览器侧:DevTools → Performance 看帧耗时与长任务
引擎侧:game.project 开 Profiler,看 update/render 各自耗时
两者对照:定位是「JS 侧卡」还是「引擎侧卡」
心智:Web 性能三件事——少 draw call(合批)、少 WASM↔JS 往返(批量)、控内存(贴图是元凶)——iOS Safari 的内存墙是最常见的线上崩溃来源。
6. 平台集成:JS 互操作与生命周期
Defold 通过「页面挂全局对象 + 引擎调用」的方式与页面通信:
// index.html 里暴露一个全局对象给引擎调用
window.GameBridge = {
onLevelComplete: function (level, score) {
// 上报到后端 / 触发广告
console.log('level', level, 'score', score);
},
getPlayerName: function () {
return localStorage.getItem('player_name') || 'Guest';
}
};
-- 通过 html5.run 调用页面 JS(注意:字符串拼接,注意转义)
local function report_score(score)
html5.run("GameBridge.onLevelComplete(3, " .. tostring(score) .. ")")
end
html5.run 的注意点:
- 参数是「一段 JS 源码字符串」,不是函数调用——注入要转义
- 返回值是字符串,复杂数据用 JSON 序列化
- 只有 HTML5 平台可用,其他平台要用 if 分支或空实现
-- 跨平台安全写法:用条件分支包裹
local function report_score(score)
if html5 and html5.run then
html5.run("GameBridge.onLevelComplete(3, " .. score .. ")")
end
end
音频解锁:浏览器要求「用户手势」后才能播音频:
-- 在「开始游戏」按钮的点击里触发一次声音,解锁音频上下文
function on_input(self, action_id, action)
if action_id == hash("start") and action.pressed then
sound.play("/sounds/ui_click.ogg") -- 首次手势内播放即解锁
msg.post("#", "start_game")
end
end
全屏与缓存:
全屏:html5.run("document.documentElement.requestFullscreen()")
缓存:Service Worker 缓存 index.html 与 wasm(大文件走 Cache Storage)
—— 注意 wasm 更新要改版本号,否则用户拿到旧引擎
// service-worker.js:缓存静态产物
const CACHE = 'game-v3'; // 每次发版改版本号
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) =>
c.addAll(['/index.html', '/dmloader.js', '/game.wasm', '/game.js'])));
});
心智:JS 互操作走「页面挂全局对象 + html5.run 调用」,跨平台要分支包裹;音频必须靠一次用户手势解锁;Service Worker 缓存要改版本号,否则用户永远拿旧引擎。
速查表
| 需求 | 做法 |
|---|---|
| 命令行构建 | bob.jar --platform js-web --archive |
| 打包资源 | --archive(必开) |
| 本地预览 | python3 -m http.server(勿用 file://) |
| 自定义加载页 | Module.onProgress / onGameLoaded |
| 纹理压缩 | game.project 开 Texture Compression + ASTC/ETC2 |
| 减 draw call | 同图集合批、统一材质 |
| 控内存 | unload 不用的 collection proxy |
| 调页面 JS | html5.run("GameBridge.xxx(...)") |
| 音频解锁 | 首次点击里播一次音效 |
| 离线缓存 | Service Worker + 版本号 |
| 性能定位 | DevTools Performance + 引擎 Profiler 对照 |
一句话记忆:Defold HTML5 = WASM 引擎 + JS 胶水 + 归档资源——--archive 必开、加载页走 dmloader 钩子;包体三刀(纹理压缩/资源裁剪/代码裁剪)、运行三招(合批/少往返/控内存);互操作挂全局对象用 html5.run、音频靠手势解锁、缓存改版本号——Web 上「小一点、少一点、稳一点」就是性能。
小结
Defold 的 HTML5 导出把「跨平台一致性」和「Web 性能」放在天平两端:引擎是 WASM,逻辑与渲染行为跟原生几乎一致,但包体、内存、加载方式都要重新设计。落地时抓住四条:一是构建永远带 --archive,并自定义加载页给出进度与错误兜底;二是包体先优化纹理(ASTC/ETC2 能把贴图压到十分之一),再裁资源与调试代码;三是运行时优先减 draw call 与控内存,iOS Safari 的内存墙必须提前评估;四是平台集成走「页面全局对象 + html5.run」并做跨平台分支,音频记得在首次用户手势里解锁。做到这四点,Defold 的 Web 版就能达到「可上线」的品质。
延伸阅读
- 跨平台发布流程
- 性能优化总览
- C++ 与 Emscripten — WASM 编译与 JS 胶水的底层
- Web 性能与核心指标 — 首屏与交互性能的度量方法
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。