引言
「对象池」是游戏开发里被引用最多的优化手段之一,但它在 Defold 里的地位和 Unity、Cocos 完全不同。Defold 官方文档写得很直白:引擎已经在底层做了对象池,自己再包一层池只会更慢。所以这篇文章不会给你一个照搬过来的对象池类,而是先说清 Defold 的内存模型,再指出真正值得池化的两个地方:Lua 表,以及资源加载时机。
目录
- 1. 先破除一个误解
- 2. Defold 的内存构成
- 3. 工厂与动态加载
- 4. 什么该池化什么不该
- 5. Lua 表池实战
- 6. 资源生命周期与引用计数
- 7. 组件计数与 HTML5 堆
- 8. 泄漏排查清单
- 9. 速查表
- 相关阅读
- 延伸阅读
1. 先破除一个误解
Defold 官方 Factory 手册的最后一节标题就是「Pooling of game objects」,原文结论是:
It may seem like a good idea to save spawned game objects in a pool
and reuse them. However, the engine is already doing object pooling
under the hood so additional overhead will only slow things down.
It is both faster and cleaner to delete game objects and spawn new ones.
翻译过来:把 factory.create() 出来的 game object 存起来复用,看起来能省,实际上引擎内部已经池化了,你的池只是额外一层开销。正确做法是直接 go.delete(),需要时再 factory.create()。
那为什么还有「对象池」这个话题?因为真正的开销不在 game object 的创建销毁,而在下面三处:
1. Lua 表与字符串的频繁创建 —— 触发 GC 抖动
2. 资源首次加载 —— 造成掉帧卡顿
3. 组件计数配置过大 —— 常驻内存浪费
本文的「池化」指的是针对这三处的优化,而不是给 game object 套池。
2. Defold 的内存构成
1. 四个内存区域
引擎与资源 引擎代码、纹理、声音、字体等,由资源树决定
对象与组件 集合创建时按组件计数一次性预分配
Lua 堆 脚本里的表、字符串、闭包,由 Lua GC 管理
图形缓冲 顶点、索引、后备缓冲,受分辨率与 high_dpi 影响
其中对象与组件是预分配的:集合创建时就按 game.project 里的各项 max_count 分配好,之后不再增长。这是 Defold 内存占用看起来「一上来就很高」的原因,也是调优收益最大的地方。
2. 度量手段
-- Lua 堆用量,单位 KB
local kb = collectgarbage("count")
print(string.format("lua heap: %.1f KB", kb))
-- 主动触发一次 GC(谨慎使用,会卡顿)
collectgarbage("collect")
-- HTML5 平台看 wasm 堆
if html5 then
print(html5.run("HEAP8.length") / 1024 / 1024)
end
更完整的视角用 Profiler:在游戏里按 F1 打开,看 Game objects、Components、Lua memory、Textures 等分项。先量再调,不要凭感觉改数字。
3. 工厂与动态加载
1. 默认加载时机
工厂组件引用的资源,默认在工厂组件被加载时就一并进入内存。这意味着只要集合里有这个工厂,资源就一直占着,即使一个都没生成。
-- 普通工厂
local id = factory.create("#bullet_factory", pos, nil, { damage = 10 }, 1.5)
-- 批量创建后统一删除
local ids = {}
for i = 1, 20 do table.insert(ids, factory.create("#enemy_factory")) end
go.delete(ids) -- 也接受表,一次性删掉
2. Load Dynamically
勾选工厂的 Load Dynamically 后,资源不再随组件加载,改由你控制时机:
-- 同步:第一次 create 时才加载,会造成一次卡顿
function init(self)
self.go_id = factory.create("#bullet_factory")
end
-- 异步:显式 load,加载完回调里再生成,不卡帧
local function load_complete(self, url, result)
self.go_id = factory.create(url)
end
function init(self)
factory.load("#bullet_factory", load_complete)
end
function final(self)
go.delete(self.go_id)
factory.unload("#bullet_factory") -- 减引用,无人引用时真正卸载
end
这才是「对象池」在 Defold 里的正确形态:不是池 game object,而是控制资源的加载与卸载时机。子弹、敌人这类同质资源,在进入战斗场景时 load,离开时 unload。
踩坑:
factory.unload()只减少工厂组件持有的那一份引用。如果还有create出来的实例活着,资源不会真正卸载。先删实例,再 unload。
3. 动态原型
勾选 Dynamic Prototype 后可以运行时换原型,代价是该集合的组件计数优化失效,只能用 game.project 的默认上限:
factory.unload("#factory")
factory.set_prototype("#factory", "/main/levels/enemy_b.goc")
local enemy_id = factory.create("#factory")
4. 什么该池化什么不该
| 对象 | 该不该池化 | 原因 |
|---|---|---|
Game object(factory.create) | 不该 | 引擎内部已池化,多一层更慢 |
| Lua 表(子弹数据、粒子状态) | 该 | 频繁创建触发 GC |
| Lua 字符串(拼接的 key) | 该 | 用 hash() 预计算,别每帧拼 |
| 资源(图集、声音) | 该 | 控制 load / unload 时机 |
物理形状(physics.set_shape) | 谨慎 | 运行时改形状代价高 |
| GUI 节点 | 不该 | 节点数应在 .gui 文件里定死 |
判断标准很简单:如果创建动作会触碰 Lua GC 或触发资源加载,就值得优化;如果只是 factory.create,就交给引擎。
5. Lua 表池实战
1. 子弹池
子弹每帧都在生成,如果每次都建表,GC 压力会很明显。用一个简单的表池把对象复用起来:
-- bullet_pool.lua
local M = {}
local pool = {}
function M.get()
local b = table.remove(pool)
if not b then
b = { x = 0, y = 0, vx = 0, vy = 0, active = false }
end
return b
end
function M.release(b)
b.active = false
pool[#pool + 1] = b
end
return M
使用侧注意释放后立刻清引用,否则池子里的表被外部继续持有,等于没回收:
local bullet_pool = require("bullet_pool")
function update(self, dt)
for i = #self.bullets, 1, -1 do
local b = self.bullets[i]
b.x = b.x + b.vx * dt
if b.x > 2000 then
bullet_pool.release(b)
table.remove(self.bullets, i) -- 关键:从活跃列表移除
end
end
end
2. 用 hash 代替字符串
每帧拼字符串是隐形的 GC 大户:
-- 不好:每帧新建字符串
local key = "hp_" .. self.id
self[key] = self.hp
-- 好:预计算 hash,或直接用数值索引
local KEY_HP = hash("hp")
self[KEY_HP] = self.hp
3. 别过度优化
池化本身也有成本:多一次查表、多一份状态管理、更容易写出「用了已释放对象」的 bug。先用 Profiler 确认 Lua 堆确实在抖动,再上池。 大多数休闲游戏的 Lua 分配根本不构成瓶颈。
6. 资源生命周期与引用计数
1. 引用计数规则
Defold 对每个资源维护引用计数,计数归零就自动卸载:
- 集合加载时,集合内直接引用的资源计数 +1
- 工厂 / 集合工厂持有的原型资源计数 +1
- 动态加载的资源在 load 后计数 +1
- go.delete 掉实例会减少对应计数
- factory.unload 减少工厂持有的那份引用
所以「删掉所有实例 + 删掉持有工厂的对象」= 资源自动卸载,不需要手动干预。
2. 手动加载资源
对于 game.project 里 Custom Resources 声明的文件,用 sys.load_resource() 读取;对于图集、声音等资源,用 resource.load / resource.release:
local path = "/assets/level1.atlas"
resource.load(path, { type = resource.ATLAS }, function(self, path, result)
if result then
self.atlas = resource.get(path, "atlas")
end
end)
-- 不再需要时释放引用
resource.release("/assets/level1.atlas")
3. 集合代理才是重量级
真正的大块内存是游戏世界。集合代理(Collection Proxy)会创建一个全新的世界,含独立的物理世界,开销远大于工厂:
msg.post("#level_proxy", "load") -- 异步加载
msg.post("#level_proxy", "unload") -- 卸载整个世界
用集合代理切换关卡时,务必在 unload 之后才 load 下一个,否则两个世界会同时驻留内存。标记为 Exclude 的集合代理可以从包体里剔除,配合 Live Update 从云端下载——这是控制首包体积的关键手段。
7. 组件计数与 HTML5 堆
1. 组件计数
集合创建时按 game.project 的上限一次性分配组件与资源内存。默认值往往远大于实际用量:
sprite.max_count 每集合最大精灵数
gui.max_nodes 每集合最大 GUI 节点数
light.max_count 最大光源组件数,默认 64
collection.max_instances 每世界最大 game object 数
调优方法:跑一遍最重的关卡,用 Profiler 读出各分项的真实峰值,把上限设成「峰值 + 少量余量」。注意 collection.max_instances 是每个世界的 game object 总数上限,含编辑器摆放的和运行时生成的;如果集合里有工厂,构建期无法推断确切数量,只能用这个值。
踩坑:
Dynamic Prototype一开,该集合的组件计数分析全部失效,只能用默认上限。为省内存而开动态原型的项目,往往适得其反。
2. HTML5 堆
[html5]
heap_size = 64
-- 在游戏里监控
if html5 then
print(html5.run("HEAP8.length") / 1024 / 1024)
end
小游戏 32 MB 可达,较大的游戏瞄准 64~128 MB,取 2 的幂。浏览器控制台里输入 HEAP8.length / 1024 / 1024 也能看。堆设太小会在运行时直接崩溃,设太大则白白占用内存。
3. GUI 节点数
.gui 文件里设置 Max Nodes 只给实际需要的量。属性面板的 Current Nodes 会显示当前用到的节点数,照着它调。
8. 泄漏排查清单
现象:内存只涨不降
检查:
1. 是否有表一直往 self.xxx 里 insert 却从不 remove
2. 是否注册了 timer / listener / 订阅却忘了取消
3. 是否 go.delete 了对象但外部仍持有其 id 或数据引用
4. 是否 load 了资源却没有对应 release / unload
5. 集合代理是否 load 了新的却没 unload 旧的
6. 是否每帧都在新建闭包或字符串
排查工具:
-- 定时打印 Lua 堆,观察是否单调上升
timer.delay(5, true, function(self, handle, time_elapsed)
print("lua kb:", collectgarbage("count"))
end)
如果 Lua 堆稳定但进程内存涨,问题在资源侧(纹理、声音、集合代理);如果 Lua 堆持续上涨,问题在脚本侧。先分清是哪个堆,再去查对应清单,能省掉大量无效搜索。
9. 速查表
| 需求 | 做法 | 备注 |
|---|---|---|
| 生成对象 | factory.create("#f", pos, rot, props, scale) | 别自己池化 |
| 删除对象 | go.delete(id) / go.delete(ids_table) | 支持传表批量删 |
| 延迟加载资源 | 工厂勾 Load Dynamically + factory.load | 回调里再 create |
| 卸载资源 | factory.unload("#f") | 先删实例 |
| 换原型 | factory.set_prototype | 会禁用计数优化 |
| 加载单资源 | resource.load(path, {type=...}, cb) | 配套 resource.release |
| 自定义资源 | sys.load_resource(path) | 需在 game.project 声明 |
| 切关卡 | 集合代理 load / unload | 别同时驻留两个世界 |
| 看 Lua 堆 | collectgarbage("count") | 单位 KB |
| 看 HTML5 堆 | html5.run("HEAP8.length") | 除以 1024 得 MB |
| 调组件上限 | Profiler 读峰值后改 game.project | 一次性预分配 |
| 表池 | 自建 pool 表 + table.remove/insert | 释放后必须清引用 |
一句话记忆:Defold 里不要给 game object 套池,引擎已经做了;真正要池化的是 Lua 表和资源加载时机,真正要调的是组件计数上限——按「对象/组件 → Lua 堆 → 资源」三层分开量、分开调。
相关阅读
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。