做游戏引擎架构设计,越到后面越会发现一个绕不开的规律:渲染、物理、动画这些子系统再华丽,执行时都依赖我们怎么组织游戏对象、怎么管理它们引用的资源。今天这篇是游戏引擎架构深度解析系列的第四篇,主题就是游戏对象与资源管理。前几篇解决的是“一帧画面怎么出来”,这一篇要解决的是“游戏世界里的实体如何活着、复用、死去,以及它们手里的贴图、模型、音频文件如何高效地进出内存”。无论你是自己写轻量引擎,还是在 Unity、Unreal 这类商业引擎底层做框架定制,理解对象和资源的管理方式,都能帮你定位卡顿、控制内存峰值、减少莫名其妙的崩溃。文章会从对象模型讲起,再深入资源管线的完整链路,最后分享一些实际工程中踩过的坑和排查思路。
1. 游戏对象的本质:从继承树到组件化的演进
1.1 为什么传统继承模型撑不起复杂游戏
早期游戏对象设计很直观:飞机是一个类,敌人是一个类,玩家是一个类,通过继承共享逻辑。Character继承Entity,Player继承Character,Enemy也继承Character,这看起来很合理。但随着玩法组合越来越复杂,继承树开始变形。
举个例子,你有一个FlyingEnemy,它需要飞行逻辑,于是你从FlyingCharacter继承。接着游戏里出现了一颗追踪导弹,它也需要飞行逻辑,但它压根不是Character,它没有血条、没有脚步声、没有动画状态机。这时你怎么办?把飞行逻辑提到Entity里?那所有静态物体也会被强制拥有飞行数据。用多继承?又会引入菱形继承、虚基类、接口冲突一大堆问题。
更麻烦的是运行时变化。继承关系在编译期就固定了,但游戏里一个对象在运行时可能被附加“燃烧”“冻结”“隐身”“可被拾取”等任意状态。用继承模型表达这些临时能力,要么事先穷举所有组合,要么频繁创建子类,代码膨胀速度会超出预期。
所以现代引擎基本抛弃了“一个类描述一个实体”的做法。Unity 里GameObject本身没有复杂的游戏逻辑,它是一个挂在场景里的容器,真正干活的是挂在上面的MonoBehaviour组件。Unreal 里虽然保留了较强的 C++ 类继承,但 Actor 内部也大量使用组件(UActorComponent)来组合能力。这个转变的本质是:把“对象是什么”从继承树上解耦,改为“对象拥有什么”的组合模型。
1.2 组件模式与 ECS 的核心思路
组件模式的核心有两点:一个轻量的对象外壳,和一组可任意挂载的组件。对象外壳负责管理组件的生命周期、访问接口、消息派发;组件之间尽量不直接互相引用,而是通过外壳转发或者通过特定接口通信。
比如一个子弹对象:
TransformComponent存位置、旋转、缩放MeshRendererComponent负责渲染ColliderComponent负责碰撞ProjectileMoveComponent负责每帧更新位置- 生命时间到了,由外壳统一销毁
这样的好处是复用性强。你需要一个新的“会追踪的导弹”,不必新建类,只要给普通子弹对象挂一个HomingComponent,再配置一下参数就能实现。组件还可以跨项目复用,比如TransformComponent在角色、敌人、NPC、摄像机上都一样。
ECS(Entity-Component-System)是组件模式的进一步激进版。在 ECS 里,Entity 退化成纯 ID,Component 退化成纯数据,System 负责所有逻辑。这是对 CPU 缓存的极度友好:同一类组件是连续内存数组,处理一万个Position组件时能近乎线性遍历,而面向对象写法是在一万个对象里跳跃访问。
我建议这样理解区别:
| 模型 | 对象 | 数据 | 逻辑 | 适合场景 |
|---|---|---|---|---|
| 传统继承 | 类的实例 | 成员变量 | 类方法 | 对象类型少、关系稳定 |
| 组件模式 | 对象外壳 | 组件实例 | 各组件方法 | 对象能力组合较多、需要运行时调整 |
| ECS | 纯 ID | 多组连续数组 | 独立 System | 同类型对象数量巨大、追求性能上限 |
ECS 并不是银弹。如果项目对象数量不多、组合需求少,强行引入 ECS 反而会增加代码复杂度。但像很多独立引擎和帧同步战斗项目,ECS 对性能的收益是实打实的。
1.3 场景图与 Transform 层级:对象间关系的组织方式
游戏对象之间通常存在父子关系。角色的武器挂在手骨节点下,武器会跟随手的骨骼运动;一辆汽车沿着路径行驶,车上的乘客相对车体保持固定姿势。这种层级关系构成一棵场景树,每个节点都有局部坐标和世界坐标。
场景图的关键是矩阵变换的传播。父节点移动,子节点的世界矩阵需要重新计算。直接的做法是:每帧从根节点向下遍历,所有节点的世界矩阵全部重算。对象数量几十个时无所谓,但大型场景、密集人群、复杂机械结构下,这种全量遍历会浪费大量 CPU。
因此引擎层面会做脏标记(Dirty Flag)。父节点矩阵变化时,只标记子节点需要更新,下一帧访问到哪个节点,再按需计算。这个机制听起来简单,但实际坑很多:比如判断“某个节点是否需要更新”时,必须考虑它的祖先是否也有脏标记;又比如渲染线程可能在主线程更新矩阵的同时读取世界矩阵,需要做双缓冲或加锁。
有些引擎开始在 ECS 中管理 Transform。组件表里存LocalPosition、LocalRotation、LocalScale和ParentId,系统按层级批量计算世界矩阵。这样既保留了父子变换的便利,又获得了数组遍历的性能。在我的实践经验里,游戏对象管理最重要的三条就是:ID 要稳定、组件访问要快、层级变换要可控。
2. 资源管理的范畴与资产管线设计
2.1 “资源”到底是什么:文件、导入产物与运行时对象
很多初学者以为资源管理就是“把图片从硬盘读进内存”。实际上,一个资源从磁盘到能参与渲染,至少经过三个阶段。
第一阶段是源资产(Source Asset)。美术在 Maya、Blender、Photoshop 里制作的文件,比如.fbx模型、.png贴图、.wav音频。这些文件体积大、格式杂、不适合运行时直接加载。
第二阶段是导入资产(Imported Asset)。引擎通过导入器把源资产转成内部格式:网格会拆成顶点缓冲、索引缓冲,并生成 LOD;贴图会压缩成 ASTC、BCn 或者 ETC2;音频会转成 Ogg Vorbis 或 ADPCM;材质会序列化成统一的资源对象。导入过程还会生成依赖信息、缩略图、导入设置等元数据。
第三阶段是运行时对象(Runtime Object)。当场景真正需要这个资源时,引擎把导入资产读入内存,创建纹理对象,上传 GPU 显存,生成MaterialInstance,这时资源才真正被游戏逻辑持有。
搞清楚这三个阶段,你才能理解很多优化手段的原理。比如“贴图内存占用高”可能是源贴图尺寸太大,也可能是导入时没开压缩,还可能是运行时生成的 mipmap 层级太多。不同层级的优化手段完全不同。
2.2 从磁盘到显存:资源导入与压缩管线的基本流程
资源导入管线的标准流程大致是:
- 检出源文件变化,或者用户手动点击导入。
- 导入器解析源文件格式。FBX 导入器会把模型、蒙皮、动画剪辑全部拆出来;PNG 导入器会解析像素数据。
- 执行导入设置。包括归一化坐标轴、生成法线、计算切线、生成 LOD、设置纹理压缩格式、生成 mipmap、设定 sRGB。
- 序列化到引擎资源包(AssetBundle、Pak、普通的二进制文件),生成资源 GUID 和依赖表。
- 导入完成后,资源系统索引更新,编辑器里的资源面板刷新。
其中纹理压缩是最容易出问题的一步。移动端同一个 RTT 格式,不同 GPU 支持度不一样;有些格式需要 4 的倍数尺寸,不是的话引擎得自动补边或者重采样;sRGB 开错会导致整个场景亮度过高或过低。我见过不少项目把 iOS 的 ASTC 格式硬套到 Android 低端机上,结果黑屏、花屏、驱动崩溃一起爆。
打包管线紧接着导入流程。引擎会收集资源依赖,把多个资源打包进同一个 Bundle 或 Pak。打包的最小单位不是单个文件,而是一组有依赖关系的资源图。这里有一个非常重要的结论:打包粒度决定加载粒度。如果你不小心把一个 2GB 的角色模型和一个小图标打进了同一个包,那么加载图标时也要承受解压 2GB 资源的代价。
2.3 避免硬编码路径:GUID、依赖表与资源注册中心
早期引擎经常用字符串路径加载资源:“Models/Character/hero.fbx”。这在一人项目里简单直接,但在多人协作里是一场灾难。文件重命名、目录迁移、大小写不一致、不同平台路径分隔符、打包后路径变化,任何一个都可能导致加载失败。
商业引擎普遍的做法是引入 GUID。每个源资产在导入时分配一个全局唯一 ID,运行时资源表里记录 GUID 与导入产物文件路径的映射关系。代码里引用资源时不是直接写路径,而是引用一个由 GUID 生成的强类型资源引用(比如 Unity 里的AssetReference、Unreal 里的TSoftObjectPtr)。这样即使文件被移动,只要 GUID 不变,引用依然有效。
资源依赖表同样重要。一个场景引用的模型,模型引用材质,材质引用贴图,贴图又引用纹理导入设置。如果没有依赖表,打包者和运行时都没法知道这一堆资源该一起加载。引擎的资源注册中心通常以一张资源图的形式存在,每个资源节点都有入边和出边。打包工具从根资源出发,深度优先遍历所有依赖,生成一个闭包集合。
我踩过的坑是:某个纹理同时被 UI 和 3D 场景使用,但因为导入设置不同,被打包成两份完全不同的纹理资源。虽然 GUID 不同,但底层源文件相同,导致包体白白增加几十 MB。后来我们在依赖分析工具里增加了一个“源文件指纹”检查,两个资源如果指向同一个源文件且只有压缩参数不同,会给出警告。
3. 资源生命周期:引用计数、异步加载与卸载策略
3.1 引用计数与资源句柄:谁持有资源,谁负责释放
资源管理最核心的难题不是“怎么加载”,而是“什么时候释放”。你不能粗暴地在一个对象销毁时立刻删除它引用的贴图,因为同一个贴图可能还被另外几百个对象引用着。
所以引擎资源系统普遍使用引用计数。每次成功加载一个资源,计数器加一;每个持有资源的对象销毁时,计数器减一;计数器归零时,标记为“可释放”。为了避免悬垂指针,调用方一般不直接持有资源裸指针,而是持有一个句柄(Handle)。句柄可以理解成资源表里的一个索引项,真正的资源指针存在表里。句柄有效时,资源一定还在内存中;资源释放后,再访问句柄会得到明确的“无效”错误,而不是直接崩溃。
这里要注意强引用和弱引用的区别:
- 强引用:句柄持有期间资源不会被卸载,适合游戏逻辑直接持有的资源。
- 弱引用:记录“我可能想用”,但不阻止资源卸载。资源被释放后,弱引用自动失效,适合编辑器监视器、调试工具、缓存系统。
实际开发中,资源泄漏绝大多数是强引用没有按预期释放。协程持有了加载句柄、事件的回调闭包捕获了资源对象、UI 界面销毁时没有注销资源监听,这些都是常见泄漏点。
3.2 异步加载的线程模型与回调生命周期
游戏逻辑不能因为读取磁盘而卡住。你不可能在敌人波次进攻时,为了读一个模型把主线程停 200 毫秒。所以资源加载必须异步化。
典型的异步加载流程分四条线:
- 主线程发出加载请求。引擎收到请求后,把任务加入 IO 线程池,同时注册回调。
- IO 线程读取文件。这一步只做磁盘读取,拿到的是压缩后的二进制块。
- 后台 worker 线程反序列化。解压、解析资源格式、生成 CPU 数据(顶点数组、像素数据)。
- 主线程提交 GPU 资源。图形 API 要求纹理、缓冲区的创建和上传一般在主线程或指定的渲染线程完成,不能随随便便在 IO 线程调用。
所以异步加载并不是“全部放到后台线程”,而是“把耗时部分放到后台,把必须与图形上下文相关的部分留在主线程”。你看到加载界面时,主线程可能在处理一批资源的上传和场景初始化,IO 线程在并行读取下一批。
回调生命周期是这里最脏的角落。你发出去一个异步加载请求,等资源加载完成时,发起请求的那个对象可能已经被销毁了。如果回调里直接操作这个对象,轻则访问无效内存,重则程序崩溃。解决办法是给每次加载请求生成一个令牌(Token)。对象销毁时,把令牌标记为失效;加载完成回调里先检查令牌,无效就直接丢弃结果。另一种做法是用弱引用来持有发起者,回调时先lock,拿不到说明对象已死。
3.3 卸载决策:何时真正释放资源,何时只是丢弃缓存
很多人以为“引用计数归零就应该立刻释放资源”。从内存确定性角度看没错,但从性能角度看不一定。GPU 资源的创建开销很大,反复加载卸载同一个贴图,会因为纹理上传、缓存未命中造成明显的卡顿。因此资源系统通常会有一层缓存。
缓存的常见策略包括:
- 常驻资源:字体、UI 图集、全局特效使用的材质,加载后常驻不卸载。
- LRU 缓存:近期使用过的资源保留一个固定大小缓存池,超时或超预算后按最久未使用顺序淘汰。
- 按包卸载:以关卡、场景、大区为边界,整体加载一批资源,离开时整体卸载。
还要考虑“延迟卸载”。因为渲染线程可能还在使用某个资源,主线程逻辑里已经没有引用了,但渲染命令队列里还有已经提交的绘制指令。如果在主线程立刻删除,渲染线程后续取到的资源就是悬垂的。安全做法是标记为“待释放”,等渲染线程消费完这一帧或几帧的指令后,再真正回收内存。我曾经遇到过一个帧率掉到个位数的问题,最终定位到是纹理没有延迟释放,渲染线程每帧都要处理大量失效资源。
4. 对象池与实例化优化:高频对象创建的性能陷阱
4.1 new 一个对象很贵:内存分配、构造函数与引擎注册
游戏里子弹、敌人、飘字、特效、掉落物都是高频创建的对象。如果每次都直接new,会有三笔成本:
第一,内存分配。C++ 下new和delete涉及堆分配器锁、空闲链表遍历、内存碎片;C# 下频繁分配类对象会加大托管堆压力和 GC 频率。即便现代内存分配器快了很多,高频调用依然不是免费的。
第二,构造函数和初始化逻辑。一个子弹对象可能要申请物理体、注册碰撞组件、设置层遮罩、加载特效、启动协程。这些操作比单纯分配内存更贵。
第三,引擎注册。在 Unity 里调用Instantiate不仅仅是 new 一个类,它要创建 GameObject、Awake、OnEnable、初始化 Transform、向场景管理器注册;Unreal 里SpawnActor也要经过完整的 Actor 初始化流程。把这一整套流程放到每帧 500 次子弹生成里,CPU 功耗会非常可观。
对象池的思路很简单:提前分配好一批实例,需要时取出复用,用完归还,而不是销毁重建。真正规避的是上述三笔成本的叠加。
4.2 对象池的完整实现范式
一个可复用的对象池至少要有这几个部分:
- 池容器:通常用栈或队列,保存当前空闲对象。栈适合 LIFO,刚释放的对象很可能还是热的,能更好地利用 CPU 缓存。
- 获取操作 Get:从池里弹出一个对象,
SetActive(true),重置状态,重新挂到场景层级下,然后返回给调用方。 - 释放操作 Release:把对象
SetActive(false),清空引用、停止协程、重置位置,然后压回池。 - 预热操作:游戏启动或关卡加载时,预先创建一批对象到池里,避免战斗时边创建边分配。
- 上限控制:池不能无限膨胀,需要设置最大容量。超出容量的对象要么直接销毁,要么扩容但记日志。
在 C++ 里,池对象通常用unique_ptr管理,避免裸指针误释放;在 C# 里,池容器直接持有 GameObject 引用,同时要防止对象在池里仍被其他逻辑引用。防止“双重入池”也很重要,释放方法开头要检查对象是否已经在池里。
一个常用的伪代码结构:
public class ObjectPool<T> where T : class { private Stack<T> _free = new Stack<T>(); private Func<T> _factory; private Action<T> _onGet; private Action<T> _onRelease; public T Get() { T item = _free.Count > 0 ? _free.Pop() : _factory(); _onGet?.Invoke(item); return item; } public void Release(T item) { if (_free.Contains(item)) return; // 防止重复入池 _onRelease?.Invoke(item); _free.Push(item); } }注意这里用Stack.Contains判断重复可能性能不佳,生产级实现可以给每个对象加一个isInPool标记。
4.3 典型案例:子弹、敌人、特效与 UI 列表
子弹是最典型的池应用。射击游戏每秒可能生成几十颗子弹,每颗子弹只会存活几百毫秒。如果每次生成都Instantiate,再让子弹命中后Destroy,内存碎片和 GC 压力会非常明显。用对象池后,子弹从出生到死亡只是状态变化,不会触发引擎的创建销毁流程。
敌人波次也可以池化。同一个敌人预制体在波次结束后不销毁,而是重置到待机状态,下一波再次激活。这样敌人身上的引用、动画状态、AI 组件都不会反复重建,表现是加载下一波变快、卡顿减少。
特效和飘字系统尤其需要池。伤害数字、受击特效、脚步尘土这些对象数量大、生命周期短、显示时间可能只有几十帧。每次生成和销毁的 CPU 开销不值得,池化后配合延迟隐藏,几乎零成本。
UI 列表的虚拟化本质上也是对象池的一种变体。长列表滚动时,不创建数百个 UI 控件,而是只创建可视区域内的少量控件,滚动时不断复用和重新绑定数据。这和游戏对象复用的思路一致,只是被绑定的数据源不同。
5. 流式加载与世界分区:大型游戏的资源调度
5.1 开启大世界后,一次性加载行不通
早期关卡式游戏可以在关卡加载时把所有资源全部读进内存。但开放世界、大场景、无缝地图这种模式彻底改变游戏:地图可能是几十平方公里,美术资源总量有几十 GB,内存根本放不下,磁盘也不可能一次性读完。
所以引擎必须把世界切分成多个区块,只加载玩家附近的区块,卸载远离的区块。这个机制在不同引擎里名字不同,核心思路是一样的:空间分区 + 流式加载。
空间分区可以选择均匀网格、四叉树、八叉树或基于地形的 Cell。每个区块对应一个资源包,包含该区域的地形数据、静态网格、贴图、NPC 配置文件、触发事件数据。当玩家跨越区块边界时,引擎动态计算应该加载哪些新区块、卸载哪些旧区块。
区块大小的设计要非常讲究。太小,玩家走几步就要触发加载,加载频繁,容易穿帮;太大,单个区块资源量高,加载时间长,内存占用大。我做过一个项目,最初区块边长 128 米,城市里走 10 秒就跨区,加载队列天天爆满。后来调到 256 米,配合预加载,情况立刻好转。
5.2 加载优先级与带宽预算:先加载什么,后加载什么
流式加载最大的问题是“玩家移动速度可能超过加载速度”。尤其骑乘坐骑、高速载具、瞬移技能,会瞬间产生大量区块加载需求。这时候如果没有优先级策略,同一个帧里可能同时排队了 50 个区块请求,磁盘和 IO 线程直接被塞满。
实际工程里,加载优先级通常由几种因素综合决定:
- 距离:离玩家越近的区块,优先级越高。
- 可见性:玩家视野方向上的区块,比身后的区块更重要。
- 内容类型:地形和道路应该最先加载,其次是不可或缺的建筑和碰撞数据,最后才是植被、贴花、装饰物。
- 当前状态:如果玩家正在战斗,战斗区域的动态对象优先级要高于远处地面纹理。
线程池会给高优先级任务分配更多线程资源,或者用一个按优先级排序的队列。但无论怎么排,都得设置带宽预算。这里带宽指的是每秒最多处理多少 MB 的解压和加载数据。预算过低会频繁穿帮,预算过高则单帧卡顿。稳妥的做法是:每帧用固定的时间片处理加载队列,比如 4~6 毫秒;剩余加载任务下一帧继续。
卡顿还有一个隐蔽来源:资源解压和反序列化在后台线程做没问题,但上传 GPU 资源到显存时必须送去渲染命令。如果同一帧提交太多新贴图,GPU 和驱动会被打爆。流式加载时要限制每帧最多上传多少个纹理、多少 MB 顶点数据,超过的顺延到后续帧。
5.3 预加载、关卡切换与资源冒烟
流式加载不能只被动响应玩家位置。好的体验需要在玩家靠近边界前,提前加载下一区块。这个“预加载距离”必须明显大于卸载距离,否则你走到边界时会看到材质慢慢弹出的现象。
预加载还要跟关卡切换联动。玩家进入新关卡时,不能什么资源都没有就黑屏跑起来。通常做法是:
- 关卡切换前,收集目标关卡所有根资源及依赖资源清单。
- 显示 Loading 界面,同时启动资源加载任务。
- 等必须资源加载完成(地形、关键 prefab、UI 图集、基础材质),再初始化场景。
- 非关键资源(远处花花草草、装饰音效)在场景激活后按需异步补齐。
关卡切换后最重要的是清理旧资源。切完场景立刻把上一关的资源包全部卸载会带来风险,因为渲染线程可能还持有上一帧的绘制指令。实践上要做一个“延迟清理队列”,把旧关卡资源标记为待释放,等渲染信号同步后再真正卸载。
资源冒烟(Smoke Test)是QA常做的事:把所有关卡按顺序强制加载一遍,检查是否有资源缺失、包损坏、依赖遗漏。这个测试必须在打包流程里自动化,否则发布前才发现某个边角资源没打进去,会很被动。
6. 踩坑实录:这些年在对象与资源管理上翻过的车
6.1 引用计数泄漏:内存只升不降的排查链路
有一次我们游戏长时间运行会越来越卡,内存曲线一路向上。用 Profiler 看,Managed Heap 没有明显异常,Native 内存中纹理和网格数量却在持续增长。
排查过程是这样的:先在全引擎里加了资源对象计数,每次加载某个资源打点,每次释放打点。运行半小时后对比发现,战斗关卡中某个怪物模型的引用计数一直在增加。最终定位到,怪物死亡后,技能系统释放特效时异步加载了模型,但加载完成时的回调闭包里捕获了已销毁敌人的状态对象,状态对象又引用了模型资源。回调没人清理,导致资源一直被强引用,永远不会释放。
解决方法是:凡是异步回调,发起者必须持有加载令牌;对象销毁时令牌失效,回调直接丢弃。同时,所有资源持有方应该统一走句柄系统,而不是裸指针加引用计数。
6.2 异步加载回调时对象已被销毁
还有一个高频崩溃,发生在玩家快速切换场景时。比如玩家点击进入下一个传送门,旧场景里的 NPC 正在异步加载贴图。贴图加载完成后,回调尝试设置 NPC 的材质,结果 NPC 已经随旧场景销毁了,代码访问了一个无效对象。
症状可能是:
- Unity 下抛
MissingReferenceException - Unreal 下访问
IsValid()失败 - 自研引擎直接段错误
这种问题的根源是异步边界上的生命周期没有约定清楚。我的建议是:对象在发起异步操作时,同时注册一个生命周期令牌;销毁时把令牌状态置为已失效。异步回调执行时,不管拿到的是什么,先检查令牌状态,失效就立刻 return。层层传递裸指针的代码,早晚要出事。
如果实在不想到处传令牌,还有一种简化方案:加载完成后不直接操作目标对象,而是把结果放进一个“待提交队列”,由对象自己每帧检查这个队列。对象活着,才消费队列;对象死了,队列直接清空。
6.3 资源重复打包与冗余加载:包体变大、加载变慢
包体和加载时长失控,很多时候不是资源本身太大,而是同一份资源被重复打包了很多次。
我们曾遇到过一个问题:游戏安装包 2.8GB,但美术资产源文件总和只有 1.6GB。用依赖分析工具扫描后才发现,某个角色模型的纹理因为导入设置不同,被打包到了三个不同的大包里。一套是 UI 立绘用的无压缩版本,一套是场景模型用的 ASTC 版本,还有一套是头像图标用的 ETC2 版本。三份纹理源文件是同一个,但打包器认为它们是三个独立资源,分别打进了不同的 Bundle。
解决思路是加强打包依赖分析工具,对源文件指纹做归一化检查。打包时如果发现两个资源指向同一个源文件,就尝试合并成同一个资源引用,或者在导入管线里只保留合适的一份,代码层引用另一份时也自动映射过去。
另外,冗余加载往往来自“场景里没有被真正使用,但被引用”的资源。美术随手拖进场景的材质球、测试遗留的粒子系统、被禁用但没移除的组件,都会在资源收集时被当成依赖打进去。我建议在打包前做一次“真实使用率分析”:以场景运行时的最终引用为准收集资源,而不是以场景编辑器的原始引用为准。这一步能砍掉不少莫名其妙的体积。
最后再分享一个我自己坚持的工程习惯:每次大规模改完资源管理相关代码,都跑一遍“长时间运行 + 内存上限断言”的测试。比如设定关卡内运行三小时,内存波动不能超过初始值的 20%。资源泄漏和生命周期问题,往往是这类测试先暴露出来的。游戏对象与资源管理,本质上是把“什么时候创建、谁持有、什么时候释放”这些规则定清楚。规则越明确,游戏引擎的整体稳定性越可靠。