把 Unity 场景从 Prefab 硬引用里解放出来:DynamicSpawn 动态场景加载实践

效果展示

未解开引用,场景会强制依赖预制体,导致加载场景时,同时会把预制体所在的ab包也加载,导致加载场景时段时间卡顿。

已解开引用,同时编辑器下的测试功能仍然有效!仍然便捷的指定引用关系,以及快速加载卸载测试!

YooAsset引用分析器,已经看到预制体和场景之间无任何引用关系!纯靠代码加载!

省去了大量时间,并且可以继承重新实现本场景特定加载流程自定义化!

源码库

https://github.com/RSJWY/TEngine

本文基于项目中的 DynamicSpawn 实现编写,核心代码位于 Assets/GameScripts/HotFix/GameLogic/Scenes/DynamicSpawn/,编辑器工具位于 Assets/Editor/SceneTools/DynamicSpawn/

正文

在 Unity 项目里,场景经常会逐渐变成一个“巨型 Prefab 容器”:美术和关卡同学为了摆放方便,把大量装饰物、交互物、机关、UI 触发点、特效根节点直接拖进 .unity 场景文件。前期这很直观,打开场景就能看到全部内容;但项目进入资源分包、热更新、真机加载优化阶段后,这种做法会暴露出几个很现实的问题:

  1. 场景文件越来越大,协作冲突也越来越频繁。

  2. 场景对 Prefab 形成 PPtr 硬引用,AssetBundle 依赖关系被场景“粘”住。

  3. 场景激活时对象一次性反序列化,加载峰值难控制。

  4. 运行时逻辑很难判断“动态对象是否都已经就位”。

  5. 编辑器里如果没有配套工具,改成纯代码加载又会牺牲摆放体验。

DynamicSpawn 解决的不是单一的“异步加载 Prefab”问题,而是把“场景摆放”“资源依赖”“运行时加载”“完成通知”“编辑器预览”这几件事重新拆开,让场景文件只保存轻量占位点,真正的 Prefab 在运行时按 YooAsset 的 location 动态加载,并且可以继承“DynamicSceneSpawner” 类,自定义加载部分;以及使用“TryGetRegistered(string key, out GameObject go)” 函数,从所有的托管节点中,根据注册键查找已加载的 GameObject。

一句话概括

DynamicSpawnDynamicSpawnPoint 替代场景中的真实 Prefab 实例。每个占位点只保存 Transform、location、对齐模式和可选 registerKey。运行时由 DynamicSceneSpawner 收集这些占位点,分批调用 GameModule.Resource.LoadGameObjectAsync 加载真实对象,并在全部完成后发送 Event_DynamicSpawnComplete,让场景业务管理器安全地继续初始化。

它保留了编辑器里的“所见即所得”能力:工具可以临时放置预览实例,但这些预览对象带有 HideFlags.DontSave,不会写进场景文件,也不会重新制造场景到 Prefab 的打包依赖。

但是为了安全,请在打包前,手卸载预览!

传统场景摆放的问题

最常见的做法是把 Prefab 实例直接拖进场景:

MainScene.unity
├── Building_A.prefab instance
├── Tree_01.prefab instance
├── Chest.prefab instance
├── Portal.prefab instance
└── ...

这种结构的优点是直观,但代价也清楚:

  • .unity 文件会序列化大量对象层级和组件数据。

  • 场景文件会保存对 Prefab 资源的 PPtr 引用(PPtr 就是 Unity 用来在磁盘和运行时之间“定位对象”的引用标识,不是 C# 指针,而是“资源定位结构”。)

  • 资源收集时,场景所在 Bundle 可能被迫依赖这些 Prefab 所在 Bundle。

  • 进入场景时,Unity 会处理大量对象反序列化,难以按帧削峰。

  • 如果后续想把某个对象改成热更资源,场景硬引用会成为阻力。

DynamicSpawn 的目标是让场景变成更轻的“布局描述”:

MainScene.unity
├── [Spawn] Building_A  -> location = "Building_A"
├── [Spawn] Tree_01     -> location = "Tree_01"
├── [Spawn] Chest       -> location = "Chest", registerKey = "MainChest"
├── [Spawn] Portal      -> location = "Portal", registerKey = "LevelPortal"
└── ...

真实 Prefab 不再被场景直接引用,场景只告诉运行时:这个位置要加载哪个资源,以及加载出来后如何对齐。

核心结构

DynamicSpawnPoint:场景里的轻量占位点

DynamicSpawnPoint 是挂在空 GameObject 上的组件,运行时字段很少:

  • location:YooAsset 资源地址,通常是文件名,不含路径和扩展名。

  • alignMode:加载出的实例如何对齐占位点。

  • registerKey:可选的运行时查找键。

对应代码在:Assets/GameScripts/HotFix/GameLogic/Scenes/DynamicSpawn/DynamicSpawnPoint.cs

其中最关键的设计是:编辑器下可以有 Prefab 辅助引用,但它不是运行时对象引用,而是 GUID 字符串。

#if UNITY_EDITOR
[HideInInspector]
[SerializeField]
private string prefabGuid;
#endif

这意味着制作人员仍然可以在 Inspector 中拖入 Prefab 辅助填写 location 和放置预览,但场景序列化数据里不会保留 GameObject PPtr 引用。旧版本如果已经保存过直接 Prefab 引用,也可以通过 MigrateLegacyReferenceIfNeeded() 迁移成 GUID 字符串并清空旧引用。

这是整个功能最重要的一点:它不是简单把 Instantiate 挪到运行时,而是主动避免场景和 Prefab 在打包依赖层面继续绑定。

SpawnAlignMode:两种对齐策略

项目里提供了两个对齐模式:

  • AlignToPlaceholder:实例作为占位点子物体,并把 localPosition、localRotation、localScale 归零,完全使用占位点的世界 TRS。

  • KeepPrefabLocal:实例仍挂在占位点下,但保留 Prefab 根节点自带的 local TRS,适合 Prefab 根节点本身有偏移的情况。

这个选择看起来很小,但对场景制作很实用。不同团队的 Prefab 规范不一定完全统一,有些 Prefab 根节点就是模型中心,有些 Prefab 根节点可能带了逻辑偏移。把对齐策略显式放在占位点上,可以减少“为什么运行时位置偏了”的排查成本。

DynamicSceneSpawner:收集、加载、削峰、通知

DynamicSceneSpawner 是运行时加载基类,对应代码在:Assets/GameScripts/HotFix/GameLogic/Scenes/DynamicSpawn/DynamicSceneSpawner.cs

它承担几件事:

  1. 根据初始化模式决定何时开始加载。

  2. 调用子类的 CollectSpawnItems() 收集加载项。

  3. 使用 GameModule.Resource.LoadGameObjectAsync(location, parent, token) 加载对象。

  4. batchSize 分批 await UniTask.Yield(),避免一帧内加载过多。

  5. 对齐实例到占位点。

  6. registerKey 注册实例。

  7. 加载完成后发送 Event_DynamicSpawnComplete

典型加载逻辑可以概括为:

foreach (var item in items)
{
    var go = await GameModule.Resource.LoadGameObjectAsync(
        item.Location,
        parent: item.Parent,
        cancellationToken: token);

    AlignInstance(go, item);
    RegisterInstance(go, item);

    if (batchSize > 0 && reachedBatchLimit)
    {
        await UniTask.Yield(PlayerLoopTiming.Update, token);
    }
}

CompleteSpawn();

这里用的是 TEngine 项目推荐的 GameObject 加载方式。根据资源加载规范,LoadGameObjectAsync 会处理实例化和引用计数,场景卸载或对象销毁时不需要业务侧额外 UnloadAsset