Defold HTML5 导出与 Web 性能优化

完整拆解 Defold 的 HTML5 导出:构建流程与 build/html5 产物结构、WASM 主循环与 JS 桥接机制、归档(Archive)资源加载与自定义加载页、纹理压缩与包体裁剪、WebGL 上下文与内存的运行时优化,以及 JS 互操作、音频解锁与离线缓存的平台集成。

引言

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支持度
RGBA8888321x全平台
RGB565160.5x全平台
ETC240.125x现代移动
ASTC2~80.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
调页面 JShtml5.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 版就能达到「可上线」的品质。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 行为树与游戏 AI 决策
  2. Defold 材质与着色器语言详解
  3. Defold GUI 响应式布局与多分辨率适配