1. 从“一个对象”到“一套系统”:游戏对象模型的设计演进
聊到游戏对象,很多刚入行的朋友第一反应就是“类呗,写个 GameObject 类,里面有 Transform、Mesh、Material,再挂个脚本”。这种思路本身没错,但真正撑起一个商业引擎的对象系统,远不是“一个类”这么简单。你把对象当作“一个装着数据和逻辑的面包机”,和使用“一堆乐高积木自由拼装”的思路,是两种完全不同的工程复杂度。
1.1 组件模式:为什么单继承大树走不通
最早的游戏引擎,比如上世纪九十年代的一些引擎,非常喜欢用深继承。基类叫 Entity,下面分 Actor、StaticMeshActor、Pawn、Character,再往下分各种具体敌人类型。看着很清晰,但实际做项目时问题马上就来了:我想要一个“会播放动画的静态物体怎么办”?继承树里没有这个位置。强行加一个叫 AnimStaticActor 的类,过两天又冒出来“会物理模拟的静态粒子发射器”,继承树开始变成蜘蛛网。
组件模式把这个问题从根本上解决了。对象本身退化成“一个 ID + 一堆组件的容器”,行为全部由挂在上面的组件构成。引擎启动时,Transform 组件管位置,MeshComponent 管渲染数据,CollisionComponent 管碰撞体,PlayerInputComponent 管收集按键。不同系统各扫门前雪,通过组件之间的数据交互联动,对象之间没有硬编码的父子关系,组合的自由度完全打开。
以 Unity 为代表的引擎把这种模式推到极致,GameObject 上挂什么都行,Transform 是唯一默认必备的组件。可别小看这个“什么都能挂”,正是这种自由度,让策划、美术、程序三方可以在同一套世界里用不同的组件组合出五花八门的玩法。
1.2 ECS 架构的逆袭:当“对象”不再是一个类
这几年 ECS(Entity-Component-System)成了热门话题,很多人把它吹得神乎其神。实际上 ECS 想解决的核心问题非常朴素——CPU 缓存命中率太低。一个传统 GameObject 对象,它的 Transform、Mesh、AI 状态等内容散布在堆内存的各个角落,遍历一千个对象就要跳转一千次内存页,缓存全部打空。而 ECS 的做法是把组件按类型连续存放,渲染系统遍历时只扫描所有 Transform 组件,一个接一个读,像读取数组一样流畅。
Unity 的 DOTS、Bevy、Flecs 这些框架都是这个思路。你写系统时不再说“先取对象,再从对象拿组件”,而是直接说“给我所有包含 Velocity 和 Position 的实体集合,我逐个改”。这种思维转变不容易,但物理模拟、粒子系统这种大量对象做同样运算的场景,性能提升能到好几倍。
得说句公道话,传统组件模式在绝大多数游戏项目中完全够用,ECS 更适合上下同欲的大型战棋、万人同屏的割草游戏这类需要极致性能的场景。成熟的引擎往往是两种范式并存,日常玩法逻辑用组件模式,特别吃性能的大规模系统用 ECS。引擎架构里很少有“非黑即白”,更多是“在正确的地方用正确的工具”。
1.3 对象的生命周期:从创建到销毁的每个阶段
对象设计看起来简单,要命的是生命周期管理。一个对象从创建到销毁,中间涉及初始化、激活、停用、销毁多个状态,每一步都可能踩坑。比如初始化顺序,你有 A、B、C 三个组件,A 的初始化要读 B 的数据,如果引擎只按挂载顺序初始化,A 拿到的就是还没准备好的脏数据。我见过不少团队用“等一帧”这种暴力延迟方案,能跑但埋了不少隐雷。
常见做法是把初始化分阶段。Awake 阶段只做组件自身的非外部依赖准备,OnEnable 阶段注册事件、绑定全局服务,Start 阶段才开始跨组件读取数据——Unity 就是这套节奏。这样即使初始化顺序有变动,大多数情况下也只是读到了“尚未激活”的数据,而不是“完全不存在的空引用”。
销毁阶段的坑更隐蔽。你销毁一个对象时,它身上挂的协程、监听的事件、持有的资源引用,都要逐个释放。如果 A 组件持有 B 组件的引用,B 先销毁了,A 在 OnDestroy 回调里读 B,大概率直接空指针崩溃。成熟的引擎对象管理器会做“延迟销毁”,把真正释放内存的操作放到帧末统一执行,让每个组件在完全销毁前还有机会安全地处理依赖关系。
2. 资源管理:对象背后的隐形命脉
对象是轻飘飘的壳,真正占内存的是它们引用的资源——Mesh、Texture、Material、Animation、AudioClip、Prefab。资源管理做得好不好,直接决定游戏的加载速度、内存峰值和运行流畅度。这块做得差的项目,轻则读条时间长得让人崩溃,重则主城地图走到一半,突然掉帧到幻灯片。
2.1 资源生命周期:引用计数与自动卸载机制
资源管理第一课就是引用计数。每个资源被加载时计数为 1,被一个对象引用就加 1,引用断开就减 1,减到 0 就把资源从内存中卸载。看起来很完美,但实践中马上会遇到循环引用问题——材质球引用纹理,纹理的某个元数据又间接触发对材质的依赖,计数永远到不了 0,资源就永远驻留内存。
更现实的问题是程序员手动维护引用计数极易出错。忘记在 OnDestroy 里删引用,资源就泄漏;在错误时机删了还在用的引用,画面直接变紫变黑。Unity 的 Resources 系统当初被骂得厉害,核心问题就是它把所有加载的资源都当作“永久驻留”,管理器崩溃时会先卸载所有 Resources 资源。所以现在的引擎设计普遍倾向于更“自动”的方案:Unity 的 Addressable Assets、Unreal 的 UObject 垃圾回收、Godot 的 Resource 引用计数,底层机制不同,但目标一致——让资源生命周期尽可能少依赖人工管理。
2.2 GPU 内存与 CPU 内存:双轨管理的复杂度
资源的难点还在于,CPU 内存和 GPU 内存需要各自管理。CPU 侧有个纹理对象,里面存的是纹理的元数据和文件原始压缩数据;GPU 侧分配了显存,存的是解压后的像素格式,比如 RGBA8888。这两个内存的生命周期不完全同步,你先释放 CPU 侧的源数据,有可能 GPU 侧的显存还在摸黑渲染。
所以很多引擎把上传 GPU 和释放 CPU 原始数据的动作做了分离。纹理首次被渲染系统真正使用时才上传显存,上传完成后如果确认不再需要源文件(比如非异步重载场景),CPU 侧的压缩数据就可以释放掉。这个“延迟上传”的机制解决了一个经典问题——你把一千张贴图全部放进队列准备加载,如果不做延迟处理,解压和上传会直接卡死主线程。
2.3 GUID 寻址:让资源引用不再依赖字符串路径
早年引擎用字符串路径管理资源引用:“Textures/Environment/Ground/Grass_01.png”。听着直接,写起来也顺手。但项目一大,坑全出来了。改目录结构时字符串断掉;文件名敲错一个字母,运行时才发现贴图是紫的;同名字不同目录的资源互相覆盖。这些问题的根源都是“人可读的字符串”不是“机器可鉴别的唯一标识”。
现代引擎普遍的做法是给每个资源分配一个不可变的 GUID,所有引用都指向 GUID,字符串路径只是一个“显示名”。打包时引擎根据 GUID 重新生成路径,运行时无论资源目录怎么挪,引用都不会断。Addressable 系统更进一步,允许你用字符串地址查询资源,但底层 map 到 GUID。这套设计里,资源管理器就是一个巨型字典,key 是 GUID,value 是资源的缓存地址。游戏加载时先把资源清单读进来建立映射表,然后所有异步加载请求都基于映射表寻址,整个过程干净利落。
3. 实操拆解:对象管理器与资源加载流程设计
理论说再多,不动手做一遍总感觉隔着一层。接下来我按自己的实践经验,把对象管理和资源加载的核心环节拆成可落地的工程步骤。下面这个方案你可以当模板用,换到不同引擎只需改接口名。
3.1 对象管理器:注册、查找与批量操作
我先实现一个对象管理器,它对内维护对象列表,对外提供查找和批量操作的接口。核心数据结构用字典,key 是对象 ID,value 是对象的弱引用。
public class ObjectManager { private Dictionary<int, WeakReference<GameObject>> objects = new(); private int nextId = 0; public int Register(GameObject obj) { int id = nextId++; objects[id] = new WeakReference<GameObject>(obj); return id; } public GameObject Find(int id) { if (objects.TryGetValue(id, out var weakRef) && weakRef.TryGetTarget(out var target)) { return target; } return null; } public void Unregister(int id) { objects.Remove(id); } }几个小细节特别提醒一下。用 WeakReference 而不是直接持有强引用,是为了避免管理器不小心延长对象生命周期。注册时返回自增 ID,而不是把对象的哈希码当 ID。对象哈希码在对象销毁后可能被新对象复用,自增 ID 保证唯一性。查找对象时一定要判空,对象销毁但管理器还没收到通知的情况很常见,Find 返回 null 是正常流程,不是 Bug,调用方要做好空处理。
批量操作要善用 ID 列表而不是直接遍历字典值。比如帧末统一清理死亡对象,销毁队列里存一串 ID,逐一出队查询再销毁,比每次全量遍历高效得多。
3.2 资源加载管线:同步、异步与取消
资源加载的核心原则很简单:能异步就别同步。同步加载在主线程上读磁盘、解压、上传 GPU,哪怕只是几十毫秒,也会造成明显的卡顿。异步加载把“发请求”和“拿结果”拆成两步,主线程只管发请求,后台线程负责读文件和初步解析,完成后回主线程再上传 GPU 和构造对象。
public class AssetLoader { public async Task<T> LoadAsync<T>(string guid) where T : UnityEngine.Object { var handle = Addressables.LoadAssetAsync<T>(guid); await handle.Task; return handle.Result; } }取消机制比很多人以为的复杂。你发了一百个异步加载请求,玩家突然切场景,旧场景的资源请求全部没意义了。如果只停止进度条等待,但后台线程还在傻乎乎地把资源读进来,白白浪费带宽。正确的做法是给每个加载任务带上“版本号”,切场景就把版本号加一,每轮加载回调检查版本号是否匹配,不匹配就直接丢弃结果并释放掉已经加载的资源。
进度管理也有讲究,一个场景的资源往往分成多个阶段。第一阶段加载场景 PREFAB 和基础公共资源,第二阶段加载 NPC、可交互物件,第三阶段才是音效、特效这些非关键的锦上添花内容。先让玩家能进入场景看到大体轮廓,再慢慢补细节,这是商业引擎普遍采用的加载策略,学名叫“分阶段加载”,体验上就是玩家几乎感觉不到读条。
3.3 对象池:高频对象的复用作法
对象池几乎是游戏开发的必选题材。子弹、敌人、飘字、掉落物,这些对象创建销毁极其频繁。每次 Instantiate 都要分配内存、初始化组件、调用 Awake,开销远大于“从池子里取出一个禁用对象,改改参数重新启用”。
public class ObjectPool<T> where T : MonoBehaviour { private Stack<T> pool = new(); private T prefab; private Transform parent; public T Get() { var instance = pool.Count > 0 ? pool.Pop() : Object.Instantiate(prefab); instance.gameObject.SetActive(true); return instance; } public void Release(T instance) { instance.gameObject.SetActive(false); pool.Push(instance); } }做对象池最容易犯的错是忘了“状态重置”。一个敌人从池子里拿出来,它的血量、朝向、AI 状态、动画状态,全部可能是上一个生命周期残留的。经验是对象池在 Release 时统一调用一个 ResetState 方法,把所有可变的运行时状态恢复到出厂默认,而不是在 Get 时零零散散地改几个字段,后者必漏。
池子的大小也值得打磨。太小了频繁新建对象,缓存优势打折扣;太大了闲置对象白白占内存。我一般会在编辑器里提供可调的池初始大小参数,跑一个基准场景,观察内存曲线和 GC 次数,再反过来调参数。这一步在项目初期很容易被忽略,等项目中期频繁掉帧再回头优化,改动成本就高了。
4. 一处资源多处引用:寻址与依赖图的工程细节
现代游戏资源量动不动几十 GB,资源之间相互引用的关系极为复杂。UI 图集引用大量小图,材质球引用纹理和 Shader,场景 Prefab 引用材质和网格。这一张依赖图如果管理不好,资源加载时就会出现引用悬空、加载顺序错乱、冗余重复加载一大堆问题。
4.1 依赖分析:加载顺序的隐式保证
我最早做资源管理器时,加载一个 Prefab 就只是加载这个 Prefab,结果运行时模型全紫、贴图全丢。原因就是没处理依赖:Prefab 里的 MeshFilter 引用的 Mesh,Mesh 又引用了它的材质,材质又引用了纹理。如果不把这些依赖全部先加载完,Prefab 实例化时读取到的都是空资源。
方案是在资源导入阶段生成一份依赖清单文件,做成 JSON 或二进制格式,记录每个资源依赖的所有子资源的 GUID。运行时加载 Prefab 时,资源管理器读取它的依赖清单,按拓扑序把所有依赖先加载完,再回调“可以实例化对象了”。这个依赖清单是在编辑器里生成的,游戏运行时只负责读,不用做动态依赖分析,性能开销非常小。
4.2 异步加载中的竞态问题
异步加载多了,竞态问题就会冒出来。同一个资源被两个系统同时请求加载,如果双方各自发一次加载命令,资源就会被加载两次放两份缓存,浪费内存不说,还可能出现“一边在释放、一边在使用”的诡异状态。
解决办法是给资源管理器加一层“请求合并”逻辑。所有资源加载请求先进一个字典,key 是 GUID,value 是等待该资源的回调列表。如果第一个请求已经把资源加载进行中,后续请求只追加回调,不重新发起加载。资源加载完成时,一次性调用所有回调,并清空等待列表。
private Dictionary<string, List<Action<object>>> pendingCallbacks = new(); public void LoadWithDependencies(string guid, Action<object> onLoaded) { if (cache.ContainsKey(guid)) { onLoaded(cache[guid]); return; } if (pendingCallbacks.TryGetValue(guid, out var callbacks)) { callbacks.Add(onLoaded); } else { pendingCallbacks[guid] = new List<Action<object>> { onLoaded }; StartCoroutine(LoadCoroutine(guid)); } }4.3 场景流切换时的批量释放策略
场景切换是资源管理的一大考验。旧场景大量资源失效,全部走引用计数减到 0 的机制去逐个释放,效率太低。商业引擎的做法是把资源按场景做分组,切换到新场景时,把所有“仅属于旧场景”的资源批量标记为可释放,然后再跳过引用计数检查直接回收。这套机制在 Addressable 里叫 “Scene 的 LoadMode”,在 Unreal 里叫 Level Streaming。
这里有个甜点:纹理和 Mesh 这类大资源,很多是跨场景共享的。主菜单背景图、常用 UI 图标、全局字体,这些资源应该放进公共池,不在场景切换时被乱动。做法是在资源管理器中给资源加一个“共享标记”,批量释放时自动跳过带标记的资源。别小看这一步,不处理好,每切一次场景玩家就会看到 UI 图标闪一下重新加载,观感非常廉价。
5. 可视化与调试层:对象和资源的体检报告
写到这我发现一个规律:对象和资源管理的设计,前 70% 的功夫在架构,后 30% 的功夫在调试工具上。没有好用的可视化调试层,资源泄漏和对象泄漏问题排查起来全靠人肉翻代码,效率低到让人崩溃。
5.1 运行时对象浏览器能看到的细节设计
我强烈建议在引擎里做一个“运行时对象浏览器”。打开后左边是对象树,显示当前场景所有活跃对象,点选一个对象,右边列出它挂的所有组件和关键属性。更进阶的做法是,对象树上可以直接看到这个对象引用的每条资源的 GUID、引用计数、内存占用。
这个工具的价值不在平时,而在出问题的那天。比如玩家反馈“进某个地图越来越卡”,打开对象浏览器一看,切换前场景有 300 个对象,切换后变成了 2000 个,多出来的全是没被清理的粒子特效预制体。再沿着引用关系追踪,很快定位到某个 UI 系统在打开关闭界面时漏了对象销毁调用。
5.2 资源追踪器:内存泄漏的照妖镜
资源泄漏比对象泄漏更隐蔽,因为对象不销毁往往立刻有表现——界面关不掉、东西消失不了。资源泄漏的典型场景是:你每打开一次背包就加载一次道具图标贴图,背包关闭后贴图没被释放,打开 50 次背包,50 份贴图叠在内存里,内存曲线稳步上涨。
排查思路是给资源管理器加一个“申请记录”。每次加载一个资源,就把这次申请的堆栈写进日志;每次释放一个资源,就把对应的记录删除。做统计时按资源 GUID 聚合,瞬间就能看到哪些资源被申请次数最多、谁还在持有引用。这个功能就像内存分析工具的“引用树”,顺着路径走到头就能看到持有着是谁。
5.3 QA 人员也能用的状态面板
调试工具不能只在编辑器里能用,游戏跑到 QA 手里后照样得能监控。我习惯做一个隐藏的调试面板,按下特定组合键弹出。面板显示当前对象总数、资源缓存条目数、总内存占用量、最近一分钟的内存峰值变化。
QA 最常说的一句话是“刚才突然卡了一下,但不知道是啥”。有了这个面板,他们就能在卡顿瞬间按下截图,把内存数据和对象统计发给你。把调试信息做到 QA 触手可及的位置,解放的不只是程序员,整个团队的沟通效率都会上一个台阶。
6. 走查与实战对照:常见问题排查技巧
到了平时最容易踩坑的环节了。下面按我实际经历总结的问题,列成速查表,再补几个针对性案例。
| 现象 | 可能原因 | 排查技巧 |
|---|---|---|
| 切换场景后内存不降 | 旧场景对象未销毁或资源未释放 | 查看对象浏览器的对象总数是否回落到基准值 |
| 频繁 GC 导致卡顿 | 每帧创建临时对象,比如每帧 Alloc 一个 List 或字符串 | 用 Profiler 抓 GC Alloc 高的函数,改用对象池或缓存 |
| 纹理显存占用异常高 | 没有做纹理压缩或没开 Mipmap | 检查导入设置,查看纹理格式是否合理 |
| 图集碎图引用失效 | 引言用了字符串路径且目录变化 | 改用 GUID 寻址,抛弃字符串引用 |
| 异步加载完成后资源不可用 | 依赖的资源未加载完就实例化对象 | 加依赖清单,按拓扑序加载 |
| 打开物体池后对象状态错乱 | 释放时未重置运行时状态 | 加 ResetState 方法并在 Release 时统一调用 |
案例一,我接手过一个项目,每开一次商店界面,内存就涨 30MB 直到崩溃。用资源追踪器一看,商店图标贴图被申请了 40 次,持有着全部指向一个 UI 控制器,这个控制器的 OnDestroy 里漏了释放操作。加上一行释放代码,内存曲线立刻稳定。
案例二更隐蔽。项目运行到中期,所有地图规模都不大但越玩越卡。用 Profiler 抓帧数据发现,每帧创建一个包含 300 个元素的 List,用于收集附近单位。这个 List 本来是静态的,某次重构时被改成了方法内局部变量。恢复静态缓存后,帧时间从 28ms 降回了 12ms。这类问题,没有 Profiler 辅助很难通过阅读代码发现。
7. 端点检视:对象和资源管理器的几个底层设计选择
到了打字环节,这个部分更偏方法论,聊几个对象和资源管理器常见的底层设计分叉。
7.1 “对象管理器”和“场景管理器”的边界与取舍
对象管理器负责生命周期,场景管理器负责场景加载卸载和切换。这两个职责很容易重叠。我见过好些项目把场景切换时该做的事全塞进对象管理器,对象管理器被迫知道“什么是关卡、什么是存档点”,对象管理器变成上帝类,越改越乱,最后内聚度崩坏。
我的建议是边界划清楚:对象管理器只处理对象个体,不管场景的组织结构;场景管理器只关心场景加载状态和场景之间的切换逻辑,内部再复用对象管理器的接口去批量销毁和创建。对象永远不会主动“知道”自己属于哪个场景,这个信息存在场景管理器自己的映射表里。职责分离带来的收益是长期维护时脑子不用装太多东西,改一个功能不会波及另一边。
7.2 序列化与反序列化:对象状态的持久化
对象和资源的持久化设计往往被忽略,实际做存档读档时又痛不欲生。最简单的做法是给对象挂一个“序列化组件”,里面记录需要持久化的字段,存档时统一收集这些字段写成 JSON 或二进制。读取时先实例化基础对象,再按字段回填数据,最后调用一个 OnPostDeserialize 回调用于重建跨组件的状态。
有个经验教训:不要在序列化里硬编码字段名数组。项目改字段名时序列化代码不会自动更新,运行时反序列化直接抛异常,还特别难发现。“Oh no, the save file is corrupted” 这句话我听了太多次。正解是直接用引擎的序列化系统,比如 Unity 的 SerializeField 和 JsonUtility,让字段名和序列化键自动同步。
7.3 模块解耦与插件化:让对象管理系统不绑架全工程
对象和资源管理器是引擎最底层的两块基础设施,但也要给上层留出扩展空间。我会把资源和对象的访问全部封装成接口,而不是暴露具体类。下层实现可以随时替换,上层代码只依赖接口不依赖实现。举个例子,编辑器模式用同步加载实现 IAssetProvider,发布包用 Addressables 异步实现,逻辑代码不需要改一行。
这种插件化设计在商业项目中极为重要,因为不同平台的资源加载行为差异很大。PC 的磁盘 IO 快,同步加载勉强能接受;手机和 WebGL 的加载效率和内存限制完全不同。把变化封装在实现层,平台适配就是写一个新的实现类,而不是改上层逻辑。
8. 进阶玩法:对象和资源管理的后续扩展空间
基础框架搭完后,不要停在“能跑”的位置。我列几个后续值得投入的方向。
8.1 预测式加载:用玩家行为预判资源需求
预测式加载不是新概念,实现也从简到繁都有。最简单的方案是记录场景中的“触发区域”,玩家进入某个区域时提前加载相邻区域的核心资源。更智能的方案是收集大量玩家数据,统计每个关卡节点后玩家最可能去往哪个区域,在那个区域放置“热资源”提前加载。
实现预测加载最需要注意的是“别抢资源”。预测加载用的带宽和内存必须限制在极低水位,比如不超过总内存的 5%。否则预测加载和正式加载互相打架,加载队列卡死,反而比不预测更糟糕。
8.2 对象流送:大型开放世界的关键技术
开放世界常用的大型地图技术本质上是把对象和资源管理推广到超大规模。地图切成多个 chunk,每个 chunk 包含网格数据、纹理、植被实例、NPC 出生逻辑。玩家朝某个方向移动时,引擎预测玩家即将进入的 chunk,提前加载这些 chunk 的资源;玩家远离的 chunk 则逐步卸载。
对象流送的复杂之处在于 chunk 边界的管理。一个河流系统横跨两个 chunk,河流对象该属于哪个 chunk?我见过用“归属主 chunk + 跨 chunk 引用”的方案:河流对象的主数据挂在一个 chunk 上,其他 chunk 只存轻量引用。这个设计让河流系统统一更新,同时避免边界处出现断流。对于植被这类数量多、单体小的对象,流送时都会用 GPU 实例化减少 DrawCall,细节才是大型场景流畅的命门。
8.3 资源版本管理与热更新
资源管理的最后一块拼图是版本管理和热更新。玩家已经下载了 v1.0 的客户端,运营发了 v1.1,里面改了两张贴图和一个场景 Prefab。如果没有合理的资源版本机制,玩家每次启动都要下载全部资源,更新效率极低。
做法是给每个资源生成一个哈希值,构建时生成一份“资源版本清单”。客户端启动时拉取服务器上的最新清单,和本地清单做比对,差异部分走增量下载。这套机制在移动端游戏几乎是标配,做的时候要留意版本回退问题——如果服务器发了一个坏版本,客户端要有快速回滚到本地版本的能力。我经历过的项目中,资源回滚机制做得好的,运营事故影响面小得多;做不好的,一个坏包能折腾玩家一整天。
9. 写在最后的工程心得
游戏对象和资源管理这块,架构设计和技术方案是网上能查到的东西,但真正让系统“好用”的,往往是那些写在代码之外的工程习惯。我总结几条最核心的实操建议。
第一条,所有加载和释放的路径都必须有日志。线上排查资源泄漏时,没有日志寸步难行。日志格式也不是随手写写,要包含 GUID、调用栈、时间戳和发起者名字,这样回溯才能直接定位。我在实际项目中遇到的 70% 的资源问题,都是靠日志反查找到证据链的。
第二条,做对象池、资源缓存这类优化,一定要有可观测的指标。别说完事就完了,把性能数据、内存占用、命中率这些数据做成面板固化下来。优化效果好不好,不靠感觉,靠曲线。养成这个习惯之后,你会发现每次发布会性能优化时突然有底——所有数字都有出处。
第三条,给资源管理器加“过载保护”。游戏运行时,如果同时发起太多资源请求,很容易把加载线程和内存打爆。我习惯给加载队列设置上限:超过 200 个待处理请求时,不再接收新的加载请求,先让已有请求完成处理。开宝箱动画刚播完的那个瞬间,如果恰好有预加载任务在进行,很容易触发这个保护,保住玩家的流畅体验。
第四条,对象销毁一定要做成“安全的”。就算逻辑上已经确认对象没有外部引用了,销毁时还是可能因为事件回调、协程残留等问题出幺蛾子。我的做法是把销毁调用收集到延迟队列,在帧末统一执行。这个框架设计的“脏活”看似不怎么起眼,但它能把崩溃率压下去一大截。
游戏引擎的对象和资源管理系统,永远没有“完美”的方案。每个引擎在最合适的场景下做出不同的取舍,Unity 把易用性拉满,UE 在性能和控制力上做文章,Godot 在轻量化和自由上兼顾。做引擎的人要做的不是追着热点换方案,而是看清自己项目的规模、团队习惯和目标平台,在架构和工程习惯上找到适合自己的那条路。这套系统是地基,地基稳了,上层的玩法和画面才有放手折腾的空间。