Appearance
题海扩展:Unity API 与实战
SerializeReference 有什么用?
标准答案
[SerializeReference] 的作用是:让 Unity 按“托管引用”来序列化普通 C# 对象,主要用来解决普通 [SerializeField] 很难处理的多态、接口、抽象类、null、共享引用、对象图等问题。
普通 [SerializeField] 更像是“把数据按值嵌进去”;[SerializeReference] 更像是“记录这个字段指向哪个托管对象、这个对象真实类型是什么”。
典型使用场景
比如技能系统里,一个技能可能有多种释放条件:
- 等级达到要求
- MP 足够
- 目标在范围内
- 玩家拥有某个 Buff
- 某些条件组合成 AND / OR 条件树
这些条件都可以实现同一个接口:
using System; // 引入 Serializable 特性所在的命名空间。
using System.Collections.Generic; // 引入 List 集合类型。
using UnityEngine; // 引入 UnityEngine 类型,例如 ScriptableObject 和 GameObject。
public interface ISkillCondition // 定义技能条件接口。
{ // 接口开始。
bool IsSatisfied(GameObject caster); // 定义判断条件是否满足的方法。
} // 接口结束。
[Serializable] // 标记这个普通 C# 类可以被 Unity 序列化。
public sealed class LevelCondition : ISkillCondition // 定义等级条件类,并实现技能条件接口。
{ // 类开始。
[SerializeField] private int _needLevel = 1; // 序列化需要的等级数值。
public bool IsSatisfied(GameObject caster) // 实现条件判断方法。
{ // 方法开始。
return caster != null && _needLevel > 0; // 示例逻辑:真实项目中应读取角色等级判断。
} // 方法结束。
} // 类结束。
[Serializable] // 标记这个普通 C# 类可以被 Unity 序列化。
public sealed class MpCondition : ISkillCondition // 定义 MP 条件类,并实现技能条件接口。
{ // 类开始。
[SerializeField] private int _needMp = 10; // 序列化需要的 MP 数值。
public bool IsSatisfied(GameObject caster) // 实现条件判断方法。
{ // 方法开始。
return caster != null && _needMp >= 0; // 示例逻辑:真实项目中应读取角色 MP 判断。
} // 方法结束。
} // 类结束。
[CreateAssetMenu(menuName = "Demo/Skill Config")] // 允许在 Unity 菜单中创建技能配置资源。
public sealed class SkillConfig : ScriptableObject // 定义技能配置资源类。
{ // 类开始。
[SerializeReference] private List<ISkillCondition> _conditions = new List<ISkillCondition>(); // 用托管引用序列化多态条件列表。
public bool CanCast(GameObject caster) // 定义技能是否可以释放的方法。
{ // 方法开始。
foreach (ISkillCondition condition in _conditions) // 遍历所有配置的技能条件。
{ // foreach 开始。
if (condition != null && !condition.IsSatisfied(caster)) // 如果某个条件存在且不满足。
{ // if 开始。
return false; // 返回 false,表示技能不能释放。
} // if 结束。
} // foreach 结束。
return true; // 所有条件都满足,返回 true。
} // 方法结束。
} // 类结束。底层原理
[SerializeReference] 不会把字段只当成声明类型来保存,而是会额外记录:
- 这个对象的引用 ID
- 真实运行时类型
- 对象内部序列化数据
Unity 会在宿主对象的序列化数据里维护一份 managed reference 表。字段里保存的是引用关系,表里保存具体对象数据和类型信息。
所以它能做到普通序列化不方便做到的事:
- 字段声明为接口,实际保存某个实现类。
- 字段声明为抽象基类,实际保存子类。
- 列表里每个元素是不同类型。
- 同一个宿主对象内,多个字段可以指向同一个托管对象。
- 可以表达一些复杂对象图。
和 ScriptableObject 的区别
[SerializeReference] 是序列化普通 C# 托管对象,不是 Unity 资源引用。
如果你希望多个配置、多个角色、多个资源共享同一份数据,通常更适合用 ScriptableObject。 如果这份对象只属于当前宿主配置,并且需要多态结构,SerializeReference 更合适。
Unity 工程实践
我会在这些地方考虑用它:
- 技能条件配置
- Buff 效果列表
- 行为树节点
- 对话节点
- 任务目标条件
- AI 决策节点
- 编辑器工具里的多态配置
但我不会所有配置都用它。简单固定字段,用 [SerializeField] 更清晰;需要跨资源共享的数据,用 ScriptableObject 更合适。
常见坑点
SerializeReference 保存了类型信息,所以类型改名、移动命名空间、程序集变化,都可能导致旧数据反序列化失败。项目里要注意类型稳定,必要时使用迁移方案。
默认 Inspector 对类型选择的支持在不同 Unity 版本里体验不完全一样。实际项目里常配合自定义 PropertyDrawer、Odin,或者自己的类型选择器来编辑。
它也会让序列化数据更复杂,不适合滥用。尤其大型配置系统里,要考虑可读性、版本兼容、工具链支持和数据迁移。
面试可以这样说
“SerializeReference 是 Unity 对普通托管对象的引用序列化能力。它和普通 [SerializeField] 最大区别是能保留真实运行时类型和引用关系,所以适合接口、抽象类、多态列表、行为树、技能条件树这类配置。它不是 UnityEngine.Object 资源引用,也不适合跨资源共享;跨资源共享我会优先考虑 ScriptableObject。它的坑主要是类型名变更、Inspector 编辑体验和数据迁移问题,所以我会在确实需要多态对象图时使用,而不是所有配置都用。”
HideInInspector 和 NonSerialized 区别是什么?
标准答案
HideInInspector 和 NonSerialized 最大区别是:
HideInInspector 是隐藏 Inspector 显示,但字段仍然可以被 Unity 序列化保存。 NonSerialized 是禁止字段被序列化,所以它通常也不会出现在 Inspector,也不会保存到 Scene / Prefab。
一句话:HideInInspector 管显示,NonSerialized 管保存。
代码示例
c
using System; // 引入 NonSerialized 特性所在的命名空间。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Unity 特性。
public sealed class AttributeExample : MonoBehaviour // 定义一个 Unity 组件示例类。
{ // 类开始。
public int visibleValue = 1; // public 字段默认会被 Unity 序列化,并显示在 Inspector。
[HideInInspector] // 让字段不显示在 Inspector。
public int hiddenButSaved = 2; // 这个字段仍然会被 Unity 序列化保存到场景或 Prefab。
[NonSerialized] // 让字段不参与序列化。
public int runtimeCache = 3; // 这个字段不会被 Unity 保存,重新加载后会恢复默认值。
[SerializeField] // 让 private 字段也参与 Unity 序列化。
private int privateSaved = 4; // 这个私有字段会显示在 Inspector 并保存。
[SerializeField] // 让 private 字段参与 Unity 序列化。
[HideInInspector] // 让这个已序列化字段不显示在 Inspector。
private int privateHiddenButSaved = 5; // 这个私有字段会保存,但不会显示。
} // 类结束。底层理解
Unity Inspector 通常显示的是“Unity 能序列化的字段”。比如 public 字段默认会被序列化,[SerializeField] private 字段也会被序列化。
HideInInspector 不改变“是否序列化”,它只是告诉 Inspector:这个字段别显示出来。字段值仍然可能写进 Scene、Prefab、Asset 的序列化数据里。
NonSerialized 则是告诉序列化系统:这个字段不要保存。它主要用来排除 public 字段,或者明确表达这个字段只是运行时临时数据。
Unity 工程实践
HideInInspector 适合这种情况:字段确实要保存,但不希望策划、美术或其他同事在 Inspector 里误改。例如内部状态、工具生成的数据、调试开关、旧版本兼容字段。
NonSerialized 适合运行时缓存:比如运行时查找出来的组件引用、临时计算结果、事件委托、对象池运行状态、字典缓存等。这些数据不应该保存进 Prefab 或场景,重新运行时应该重新构建。
常见坑点
很多人会误以为 HideInInspector 是“不保存”,这是错的。它只是隐藏,数据仍然可能存在。排查 Prefab 或场景旧数据问题时,隐藏字段也可能影响运行结果。
另一个坑是 public 字段默认会被 Unity 序列化。如果你写了一个 public 运行时缓存,但忘了加 [NonSerialized],Unity 可能会把它保存下来,导致一些奇怪的状态污染。
面试可以这样说
TIP
“HideInInspector 只影响 Inspector 展示,不影响 Unity 序列化;字段仍然会保存到 Scene 或 Prefab。NonSerialized 则是不让字段参与序列化,适合运行时缓存和临时状态。实际项目里,我会用 HideInInspector 隐藏需要保存但不希望手动修改的数据,用 NonSerialized 排除不应该持久化的运行时数据。”
RequireComponent 有什么用?
标准答案
RequireComponent 是 Unity 的一个特性,用来声明某个 MonoBehaviour 依赖哪些组件。 当你把这个脚本添加到 GameObject 上时,Unity 会自动帮你补齐缺失的依赖组件。
比如移动脚本必须依赖 Rigidbody:
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Rigidbody。
[RequireComponent(typeof(Rigidbody))] // 声明这个脚本所在 GameObject 必须有 Rigidbody。
public sealed class PlayerMotor : MonoBehaviour // 定义玩家移动组件。
{ // 类开始。
private Rigidbody _rigidbody; // 缓存 Rigidbody 引用。
private void Awake() // Unity 在对象初始化时调用 Awake。
{ // Awake 开始。
_rigidbody = GetComponent<Rigidbody>(); // 获取同一个 GameObject 上的 Rigidbody。
} // Awake 结束。
public void Move(Vector3 velocity) // 定义移动方法。
{ // 方法开始。
_rigidbody.velocity = velocity; // 通过 Rigidbody 设置速度。
} // 方法结束。
} // 类结束。底层理解
RequireComponent 本质是一个标记在类上的 Attribute。Unity 在你添加这个脚本组件时,会读取这个 Attribute,检查当前 GameObject 是否已经有需要的组件。
如果没有,Unity 会自动执行类似:
c
gameObject.AddComponent<Rigidbody>();这样可以避免脚本运行时 GetComponent<Rigidbody>() 得到 null。
它解决什么问题
在 Unity 项目里,很多脚本天然依赖其他组件:
- 移动脚本依赖
Rigidbody或CharacterController - UI 淡入淡出脚本依赖
CanvasGroup - 碰撞检测脚本依赖
Collider - 动画控制脚本依赖
Animator - 音效脚本依赖
AudioSource
如果依赖没有写出来,全靠人工记住,很容易 Prefab 配漏。RequireComponent 就是把这种依赖关系写进代码里。
注意限制
RequireComponent 主要在“添加脚本时”生效。 如果一个旧 Prefab 很早就已经挂了脚本,后来你才给脚本加上 [RequireComponent],旧 Prefab 不一定会自动补齐依赖,所以关键资源还是要做检查。
另外,它不能替代组件缓存。即使用了 RequireComponent,也不要在 Update 里频繁 GetComponent,通常还是在 Awake 里缓存引用。
还有一点:它只是组件依赖声明,不是完整的架构依赖管理。比如战斗系统依赖属性系统、资源系统依赖 Addressables,这种模块级依赖不能靠 RequireComponent 解决。
面试可以这样说
TIP
“RequireComponent 用来声明 MonoBehaviour 对其他组件的依赖。Unity 在添加脚本时会检查并自动补齐缺失组件,比如移动脚本依赖 Rigidbody,这样可以减少 Prefab 配漏和 GetComponent 为空的问题。但它主要是编辑器和组件层面的保护,不是运行时万能校验。实际项目里我仍然会在 Awake 缓存组件,在 OnValidate 或构建前工具里检查旧 Prefab 是否缺依赖。”
DisallowMultipleComponent 有什么用?
标准答案
DisallowMultipleComponent 的作用是:禁止同一个 GameObject 上重复添加同类型组件。
比如一个角色对象上通常只应该有一个 PlayerController、一个 HealthComponent、一个状态机组件。如果重复挂了两个,可能会出现重复 Update、重复响应输入、重复订阅事件、重复扣血等问题。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Attribute。
[DisallowMultipleComponent] // 禁止同一个 GameObject 上重复添加这个组件。
public sealed class HealthComponent : MonoBehaviour // 定义血量组件。
{ // 类开始。
[SerializeField] private int _maxHp = 100; // 在 Inspector 中配置最大血量。
private int _currentHp; // 保存运行时当前血量。
private void Awake() // Unity 初始化组件时调用 Awake。
{ // Awake 开始。
_currentHp = _maxHp; // 初始化当前血量为最大血量。
} // Awake 结束。
public void TakeDamage(int damage) // 定义受伤方法。
{ // 方法开始。
_currentHp -= damage; // 扣除伤害值。
_currentHp = Mathf.Max(_currentHp, 0); // 保证血量不会小于 0。
} // 方法结束。
} // 类结束。底层理解
DisallowMultipleComponent 是一个标记在 MonoBehaviour 类上的 Attribute。Unity 在你通过 Inspector 或 AddComponent 添加组件时,会检查同一个 GameObject 上是否已经存在同类型组件。
如果已经有了,Unity 会阻止继续添加,避免同一个对象上出现多个同类组件实例。
它的重点是:同一个 GameObject 上防重复。它不是全局单例。 也就是说,场景里可以有 100 个角色对象,每个角色对象都可以有一个 HealthComponent。
和 RequireComponent 的区别
RequireComponent 是“缺依赖就补齐”。
c
[RequireComponent(typeof(Rigidbody))]意思是这个脚本需要 Rigidbody,Unity 会帮你补依赖。
DisallowMultipleComponent 是“同类组件不能重复”。
c
[DisallowMultipleComponent]意思是这个脚本在同一个 GameObject 上只能有一个。
一句话:RequireComponent 防缺少,DisallowMultipleComponent 防重复。
Unity 工程实践
适合加 DisallowMultipleComponent 的组件通常是对象级唯一职责组件:
PlayerControllerHealthComponentBuffComponentStateMachineInventoryOwnerAudioListener类似的对象级控制组件- 自定义 UI 面板控制脚本
如果一个组件本来就允许多个,比如多个 Collider、多个音效触发器、多个子行为组件,就不应该加它。
常见坑点
它不能保证全局唯一。比如 GameManager 如果要整个场景或整个游戏只有一个,不能只靠 DisallowMultipleComponent,还要用单例、启动流程、场景管理或服务注册来保证。
它也不能替代资源巡检。历史 Prefab、复制出来的对象、旧版本数据,最好还是用编辑器工具或构建前检查扫一遍。
面试可以这样说
NOTE
“DisallowMultipleComponent 用来防止同一个 GameObject 上重复添加同类型组件。它适合角色控制器、血量组件、状态机这类每个对象只应该有一个的组件,可以减少重复 Update、重复事件订阅和配置错误。它和 RequireComponent 不一样,RequireComponent 是补依赖,DisallowMultipleComponent 是防重复。但它不是全局单例,不同 GameObject 仍然可以各自挂一个。”
ExecuteAlways 和 ExecuteInEditMode 区别是什么?
标准答案
ExecuteInEditMode 和 ExecuteAlways 都能让 MonoBehaviour 在编辑模式下执行回调,但现在更推荐用 ExecuteAlways。
核心区别是:ExecuteInEditMode 是旧写法,不太适配 Unity 的 Prefab Mode;ExecuteAlways 是新写法,会在编辑模式、运行模式、Prefab Mode 中都执行,但你必须自己区分“编辑器逻辑”和“运行时逻辑”。
Unity 官方文档也明确说:ExecuteInEditMode 不推荐,因为它和 Prefab Mode 不兼容,推荐替代方案是 ExecuteAlways。
底层理解
普通 MonoBehaviour 默认只在 Play Mode 执行,比如 Awake、Update、OnEnable 等生命周期函数。
加了 [ExecuteInEditMode] 后,脚本在编辑器非播放状态也会执行部分回调。
加了 [ExecuteAlways] 后,脚本实例会在编辑、运行、Prefab Mode 等更多场景下执行。官方文档强调,使用它时要避免在编辑模式或非运行世界对象上执行运行时逻辑,推荐用 Application.IsPlaying(gameObject) 判断当前对象是否属于正在运行的世界。
代码示例
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、ExecuteAlways、Application 等类型。
[ExecuteAlways] // 让这个组件在编辑模式和运行模式都能执行回调。
public sealed class ExecuteAlwaysExample : MonoBehaviour // 定义一个 ExecuteAlways 示例组件。
{ // 类开始。
[SerializeField] private float _previewHeight = 1f; // 在 Inspector 中配置编辑器预览高度。
private void Update() // Unity 在满足条件时调用 Update。
{ // Update 开始。
if (Application.IsPlaying(gameObject)) // 判断这个 GameObject 是否属于真正的运行时世界。
{ // 运行时分支开始。
RunPlayModeLogic(); // 执行真正的游戏运行逻辑。
} // 运行时分支结束。
else // 如果当前对象不属于运行时世界。
{ // 编辑器分支开始。
RunEditorPreviewLogic(); // 执行编辑器预览逻辑。
} // 编辑器分支结束。
} // Update 结束。
private void RunPlayModeLogic() // 定义运行时逻辑方法。
{ // 方法开始。
transform.Rotate(Vector3.up, Time.deltaTime * 30f); // 运行时让物体持续旋转。
} // 方法结束。
private void RunEditorPreviewLogic() // 定义编辑器预览逻辑方法。
{ // 方法开始。
Vector3 position = transform.position; // 读取当前物体位置。
position.y = _previewHeight; // 在编辑模式下把高度调整为预览高度。
transform.position = position; // 写回位置,用于编辑器预览。
} // 方法结束。
} // 类结束。为什么不能直接写运行逻辑
因为 ExecuteAlways 在 Prefab Mode 里也可能执行。如果你在里面写生成子物体、扣血、播放特效、注册单例、写存档这类运行时逻辑,就可能在编辑 Prefab 时把资源改脏,甚至保存进 Prefab。
尤其要小心静态变量和单例。编辑器世界里的脚本实例和 Play Mode 里的脚本实例,可能通过同一个静态字段互相影响。
编辑模式回调不是正常每帧
这也是面试容易加分的点:编辑模式下的 Update 不等于运行时每帧稳定调用。官方文档说明,编辑模式对象的函数不会像运行时那样持续调用,Update 通常在场景变化或视图重绘相关情况下触发;渲染回调则和 Scene/Game 视图重绘有关。
所以不要用它做精确计时、物理模拟或运行时战斗逻辑。
怎么选择
新项目优先用 ExecuteAlways,不用 ExecuteInEditMode。
适合用 ExecuteAlways 的场景:
- 编辑器里实时预览效果。
- 自动摆放或对齐对象。
- Procedural Mesh 编辑器预览。
- Gizmos / 可视化辅助。
- Inspector 参数变化后自动刷新显示。
- Prefab 编辑时也希望看到效果。
不适合的场景:
- 纯运行时业务逻辑。
- 战斗、背包、任务、网络同步这类运行逻辑。
- 会频繁创建销毁对象的逻辑。
- 会修改资源、Prefab、场景数据但没有 Undo/脏标记管理的逻辑。
面试可以这样说
CAUTION
“ExecuteInEditMode 和 ExecuteAlways 都能让脚本在编辑模式执行,但 ExecuteInEditMode 是旧写法,不适配 Prefab Mode,Unity 现在推荐用 ExecuteAlways。ExecuteAlways 会让脚本在编辑、运行、Prefab Mode 中都可能执行,所以必须用 Application.IsPlaying(gameObject) 区分当前对象是否属于运行时世界。否则运行时逻辑可能在编辑 Prefab 时执行,导致 Prefab 被错误修改保存。编辑模式下的 Update 也不是正常游戏帧,不适合做真正的运行时逻辑。”
RuntimeInitializeOnLoadMethod 什么时候执行?
标准答案
RuntimeInitializeOnLoadMethod 会在 Unity 运行时启动阶段自动执行被标记的 静态方法,不需要把脚本挂到场景对象上。
它什么时候执行,取决于你传的 RuntimeInitializeLoadType。如果不写参数,默认是 AfterSceneLoad,也就是第一个场景加载完成后执行。Unity 官方也说明了这个 Attribute 用于运行时加载时初始化方法,并且多个同类方法之间的执行顺序不保证:RuntimeInitializeOnLoadMethodAttribute、RuntimeInitializeLoadType。
常见执行时机
SubsystemRegistration:非常早,适合重置静态字段,尤其是关闭 Domain Reload 时。 AfterAssembliesLoaded:程序集加载完成后。 BeforeSplashScreen:启动画面前。 BeforeSceneLoad:第一个场景加载前,适合创建全局 Bootstrap。 AfterSceneLoad:第一个场景加载后,也是默认值。
代码示例
c
using UnityEngine; // 引入 UnityEngine,使用 RuntimeInitializeOnLoadMethod 和 Debug。
public static class RuntimeBootstrap // 定义一个运行时启动引导类。
{ // 类开始。
private static bool _initialized; // 保存是否已经初始化过的静态标记。
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] // 在非常早的阶段执行,常用于重置静态数据。
private static void ResetStatics() // 定义重置静态数据的方法。
{ // 方法开始。
_initialized = false; // 重置初始化标记,避免关闭域重载后残留旧状态。
} // 方法结束。
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] // 在第一个场景加载前执行。
private static void BeforeSceneLoad() // 定义场景加载前初始化方法。
{ // 方法开始。
if (_initialized) // 如果已经初始化过。
{ // if 开始。
return; // 直接返回,避免重复初始化。
} // if 结束。
_initialized = true; // 标记已经完成初始化。
Debug.Log("Before first scene load"); // 输出日志,表示场景加载前初始化已执行。
} // 方法结束。
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] // 在第一个场景加载后执行。
private static void AfterSceneLoad() // 定义场景加载后初始化方法。
{ // 方法开始。
Debug.Log("After first scene load"); // 输出日志,表示场景加载后初始化已执行。
} // 方法结束。
} // 类结束。Unity 工程实践
我一般会这样用:
SubsystemRegistration 用来清理静态状态,比如事件总线、单例缓存、对象池静态表。这个在 Enter Play Mode Options 关闭 Domain Reload 时特别有用。
BeforeSceneLoad 用来创建全局入口,比如 GameRoot、日志系统、资源系统、服务注册器。
AfterSceneLoad 用来访问已经加载的场景对象,但不能假设所有 Start 都已经执行。
常见坑点
不要在多个 RuntimeInitializeOnLoadMethod 之间依赖执行顺序。Unity 不保证同一阶段多个方法的顺序。如果有严格顺序,应该写一个总入口,在里面按顺序调用。
不要在 BeforeSceneLoad 里依赖场景对象的 Awake 已经完成。 不要把它当成普通 MonoBehaviour 生命周期,它不是挂在对象上的生命周期方法,而是运行时启动钩子。
面试可以这样说
WARNING
“RuntimeInitializeOnLoadMethod 是 Unity 的运行时启动初始化特性,用来标记静态方法,让它在 Player 启动或进入 Play Mode 时自动执行。具体执行时机由 RuntimeInitializeLoadType 控制,不传参数默认是 AfterSceneLoad。我常用 SubsystemRegistration 重置静态数据,用 BeforeSceneLoad 创建全局 Bootstrap,用 AfterSceneLoad 做依赖场景加载完成后的初始化。需要注意同阶段多个方法顺序不保证,强顺序初始化要集中到一个 Bootstrap 里管理。”
Application.persistentDataPath 适合存什么?
标准答案
Application.persistentDataPath 适合存运行时生成、希望下次启动还能保留的本地数据,比如存档、玩家设置、热更下载资源、日志、截图、离线缓存等。
Unity 官方对它的定义是:这是一个用于保存“希望在多次运行之间保留的数据”的持久化目录;应用更新通常不会删除这些文件,但用户可以直接清掉这些数据。不同平台路径不同,比如 Windows 常在 AppData/LocalLow,Android 常在 Android/data/<package>/files,iOS 在应用容器的 Documents 目录。
适合存什么
适合存:
- 玩家存档:关卡进度、背包、金币、本地设置。
- 配置设置:画质、音量、按键、自定义 UI。
- 热更新资源:下载的 AssetBundle、Addressables 包、Patch 文件。
- 用户生成内容:截图、录像索引、自定义地图。
- 日志文件:崩溃前日志、性能采样、问题上报缓存。
- 本地数据库:SQLite、离线数据缓存。
不适合存:
- 包体内只读资源,这类更适合
StreamingAssets或资源系统。 - 临时缓存,临时文件更适合
Application.temporaryCachePath。 - 服务器权威数据,比如支付结果、排行榜、关键经济数据。
- 明文 Token、密码、强安全数据。
代码示例
c
using System.IO; // 引入文件和目录操作 API。
using UnityEngine; // 引入 UnityEngine,使用 Application 和 JsonUtility。
public static class LocalSaveManager // 定义一个本地存档管理类。
{ // 类开始。
private const string SaveFolderName = "saves"; // 定义存档子目录名称。
public static string GetSavePath(string fileName) // 根据文件名获取完整存档路径。
{ // 方法开始。
string saveFolder = Path.Combine(Application.persistentDataPath, SaveFolderName); // 把持久化目录和 saves 子目录组合起来。
Directory.CreateDirectory(saveFolder); // 确保存档目录存在,如果已存在不会报错。
return Path.Combine(saveFolder, fileName); // 返回完整文件路径。
} // 方法结束。
public static void SaveJson<T>(string fileName, T data) // 把对象保存成 JSON 文件。
{ // 方法开始。
string path = GetSavePath(fileName); // 获取正式存档路径。
string tempPath = path + ".tmp"; // 准备一个临时文件路径。
string json = JsonUtility.ToJson(data, true); // 把对象转换成格式化 JSON 字符串。
File.WriteAllText(tempPath, json); // 先写入临时文件,降低写一半损坏正式文件的风险。
if (File.Exists(path)) // 判断正式文件是否已经存在。
{ // if 开始。
File.Delete(path); // 删除旧的正式文件。
} // if 结束。
File.Move(tempPath, path); // 把临时文件移动成正式文件。
} // 方法结束。
public static T LoadJson<T>(string fileName, T defaultValue) // 从 JSON 文件读取对象。
{ // 方法开始。
string path = GetSavePath(fileName); // 获取完整存档路径。
if (!File.Exists(path)) // 如果文件不存在。
{ // if 开始。
return defaultValue; // 返回默认值。
} // if 结束。
string json = File.ReadAllText(path); // 读取 JSON 文件内容。
return JsonUtility.FromJson<T>(json); // 把 JSON 字符串还原成对象。
} // 方法结束。
} // 类结束。工程实践
真实项目里我会在 persistentDataPath 下继续分目录,比如:
c
/saves
/settings
/patch
/logs
/screenshots热更资源还要配合 manifest、版本号、hash 校验和清理策略。不能下载完就随便堆在目录里,否则版本多了会占满空间。
存档也不能只写一次正式文件。更稳的做法是:先写临时文件,再校验,再替换正式文件,必要时保留 .bak 备份,避免断电、闪退导致存档损坏。
和其他路径区别
persistentDataPath:可写、持久,适合存本地用户数据。 streamingAssetsPath:随包发布的 StreamingAssets 路径,官方说明它是 StreamingAssets 文件夹路径,构建后位置随平台不同,Android/WebGL 还要注意不能直接用普通同步文件 API 读。
temporaryCachePath:临时缓存目录,官方说明它是 temporary data/cache directory,适合可清理的缓存数据。
面试可以这样说
IMPORTANT
“persistentDataPath 适合存本地持久化数据,比如存档、设置、热更下载资源、日志和截图。它是可写目录,应用更新通常不会清掉,但用户卸载、清数据或手动删除会让文件消失。它不是安全存储,客户端本地文件都不可信,所以关键经济数据、支付结果、排行榜不能只信本地。项目里我会按 saves、patch、logs 分目录管理,并做版本号、hash 校验、临时文件写入、备份和容量清理。”
Application.streamingAssetsPath 有什么特点?
一句话定义:Application.streamingAssetsPath 是 Unity 构建后 StreamingAssets 目录的运行时访问路径,适合读取“随首包一起发布的原始文件”,不适合写存档或热更缓存。
核心特点:
- 放在
Assets/StreamingAssets下的文件会原样打进包里,不走普通资源导入流程。 - 它主要用于“读”,运行时写入请用
Application.persistentDataPath。 - 不同平台路径不同:PC/iOS 可能像普通文件路径,Android/WebGL 可能是 URL 或包内路径。
- 跨平台读取时,推荐用
UnityWebRequest,不要无脑File.ReadAllText。 - 它会增加首包大小,所以不适合放大量可下载、可更新资源。
- 它不是加密保险箱,敏感密钥不要放这里。
适合放什么:首包配置 JSON、初始 AssetBundle、开场视频、协议文本、必须以原始文件形式存在的数据。
不适合放什么:玩家存档、日志、热更下载文件、临时缓存、敏感信息。
c
using System.Collections; // 引入 IEnumerator,用于协程读取文件。
using System.IO; // 引入 Path,用于拼接文件路径。
using UnityEngine; // 引入 UnityEngine,使用 Application、MonoBehaviour 和 Debug。
using UnityEngine.Networking; // 引入 UnityWebRequest,用于跨平台读取文件。
public sealed class StreamingAssetsReader : MonoBehaviour // 定义一个读取 StreamingAssets 的组件。
{ // 类开始。
public IEnumerator ReadTextFile(string relativeFileName) // 定义协程,通过相对文件名读取文本。
{ // 方法开始。
string uri = BuildStreamingAssetUri(relativeFileName); // 构造适配不同平台的读取地址。
using (UnityWebRequest request = UnityWebRequest.Get(uri)) // 创建一个 GET 请求读取文件内容。
{ // using 作用域开始。
yield return request.SendWebRequest(); // 等待读取完成,不阻塞主线程一整帧。
if (request.result != UnityWebRequest.Result.Success) // 判断读取是否失败。
{ // if 开始。
Debug.LogError(request.error); // 输出失败原因。
yield break; // 结束协程。
} // if 结束。
string text = request.downloadHandler.text; // 取出文本内容。
Debug.Log(text); // 打印读取结果,实际项目中可以交给配置解析器。
} // using 作用域结束,释放请求对象。
} // 方法结束。
private static string BuildStreamingAssetUri(string relativeFileName) // 构造 StreamingAssets 文件地址。
{ // 方法开始。
string path = Path.Combine(Application.streamingAssetsPath, relativeFileName); // 拼接根路径和相对文件名。
if (path.Contains("://")) // 判断路径是否已经是 URL,例如 Android 或 WebGL。
{ // if 开始。
return path; // 已经是 URL 时直接返回。
} // if 结束。
return "file://" + path; // 普通本地路径补上 file 协议,交给 UnityWebRequest 读取。
} // 方法结束。
} // 类结束。TIP
面试补一句:我会把它理解成“首包只读原始文件目录”。如果资源需要版本管理、依赖、卸载和热更新,就应该上 AssetBundle 或 Addressables,而不是长期依赖 StreamingAssets。
参考:Unity 官方
Application.streamingAssetsPath,Unity 官方 Streaming Assets。
StreamingAssets 和 Resources 区别是什么?
标准答案
StreamingAssets 和 Resources 都会把内容打进包里,但定位完全不同:
StreamingAssets 更像“原始文件目录”,运行时通过 Application.streamingAssetsPath 拿路径读取文件。
Resources 更像“Unity 内置资源库”,运行时通过 Resources.Load<T>() 按路径加载 Unity 资源对象。
核心区别
| 对比点 | StreamingAssets | Resources |
|---|---|---|
| 放置目录 | Assets/StreamingAssets | 任意 Resources 文件夹 |
| 文件形态 | 原文件保留 | 被 Unity 导入、打进资源索引 |
| 加载方式 | File 或 UnityWebRequest | Resources.Load<T>() |
| 返回内容 | 字节、文本、视频、原始文件 | Prefab、Texture、AudioClip 等对象 |
| 平台差异 | Android/WebGL 可能是 URL | API 屏蔽平台差异 |
| 卸载方式 | 读出来的内容自己管理 | UnloadAsset / UnloadUnusedAssets |
| 适合场景 | 原始配置、视频、种子包 | 小量内置资源、Demo 快速加载 |
| 大项目建议 | 可用但别滥用 | 尽量少用,优先 Addressables |
底层理解
StreamingAssets 的重点是“不要处理我的文件,原样放进包里”。比如你放一个 config.json、一个开场视频、一个初始 AssetBundle,Unity 不会把它变成普通可引用资源,而是构建后复制到平台对应位置。
Resources 的重点是“让我运行时能按字符串路径加载资源”。Unity 会把所有 Resources 目录下的资源记录进一个可加载表,所以即使场景里没有引用,也能通过字符串加载出来。
代码示例
c
using System.Collections; // 引入 IEnumerator,用于协程读取 StreamingAssets。
using System.IO; // 引入 Path,用于拼接文件路径。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Application、Resources 和 Debug。
using UnityEngine.Networking; // 引入 UnityWebRequest,用于跨平台读取 StreamingAssets 文件。
public sealed class BuiltInAssetLoadExample : MonoBehaviour // 定义一个演示 StreamingAssets 和 Resources 区别的组件。
{ // 类开始。
public IEnumerator LoadStreamingAssetsText(string fileName) // 定义协程,从 StreamingAssets 读取文本文件。
{ // 方法开始。
string path = Path.Combine(Application.streamingAssetsPath, fileName); // 拼接 StreamingAssets 根路径和文件名。
string uri = path.Contains("://") ? path : "file://" + path; // 如果不是 URL,就补上 file 协议。
using (UnityWebRequest request = UnityWebRequest.Get(uri)) // 创建 UnityWebRequest,用于跨平台读取文件。
{ // using 开始。
yield return request.SendWebRequest(); // 等待读取完成。
if (request.result != UnityWebRequest.Result.Success) // 判断读取是否失败。
{ // if 开始。
Debug.LogError(request.error); // 输出失败原因。
yield break; // 结束协程。
} // if 结束。
string text = request.downloadHandler.text; // 取得文本内容。
Debug.Log(text); // 打印文本内容。
} // using 结束。
} // 方法结束。
public GameObject LoadResourcesPrefab(string prefabPath) // 定义方法,从 Resources 加载 Prefab。
{ // 方法开始。
GameObject prefab = Resources.Load<GameObject>(prefabPath); // 按 Resources 内部相对路径加载 Prefab。
return prefab; // 返回加载到的 Unity 资源对象。
} // 方法结束。
} // 类结束。面试加分说法
NOTE
我不会把它们当成完整资源管理方案。StreamingAssets 适合少量原始文件,Resources 适合少量内置资源;如果项目涉及依赖、版本、分包、卸载、热更新,就应该用 AssetBundle 或 Addressables 做系统化管理。
PlayerPrefs 适合存什么?不适合存什么?
标准答案
PlayerPrefs 适合存“轻量、低频、可恢复默认值”的本地偏好设置,不适合存重要存档、账号数据、付费数据、背包金币这类核心业务数据。
适合存什么
- 音量大小:音乐音量、音效音量。
- 画质设置:低中高画质、帧率开关、阴影开关。
- 语言设置:中文、英文、日文。
- 操作习惯:灵敏度、反转 Y 轴、震动开关。
- 新手引导标记:某个提示是否已经看过。
- 本地 UI 偏好:上次打开的页签、排序方式。
- 不影响公平性的本地记录:比如离线 Demo 的最高分。
不适合存什么
- 金币、钻石、体力、装备、背包。
- 账号 Token、密码、手机号等敏感信息。
- 付费结果、充值状态、签到奖励。
- 大型存档、复杂 JSON、大量关卡数据。
- 反作弊相关数据。
- 需要跨设备同步的数据。
底层原理
PlayerPrefs 本质是 Unity 封装的一套本地 key-value 存储,只支持三种类型:
c
int
float
string不同平台底层落地位置不同,比如 Windows 可能落到注册表,移动端可能落到系统偏好存储。它不是数据库,也不是加密系统,所以玩家有机会修改它。
代码示例
c
using UnityEngine; // 引入 UnityEngine,使用 PlayerPrefs 和 Mathf。
public static class GameSettingsPrefs // 定义一个静态类,统一管理游戏设置存取。
{ // 类开始。
private const string MusicVolumeKey = "settings.music_volume"; // 定义音乐音量的 key,避免到处写字符串。
private const string LanguageKey = "settings.language"; // 定义语言设置的 key,避免 key 写错。
private const string TutorialDoneKey = "settings.tutorial_done"; // 定义新手引导完成标记的 key。
public static void SetMusicVolume(float volume) // 定义保存音乐音量的方法。
{ // 方法开始。
float safeVolume = Mathf.Clamp01(volume); // 把音量限制在 0 到 1 之间,避免非法值。
PlayerPrefs.SetFloat(MusicVolumeKey, safeVolume); // 把音量保存到 PlayerPrefs。
PlayerPrefs.Save(); // 立刻落盘,适合设置界面点击确定时调用。
} // 方法结束。
public static float GetMusicVolume() // 定义读取音乐音量的方法。
{ // 方法开始。
return PlayerPrefs.GetFloat(MusicVolumeKey, 1.0f); // 读取音量,如果没有保存过就返回默认值 1。
} // 方法结束。
public static void SetLanguage(string language) // 定义保存语言的方法。
{ // 方法开始。
PlayerPrefs.SetString(LanguageKey, language); // 保存语言字符串。
PlayerPrefs.Save(); // 保存到本地存储。
} // 方法结束。
public static string GetLanguage() // 定义读取语言的方法。
{ // 方法开始。
return PlayerPrefs.GetString(LanguageKey, "zh-CN"); // 读取语言,如果没有保存过就默认中文。
} // 方法结束。
public static void SetTutorialDone(bool done) // 定义保存新手引导状态的方法。
{ // 方法开始。
PlayerPrefs.SetInt(TutorialDoneKey, done ? 1 : 0); // 用 1 和 0 表示 bool。
PlayerPrefs.Save(); // 保存到本地存储。
} // 方法结束。
public static bool IsTutorialDone() // 定义读取新手引导状态的方法。
{ // 方法开始。
return PlayerPrefs.GetInt(TutorialDoneKey, 0) == 1; // 读取 int 并转换成 bool。
} // 方法结束。
} // 类结束。面试加分说法
TIP
我会把 PlayerPrefs 定位为“玩家偏好配置”,不是“正式存档系统”。如果数据影响资产、进度、公平性,就要用正式存档方案,至少要有结构化文件、校验、备份、版本迁移;联网游戏还应该由服务端权威保存。
SceneManager.LoadScene 和 LoadSceneAsync 区别是什么?
标准答案
SceneManager.LoadScene 是同步式场景切换,写法简单,但容易造成卡顿或黑屏;SceneManager.LoadSceneAsync 是异步加载场景,会返回 AsyncOperation,可以做进度条、加载界面、延迟激活。
核心区别
| 对比点 | LoadScene | LoadSceneAsync |
|---|---|---|
| 加载方式 | 同步式调用 | 异步加载 |
| 返回值 | 没有进度对象 | 返回 AsyncOperation |
| 是否能做进度条 | 不方便 | 可以读 progress |
| 是否能控制激活 | 不方便 | 可用 allowSceneActivation |
| 卡顿风险 | 较高 | 较低,但激活阶段仍可能卡 |
| 适合场景 | 小场景、测试场景 | 大场景、正式项目、移动端 |
底层理解
场景加载不是只把 .unity 文件读进来,还包括资源依赖加载、对象反序列化、GameObject 创建、组件初始化、Awake、OnEnable 等过程。
LoadScene 控制权少,调用后 Unity 会尽快完成场景切换,容易让当前帧或下一帧出现明显停顿。
LoadSceneAsync 把加载过程拆开,你可以在加载界面里每帧更新进度。常见做法是等 progress 到 0.9,然后在玩家点击继续、淡入淡出结束、资源预热完成后,再设置:
c
operation.allowSceneActivation = true;注意:异步加载不等于完全不卡。最后激活新场景时,如果新场景里对象很多、Awake 很重、UI 初始化很重,还是可能出现尖峰。
代码示例
c
using System.Collections; // 引入 IEnumerator,用于编写协程。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Debug 和 Mathf。
using UnityEngine.SceneManagement; // 引入场景管理命名空间,使用 SceneManager 和 AsyncOperation。
public sealed class SceneLoadExample : MonoBehaviour // 定义一个场景加载示例组件。
{ // 类开始。
public IEnumerator LoadSceneWithProgress(string sceneName) // 定义一个带进度控制的异步加载协程。
{ // 方法开始。
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName); // 开始异步加载目标场景。
operation.allowSceneActivation = false; // 暂时不允许自动激活场景,让加载界面有控制权。
while (operation.progress < 0.9f) // Unity 场景异步加载通常会在 0.9 处等待激活。
{ // while 开始。
float progress = Mathf.Clamp01(operation.progress / 0.9f); // 把 0 到 0.9 映射成 0 到 1 的进度。
Debug.Log("Loading: " + progress); // 输出当前加载进度,实际项目中这里更新进度条。
yield return null; // 等待下一帧,避免阻塞主线程。
} // while 结束。
Debug.Log("Loading: 1"); // 表示资源加载阶段已经完成。
yield return null; // 可以多等一帧,让 UI 有机会显示满进度。
operation.allowSceneActivation = true; // 允许 Unity 激活新场景。
} // 方法结束。
} // 类结束。面试加分说法
IMPORTANT
正式项目里我一般不会裸用 LoadScene 切大场景,而是做一个场景加载流程:先显示 Loading UI,再异步加载场景和依赖资源,进度到 0.9 后等待淡入淡出或预初始化完成,最后激活场景。这样能把黑屏和卡顿控制在可预期范围内。
参考:Unity 官方
SceneManager.LoadScene,SceneManager.LoadSceneAsync,AsyncOperation.allowSceneActivation。
AsyncOperation.allowSceneActivation 有什么用?
标准答案
AsyncOperation.allowSceneActivation 是异步场景加载的“激活开关”。 LoadSceneAsync 会先把场景加载到接近完成,如果把 allowSceneActivation 设为 false,Unity 会让进度停在 0.9,暂时不切入新场景;等你把它设回 true,Unity 才会真正激活场景,AsyncOperation 才会完成。
它解决什么问题
比如你做 Loading 界面时,不希望场景一加载完就突然切过去,而是希望:
- 进度条先走满。
- 播放淡出动画。
- 等玩家点击“继续”。
- 预热一些资源或初始化数据。
- 最后再进入新场景。
这时就用:
c
operation.allowSceneActivation = false;等准备好了再:
c
operation.allowSceneActivation = true;底层理解
LoadSceneAsync 返回的是一个 AsyncOperation。 当 allowSceneActivation = false 时:
progress通常会停在0.9。isDone会保持false。- 新场景不会真正激活。
- 后续排队的异步操作也可能被阻塞。
- 只有设回
true后,才会进入激活阶段。
注意:0.9 不代表“已经进入场景”,它更像是“加载阶段基本完成,正在等激活许可”。
代码示例
c
using System.Collections; // 引入 IEnumerator,用于编写协程。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Debug 和 Mathf。
using UnityEngine.SceneManagement; // 引入场景管理命名空间,使用 SceneManager 和 AsyncOperation。
public sealed class SceneActivationGate : MonoBehaviour // 定义一个控制异步场景激活的组件。
{ // 类开始。
private bool canEnterScene; // 记录是否允许进入新场景。
public void ConfirmEnterScene() // 定义按钮点击后调用的方法。
{ // 方法开始。
canEnterScene = true; // 玩家确认后,允许激活新场景。
} // 方法结束。
public IEnumerator LoadSceneWithGate(string sceneName) // 定义一个带激活闸门的加载协程。
{ // 协程开始。
canEnterScene = false; // 每次加载前先重置确认状态。
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName); // 开始异步加载目标场景。
operation.allowSceneActivation = false; // 暂时禁止场景自动激活。
while (operation.progress < 0.9f) // 等待加载阶段到达可激活状态。
{ // while 开始。
float progress = Mathf.Clamp01(operation.progress / 0.9f); // 把 0 到 0.9 映射成 0 到 1。
Debug.Log("Loading: " + progress); // 输出进度,实际项目中这里更新 UI 进度条。
yield return null; // 等待下一帧,避免阻塞主线程。
} // while 结束。
Debug.Log("Loading: 1"); // 进度条可以显示为 100%。
while (!canEnterScene) // 等待玩家点击继续或等待淡出动画结束。
{ // while 开始。
yield return null; // 每帧继续等待。
} // while 结束。
operation.allowSceneActivation = true; // 放行场景激活,真正切入新场景。
} // 协程结束。
} // 类结束。面试加分说法
allowSceneActivation 不是“暂停加载”,而是“加载完后暂停激活”。 它常用于 Loading 页控制切场景节奏,但最后激活场景时仍可能出现尖峰,因为新场景对象的 Awake、OnEnable、UI 初始化、资源实例化都可能集中发生。所以大项目还要配合资源预加载、分帧初始化、对象池和场景拆分。
参考:Unity 官方
AsyncOperation.allowSceneActivation,SceneManager.LoadSceneAsync。
场景异步加载进度为什么常停在 0.9?
标准答案
场景异步加载进度常停在 0.9,是因为 Unity 把场景加载分成了两个阶段:
0 ~ 0.9:加载场景数据和依赖资源。0.9 ~ 1.0:激活场景,真正切换过去。
如果你把 AsyncOperation.allowSceneActivation 设成 false,Unity 会在加载到 0.9 后暂停,不会进入新场景,isDone 也不会变成 true。等你设回 true,它才会继续从 0.9 到 1.0。
底层理解
progress = 0.9 不是“卡住了”,而是“加载阶段已经基本完成,正在等激活许可”。
场景激活不是一个小动作,它可能包含:
- 切换当前激活场景。
- 卸载旧场景。
- 创建新场景对象。
- 反序列化组件。
- 调用
Awake。 - 调用
OnEnable。 - 初始化 UI、脚本、引用关系。
所以 Unity 不把 0.9 直接当作完成,而是把最后的激活阶段留到 allowSceneActivation = true 后执行。
进度条怎么写
不要直接把 operation.progress 当作 UI 进度,因为它最多会到 0.9。常见写法是:
c
using System.Collections; // 引入 IEnumerator,用于编写协程。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Mathf 和 Debug。
using UnityEngine.SceneManagement; // 引入场景管理命名空间,使用 SceneManager 和 AsyncOperation。
public sealed class LoadingProgressExample : MonoBehaviour // 定义一个异步加载进度示例组件。
{ // 类开始。
public IEnumerator LoadScene(string sceneName) // 定义异步加载场景的协程。
{ // 方法开始。
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName); // 开始异步加载目标场景。
operation.allowSceneActivation = false; // 暂时禁止场景自动激活。
while (operation.progress < 0.9f) // 等待加载阶段完成。
{ // while 开始。
float uiProgress = Mathf.Clamp01(operation.progress / 0.9f); // 把 0 到 0.9 映射成 0 到 1。
Debug.Log("UI Progress: " + uiProgress); // 输出 UI 进度,实际项目中这里更新进度条。
yield return null; // 等待下一帧,避免阻塞主线程。
} // while 结束。
Debug.Log("UI Progress: 1"); // 让进度条显示为 100%。
yield return null; // 等待一帧,让满进度 UI 有机会显示出来。
operation.allowSceneActivation = true; // 放行场景激活,进入新场景。
} // 方法结束。
} // 类结束。常见坑
如果进度一直停在 0.9,优先检查是不是忘了:
c
operation.allowSceneActivation = true;另外,就算用了异步加载,最后激活场景时仍然可能卡一下。因为大量对象的 Awake、OnEnable、UI 初始化、资源实例化可能集中在激活阶段。项目里要配合资源预加载、分帧初始化、对象池、场景拆分来优化。
参考:Unity 官方
AsyncOperation.allowSceneActivation,SceneManager.LoadSceneAsync。
DontDestroyOnLoad 如何避免重复单例?
标准答案
DontDestroyOnLoad 只能保证对象切场景时不被销毁,但它不会自动保证“全局唯一”。 避免重复单例的关键是:在 Awake 里先判断是否已经有实例,如果已经有,就销毁新创建的重复对象;如果没有,才把自己设为 Instance,并调用 DontDestroyOnLoad(gameObject)。
为什么会重复
常见情况是:
场景 A 里有一个 GameManager,它调用了 DontDestroyOnLoad,切到场景 B 后不会销毁。 但场景 B 里也放了一个 GameManager 预制体,于是 B 的 Awake 又执行了一次,场景里就出现两个管理器。
正确写法
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、GameObject 和 DontDestroyOnLoad。
public sealed class GameManager : MonoBehaviour // 定义一个不可继承的游戏管理器单例。
{ // 类开始。
public static GameManager Instance { get; private set; } // 对外提供只读 Instance,外部不能随便改。
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] // 在运行时初始化阶段重置静态字段。
private static void ResetStaticInstance() // 定义静态字段清理方法,处理关闭 Domain Reload 的情况。
{ // 方法开始。
Instance = null; // 清空旧的静态引用,避免上一次运行残留。
} // 方法结束。
private void Awake() // Awake 最早执行,适合做单例守门判断。
{ // 方法开始。
if (Instance != null && Instance != this) // 如果已经存在另一个实例,说明当前对象是重复单例。
{ // if 开始。
Destroy(gameObject); // 销毁当前重复对象。
return; // 立刻返回,避免重复对象继续初始化或注册事件。
} // if 结束。
Instance = this; // 把第一个合法对象设置为全局实例。
DontDestroyOnLoad(gameObject); // 让这个对象切换场景时不被销毁。
} // 方法结束。
private void OnDestroy() // 对象销毁时执行清理。
{ // 方法开始。
if (Instance == this) // 只有当前对象就是单例实例时才清空。
{ // if 开始。
Instance = null; // 清空静态引用,避免指向已销毁对象。
} // if 结束。
} // 方法结束。
} // 类结束。工程里怎么避免更稳
- 单例守门逻辑放
Awake,不要放Start,因为Start太晚了,重复对象可能已经注册事件或初始化资源。 Destroy(gameObject)后一定要return,否则重复对象还会继续执行后面的初始化逻辑。- 最好用一个
Bootstrap启动场景统一创建全局管理器,后续业务场景不要再摆同类 Manager。 - 如果关闭了 Enter Play Mode 的 Domain Reload,要主动清理静态
Instance,否则编辑器里会出现“上一次运行残留”的假单例问题。 - 单例对象如果挂了事件、网络回调、协程,要在销毁时取消订阅和停止逻辑,避免隐藏引用导致泄漏。
面试加分说法
IMPORTANT
DontDestroyOnLoad 解决的是生命周期问题,不解决唯一性问题。唯一性要靠 Instance 守门判断;初始化入口要靠 Bootstrap 规范;静态残留要靠运行时初始化清理。项目里我会尽量减少场景里重复摆全局管理器,而是让全局服务从统一入口创建。
Instantiate 带 parent 参数时坐标有什么区别?
标准答案
Instantiate 带 parent 参数时,关键区别在于:你想让新对象按“父物体局部坐标”摆放,还是保持“世界坐标”不变。
常见有三种写法:
| 写法 | 坐标含义 |
|---|---|
Instantiate(prefab, parent) | 创建后直接挂到 parent 下,通常按父物体局部空间理解 |
Instantiate(prefab, parent, false) | 不保持世界坐标,使用相对父物体的局部坐标 |
Instantiate(prefab, parent, true) | 保持世界坐标不变,Unity 反算 localPosition |
Instantiate(prefab, position, rotation, parent) | position 和 rotation 是世界坐标和世界旋转 |
底层理解
transform.position 是世界坐标。 transform.localPosition 是相对父物体的局部坐标。
当你给新对象指定 parent 时,Unity 需要决定一件事:
“这个对象看起来的位置要不要保持原来的世界位置?”
如果 worldPositionStays = true,Unity 会保持 position 不变,然后重新计算 localPosition。 如果 worldPositionStays = false,Unity 更关注对象在父节点下面的局部坐标。
代码示例
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、GameObject、Transform 和 Vector3。
public sealed class InstantiateParentExample : MonoBehaviour // 定义一个演示 Instantiate parent 坐标区别的组件。
{ // 类开始。
[SerializeField] private GameObject prefab; // 在 Inspector 中拖入要实例化的预制体。
[SerializeField] private Transform parent; // 在 Inspector 中拖入新对象的父节点。
public void CreateAsChildLocal() // 创建一个按父物体局部空间摆放的对象。
{ // 方法开始。
GameObject obj = Instantiate(prefab, parent); // 创建对象并直接挂到 parent 下。
obj.transform.localPosition = Vector3.zero; // 把对象放到父物体的局部原点。
obj.transform.localRotation = Quaternion.identity; // 让对象相对父物体没有旋转。
obj.transform.localScale = Vector3.one; // 让对象相对父物体保持 1 倍缩放。
} // 方法结束。
public void CreateKeepWorldPosition() // 创建一个保持世界坐标不变的对象。
{ // 方法开始。
GameObject obj = Instantiate(prefab, parent, true); // 创建对象并挂到 parent 下,同时保持世界坐标。
Debug.Log(obj.transform.position); // 打印世界坐标,通常看起来不会因为换父节点而移动。
Debug.Log(obj.transform.localPosition); // 打印局部坐标,这个值可能被 Unity 重新计算过。
} // 方法结束。
public void CreateAtWorldPosition(Vector3 worldPosition) // 在指定世界坐标创建对象。
{ // 方法开始。
Quaternion worldRotation = Quaternion.identity; // 准备一个世界旋转。
GameObject obj = Instantiate(prefab, worldPosition, worldRotation, parent); // 按世界坐标创建对象,然后挂到 parent 下。
Debug.Log(obj.transform.position); // 打印世界坐标,应该接近传入的 worldPosition。
} // 方法结束。
} // 类结束。Unity 项目里怎么用
UI 里通常这样写:
c
GameObject item = Instantiate(itemPrefab, content);然后再设置 RectTransform 的 anchoredPosition、localScale 等,因为 UI 更关注父节点下的局部布局。
场景物体里,如果你想在某个世界坐标生成特效、怪物、掉落物,就更常用:
c
Instantiate(prefab, worldPosition, worldRotation, parent);常见坑
parent 不代表“世界坐标一定不变”。 父物体如果有缩放、旋转,子物体的 localPosition、localRotation、localScale 都会受到父层级影响。UI 错位、特效位置偏移、挂点武器旋转异常,经常就是没分清 position 和 localPosition。
参考:Unity 官方
Object.Instantiate,Transform.SetParent。
Destroy(obj, time) 如何工作?
标准答案
Destroy(obj, time) 的作用是:在 time 秒后销毁 Unity 对象。 但它不是像 C++ delete 那样立刻释放内存,而是向 Unity 提交一个“延迟销毁请求”。到时间后,Unity 会在安全时机真正移除对象。
核心规则
Destroy(obj):不传时间,通常会在当前Update结束后、渲染前销毁。Destroy(obj, time):等待time秒后再销毁。time受Time.timeScale影响,暂停游戏时延迟销毁也可能暂停。Destroy(gameObject):销毁整个 GameObject、组件和子物体。Destroy(component):只销毁这个组件,GameObject 还在。- 销毁对象不代表资源内存马上下降,还要看引用、GC、资源卸载。
底层理解
Unity 不会在你调用 Destroy 的那一行立刻把对象从场景结构里删掉,因为当前帧可能还在遍历组件、执行生命周期、处理物理或准备渲染。 所以 Unity 会把对象标记为“待销毁”,等到帧末安全点再真正处理。
这也是为什么调用后你可能还拿着引用,但 Unity 对象已经进入销毁流程了。面试里可以补一句:Unity 对象销毁后还有 C# 包装对象存在,所以有时会出现“看起来像 null”的特殊比较行为。
代码示例
c
using System.Collections; // 引入 IEnumerator,用于写不受 timeScale 影响的实时延迟协程。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、GameObject、Destroy 和 Time。
public sealed class DestroyDelayExample : MonoBehaviour // 定义一个演示 Destroy 延迟销毁的组件。
{ // 类开始。
[SerializeField] private GameObject target; // 在 Inspector 中指定要销毁的目标对象。
public void DestroyAfterScaledSeconds() // 定义一个按游戏时间延迟销毁的方法。
{ // 方法开始。
if (target == null) // 如果目标已经为空或已经被销毁。
{ // if 开始。
return; // 直接返回,避免重复销毁。
} // if 结束。
Destroy(target, 2f); // 2 秒后销毁目标对象,这里的 2 秒受 Time.timeScale 影响。
target = null; // 主动清空引用,避免后续代码继续使用这个待销毁对象。
} // 方法结束。
public void DestroyOnlyThisComponent() // 定义一个只销毁当前组件的方法。
{ // 方法开始。
Destroy(this, 1f); // 1 秒后销毁当前脚本组件,但不会销毁整个 GameObject。
} // 方法结束。
public void DestroyAfterRealtime(GameObject obj, float seconds) // 定义一个按真实时间延迟销毁的方法。
{ // 方法开始。
StartCoroutine(DestroyByRealtime(obj, seconds)); // 启动协程,用 unscaledDeltaTime 自己计时。
} // 方法结束。
private IEnumerator DestroyByRealtime(GameObject obj, float seconds) // 定义一个不受 timeScale 影响的销毁协程。
{ // 协程开始。
float timer = 0f; // 初始化真实时间计时器。
while (timer < seconds) // 只要还没达到目标秒数,就继续等待。
{ // while 开始。
timer += Time.unscaledDeltaTime; // 使用未缩放时间累加,暂停游戏时也会继续走。
yield return null; // 等待下一帧。
} // while 结束。
if (obj != null) // 如果对象还没有被提前销毁。
{ // if 开始。
Destroy(obj); // 立刻提交销毁请求,在帧末安全点销毁对象。
} // if 结束。
} // 协程结束。
} // 类结束。常见坑
Destroy(obj, time) 不适合高频对象,比如子弹、飘字、特效。大量创建和销毁会带来 CPU 开销和 GC 压力,项目里通常用对象池:到时间后不 Destroy,而是 SetActive(false) 并归还池子。
如果你想“暂停期间也倒计时销毁”,不要直接依赖 Destroy(obj, time),可以用 Time.unscaledDeltaTime 或 WaitForSecondsRealtime 自己控制。
参考:Unity 官方
Object.Destroy。
SetActive(false) 会触发哪些回调?
标准答案
SetActive(false) 会让这个 GameObject 变成不活跃。如果它的 activeInHierarchy 从 true 变成 false,Unity 会触发它身上脚本的 OnDisable(),并且子物体如果也因此变成不活跃,子物体上的脚本也会触发 OnDisable()。
它不会触发 OnDestroy(),因为对象只是被禁用,不是被销毁。
会发生什么
| 行为 | 是否发生 |
|---|---|
OnDisable() | 会触发 |
Update() | 停止调用 |
FixedUpdate() | 停止调用 |
LateUpdate() | 停止调用 |
| 协程 | 挂在该对象上的协程会停止 |
OnDestroy() | 不会触发 |
| 子物体 | 会一起变成不活跃 |
再次 SetActive(true) | 触发 OnEnable() |
底层理解
SetActive(false) 改的是 activeSelf。 真正决定对象是否在场景中运行的是 activeInHierarchy。
比如:
c
Parent.SetActive(false)
Child.activeSelf 还是 true
Child.activeInHierarchy 会变成 false也就是说,子物体自己虽然“本地开关”还是开着,但因为父物体关了,所以它在层级中依然是不活跃的。
代码示例
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、GameObject 和 Debug。
public sealed class SetActiveCallbackExample : MonoBehaviour // 定义一个观察 SetActive 回调的组件。
{ // 类开始。
private void OnEnable() // 当对象从不活跃变成活跃时调用。
{ // 方法开始。
Debug.Log(name + " OnEnable"); // 打印 OnEnable,表示对象重新进入运行状态。
} // 方法结束。
private void Start() // 第一次启用并进入 Update 前调用。
{ // 方法开始。
Debug.Log(name + " Start"); // 打印 Start,注意 Start 通常只会执行一次。
} // 方法结束。
private void Update() // 对象活跃且脚本启用时每帧调用。
{ // 方法开始。
Debug.Log(name + " Update"); // 打印 Update,SetActive(false) 后不会继续调用。
} // 方法结束。
private void OnDisable() // 当对象从活跃变成不活跃时调用。
{ // 方法开始。
Debug.Log(name + " OnDisable"); // 打印 OnDisable,适合取消事件和停止表现逻辑。
} // 方法结束。
private void OnDestroy() // 当对象真正被销毁时调用。
{ // 方法开始。
Debug.Log(name + " OnDestroy"); // 打印 OnDestroy,SetActive(false) 不会触发这里。
} // 方法结束。
} // 类结束。对象池里怎么用
对象池通常用 SetActive(false) 回收对象,所以 OnDisable() 经常用来做轻量清理:
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Vector3。
public sealed class Bullet : MonoBehaviour // 定义一个子弹组件。
{ // 类开始。
private Vector3 velocity; // 保存子弹速度。
private float lifeTime; // 保存子弹剩余生命周期。
private void OnEnable() // 子弹从对象池取出并启用时调用。
{ // 方法开始。
lifeTime = 3f; // 重置生命周期。
} // 方法结束。
private void Update() // 子弹启用后每帧移动。
{ // 方法开始。
transform.position += velocity * Time.deltaTime; // 根据速度移动子弹。
lifeTime -= Time.deltaTime; // 扣除生命周期。
if (lifeTime <= 0f) // 如果生命周期结束。
{ // if 开始。
gameObject.SetActive(false); // 禁用对象,相当于归还对象池。
} // if 结束。
} // 方法结束。
private void OnDisable() // 子弹被禁用时调用。
{ // 方法开始。
velocity = Vector3.zero; // 清空速度,避免下次复用残留。
lifeTime = 0f; // 清空生命周期。
} // 方法结束。
} // 类结束。面试加分说法
NOTE
我会把 SetActive(false) 理解成“停用对象和整棵子层级”,不是销毁对象。它会触发 OnDisable,停止更新和协程;但不会触发 OnDestroy。在对象池里,OnDisable 适合做轻量状态清理,但不要放特别重的资源释放逻辑,否则频繁回收对象也会卡。
参考:Unity 官方
GameObject.SetActive,MonoBehaviour.OnDisable。
禁用脚本组件会不会停止 Update?
标准答案
会。把脚本组件禁用,比如:
c
myScript.enabled = false;这个脚本的 Update、FixedUpdate、LateUpdate 等更新回调就不会再执行,并且会触发 OnDisable()。 但注意:这只是禁用这个 MonoBehaviour 组件,不是禁用整个 GameObject。
它会停止什么
| 内容 | 是否停止 |
|---|---|
Update() | 会停止 |
FixedUpdate() | 会停止 |
LateUpdate() | 会停止 |
OnGUI() | 会停止 |
OnDisable() | 会触发 |
OnDestroy() | 不会触发 |
| 其他组件 | 不受影响 |
| GameObject 本身 | 仍然 active |
| 已启动协程 | 禁用脚本本身不一定停止 |
容易混淆的点
script.enabled = false 和 gameObject.SetActive(false) 不一样。
script.enabled = false:只停这个脚本。 gameObject.SetActive(false):停整个对象和子层级,Renderer、Collider、其他脚本也一起不活跃。
另外,协程是高频坑点:禁用 MonoBehaviour.enabled 并不会自动停止这个脚本已经启动的协程;如果要停,需要手动 StopCoroutine 或 StopAllCoroutines。但如果是 SetActive(false) 禁用整个 GameObject,协程会受到影响。
代码示例
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Debug 和 enabled。
public sealed class DisableScriptExample : MonoBehaviour // 定义一个演示禁用脚本组件的示例脚本。
{ // 类开始。
private void OnEnable() // 当脚本组件从禁用变为启用时调用。
{ // 方法开始。
Debug.Log("OnEnable"); // 打印启用回调。
} // 方法结束。
private void Update() // 脚本启用并且 GameObject 活跃时每帧调用。
{ // 方法开始。
Debug.Log("Update running"); // 打印 Update 正在运行。
} // 方法结束。
private void OnDisable() // 当脚本组件从启用变为禁用时调用。
{ // 方法开始。
Debug.Log("OnDisable"); // 打印禁用回调。
} // 方法结束。
public void DisableThisScript() // 定义一个禁用当前脚本的方法。
{ // 方法开始。
enabled = false; // 禁用当前 MonoBehaviour,之后 Update 不再执行。
} // 方法结束。
} // 类结束。面试加分说法
CAUTION
enabled=false 停的是这个 Behaviour 的调度,不是销毁对象,也不是禁用整个层级。它会触发 OnDisable,停止 Update 类回调;但对象、Transform、其他组件仍然存在。对象池里如果只是想停某个逻辑脚本可以用 enabled=false,如果要整体回收表现对象,通常用 SetActive(false)。
参考:Unity 官方
Behaviour.enabled,MonoBehaviour.OnDisable,MonoBehaviour.StartCoroutine。
禁用 GameObject 会不会停止协程?
标准答案
会。gameObject.SetActive(false) 会停止挂在这个 GameObject 上的协程。 并且再次 SetActive(true) 后,之前被停止的协程不会自动从原来的 yield 位置继续执行,需要你重新 StartCoroutine。
要和禁用脚本组件区分
| 操作 | Update 是否停止 | 协程是否停止 |
|---|---|---|
script.enabled = false | 会停止 | 已启动协程通常不会自动停止 |
gameObject.SetActive(false) | 会停止 | 挂在该对象上的协程会停止 |
Destroy(gameObject) | 会停止 | 会停止,并最终销毁对象 |
底层理解
协程是由某个 MonoBehaviour 启动和托管的。 如果整个 GameObject 被禁用,这个对象上的 MonoBehaviour 也进入不活跃状态,Unity 会停止附着在它上面的协程。
但如果协程是由另一个常驻对象启动的,比如 CoroutineRunner、GameManager、DontDestroyOnLoad 对象,那么目标对象被禁用,不一定会影响那个外部协程。
代码示例
c
using System.Collections; // 引入 IEnumerator,用于编写协程。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Debug、WaitForSeconds 和 GameObject。
public sealed class CoroutineDisableExample : MonoBehaviour // 定义一个演示 GameObject 禁用和协程关系的组件。
{ // 类开始。
private Coroutine runningCoroutine; // 保存当前正在运行的协程句柄。
private void OnEnable() // GameObject 被启用时调用。
{ // 方法开始。
runningCoroutine = StartCoroutine(PrintLoop()); // 重新启动协程,因为之前禁用对象时协程已经停止。
} // 方法结束。
private void OnDisable() // GameObject 被禁用时调用。
{ // 方法开始。
runningCoroutine = null; // 清空协程句柄,因为对象禁用后协程不会继续跑。
Debug.Log("GameObject disabled, coroutine stopped"); // 打印提示,说明对象禁用会停止协程。
} // 方法结束。
private IEnumerator PrintLoop() // 定义一个循环打印的协程。
{ // 协程开始。
while (true) // 无限循环,直到协程被停止。
{ // while 开始。
Debug.Log("Coroutine running"); // 打印协程正在运行。
yield return new WaitForSeconds(1f); // 等待 1 秒后继续执行。
} // while 结束。
} // 协程结束。
public void RecycleToPool() // 模拟对象池回收对象。
{ // 方法开始。
gameObject.SetActive(false); // 禁用整个 GameObject,同时停止挂在该对象上的协程。
} // 方法结束。
} // 类结束。对象池里的常见坑
WARNING
如果对象池回收用的是 SetActive(false),那么这个对象自己启动的攻击协程、AI 协程、倒计时协程都会停。下次从池子取出时,如果还需要这些逻辑,应该在 OnEnable 里重新启动。
如果某个任务不能因为对象禁用而停止,比如全局下载、全局计时、场景加载进度,就不要挂在会被禁用的对象上,而是挂到常驻的 CoroutineRunner 或管理器上。
参考:Unity 官方
GameObject.SetActive,MonoBehaviour.StartCoroutine。
StartCoroutine(string) 和 StartCoroutine(IEnumerator) 区别是什么?
标准答案
StartCoroutine(IEnumerator) 是直接把协程迭代器传给 Unity,类型安全、参数灵活、开销更低,项目里更推荐。
StartCoroutine(string) 是通过方法名字符串启动协程,Unity 需要按名字查找方法,有运行时开销,且只能传一个参数。它的主要好处是可以用同一个方法名字符串 StopCoroutine("MethodName") 停止。
核心区别
| 对比点 | StartCoroutine(IEnumerator) | StartCoroutine(string) |
|---|---|---|
| 调用方式 | StartCoroutine(Fade(1f)) | StartCoroutine("Fade", 1f) |
| 类型检查 | 编译期检查 | 字符串写错运行时才发现 |
| 参数 | 任意参数都可以 | 最多一个参数 |
| 性能 | 更好 | 有额外查找开销 |
| 重构安全 | 方法改名 IDE 能跟着改 | 字符串容易漏改 |
| 停止方式 | 保存 Coroutine 或 IEnumerator | 可用方法名停止 |
代码示例
c
using System.Collections; // 引入 IEnumerator,用于定义协程方法。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Coroutine、Debug 和 WaitForSeconds。
public sealed class CoroutineStartExample : MonoBehaviour // 定义一个演示两种 StartCoroutine 写法的组件。
{ // 类开始。
private Coroutine directHandle; // 保存 IEnumerator 版本启动后返回的 Coroutine 句柄。
public void StartDirect() // 定义直接启动 IEnumerator 协程的方法。
{ // 方法开始。
directHandle = StartCoroutine(FadeDirect(1f, 2f)); // 推荐写法:直接传 IEnumerator,参数类型安全。
} // 方法结束。
public void StopDirect() // 定义停止直接启动协程的方法。
{ // 方法开始。
if (directHandle != null) // 如果之前保存过 Coroutine 句柄。
{ // if 开始。
StopCoroutine(directHandle); // 用同一个 Coroutine 句柄停止协程。
directHandle = null; // 清空句柄,避免重复停止。
} // if 结束。
} // 方法结束。
public void StartByString() // 定义通过字符串方法名启动协程的方法。
{ // 方法开始。
StartCoroutine(nameof(FadeByName), 1f); // 字符串版本:按方法名查找,只能传一个参数。
} // 方法结束。
public void StopByString() // 定义通过字符串方法名停止协程的方法。
{ // 方法开始。
StopCoroutine(nameof(FadeByName)); // 用同一个方法名停止字符串方式启动的协程。
} // 方法结束。
private IEnumerator FadeDirect(float duration, float targetAlpha) // 定义推荐用法的协程,可以传多个强类型参数。
{ // 协程开始。
Debug.Log("Fade direct start"); // 打印协程开始。
yield return new WaitForSeconds(duration); // 等待指定秒数。
Debug.Log("Target alpha: " + targetAlpha); // 打印目标透明度。
} // 协程结束。
private IEnumerator FadeByName(float duration) // 定义字符串版本调用的协程,只演示一个参数。
{ // 协程开始。
Debug.Log("Fade by name start"); // 打印协程开始。
yield return new WaitForSeconds(duration); // 等待指定秒数。
Debug.Log("Fade by name end"); // 打印协程结束。
} // 协程结束。
} // 类结束。面试加分说法
我平时默认用 StartCoroutine(IEnumerator),因为它更像普通 C# 方法调用,参数安全,也不怕重构改名。需要停止时,我会保存 Coroutine 句柄,用 StopCoroutine(handle) 停。
字符串版本我只在确实需要“按方法名启动/停止”时用,而且会尽量用 nameof(MethodName),不要手写 "MethodName",减少改名漏改的问题。
还有一个坑:启动和停止协程的方式不要混用。比如你用 StartCoroutine(Fade()) 启动,就不要随手用 StopCoroutine("Fade") 去停;最好用同一种方式管理。
参考:Unity 官方
MonoBehaviour.StartCoroutine,MonoBehaviour.StopCoroutine。
StopCoroutine 有哪些重载?有什么坑?
标准答案
StopCoroutine 主要有三种重载:
c
StopCoroutine(string methodName)
StopCoroutine(IEnumerator routine)
StopCoroutine(Coroutine routine)另外还有一个相关方法:
c
StopAllCoroutines()最重要的坑是:启动协程和停止协程的方式要匹配。不要用一种方式启动,再随手用另一种方式停止。
三种重载区别
| 重载 | 用法 | 适合场景 | 坑 |
|---|---|---|---|
StopCoroutine(string) | 按方法名停止 | 用字符串启动的协程 | 字符串写错、改名漏改、有查找开销 |
StopCoroutine(IEnumerator) | 按同一个迭代器对象停止 | 你保存了原始 IEnumerator | 重新调用一次方法会生成新对象,停不掉旧协程 |
StopCoroutine(Coroutine) | 按 StartCoroutine 返回句柄停止 | 项目里最推荐 | 句柄要保存,停止后最好置空 |
StopAllCoroutines() | 停止当前脚本上所有协程 | 当前脚本退出或清理 | 只影响当前 MonoBehaviour,不影响别的脚本 |
代码示例
c
using System.Collections; // 引入 IEnumerator,用于定义协程方法。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Coroutine、Debug 和 WaitForSeconds。
public sealed class StopCoroutineExample : MonoBehaviour // 定义一个演示 StopCoroutine 重载的组件。
{ // 类开始。
private Coroutine coroutineHandle; // 保存 Coroutine 句柄,推荐项目里这样停协程。
private IEnumerator cachedRoutine; // 保存 IEnumerator 对象,演示 IEnumerator 重载。
public void StartByHandle() // 用 Coroutine 句柄方式启动协程。
{ // 方法开始。
coroutineHandle = StartCoroutine(LoopPrint()); // 启动协程并保存返回的句柄。
} // 方法结束。
public void StopByHandle() // 用 Coroutine 句柄方式停止协程。
{ // 方法开始。
if (coroutineHandle == null) // 如果句柄为空,说明没有正在记录的协程。
{ // if 开始。
return; // 直接返回,避免 StopCoroutine(null) 抛异常。
} // if 结束。
StopCoroutine(coroutineHandle); // 用当初保存的 Coroutine 句柄停止协程。
coroutineHandle = null; // 停止后清空句柄,避免重复停止。
} // 方法结束。
public void StartByEnumerator() // 用 IEnumerator 对象方式启动协程。
{ // 方法开始。
cachedRoutine = LoopPrint(); // 保存这一次创建出来的 IEnumerator 对象。
StartCoroutine(cachedRoutine); // 用这个 IEnumerator 对象启动协程。
} // 方法结束。
public void StopByEnumerator() // 用 IEnumerator 对象方式停止协程。
{ // 方法开始。
if (cachedRoutine == null) // 如果没有保存过 IEnumerator。
{ // if 开始。
return; // 直接返回,避免传空。
} // if 结束。
StopCoroutine(cachedRoutine); // 必须传同一个 IEnumerator 对象,才能停止对应协程。
cachedRoutine = null; // 停止后清空引用。
} // 方法结束。
public void StartByString() // 用字符串方法名启动协程。
{ // 方法开始。
StartCoroutine(nameof(LoopPrint)); // 用方法名字符串启动协程,nameof 比手写字符串更安全。
} // 方法结束。
public void StopByString() // 用字符串方法名停止协程。
{ // 方法开始。
StopCoroutine(nameof(LoopPrint)); // 用同一个方法名字符串停止协程。
} // 方法结束。
public void StopEverythingOnThisScript() // 停止当前脚本上的所有协程。
{ // 方法开始。
StopAllCoroutines(); // 只停止当前 MonoBehaviour 上启动的所有协程。
coroutineHandle = null; // 清空句柄记录。
cachedRoutine = null; // 清空 IEnumerator 记录。
} // 方法结束。
private IEnumerator LoopPrint() // 定义一个循环打印的协程。
{ // 协程开始。
while (true) // 无限循环,直到协程被停止。
{ // while 开始。
Debug.Log("Coroutine running"); // 打印协程运行中。
yield return new WaitForSeconds(1f); // 等待 1 秒后继续。
} // while 结束。
} // 协程结束。
} // 类结束。最容易答错的坑
StopCoroutine(LoopPrint())停不掉之前启动的协程,因为你又创建了一个新的IEnumerator对象。StopCoroutine(null)会报错,所以停止前要判空。StopAllCoroutines()只停止当前这个MonoBehaviour上的协程,不会停止其他对象或其他脚本上的协程。gameObject.SetActive(false)会停止挂在该对象上的协程;但script.enabled = false不会自动停止已经启动的协程。- 字符串版本不利于重构,方法改名后字符串可能漏改,项目里尽量用
nameof或直接用Coroutine句柄。
面试加分说法
IMPORTANT
我项目里一般用 Coroutine handle = StartCoroutine(...) 保存句柄,再用 StopCoroutine(handle) 停止。这样最清晰,也不依赖字符串查找。复杂系统里还会在 OnDisable 或对象池回收时统一停止协程,避免对象已经回收了,旧协程还在改状态。
参考:Unity 官方
MonoBehaviour.StopCoroutine,MonoBehaviour.StopAllCoroutines。
Invoke 和协程怎么选择?
标准答案
简单说:简单延迟调用用 Invoke,复杂流程控制用协程。
Invoke 更像一个轻量定时器:几秒后调用某个方法。 协程更像一个可以分帧执行的流程:可以等待、循环、传参数、保存状态、随时停止。
怎么选
| 场景 | 推荐 |
|---|---|
| 1 秒后调用一个无参方法 | Invoke |
| 每隔几秒重复调用一个方法 | InvokeRepeating |
| 需要传多个参数 | 协程 |
| 需要等待动画、加载、条件 | 协程 |
| 需要中途取消、暂停、恢复流程 | 协程 |
| 大量计时任务,比如几百个技能 CD | 自定义 TimerManager |
| 不受暂停影响的等待 | 协程 + WaitForSecondsRealtime |
核心区别
Invoke 的问题是它依赖字符串方法名,参数能力弱,逻辑复杂后不直观:
c
Invoke(nameof(Fire), 1f);
CancelInvoke(nameof(Fire));协程可以把流程写得更清楚:
c
yield return new WaitForSeconds(1f);
Fire();代码示例
c
using System.Collections; // 引入 IEnumerator,用于定义协程。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Debug、WaitForSeconds 和 Coroutine。
public sealed class InvokeVsCoroutineExample : MonoBehaviour // 定义一个演示 Invoke 和协程选择的组件。
{ // 类开始。
private Coroutine skillCoroutine; // 保存技能协程句柄,方便后续停止。
public void UseInvokeForSimpleDelay() // 定义一个使用 Invoke 的简单延迟调用示例。
{ // 方法开始。
Invoke(nameof(FireSimpleBullet), 1f); // 1 秒后调用 FireSimpleBullet,适合简单无参延迟。
} // 方法结束。
public void CancelSimpleDelay() // 定义取消 Invoke 延迟调用的方法。
{ // 方法开始。
CancelInvoke(nameof(FireSimpleBullet)); // 取消名为 FireSimpleBullet 的延迟调用。
} // 方法结束。
private void FireSimpleBullet() // 定义 Invoke 要调用的无参方法。
{ // 方法开始。
Debug.Log("Fire bullet by Invoke"); // 打印开火日志。
} // 方法结束。
public void StartSkillFlow() // 定义启动复杂技能流程的方法。
{ // 方法开始。
if (skillCoroutine != null) // 如果已经有技能流程在运行。
{ // if 开始。
StopCoroutine(skillCoroutine); // 先停止旧技能流程,避免重复执行。
} // if 结束。
skillCoroutine = StartCoroutine(SkillFlow(0.3f, 1.2f)); // 启动协程,可以传多个参数。
} // 方法结束。
public void StopSkillFlow() // 定义停止技能流程的方法。
{ // 方法开始。
if (skillCoroutine == null) // 如果没有正在运行的技能协程。
{ // if 开始。
return; // 直接返回。
} // if 结束。
StopCoroutine(skillCoroutine); // 使用保存的 Coroutine 句柄停止协程。
skillCoroutine = null; // 清空句柄,避免重复停止。
} // 方法结束。
private IEnumerator SkillFlow(float preDelay, float afterDelay) // 定义一个带前摇和后摇的技能协程。
{ // 协程开始。
Debug.Log("Skill pre cast"); // 打印技能前摇开始。
yield return new WaitForSeconds(preDelay); // 等待前摇时间。
Debug.Log("Spawn hit box"); // 生成攻击判定。
yield return new WaitForSeconds(afterDelay); // 等待后摇时间。
Debug.Log("Skill end"); // 技能流程结束。
skillCoroutine = null; // 协程自然结束后清空句柄。
} // 协程结束。
} // 类结束。面试加分说法
TIP
Invoke 和协程都不是新线程,都在 Unity 主线程调度。 我会把 Invoke 用在非常简单的延迟调用,比如提示框自动关闭、简单延迟开火。只要逻辑开始出现“多步骤、传参数、等待条件、取消、生命周期清理”,我就会改用协程。再往上,如果是大量技能 CD、Buff Tick、全局倒计时,我会写 TimerManager,避免到处散落 Invoke 和协程。
参考:Unity 官方
MonoBehaviour.Invoke,MonoBehaviour.InvokeRepeating,MonoBehaviour.StartCoroutine,WaitForSecondsRealtime。
InvokeRepeating 有什么风险?
标准答案
InvokeRepeating(methodName, time, repeatRate) 会在 time 秒后开始调用指定方法,然后每隔 repeatRate 秒重复调用一次。它适合少量、简单、无参的重复定时逻辑,但不适合复杂业务系统。
主要风险
| 风险 | 说明 |
|---|---|
| 字符串方法名 | 方法改名后字符串可能漏改,运行时才暴露 |
| 不能传复杂参数 | 本质是按方法名调用,不适合复杂业务 |
受 Time.timeScale 影响 | timeScale = 0 时不会继续正常触发 |
| 容易重复注册 | 多次调用可能导致逻辑重复执行 |
| 忘记取消 | 不 CancelInvoke,逻辑会持续重复 |
| 不方便变频 | 修改间隔通常要先取消再重新注册 |
| 分散难管理 | 大量技能 CD、Buff Tick、AI 心跳散落各处会难维护 |
什么时候可以用
适合:
- 简单心跳检测。
- 临时测试逻辑。
- 少量无参重复调用。
- 比如每 1 秒刷新一次简单提示。
不适合:
- 技能 CD 系统。
- Buff Tick 系统。
- 大量怪物 AI 心跳。
- 需要暂停、恢复、变速、分组管理的计时任务。
这些更适合用协程、自定义 TimerManager 或统一 Update Scheduler。
安全写法示例
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、InvokeRepeating、CancelInvoke 和 Debug。
public sealed class SafeInvokeRepeatingExample : MonoBehaviour // 定义一个安全使用 InvokeRepeating 的示例组件。
{ // 类开始。
private const string TickMethodName = nameof(Tick); // 使用 nameof 保存方法名,避免手写字符串改名漏改。
[SerializeField] private float startDelay = 0f; // 配置第一次调用前的延迟时间。
[SerializeField] private float repeatRate = 1f; // 配置重复调用间隔。
private void OnEnable() // 对象启用时调用。
{ // 方法开始。
StartTick(); // 启动重复调用。
} // 方法结束。
private void OnDisable() // 对象禁用时调用。
{ // 方法开始。
StopTick(); // 停止重复调用,避免对象回收后逻辑继续跑。
} // 方法结束。
public void StartTick() // 定义启动重复调用的方法。
{ // 方法开始。
if (IsInvoking(TickMethodName)) // 如果已经注册过这个重复调用。
{ // if 开始。
return; // 直接返回,避免重复注册。
} // if 结束。
InvokeRepeating(TickMethodName, startDelay, repeatRate); // 注册重复调用。
} // 方法结束。
public void StopTick() // 定义停止重复调用的方法。
{ // 方法开始。
if (!IsInvoking(TickMethodName)) // 如果当前没有注册这个重复调用。
{ // if 开始。
return; // 直接返回,避免无意义取消。
} // if 结束。
CancelInvoke(TickMethodName); // 取消指定方法名的重复调用。
} // 方法结束。
public void ChangeRepeatRate(float newRepeatRate) // 定义修改重复间隔的方法。
{ // 方法开始。
repeatRate = Mathf.Max(0.01f, newRepeatRate); // 限制最小间隔,避免传入 0 或负数。
StopTick(); // 先取消旧的重复调用。
StartTick(); // 再用新间隔重新注册。
} // 方法结束。
private void Tick() // 定义被 InvokeRepeating 重复调用的方法。
{ // 方法开始。
Debug.Log("Tick"); // 执行重复逻辑,实际项目中这里写业务代码。
} // 方法结束。
} // 类结束。面试加分说法
CAUTION
我会说:InvokeRepeating 是方便工具,不是完整计时系统。少量简单重复调用可以用;但如果项目里有大量 Buff、技能、AI、网络心跳,我会做一个统一 TimerManager,支持暂停、恢复、取消、分组、缩放时间和非缩放时间,这样更可控,也更方便排查问题。
参考:Unity 官方
MonoBehaviour.InvokeRepeating,MonoBehaviour.CancelInvoke。
Time.unscaledDeltaTime 适合什么场景?
标准答案
Time.unscaledDeltaTime 表示“不受 Time.timeScale 影响的真实帧间隔”。 它适合用在暂停时仍然要继续运行的逻辑,比如暂停菜单动画、Loading 转圈、UI 淡入淡出、真实时间倒计时。
和 deltaTime 的区别
| 时间值 | 是否受 timeScale 影响 | 适合场景 |
|---|---|---|
Time.deltaTime | 受影响 | 角色移动、战斗逻辑、怪物 AI、技能 CD |
Time.unscaledDeltaTime | 不受影响 | 暂停菜单、UI 动画、Loading、真实倒计时 |
当你写:
Time.timeScale = 0f;游戏时间会暂停。此时 Time.deltaTime 基本变成 0,但 Time.unscaledDeltaTime 仍然表示真实帧间隔,所以 UI 还能继续动。
代码示例
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、CanvasGroup、Time 和 Mathf。
public sealed class PausePanelFade : MonoBehaviour // 定义一个暂停界面淡入示例组件。
{ // 类开始。
[SerializeField] private CanvasGroup canvasGroup; // 在 Inspector 中绑定暂停面板的 CanvasGroup。
[SerializeField] private float fadeSpeed = 4f; // 设置淡入淡出的速度。
private bool visible; // 记录暂停面板是否应该显示。
public void ShowPausePanel() // 显示暂停面板的方法。
{ // 方法开始。
visible = true; // 标记面板应该显示。
Time.timeScale = 0f; // 暂停游戏时间。
} // 方法结束。
public void HidePausePanel() // 隐藏暂停面板的方法。
{ // 方法开始。
visible = false; // 标记面板应该隐藏。
Time.timeScale = 1f; // 恢复游戏时间。
} // 方法结束。
private void Update() // 每帧更新 UI 透明度。
{ // 方法开始。
float targetAlpha = visible ? 1f : 0f; // 根据显示状态决定目标透明度。
float step = fadeSpeed * Time.unscaledDeltaTime; // 使用真实帧间隔,暂停时也能继续淡入淡出。
canvasGroup.alpha = Mathf.MoveTowards(canvasGroup.alpha, targetAlpha, step); // 平滑移动到目标透明度。
} // 方法结束。
} // 类结束。常见适合场景
- 暂停菜单弹出动画。
- 暂停状态下的按钮呼吸动画。
- Loading 圆圈旋转。
- 下载进度界面动画。
- 现实时间倒计时。
- 新手引导遮罩动画。
- 不希望被慢动作影响的 UI 表现。
不适合的场景
角色移动、怪物 AI、战斗 CD、物理相关逻辑通常不应该用 unscaledDeltaTime。因为玩家暂停或慢动作时,这些玩法逻辑应该跟着游戏时间停下或变慢。
面试加分说法
NOTE
我会把它理解成“真实时间帧间隔”。如果是表现层 UI,不希望被暂停影响,就用 unscaledDeltaTime;如果是玩法层,应该跟着 timeScale 走,就用 deltaTime。协程里对应关系也类似:WaitForSeconds 受 timeScale 影响,WaitForSecondsRealtime 不受影响。
参考:Unity 官方
Time.unscaledDeltaTime,Time.deltaTime,WaitForSecondsRealtime。
Time.smoothDeltaTime 有什么用?
一句话定义:Time.smoothDeltaTime 是 Unity 对 Time.deltaTime 做平滑后的结果,用来减少单帧卡顿或波动带来的“显示抖动”。
参考 Unity 官方文档:Time.smoothDeltaTime、Time.deltaTime。
它适合做什么? 适合做表现层:FPS 显示、镜头平滑、UI 数字变化、非关键动画过渡。比如某一帧突然从 16ms 变成 80ms,直接用 deltaTime 可能让显示跳一下,而 smoothDeltaTime 会让变化更柔和。
它不适合做什么? 不适合做精确玩法逻辑,比如技能 CD、物理移动、计时器、联网同步、帧同步。因为它是“平滑后的时间”,不是这一帧真实经过的时间。面试里可以说:smoothDeltaTime 是为了看起来稳,不是为了算得更准。
代码示例:FPS 显示可以用 smoothDeltaTime
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Time 和 Mathf。
using UnityEngine.UI; // 引入 UI 命名空间,使用 Text 显示帧率。
public sealed class SmoothFpsDisplay : MonoBehaviour // 定义一个只负责显示 FPS 的组件。
{ // 类开始。
[SerializeField] private Text fpsText; // 在 Inspector 中拖入用于显示 FPS 的 UI 文本。
private float smoothedFps; // 保存再次平滑后的 FPS,避免数字跳动太快。
private void Update() // Unity 每帧调用一次 Update。
{ // Update 方法开始。
float rawFps = 1f / Time.deltaTime; // 用真实上一帧耗时计算当前帧 FPS,数值会比较跳。
float stableFps = 1f / Time.smoothDeltaTime; // 用平滑后的帧耗时计算展示用 FPS,数值更稳定。
smoothedFps = Mathf.Lerp(smoothedFps, stableFps, 0.2f); // 只平滑 UI 显示,不影响真实游戏逻辑。
fpsText.text = "FPS: " + Mathf.RoundToInt(smoothedFps); // 把平滑后的 FPS 显示到界面上。
} // Update 方法结束。
} // 类结束。CAUTION
面试回答亮点:deltaTime 更真实,适合逻辑计算;smoothDeltaTime 更稳定,适合显示和表现。如果游戏暂停、UI 动画不受暂停影响,应该考虑 Time.unscaledDeltaTime,而不是拿 smoothDeltaTime 替代。
Application.targetFrameRate 和 VSync 谁优先?
标准答案 在桌面端和 Web 端,如果 QualitySettings.vSyncCount > 0,VSync 优先,Application.targetFrameRate 会被忽略;如果 vSyncCount == 0,才会用 Application.targetFrameRate 控帧。
Unity 官方文档也明确写了这一点:Application.targetFrameRate、QualitySettings.vSyncCount。
但要注意:移动端 Android / iOS 会忽略 vSyncCount,主要用 Application.targetFrameRate 控制帧率。VR/XR 平台更特殊,通常两者都不算最终控制者,而是由 XR SDK 控制帧率。
底层理解VSync 是和屏幕刷新同步,属于更接近硬件节奏的帧同步方式。比如 60Hz 屏幕:
vSyncCount = 1:约 60 FPSvSyncCount = 2:约 30 FPSvSyncCount = 0:不等垂直同步,此时才看targetFrameRate
Application.targetFrameRate 是 Unity 软件层面尝试限制渲染频率,所以桌面端如果追求帧节奏稳定,通常优先用 VSync。移动端则常用 targetFrameRate = 30 / 60 / 120 来平衡流畅度、耗电和发热。
项目里怎么用 如果是 PC 游戏,我一般会让画质设置控制 vSyncCount;如果是手游,我会根据画质档位设置 Application.targetFrameRate。比如低端机 30,高端机 60,支持高刷再考虑 90/120,但最终仍受屏幕刷新率和设备策略限制。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Application 和 QualitySettings。
public sealed class FrameRateBootstrap : MonoBehaviour // 定义一个启动时设置帧率策略的组件。
{ // 类开始。
[SerializeField] private int mobileFrameRate = 60; // 移动端目标帧率,通常按画质档设置为 30 或 60。
private void Awake() // Awake 在对象初始化阶段执行,适合设置全局帧率策略。
{ // 方法开始。
bool isMobile = Application.isMobilePlatform; // 判断当前运行平台是否是移动端。
if (isMobile) // 如果当前是 Android 或 iOS 等移动平台。
{ // 移动端分支开始。
QualitySettings.vSyncCount = 0; // 移动端通常忽略 vSyncCount,这里设为 0 避免误解。
Application.targetFrameRate = mobileFrameRate; // 移动端主要用 targetFrameRate 控制目标帧率。
} // 移动端分支结束。
else // 如果当前不是移动平台,一般按桌面端策略处理。
{ // 桌面端分支开始。
QualitySettings.vSyncCount = 1; // 桌面端开启 VSync,让渲染跟随屏幕刷新。
Application.targetFrameRate = -1; // 桌面端 VSync 开启时 targetFrameRate 会被忽略,设回默认即可。
} // 桌面端分支结束。
} // Awake 方法结束。
} // 类结束。WARNING
面试记忆版 桌面/Web:vSyncCount > 0 时 VSync 优先;vSyncCount == 0 时 targetFrameRate 才生效。移动端:vSyncCount 基本不管用,用 targetFrameRate。XR:看 SDK。答到这里,比只说“VSync 优先”稳很多。
QualitySettings 常调哪些参数?
标准答案QualitySettings 常调的不是“所有画质参数”,而是项目里用来做高中低画质档的全局参数:帧率/VSync、阴影、抗锯齿、贴图 Mip、LOD、反射、软粒子、纹理串流、异步上传等。官方入口可以看 Unity 的 QualitySettings 和 SetQualityLevel。
常调参数分类
QualitySettings.SetQualityLevel:切换低、中、高画质档。QualitySettings.vSyncCount:桌面端控制垂直同步。Application.targetFrameRate:移动端常配合控制目标帧率。QualitySettings.antiAliasing:MSAA 抗锯齿,常见 0、2、4、8。QualitySettings.shadows:是否开阴影,硬阴影还是软阴影。QualitySettings.shadowDistance:阴影距离,移动端优化非常常用。QualitySettings.shadowResolution:阴影贴图分辨率。QualitySettings.shadowCascades:级联阴影数量,越多越贵。QualitySettings.lodBias:LOD 切换距离,低端机可让模型更早降级。QualitySettings.maximumLODLevel:限制最高 LOD 等级。QualitySettings.globalTextureMipmapLimit:限制全局贴图 Mip,降低显存。QualitySettings.realtimeReflectionProbes:实时反射探针,移动端慎用。QualitySettings.softParticles:软粒子,需要深度,透明特效多时要注意。
项目里怎么回答 我一般不会只说“调 QualitySettings”,而是按画质档拆:低端机关阴影或缩短 shadowDistance,关 MSAA 或降到 2x,降低贴图 Mip,提高 LOD 降级速度;高端机再打开更高阴影、抗锯齿、反射和更远视距。URP/HDRP 下还要注意,有些参数不完全在 QualitySettings,比如 Render Scale、后处理、Renderer Feature,要去管线资源或 Volume 里配。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、QualitySettings 和 Application。
public sealed class QualitySettingsApplier : MonoBehaviour // 定义一个用于应用画质档的组件。
{ // 类开始。
public void ApplyLowQuality() // 定义应用低画质的方法。
{ // 方法开始。
QualitySettings.SetQualityLevel(0, true); // 切换到第 0 个画质档,并应用开销较大的变化。
QualitySettings.vSyncCount = 0; // 关闭 VSync,移动端通常主要用 targetFrameRate 控制帧率。
Application.targetFrameRate = 30; // 设置目标帧率为 30,降低耗电和发热。
QualitySettings.antiAliasing = 0; // 关闭 MSAA,减少 GPU 采样成本。
QualitySettings.shadows = ShadowQuality.Disable; // 关闭实时阴影,低端机收益通常很明显。
QualitySettings.shadowDistance = 20f; // 即使开启阴影,也限制阴影绘制距离。
QualitySettings.shadowResolution = ShadowResolution.Low; // 降低阴影贴图分辨率。
QualitySettings.shadowCascades = 0; // 关闭级联阴影,减少阴影渲染开销。
QualitySettings.lodBias = 0.7f; // 让模型更早切到低精度 LOD。
QualitySettings.maximumLODLevel = 1; // 限制最高模型细节等级,减少面数压力。
QualitySettings.globalTextureMipmapLimit = 1; // 全局降低一级贴图 Mip,减少显存占用。
QualitySettings.realtimeReflectionProbes = false; // 关闭实时反射探针,减少渲染开销。
QualitySettings.softParticles = false; // 关闭软粒子,降低透明特效和深度相关成本。
} // 方法结束。
} // 类结束。IMPORTANT
面试关键点QualitySettings 是画质档入口,不是所有渲染开关的唯一入口。真正项目里要结合平台、管线、设备性能和实际 Profiling 数据来调。比如移动端优先砍阴影距离、抗锯齿、透明特效和贴图内存;PC 端则更常考虑 VSync、分辨率、阴影质量和后处理。
Layer 和 Tag 区别是什么?
标准答案Tag 是“身份标签”,主要给脚本判断对象是谁;Layer 是“系统分层”,主要给相机、物理、射线检测等引擎系统做过滤。官方文档里 GameObject.tag 用于识别对象,GameObject.layer 是对象所在层,LayerMask 用于按层过滤检测;判断 Tag 推荐用 CompareTag。
参考:Unity 官方 GameObject.tag、GameObject.layer、CompareTag、LayerMask。
底层理解Tag 更像名字牌,比如 Player、Enemy、Boss,脚本可以问:“这个对象是不是敌人?” Layer 更像通道,本质是 0~31 的整数层,通常配合位掩码 LayerMask 使用,系统可以问:“这次 Raycast 只检测 Enemy 层吗?”、“这个 Camera 是否渲染 UI 层?”、“这两个 Layer 是否允许碰撞?”
项目里怎么用 如果我要判断碰到的是不是玩家,可以用 CompareTag("Player")。 如果我要让攻击射线只打到敌人,不打到地面、特效、UI,就应该用 LayerMask。 所以面试里可以这样说:Tag 管语义,Layer 管过滤;业务身份用 Tag,渲染/物理/射线过滤用 Layer。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Camera、Physics、LayerMask 等 Unity 类型。
public sealed class LayerAndTagExample : MonoBehaviour // 定义一个演示 Layer 和 Tag 配合使用的组件。
{ // 类开始。
[SerializeField] private Camera mainCamera; // 在 Inspector 中绑定相机,避免频繁使用 Camera.main。
[SerializeField] private LayerMask targetMask; // 在 Inspector 中选择射线允许检测的 Layer。
private void Update() // 每帧检测鼠标指向的对象。
{ // Update 方法开始。
Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition); // 从屏幕鼠标位置发出一条射线。
bool hitSomething = Physics.Raycast(ray, out RaycastHit hit, 100f, targetMask); // 只检测 targetMask 包含的 Layer。
if (!hitSomething) // 如果射线没有命中任何目标。
{ // if 代码块开始。
return; // 直接返回,不继续做 Tag 判断。
} // if 代码块结束。
if (hit.collider.CompareTag("Enemy")) // 用 Tag 判断命中的对象是不是敌人。
{ // if 代码块开始。
Debug.Log("射中了敌人:" + hit.collider.name); // 输出命中的敌人名字。
} // if 代码块结束。
} // Update 方法结束。
} // 类结束。TIP
常见坑 一个 GameObject 只能有一个 Tag,也只能有一个 Layer。Tag 不是多标签系统,复杂分类不要全塞 Tag。Layer 改父物体时不会自动递归改所有子物体,项目里常要写工具递归设置。还有,频繁判断 Tag 时推荐 CompareTag,不要到处写字符串比较。
Sorting Layer 和 Layer 区别是什么?
标准答案Sorting Layer 是渲染排序用的,主要决定 2D 精灵、Tilemap、Canvas 等“谁画在谁上面”;Layer 是 GameObject 的系统分层,主要用于相机剔除、物理碰撞矩阵、Raycast 过滤。
参考 Unity 官方:2D Sorting、Renderer.sortingLayerName、Renderer.sortingOrder、GameObject.layer、LayerMask。
底层理解Sorting Layer 走的是渲染排序逻辑。比如背景在 Background,角色在 Character,特效在 Effect,UI 在 UI,这样 Unity 渲染时就知道谁应该盖在谁上面。同一个 Sorting Layer 里,还可以用 Order in Layer,也就是 sortingOrder,数值越大通常越靠前显示。
Layer 走的是系统过滤逻辑。比如相机的 Culling Mask 决定哪些 Layer 会被渲染;Physics 的 Layer Collision Matrix 决定哪些 Layer 会互相碰撞;Physics.Raycast 可以传入 LayerMask,只检测敌人层、地面层或可交互层。
项目里怎么用 如果是 2D 游戏,角色、地面、特效、UI 的前后遮挡用 Sorting Layer + Order in Layer。 如果是点击地面、攻击检测、摄像机只渲染某些对象,用 Layer + LayerMask。 所以面试里可以一句话打中:Sorting Layer 管画面叠放顺序,Layer 管系统是否处理这个对象。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、SpriteRenderer、LayerMask 和 Physics。
public sealed class SortingLayerAndLayerExample : MonoBehaviour // 定义一个演示 Sorting Layer 和 Layer 区别的组件。
{ // 类开始。
[SerializeField] private SpriteRenderer spriteRenderer; // 在 Inspector 中绑定当前对象的 SpriteRenderer。
[SerializeField] private LayerMask enemyMask; // 在 Inspector 中选择射线允许命中的敌人 Layer。
private void Awake() // Awake 在对象初始化时调用。
{ // 方法开始。
spriteRenderer.sortingLayerName = "Character"; // 设置渲染排序层,让角色画在对应 Sorting Layer 中。
spriteRenderer.sortingOrder = 10; // 设置同层内排序,数值越大通常越靠前显示。
gameObject.layer = LayerMask.NameToLayer("Enemy"); // 设置 GameObject 的 Layer,用于相机、物理和射线过滤。
} // 方法结束。
private void Update() // Update 每帧执行一次。
{ // 方法开始。
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 从鼠标位置向场景发出射线。
bool hitEnemy = Physics.Raycast(ray, out RaycastHit hit, 100f, enemyMask); // 只检测 enemyMask 包含的 Layer。
if (hitEnemy) // 如果射线命中了敌人 Layer 上的对象。
{ // if 代码块开始。
Debug.Log("命中对象:" + hit.collider.name); // 输出命中的对象名字。
} // if 代码块结束。
} // 方法结束。
} // 类结束。NOTE
常见坑Layer 不会决定 Sprite 谁盖住谁;Sorting Layer 也不会影响 Raycast 或物理碰撞。普通 3D 不透明物体更多受深度测试影响,不是单靠 Sorting Layer 排序;透明物体还会受到 Render Queue、距离排序、材质等影响。
Physics.RaycastAll 和 RaycastNonAlloc 区别是什么?
标准答案Physics.RaycastAll 会返回一个新的 RaycastHit[] 数组,写起来方便,但高频调用会产生托管内存分配,容易带来 GC。Physics.RaycastNonAlloc 则是把命中结果写进你提前准备好的数组里,返回命中数量,适合 Update、AI 感知、技能检测、子弹检测这种高频场景。参考 Unity 官方:Physics.RaycastAll、Physics.RaycastNonAlloc。
核心区别
RaycastAll:简单,返回所有命中结果,但会新建数组。RaycastNonAlloc:不新建结果数组,复用 buffer,减少 GC。- 两者返回结果顺序都不要默认当成“从近到远”,需要最近目标时要自己按
distance处理。 RaycastNonAlloc的 buffer 如果太小,命中结果会装不下,返回的也不一定是最近的几个,所以容量要按场景估计。
项目里怎么选 低频、调试、编辑器工具、一次性检测,用 RaycastAll 没问题。 每帧检测、大量怪物视野、技能范围射线、自动瞄准、子弹检测,优先用 RaycastNonAlloc,同时配合 LayerMask 缩小检测范围。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Physics、RaycastHit、LayerMask 等类型。
public sealed class EnemyRaySensor : MonoBehaviour // 定义一个敌人射线感知组件。
{ // 类开始。
[SerializeField] private LayerMask enemyMask; // 在 Inspector 中选择敌人所在的 Layer。
[SerializeField] private float detectRange = 20f; // 设置射线检测距离。
private readonly RaycastHit[] hitBuffer = new RaycastHit[16]; // 预分配命中结果数组,运行时反复复用,避免每帧分配。
private void Update() // 每帧执行检测逻辑。
{ // Update 方法开始。
Vector3 origin = transform.position; // 获取射线起点,一般是当前角色位置。
Vector3 direction = transform.forward; // 获取射线方向,一般是角色面朝方向。
int hitCount = Physics.RaycastNonAlloc(origin, direction, hitBuffer, detectRange, enemyMask, QueryTriggerInteraction.Ignore); // 把命中结果写入 hitBuffer,并返回命中数量。
float nearestDistance = float.MaxValue; // 记录当前找到的最近距离。
Collider nearestTarget = null; // 记录当前找到的最近目标。
for (int i = 0; i < hitCount; i++) // 只遍历 0 到 hitCount - 1,不能遍历整个数组。
{ // for 循环开始。
RaycastHit hit = hitBuffer[i]; // 取出当前命中结果。
if (hit.distance < nearestDistance) // 如果当前命中点比之前记录的更近。
{ // if 代码块开始。
nearestDistance = hit.distance; // 更新最近距离。
nearestTarget = hit.collider; // 更新最近目标。
} // if 代码块结束。
} // for 循环结束。
if (nearestTarget != null) // 如果找到了最近目标。
{ // if 代码块开始。
Debug.DrawLine(origin, nearestTarget.transform.position, Color.red); // 画一条调试线,方便在 Scene 视图观察命中目标。
} // if 代码块结束。
} // Update 方法结束。
} // 类结束。NOTE
面试追问点 如果面试官问“NonAlloc 一定更好吗?”可以答:不是。它减少 GC,但需要自己管理数组容量和有效数量;如果 buffer 太小,结果可能丢失;如果需要最近命中,还要自己比较 distance。真正大批量射线还可以继续考虑 RaycastCommand 做批处理和 Job 化。
OverlapSphere 可以做什么?
标准答案Physics.OverlapSphere 用来做球形范围检测:给一个中心点和半径,Unity 会返回所有“碰到这个球体或在球体内部”的 Collider。
它能做什么? 最常见就是范围类玩法:
- 爆炸伤害:炸弹中心点附近的敌人都会受到伤害。
- AOE 技能:治疗圈、火圈、冰冻范围、冲击波。
- AI 感知:怪物找附近玩家,再判断视野角和遮挡。
- 拾取检测:玩家附近有没有金币、道具、NPC。
- 出生点检测:生成怪物前看看附近是否已经有对象。
- 目标筛选:先用球形范围粗选,再按阵营、距离、角度筛选。
底层理解 它不是碰撞事件,也不是射线。OnTriggerEnter 是别人进入触发器时被动通知;Raycast 是一条线检测;OverlapSphere 是你主动问物理系统:“这个球形区域里有哪些 Collider?”
代码示例:范围技能检测敌人
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Physics、Collider、LayerMask 等类型。
public sealed class AreaSkillDetector : MonoBehaviour // 定义一个范围技能检测组件。
{ // 类开始。
[SerializeField] private float radius = 5f; // 设置技能检测半径。
[SerializeField] private LayerMask enemyMask; // 设置敌人所在 Layer,只检测敌人层。
private readonly Collider[] hitBuffer = new Collider[32]; // 预分配 Collider 数组,避免高频检测产生 GC。
public void CastAreaSkill() // 定义释放范围技能的方法。
{ // 方法开始。
Vector3 center = transform.position; // 使用当前角色位置作为技能中心点。
int count = Physics.OverlapSphereNonAlloc(center, radius, hitBuffer, enemyMask, QueryTriggerInteraction.Ignore); // 把范围内敌人写入 hitBuffer。
for (int i = 0; i < count; i++) // 只遍历有效命中数量,不要遍历整个数组。
{ // 循环开始。
Collider target = hitBuffer[i]; // 取出当前命中的 Collider。
float distance = Vector3.Distance(center, target.transform.position); // 计算目标到技能中心的距离。
float damageRate = 1f - Mathf.Clamp01(distance / radius); // 距离越远,伤害比例越低。
Debug.Log("命中:" + target.name + ",伤害系数:" + damageRate); // 输出命中目标和伤害系数。
} // 循环结束。
} // 方法结束。
private void OnDrawGizmosSelected() // 选中对象时绘制调试范围。
{ // 方法开始。
Gizmos.color = Color.red; // 设置 Gizmos 颜色为红色。
Gizmos.DrawWireSphere(transform.position, radius); // 在 Scene 视图画出检测球范围。
} // 方法结束。
} // 类结束。WARNING
常见坑OverlapSphere 只是范围粗选,不代表目标真的“看得见”。如果中间有墙,仍然可能被范围查出来,所以 AI 视野或爆炸遮挡通常还要补一条 Raycast。另外,普通 OverlapSphere 会返回新数组,高频使用更推荐 OverlapSphereNonAlloc。如果只想知道“有没有东西”,可以用 Physics.CheckSphere。
Physics.IgnoreCollision 有什么用?
标准答案Physics.IgnoreCollision 用来让两个指定 Collider 之间忽略碰撞。最典型场景是:子弹从玩家身上生成时,不希望子弹立刻撞到玩家自己,但子弹仍然要能打到敌人和墙。
参考 Unity 官方:Physics.IgnoreCollision、Physics.IgnoreLayerCollision。
底层理解 它是“Collider 对 Collider”的局部规则:
IgnoreCollision(a, b, true):让a和b不再发生碰撞。IgnoreCollision(a, b, false):恢复a和b的碰撞。- 它不会影响
a和其他 Collider。 - 它不会影响
b和其他 Collider。 - 它不是改 Layer 碰撞矩阵,也不是禁用 Collider。
项目里怎么用 常见用法:子弹、投掷物、技能碰撞盒生成后,忽略发射者自身。否则子弹刚从角色枪口或身体附近生成,可能第一帧就撞到自己。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Collider、Physics、GameObject 等类型。
public sealed class BulletSpawner : MonoBehaviour // 定义一个子弹生成组件。
{ // 类开始。
[SerializeField] private GameObject bulletPrefab; // 在 Inspector 中绑定子弹预制体。
[SerializeField] private Transform firePoint; // 在 Inspector 中绑定子弹生成位置。
[SerializeField] private Collider ownerCollider; // 在 Inspector 中绑定发射者自己的 Collider。
public void Fire() // 定义发射子弹的方法。
{ // 方法开始。
GameObject bullet = Instantiate(bulletPrefab, firePoint.position, firePoint.rotation); // 在枪口位置生成子弹对象。
Collider bulletCollider = bullet.GetComponent<Collider>(); // 获取子弹身上的 Collider。
Physics.IgnoreCollision(ownerCollider, bulletCollider, true); // 让发射者和这颗子弹互相忽略碰撞。
Rigidbody bulletRigidbody = bullet.GetComponent<Rigidbody>(); // 获取子弹身上的 Rigidbody。
bulletRigidbody.linearVelocity = firePoint.forward * 30f; // 给子弹一个向前的速度。
} // 方法结束。
} // 类结束。WARNING
常见坑 对象池里尤其要注意:子弹复用时,上一发子弹可能忽略的是 A 玩家,下一发可能是 B 玩家,所以取出对象池时要重新设置忽略关系;回收前也可以按项目需要恢复碰撞,避免脏状态影响下一次使用。
和 IgnoreLayerCollision 的区别Physics.IgnoreCollision 是忽略“一对 Collider”。 Physics.IgnoreLayerCollision 是忽略“两层 Layer 之间的所有碰撞”。 如果只是“玩家自己的子弹不打自己”,用 IgnoreCollision 更精确;如果是“Player 层永远不和 Teammate 层碰撞”,才考虑 Layer 碰撞矩阵或 IgnoreLayerCollision。
OnControllerColliderHit 什么时候触发?
标准答案OnControllerColliderHit 会在带有 CharacterController 的角色调用 CharacterController.Move,并在移动过程中撞到 Collider 时触发。它是 CharacterController 专属的碰撞命中回调,不是普通 Rigidbody 的 OnCollisionEnter。
参考 Unity 官方:OnControllerColliderHit、ControllerColliderHit、CharacterController.Move。
什么时候触发?
- 角色对象上有
CharacterController。 - 代码里调用了
controller.Move(...)。 - 移动过程中撞到了非 Trigger 的 Collider。
- Unity 会调用
OnControllerColliderHit(ControllerColliderHit hit)。 hit里能拿到被撞对象、命中点、法线、移动方向、Rigidbody 等信息。
和 OnCollisionEnter 的区别OnCollisionEnter 是 Rigidbody 物理碰撞回调;OnControllerColliderHit 是 CharacterController 移动碰撞回调。CharacterController 更像“手动移动的角色控制器”,不是靠 Rigidbody 受力运动,所以它撞墙、推箱子、侧面检测,常用这个回调处理。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、CharacterController、Rigidbody 等类型。
public sealed class ControllerHitPushExample : MonoBehaviour // 定义一个 CharacterController 撞到物体后推动物体的示例组件。
{ // 类开始。
[SerializeField] private float moveSpeed = 5f; // 设置角色移动速度。
[SerializeField] private float pushPower = 2f; // 设置推动刚体的力度。
private CharacterController controller; // 保存角色身上的 CharacterController 引用。
private void Awake() // Awake 在对象初始化时调用。
{ // 方法开始。
controller = GetComponent<CharacterController>(); // 获取当前对象身上的 CharacterController。
} // 方法结束。
private void Update() // Update 每帧读取输入并移动角色。
{ // 方法开始。
float horizontal = Input.GetAxisRaw("Horizontal"); // 读取水平输入。
float vertical = Input.GetAxisRaw("Vertical"); // 读取垂直输入。
Vector3 input = new Vector3(horizontal, 0f, vertical); // 把输入转换成 XZ 平面的移动方向。
Vector3 motion = input.normalized * moveSpeed * Time.deltaTime; // 根据速度和 deltaTime 计算本帧位移。
controller.Move(motion); // 调用 CharacterController.Move,撞到 Collider 时可能触发 OnControllerColliderHit。
} // 方法结束。
private void OnControllerColliderHit(ControllerColliderHit hit) // CharacterController 移动撞到 Collider 时调用。
{ // 方法开始。
Rigidbody body = hit.rigidbody; // 获取被撞对象身上的 Rigidbody。
if (body == null) // 如果被撞对象没有 Rigidbody。
{ // if 开始。
return; // 不能推动,直接返回。
} // if 结束。
if (body.isKinematic) // 如果被撞刚体是运动学刚体。
{ // if 开始。
return; // 运动学刚体不应该被普通物理力推动。
} // if 结束。
if (hit.moveDirection.y < -0.3f) // 如果主要是从上往下压到物体。
{ // if 开始。
return; // 不处理向下挤压,避免角色站在物体上时乱推。
} // if 结束。
Vector3 pushDirection = new Vector3(hit.moveDirection.x, 0f, hit.moveDirection.z); // 只保留水平方向,避免把物体往上推。
body.AddForce(pushDirection * pushPower, ForceMode.VelocityChange); // 给被撞刚体一个速度变化,实现推箱子效果。
} // 方法结束。
} // 类结束。IMPORTANT
常见坑 直接改 transform.position 不会走 CharacterController.Move 的碰撞流程,所以通常不会触发这个回调。Trigger 区域不要用它处理,Trigger 更适合 OnTriggerEnter / OnTriggerStay / OnTriggerExit。另外,角色一次 Move 过程中可能碰到多个面或多个 Collider,所以回调可能不止一次。
CharacterController.Move 和 SimpleMove 区别是什么?
标准答案CharacterController.Move 传入的是本帧位移量,重力、跳跃、下落、击退都要自己算;SimpleMove 传入的是速度,Unity 会自动乘时间并应用重力,但会忽略 Y 轴速度,所以不适合跳跃和飞行。
参考 Unity 官方:CharacterController.Move、CharacterController.SimpleMove。
核心区别
Move(Vector3 motion):参数是位移,比如velocity * Time.deltaTime。SimpleMove(Vector3 speed):参数是速度,单位是米/秒,不要自己乘deltaTime。Move不自动处理重力。SimpleMove会自动应用重力。Move返回CollisionFlags,能知道撞到上方、侧面、下方。SimpleMove返回bool,表示角色是否接地。- 需要跳跃、击退、冲刺、空中控制,优先用
Move。 - 只是简单地面走路,可以用
SimpleMove。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、CharacterController、Vector3 等类型。
public sealed class MoveExample : MonoBehaviour // 定义一个使用 CharacterController.Move 的角色移动示例。
{ // 类开始。
[SerializeField] private CharacterController controller; // 在 Inspector 中绑定 CharacterController。
[SerializeField] private float moveSpeed = 5f; // 设置水平移动速度。
[SerializeField] private float gravity = -9.81f; // 设置重力加速度。
private float verticalVelocity; // 保存角色当前的垂直速度。
private void Update() // 每帧处理移动。
{ // 方法开始。
float x = Input.GetAxisRaw("Horizontal"); // 读取水平输入。
float z = Input.GetAxisRaw("Vertical"); // 读取前后输入。
Vector3 horizontal = new Vector3(x, 0f, z).normalized * moveSpeed; // 计算水平速度。
if (controller.isGrounded && verticalVelocity < 0f) // 如果角色接地并且还在向下掉。
{ // if 开始。
verticalVelocity = -2f; // 给一个小的向下速度,让接地更稳定。
} // if 结束。
verticalVelocity += gravity * Time.deltaTime; // 自己累加重力速度。
Vector3 velocity = horizontal + Vector3.up * verticalVelocity; // 合成水平速度和垂直速度。
controller.Move(velocity * Time.deltaTime); // Move 需要传入本帧位移,所以要乘 deltaTime。
} // 方法结束。
} // 类结束。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、CharacterController、Vector3 等类型。
public sealed class SimpleMoveExample : MonoBehaviour // 定义一个使用 CharacterController.SimpleMove 的角色移动示例。
{ // 类开始。
[SerializeField] private CharacterController controller; // 在 Inspector 中绑定 CharacterController。
[SerializeField] private float moveSpeed = 5f; // 设置移动速度。
private void Update() // 每帧处理移动。
{ // 方法开始。
float x = Input.GetAxisRaw("Horizontal"); // 读取水平输入。
float z = Input.GetAxisRaw("Vertical"); // 读取前后输入。
Vector3 speed = new Vector3(x, 0f, z).normalized * moveSpeed; // 计算水平速度,单位是米每秒。
bool grounded = controller.SimpleMove(speed); // SimpleMove 传速度,不要乘 deltaTime,并自动应用重力。
Debug.Log("是否接地:" + grounded); // 输出 SimpleMove 返回的接地结果。
} // 方法结束。
} // 类结束。IMPORTANT
面试记忆版Move 是“我给你本帧移动多少”,适合完整角色控制;SimpleMove 是“我给你水平速度,你帮我简单走路和下落”,适合非常简单的地面移动。项目里真正的第三人称角色、跳跃、击退、冲刺、坡面处理,通常会选 Move。
NavMeshAgent 如何动态避障?
标准答案NavMeshAgent 动态避障主要靠两套机制:第一套是 Agent 本地避让,也就是多个 NavMeshAgent 靠近时自动调整速度,避免互相挤住;第二套是 NavMeshObstacle + Carving,让停下来的大障碍在 NavMesh 上“切洞”,让 Agent 重新规划路径绕过去。
参考 Unity 官方:NavMeshAgent.obstacleAvoidanceType、NavMeshAgent.avoidancePriority、NavMeshObstacle.carving。
底层理解NavMeshAgent 的避障不是实时重建整张地图,它更像“我按全局路径走,但眼前有人或小障碍时,我临时调整速度和方向”。所以它适合处理角色之间的拥挤、短距离绕开。
如果是一个大箱子、门、车辆停在路中间,单靠局部避让可能不够,因为 Agent 的全局路径还是会认为那里能走。这时用 NavMeshObstacle 开 carving,让障碍在 NavMesh 上切出不可走区域,Agent 之后重新算路,就会真正绕开。
项目里怎么做
- 角色和怪物:用
NavMeshAgent,调radius、height、obstacleAvoidanceType、avoidancePriority。 - 箱子、门、车辆:用
NavMeshObstacle。 - 移动很频繁的障碍:不要频繁 Carve,成本高,通常让它先作为移动障碍被局部避让。
- 停下来的大障碍:开启 Carving,让它修改 NavMesh。
- 大量怪物:降低避障质量,分批寻路,避免每帧大量
SetDestination。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Transform 等基础类型。
using UnityEngine.AI; // 引入 UnityEngine.AI,使用 NavMeshAgent 和 NavMeshObstacle。
public sealed class NavMeshDynamicAvoidanceExample : MonoBehaviour // 定义一个动态避障配置示例组件。
{ // 类开始。
[SerializeField] private NavMeshAgent agent; // 在 Inspector 中绑定当前角色的 NavMeshAgent。
[SerializeField] private Transform target; // 在 Inspector 中绑定角色要追踪的目标。
[SerializeField] private NavMeshObstacle obstacle; // 在 Inspector 中绑定场景里的动态障碍物。
private void Awake() // Awake 在对象初始化时调用。
{ // 方法开始。
agent.radius = 0.45f; // 设置 Agent 半径,半径越准,避障和过窄路越稳定。
agent.height = 2.0f; // 设置 Agent 高度,用于匹配角色体型。
agent.speed = 3.5f; // 设置 Agent 移动速度。
agent.acceleration = 12f; // 设置 Agent 加速度,避免转向和启动太僵硬。
agent.angularSpeed = 720f; // 设置 Agent 转向速度,让角色能更快朝向移动方向。
agent.obstacleAvoidanceType = ObstacleAvoidanceType.HighQualityObstacleAvoidance; // 设置较高质量的局部避障。
agent.avoidancePriority = 50; // 设置避障优先级,数值越小越重要,默认 50。
obstacle.carving = true; // 让障碍物可以在 NavMesh 上切出不可走区域。
obstacle.carveOnlyStationary = true; // 只在障碍物静止后 Carve,减少频繁更新带来的 CPU 开销。
obstacle.carvingMoveThreshold = 0.2f; // 障碍物移动超过这个距离后,认为它发生了明显移动。
obstacle.carvingTimeToStationary = 0.5f; // 障碍物静止超过这个时间后,再重新 Carve。
} // 方法结束。
private void Update() // Update 每帧执行一次。
{ // 方法开始。
if (target == null) // 如果没有目标。
{ // if 开始。
return; // 直接返回,不设置路径。
} // if 结束。
agent.SetDestination(target.position); // 设置 Agent 的目标点,让它沿 NavMesh 寻路。
} // 方法结束。
} // 类结束。常见坑 不要把一个移动角色同时做成 NavMeshAgent 和 NavMeshObstacle,很容易出现自己挡自己、抖动、路径异常。Agent 是“会走路的单位”,Obstacle 是“挡路的障碍”。如果一个对象要从障碍变成角色,通常要在状态切换时启用一个、禁用另一个。
NOTE
面试记忆版NavMeshAgent 的动态避障分为:局部避让和全局路径变化。Agent 之间靠 obstacleAvoidanceType、avoidancePriority 做局部避让;真正改变路面的障碍用 NavMeshObstacle.carving。移动障碍少 Carve,静止大障碍再 Carve。
NavMeshObstacle 有什么用?
标准答案NavMeshObstacle 是给 Unity 导航系统用的“动态障碍物”。它的作用是让 NavMeshAgent 在运行时避开某些物体,比如箱子、门、车辆、临时路障。
参考 Unity 官方:NavMeshObstacle、NavMesh Obstacle Manual。
底层理解 不开 Carving 时,NavMeshObstacle 主要影响 Agent 的局部避让:Agent 靠近障碍时会尝试绕一下,但全局路径不一定会重新绕远。 开启 Carving 后,障碍会在 NavMesh 上“挖洞”,这个区域变成不可走,Agent 重新寻路时就会真正绕开。
常见用途
- 可推动箱子挡路。
- 门关闭后阻挡 AI 通行。
- 车辆、石头、临时路障改变路线。
- 战斗中临时生成障碍,让怪物绕路。
- 大型动态物体停住后影响寻路。
代码示例:配置一个可 Carve 的动态障碍
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Vector3 等类型。
using UnityEngine.AI; // 引入 UnityEngine.AI,使用 NavMeshObstacle 相关类型。
public sealed class DynamicNavMeshObstacle : MonoBehaviour // 定义一个动态导航障碍配置组件。
{ // 类开始。
[SerializeField] private NavMeshObstacle obstacle; // 在 Inspector 中绑定 NavMeshObstacle 组件。
private void Awake() // Awake 在对象初始化时调用。
{ // 方法开始。
obstacle.shape = NavMeshObstacleShape.Box; // 设置障碍形状为盒子,适合箱子、门、车辆等物体。
obstacle.center = Vector3.zero; // 设置障碍中心点相对当前物体的位置。
obstacle.size = new Vector3(2f, 2f, 2f); // 设置盒子障碍的大小。
obstacle.carving = true; // 开启 Carving,让障碍能在 NavMesh 上切出不可走区域。
obstacle.carveOnlyStationary = true; // 只在障碍静止时 Carve,避免移动中频繁更新导航网格。
obstacle.carvingMoveThreshold = 0.2f; // 障碍移动超过这个距离后,认为它的位置变化明显。
obstacle.carvingTimeToStationary = 0.5f; // 障碍静止超过这个时间后,再开始重新 Carve。
} // 方法结束。
} // 类结束。常见坑NavMeshObstacle 不是物理碰撞体。如果你希望角色真的撞不上去,还需要 Collider。它只是告诉导航系统“这里挡路”。另外,不要让同一个移动角色同时启用 NavMeshAgent 和 NavMeshObstacle,否则可能出现自己挡自己、抖动、路径异常。
CAUTION
面试记忆版NavMeshAgent 是会走路的单位,NavMeshObstacle 是挡路的东西。小障碍靠局部避让,大障碍或静止障碍用 Carving 改 NavMesh,让路径真正绕开。
Animator 的 CrossFade 和 Play 区别是什么?
标准答案Animator.Play 是直接切到某个动画状态,更像硬切;Animator.CrossFade 是从当前状态平滑混合到目标状态,更像带过渡的切换。
参考 Unity 官方:Animator.Play、Animator.CrossFade。
核心区别
Play:立即进入目标 State,常用于受击、死亡、打断、强制重置。CrossFade:当前动画淡出,目标动画淡入,常用于移动、攻击衔接、表演动作。Play更干脆,但容易动作突兀。CrossFade更自然,但过渡时间太长会拖手感。CrossFade的过渡时间默认是归一化时间;如果想用秒数,常用CrossFadeInFixedTime。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Animator 等 Unity 类型。
public sealed class AnimatorSwitchExample : MonoBehaviour // 定义一个动画切换示例组件。
{ // 类开始。
[SerializeField] private Animator animator; // 在 Inspector 中绑定角色 Animator。
private static readonly int AttackHash = Animator.StringToHash("Base Layer.Attack"); // 预计算 Attack 状态哈希,避免运行时反复查字符串。
private static readonly int HitHash = Animator.StringToHash("Base Layer.Hit"); // 预计算 Hit 状态哈希,适合硬切受击动画。
public void PlayHitImmediately() // 定义立即播放受击动画的方法。
{ // 方法开始。
animator.Play(HitHash, 0, 0f); // 直接切到 Hit 状态,并从第 0 帧开始播放。
} // 方法结束。
public void CrossFadeToAttack() // 定义平滑切到攻击动画的方法。
{ // 方法开始。
animator.CrossFade(AttackHash, 0.12f, 0, 0f); // 用 0.12 的归一化过渡时间混合到 Attack,并从开头播放。
} // 方法结束。
} // 类结束。项目里怎么选 如果角色从 Idle 到 Run、Run 到 Attack,希望动作自然衔接,用 CrossFade。如果角色被击飞、死亡、复活、强制回 Idle,需要立刻重置动作,用 Play 更合适。
WARNING
面试记忆版Play 是“马上跳过去”;CrossFade 是“慢慢混过去”。战斗手感里,攻击起手太慢可能是 CrossFade 过渡太长;动画突然抽一下,可能是用了 Play 硬切。
Animator 参数过多有什么问题?
标准答案 Animator 参数过多,最大问题不是“多几个参数就一定很慢”,而是状态机复杂度会爆炸:Transition 条件变多、Bool/Trigger 容易残留脏状态、动画跳转难排查、逻辑状态和动画状态耦合越来越重。Unity 官方也说明 AnimatorControllerParameter 是脚本和 AnimatorController 通信的参数,运行时通过 SetBool / SetFloat / SetInteger / SetTrigger 等接口设置:AnimatorControllerParameter。
会带来的问题
- 状态机图越来越乱:Idle、Run、Jump、Attack、Hit、Die 之间到处连线。
- Transition 条件难维护:
IsAttack == true && IsGrounded == true && IsHit == false这种条件越来越多。 - Bool 容易忘记清:比如
IsAttack没设回false,角色可能一直卡攻击。 - Trigger 容易误触发:比如上一帧 Trigger 没消费,下一次状态切换突然触发。
- 调试困难:看到动画乱跳,很难立刻知道是哪一个参数导致的。
- 多人协作风险高:策划或程序加一个参数,可能影响很多 Transition。
- 大量 Animator 时会有性能压力:尤其是频繁
SetXXX、复杂状态机、多层 Layer、Blend Tree 很多的时候。
项目里怎么优化 我一般会把参数控制在“少量核心参数”:
- 移动类:
Speed、Grounded、VerticalSpeed - 状态类:
ActionState或LocomotionState - 一次性事件:
AttackTrigger、HitTrigger - 特殊动作:必要时直接
CrossFade或Play
不要每个动作都来一个 Bool,比如 IsAttack1、IsAttack2、IsSkill3、IsRoll、IsHit 全堆上去。互斥状态更适合用 int 或逻辑层枚举统一管理。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Animator、Mathf 等 Unity 类型。
public enum AnimAction // 定义动画动作枚举,用一个参数表达互斥动作状态。
{ // 枚举开始。
None = 0, // 表示当前没有特殊动作。
Attack = 1, // 表示当前播放攻击动作。
Hit = 2, // 表示当前播放受击动作。
Die = 3 // 表示当前播放死亡动作。
} // 枚举结束。
public sealed class CharacterAnimatorDriver : MonoBehaviour // 定义一个角色动画驱动组件。
{ // 类开始。
[SerializeField] private Animator animator; // 在 Inspector 中绑定 Animator。
private static readonly int SpeedHash = Animator.StringToHash("Speed"); // 预计算 Speed 参数哈希,避免反复用字符串查找。
private static readonly int GroundedHash = Animator.StringToHash("Grounded"); // 预计算 Grounded 参数哈希。
private static readonly int ActionHash = Animator.StringToHash("Action"); // 预计算 Action 整型参数哈希。
private static readonly int ActionTriggerHash = Animator.StringToHash("ActionTrigger"); // 预计算一次性动作 Trigger 哈希。
private float lastSpeed = -1f; // 缓存上一次速度,避免相同值每帧重复写入 Animator。
private bool lastGrounded = true; // 缓存上一次接地状态,避免相同值重复写入 Animator。
public void SetMove(float speed, bool grounded) // 提供移动动画参数入口。
{ // 方法开始。
if (!Mathf.Approximately(lastSpeed, speed)) // 如果速度真的发生变化。
{ // if 开始。
animator.SetFloat(SpeedHash, speed); // 写入 Speed 参数,驱动 Blend Tree 或移动状态。
lastSpeed = speed; // 更新缓存速度。
} // if 结束。
if (lastGrounded != grounded) // 如果接地状态发生变化。
{ // if 开始。
animator.SetBool(GroundedHash, grounded); // 写入 Grounded 参数,驱动跳跃和落地状态。
lastGrounded = grounded; // 更新缓存接地状态。
} // if 结束。
} // 方法结束。
public void PlayAction(AnimAction action) // 提供特殊动作动画入口。
{ // 方法开始。
animator.SetInteger(ActionHash, (int)action); // 用一个整型参数表达当前动作类型,避免一堆互斥 Bool。
animator.SetTrigger(ActionTriggerHash); // 用 Trigger 通知 Animator 执行一次动作切换。
} // 方法结束。
} // 类结束。IMPORTANT
面试记忆版 参数过多,本质是动画状态机和业务逻辑耦合太重。好的做法是:少量稳定参数驱动 Blend Tree 和基础状态;一次性动作使用 Trigger 或直接 CrossFade/Play;互斥状态用 int/enum;参数哈希提前缓存;只在值变化时写入 Animator。
参考接口:Animator.SetFloat、Animator.SetBool、Animator.SetTrigger。
动画事件为什么不适合强业务逻辑?
标准答案 动画事件不适合放强业务逻辑,因为它是动画时间轴驱动的回调,不是稳定的业务状态机。Unity 官方说明 Animation Event 用于在动画特定时间点调用脚本函数,而且参数能力也比较有限,只支持单个参数或 AnimationEvent 对象:Unity 动画事件文档。
为什么不适合强业务?
- 动画可能被
CrossFade、打断、跳转、加速、减速影响。 - 动画事件藏在 Clip 资源里,代码里不直观,调试困难。
- 美术调整动画帧,可能不小心改变扣血、发奖、任务推进时机。
- 同一个动画 Clip 被多个角色复用时,业务规则可能完全不同。
- Trigger、Layer、动画混合可能导致事件重复或没有走到目标帧。
- 联机游戏里,客户端动画事件不能作为扣血、命中、奖励的权威来源。
适合放什么? 适合放表现层逻辑,比如脚步声、挥刀音效、特效播放、镜头震动、轻量提示。 不适合直接放扣血、任务完成、发奖励、背包扣道具、网络结算。
推荐做法 动画事件只做“轻量通知”,真正业务交给技能系统、战斗系统、状态机或服务端校验。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Debug 等 Unity 类型。
public sealed class AnimationEventBridge : MonoBehaviour // 定义动画事件桥接层,只接收动画事件,不直接写强业务。
{ // 类开始。
[SerializeField] private CombatController combatController; // 在 Inspector 中绑定战斗控制器。
public void AnimEvent_AttackHitFrame() // 这个方法可以被 Animation Event 调用。
{ // 方法开始。
combatController.OnAnimationHitFrame(); // 只通知战斗系统“动画到命中帧了”。
} // 方法结束。
} // 类结束。
public sealed class CombatController : MonoBehaviour // 定义真正处理战斗逻辑的组件。
{ // 类开始。
private bool isAttacking; // 记录当前逻辑状态是否正在攻击。
private bool hitAlreadyApplied; // 记录本次攻击是否已经结算过命中。
public void BeginAttack() // 进入攻击逻辑状态。
{ // 方法开始。
isAttacking = true; // 标记角色正在攻击。
hitAlreadyApplied = false; // 重置本次攻击的命中结算状态。
} // 方法结束。
public void OnAnimationHitFrame() // 动画事件通知命中帧到达。
{ // 方法开始。
if (!isAttacking) // 如果逻辑层并不处于攻击状态。
{ // if 开始。
return; // 忽略这次动画事件,避免错误扣血。
} // if 结束。
if (hitAlreadyApplied) // 如果本次攻击已经结算过。
{ // if 开始。
return; // 避免动画事件重复触发导致重复伤害。
} // if 结束。
hitAlreadyApplied = true; // 标记本次攻击已经结算。
Debug.Log("逻辑层校验通过,开始做命中检测。"); // 这里只演示,真实项目里会做范围检测、阵营判断和伤害结算。
} // 方法结束。
} // 类结束。TIP
面试记忆版 动画事件可以说“动画播放到这里了”,但不能决定“业务一定发生了”。强业务要由逻辑层控制,动画事件最多作为表现同步或辅助通知。
Timeline 适合什么场景?
标准答案 Timeline 适合做固定流程的演出编排:过场动画、Boss 登场、新手引导、镜头切换、角色表演、音频对白、特效显隐、关卡机关演示等。它更像一个“导演工具”,用时间轴把多个系统按顺序编排起来。
Unity 官方也说明 Timeline 用于创建 cut-scenes、cinematics、gameplay sequences、audio sequences 和复杂粒子效果:Timeline 官方文档。
底层理解 Timeline 通常由三部分组成:
Timeline Asset:保存轨道、片段、标记等时间轴数据。Timeline Instance:某个场景里实际播放这份 Timeline 的实例。PlayableDirector:负责播放、暂停、停止 Timeline,并绑定场景对象。
官方文档里也提到,
PlayableDirector负责把 Timeline Asset 和场景对象绑定起来,并控制播放、更新时间和结束行为:Playable Director、PlayableDirector API。
适合场景
- 剧情过场:镜头推进、角色动作、对白、音效同步。
- Boss 登场:锁输入、播放镜头、Boss 动画、特效、UI 提示。
- 新手引导:镜头指向目标、显示 UI、高亮按钮、播放提示音。
- 关卡机关:门打开、桥升起、平台移动、爆炸演出。
- 宣传演示:需要美术、策划可视化调整的固定流程。
不适合场景 不适合做强实时逻辑,比如战斗伤害结算、AI 决策、技能判定、复杂任务分支、联网同步。Timeline 可以发 Signal 通知逻辑层,但不要让 Timeline 成为业务真相来源。
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、SerializeField 等 Unity 基础类型。
using UnityEngine.Playables; // 引入 Playables 命名空间,使用 PlayableDirector 控制 Timeline。
public sealed class TimelineCutsceneController : MonoBehaviour // 定义一个 Timeline 过场控制器组件。
{ // 类开始。
[SerializeField] private PlayableDirector director; // 在 Inspector 中绑定播放 Timeline 的 PlayableDirector。
[SerializeField] private MonoBehaviour playerInput; // 在 Inspector 中绑定玩家输入脚本,用于过场期间禁用输入。
private void Awake() // Awake 在对象初始化时调用。
{ // 方法开始。
director.played += OnTimelinePlayed; // 订阅 Timeline 开始播放事件。
director.stopped += OnTimelineStopped; // 订阅 Timeline 停止播放事件。
} // 方法结束。
private void OnDestroy() // OnDestroy 在对象销毁时调用。
{ // 方法开始。
director.played -= OnTimelinePlayed; // 取消订阅播放事件,避免对象销毁后还被回调。
director.stopped -= OnTimelineStopped; // 取消订阅停止事件,避免事件引用导致异常。
} // 方法结束。
public void PlayCutscene() // 提供外部调用的播放过场方法。
{ // 方法开始。
director.time = 0.0; // 把 Timeline 播放时间重置到开头。
director.Play(); // 开始播放 Timeline。
} // 方法结束。
private void OnTimelinePlayed(PlayableDirector playableDirector) // Timeline 开始播放时调用。
{ // 方法开始。
if (playerInput != null) // 如果绑定了玩家输入脚本。
{ // if 开始。
playerInput.enabled = false; // 禁用玩家输入,避免过场期间玩家乱操作。
} // if 结束。
} // 方法结束。
private void OnTimelineStopped(PlayableDirector playableDirector) // Timeline 停止播放时调用。
{ // 方法开始。
if (playerInput != null) // 如果绑定了玩家输入脚本。
{ // if 开始。
playerInput.enabled = true; // 恢复玩家输入,把控制权还给玩家。
} // if 结束。
} // 方法结束。
} // 类结束。NOTE
面试记忆版 Timeline 负责“按时间编排表现”,逻辑系统负责“判断状态和结果”。如果是固定演出、多轨同步、镜头和动画配合,用 Timeline 很合适;如果是实时战斗、AI、网络同步和复杂分支,就不要硬塞进 Timeline。
Cinemachine 解决什么问题?
标准答案
Cinemachine 主要解决的是“相机控制复杂、手写脚本难维护、镜头切换不自然”的问题。它把跟随、看向目标、构图、平滑、切镜、Blend、震屏、遮挡修正这些相机逻辑做成可配置组件。
官方文档也明确说,Cinemachine 用来控制 Unity Camera,并处理目标追踪、构图、混合、镜头切换等复杂逻辑。(docs.unity3d.com)
底层原理
Cinemachine 不是替代 Unity Camera 渲染画面。真正渲染的还是场景里的 Camera。
它的核心结构是:
Cinemachine Virtual Camera / Cinemachine Camera:描述一个镜头应该怎么拍。
Cinemachine Brain:挂在真正的 Unity Camera 上,负责从多个虚拟镜头中选出当前生效的镜头,并处理 Cut 或 Blend。官方文档也说 Brain 会监控场景中激活的 Cinemachine Cameras,选择下一个控制 Unity Camera 的镜头,并管理过渡。(docs.unity3d.com)
也就是说,Cinemachine 更像“摄影指导”,不是“摄像机本体”。2.x 里常叫 CinemachineVirtualCamera,3.x 里主要叫 CinemachineCamera。
Unity 工程实践
项目里我通常会把相机拆成多个模式:
探索相机:跟随角色,保持舒服构图。
战斗相机:看向敌人或锁定点,拉近视角。
剧情相机:固定机位,配合 Timeline 演出。
受击相机:短暂震屏或偏移。
代码层不直接写大量 transform.position = ...,而是切换不同镜头的优先级或激活状态。这样相机参数可以交给策划、美术在 Inspector 里调,程序只负责“什么时候进入哪个镜头”。
代码示例:切换探索、战斗、剧情镜头
c
using UnityEngine; // 引入 Unity 的 Transform、MonoBehaviour 等基础类型。
using Cinemachine; // 引入 Cinemachine 2.x 的虚拟相机类型。
public sealed class CameraModeSwitcher : MonoBehaviour // 定义一个相机模式切换脚本。
{ // 类开始。
[SerializeField] private CinemachineVirtualCamera exploreCamera; // 引用探索模式虚拟相机。
[SerializeField] private CinemachineVirtualCamera combatCamera; // 引用战斗模式虚拟相机。
[SerializeField] private CinemachineVirtualCamera dialogueCamera; // 引用剧情对话模式虚拟相机。
private const int LivePriority = 20; // 当前生效镜头使用较高优先级。
private const int StandbyPriority = 0; // 非生效镜头使用较低优先级。
public void EnterExplore() // 进入探索相机模式。
{ // 方法开始。
SetLiveCamera(exploreCamera); // 把探索相机设置为当前最高优先级镜头。
} // 方法结束。
public void EnterCombat(Transform enemyTarget) // 进入战斗相机模式,并传入敌人目标。
{ // 方法开始。
combatCamera.LookAt = enemyTarget; // 让战斗相机看向敌人,形成锁定感。
SetLiveCamera(combatCamera); // 把战斗相机设置为当前最高优先级镜头。
} // 方法结束。
public void EnterDialogue(Transform npcTarget) // 进入剧情对话相机模式,并传入 NPC 目标。
{ // 方法开始。
dialogueCamera.LookAt = npcTarget; // 让剧情相机看向 NPC 或对话焦点。
SetLiveCamera(dialogueCamera); // 把剧情相机设置为当前最高优先级镜头。
} // 方法结束。
private void SetLiveCamera(CinemachineVirtualCamera liveCamera) // 统一处理镜头优先级切换。
{ // 方法开始。
exploreCamera.Priority = StandbyPriority; // 先把探索相机降为待机优先级。
combatCamera.Priority = StandbyPriority; // 先把战斗相机降为待机优先级。
dialogueCamera.Priority = StandbyPriority; // 先把剧情相机降为待机优先级。
liveCamera.Priority = LivePriority; // 再把目标相机提升为最高优先级。
} // 方法结束。
} // 类结束。面试加分点
TIP
不要只说“Cinemachine 是相机插件”。更好的说法是:它把相机系统从业务代码里抽象出来,用虚拟镜头描述拍摄意图,用 Brain 做镜头选择和混合,用组件处理跟随、构图、避障和反馈。
常见坑是角色在 FixedUpdate 移动,但相机在 LateUpdate 更新,可能出现轻微抖动;另外避障、目标组、震屏组件虽然方便,但大量使用时也要看性能和调试复杂度。
Camera.ScreenPointToRay 如何用于点击拾取?
标准答案
Camera.ScreenPointToRay 用来把“屏幕上的点击位置”变成“世界空间中的一条射线”,然后用 Physics.Raycast 沿着这条射线检测场景里的 Collider,命中谁就拾取谁。
官方文档里说,ScreenPointToRay 返回的是一条从相机穿过屏幕点的世界空间射线,射线从相机近裁剪面开始,屏幕坐标左下角是 (0,0),并且会忽略传入坐标的 z 值。Physics.Raycast 则会检测射线是否命中 Collider,并可用 LayerMask 过滤检测层级。
底层原理
点击屏幕时,你拿到的是二维像素坐标,比如鼠标位置 Input.mousePosition。
但场景里的物体在三维世界中,所以 Unity 需要用相机把这个二维点“反投影”成一条三维射线:
屏幕点 -> 相机近裁剪面上的点 -> 世界空间方向 -> Raycast 命中物体透视相机下,每个屏幕点射出去的方向不同;正交相机下,射线方向通常平行于相机 forward,但射线起点会随屏幕点变化。
代码示例
c
using UnityEngine; // 引入 Unity 的基础类型,例如 Camera、Ray、RaycastHit。
using UnityEngine.EventSystems; // 引入 EventSystem,用来判断点击是否落在 UI 上。
public interface IPickable // 定义可拾取接口,让不同物品都能用同一套点击逻辑。
{ // 接口开始。
void Pick(RaycastHit hit); // 定义拾取方法,并把命中信息传给物品。
} // 接口结束。
public sealed class ClickPicker : MonoBehaviour // 定义点击拾取脚本。
{ // 类开始。
[SerializeField] private Camera pickCamera; // 用于发射射线的相机,避免频繁 Camera.main。
[SerializeField] private LayerMask pickMask; // 只检测可拾取物体所在的 Layer。
[SerializeField] private float maxDistance = 100f; // 限制射线检测的最大距离。
private void Awake() // 初始化阶段执行。
{ // 方法开始。
if (pickCamera == null) // 如果 Inspector 没有手动拖相机。
{ // 判断开始。
pickCamera = Camera.main; // 缓存主相机引用。
} // 判断结束。
} // 方法结束。
private void Update() // 每帧检测输入。
{ // 方法开始。
if (!Input.GetMouseButtonDown(0)) // 如果这一帧没有按下鼠标左键。
{ // 判断开始。
return; // 直接退出,避免每帧都做拾取逻辑。
} // 判断结束。
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) // 如果当前点击落在 UI 上。
{ // 判断开始。
return; // 不穿透 UI 去拾取场景物体。
} // 判断结束。
Ray ray = pickCamera.ScreenPointToRay(Input.mousePosition); // 把鼠标屏幕坐标转换成世界射线。
if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, pickMask)) // 用射线检测指定 Layer 内的 Collider。
{ // 判断开始。
IPickable pickable = hit.collider.GetComponentInParent<IPickable>(); // 从命中的 Collider 或父物体上找可拾取组件。
if (pickable != null) // 如果命中的对象确实支持拾取。
{ // 判断开始。
pickable.Pick(hit); // 执行拾取逻辑。
} // 判断结束。
} // 判断结束。
Debug.DrawRay(ray.origin, ray.direction * maxDistance, Color.yellow, 1f); // 在 Scene 视图里画出射线,方便调试。
} // 方法结束。
} // 类结束。面试加分点
TIP
点击拾取不要只写 Physics.Raycast(ray),实际项目里要考虑四件事:缓存相机、使用 LayerMask、避免 UI 点击穿透、被拾取对象必须有 Collider。 如果是 2D 游戏,要用 Physics2D.Raycast;如果是地面点击移动,可以把 pickMask 换成地面层,然后读取 hit.point 作为目标点。
Camera.WorldToScreenPoint 如何做血条跟随?
标准答案
Camera.WorldToScreenPoint 可以把怪物头顶的 3D 世界坐标转换成屏幕像素坐标,然后把这个屏幕点转换到 UGUI 的 RectTransform 坐标里,让血条跟着怪物移动。
官方文档说明:WorldToScreenPoint 会把世界空间点转成屏幕空间点,屏幕左下角是 (0,0),返回值的 z 是该点到相机的世界距离;如果点在相机视锥外,返回的屏幕坐标可能在屏幕外。
底层原理
流程是:
c
怪物头顶世界坐标 -> Camera.WorldToScreenPoint -> 屏幕像素坐标 -> Canvas 本地坐标 -> 血条 anchoredPosition如果 screenPos.z <= 0,说明目标在相机后面,血条应该隐藏。 如果 Canvas 是 Screen Space - Overlay,UI 相机参数传 null;如果是 Screen Space - Camera,要传 Canvas 对应的 UI Camera。这个也是官方 RectTransformUtility.ScreenPointToLocalPointInRectangle 文档强调的点。
代码示例
c
using UnityEngine; // 引入 Unity 的基础类型。
public sealed class WorldHealthBarFollower : MonoBehaviour // 定义一个世界目标血条跟随脚本。
{ // 类开始。
[SerializeField] private Camera worldCamera; // 用来把世界坐标转屏幕坐标的相机。
[SerializeField] private Canvas canvas; // 血条所在的 Canvas。
[SerializeField] private RectTransform canvasRoot; // Canvas 根节点的 RectTransform。
[SerializeField] private RectTransform healthBar; // 血条自己的 RectTransform。
[SerializeField] private Transform target; // 血条要跟随的目标。
[SerializeField] private Vector3 worldOffset = new Vector3(0f, 2f, 0f); // 目标头顶偏移。
private void Awake() // 初始化引用。
{ // 方法开始。
if (worldCamera == null) // 如果没有手动拖入世界相机。
{ // 判断开始。
worldCamera = Camera.main; // 缓存主相机,避免每帧查找。
} // 判断结束。
if (canvasRoot == null) // 如果没有手动拖入 Canvas 根节点。
{ // 判断开始。
canvasRoot = (RectTransform)canvas.transform; // 使用 Canvas 自身的 RectTransform。
} // 判断结束。
} // 方法结束。
private void LateUpdate() // 在角色和相机更新后再刷新血条位置。
{ // 方法开始。
if (target == null) // 如果目标已经不存在。
{ // 判断开始。
healthBar.gameObject.SetActive(false); // 隐藏血条。
return; // 结束本帧逻辑。
} // 判断结束。
Vector3 worldPoint = target.position + worldOffset; // 计算目标头顶世界坐标。
Vector3 screenPoint = worldCamera.WorldToScreenPoint(worldPoint); // 把世界坐标转换成屏幕像素坐标。
bool inFront = screenPoint.z > 0f; // 判断目标是否在相机前方。
bool onScreen = screenPoint.x >= 0f && screenPoint.x <= Screen.width && screenPoint.y >= 0f && screenPoint.y <= Screen.height; // 判断目标是否在屏幕范围内。
bool visible = inFront && onScreen; // 同时满足前方和屏幕内才显示血条。
healthBar.gameObject.SetActive(visible); // 根据可见性显示或隐藏血条。
if (!visible) // 如果本帧不需要显示。
{ // 判断开始。
return; // 不再计算 UI 坐标。
} // 判断结束。
Camera uiCamera = canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera; // Overlay 模式传 null,Camera 模式传 UI 相机。
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRoot, screenPoint, uiCamera, out Vector2 localPoint); // 把屏幕点转成 Canvas 本地坐标。
healthBar.anchoredPosition = localPoint; // 设置血条在 Canvas 下的位置。
} // 方法结束。
} // 类结束。面试加分点
NOTE
血条跟随建议放在 LateUpdate,因为角色移动和相机移动通常已经在前面更新完了。大量怪物血条不要每个都单独频繁创建销毁,应该用对象池、可见性裁剪、距离裁剪;离屏、死亡、被遮挡的单位可以隐藏血条,减少 UI 重建和 Overdraw。
多相机渲染顺序如何控制?
标准答案
多相机渲染顺序主要看你用哪条渲染管线:
Built-in 里主要用 Camera.depth 控制,depth 小的先渲染,depth 大的后渲染,后画的更容易覆盖前面画面。官方文档也是这么描述的:Camera.depth。
URP 里常用 Base Camera + Overlay Camera Stack,Base 先渲染,Stack 里的 Overlay Camera 按列表顺序叠加。多个 Base Camera 则按 Priority 排序,高 Priority 后渲染。
底层原理
多相机不是简单的“层级越上越先画”,本质是多个 Camera 依次把颜色和深度写到同一个 Render Target。
控制顺序时要同时看:
渲染顺序:Built-in 看 depth,URP 看 Priority 和 Stack 顺序。
渲染内容:用 cullingMask 控制相机只看哪些 Layer,官方也说它用于选择性渲染场景的一部分:Camera.cullingMask。
清屏方式:clearFlags 或 URP 的 Background / Clear Depth 决定后一个相机会不会清掉前一个相机的颜色或深度。
Built-in 示例
c
using UnityEngine; // 引入 Unity 基础类型。
public sealed class BuiltInCameraOrderSetup : MonoBehaviour // 定义 Built-in 多相机顺序配置脚本。
{ // 类开始。
[SerializeField] private Camera worldCamera; // 主世界相机,负责渲染场景。
[SerializeField] private Camera weaponCamera; // 武器相机,负责渲染手模或武器。
[SerializeField] private Camera uiCamera; // UI 相机,负责渲染相机空间 UI。
[SerializeField] private LayerMask worldMask; // 世界相机可见的 Layer。
[SerializeField] private LayerMask weaponMask; // 武器相机可见的 Layer。
[SerializeField] private LayerMask uiMask; // UI 相机可见的 Layer。
private void Awake() // 初始化相机顺序。
{ // 方法开始。
ConfigureCamera(worldCamera, worldMask, 0f, CameraClearFlags.Skybox); // 世界相机最先画,并清天空盒。
ConfigureCamera(weaponCamera, weaponMask, 1f, CameraClearFlags.Depth); // 武器相机后画,只清深度,不清颜色。
ConfigureCamera(uiCamera, uiMask, 2f, CameraClearFlags.Depth); // UI 相机最后画,只清深度,不清颜色。
} // 方法结束。
private void ConfigureCamera(Camera targetCamera, LayerMask mask, float depth, CameraClearFlags clearFlags) // 封装单个相机配置。
{ // 方法开始。
targetCamera.cullingMask = mask.value; // 设置相机只渲染指定 Layer。
targetCamera.depth = depth; // 设置相机渲染顺序,数值越大越后画。
targetCamera.clearFlags = clearFlags; // 设置相机渲染前如何清理颜色和深度。
} // 方法结束。
} // 类结束。URP 示例
c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.Rendering.Universal; // 引入 URP 的相机扩展数据类型。
public sealed class UrpCameraStackSetup : MonoBehaviour // 定义 URP 相机栈配置脚本。
{ // 类开始。
[SerializeField] private Camera baseCamera; // Base Camera,负责渲染主场景。
[SerializeField] private Camera weaponOverlayCamera; // Overlay Camera,负责叠加武器层。
[SerializeField] private Camera uiOverlayCamera; // Overlay Camera,负责叠加 UI 特效层。
private void Awake() // 初始化 URP Camera Stack。
{ // 方法开始。
UniversalAdditionalCameraData baseData = baseCamera.GetUniversalAdditionalCameraData(); // 获取 Base 相机的 URP 扩展数据。
UniversalAdditionalCameraData weaponData = weaponOverlayCamera.GetUniversalAdditionalCameraData(); // 获取武器相机的 URP 扩展数据。
UniversalAdditionalCameraData uiData = uiOverlayCamera.GetUniversalAdditionalCameraData(); // 获取 UI 相机的 URP 扩展数据。
baseData.renderType = CameraRenderType.Base; // 把主相机设置为 Base Camera。
weaponData.renderType = CameraRenderType.Overlay; // 把武器相机设置为 Overlay Camera。
uiData.renderType = CameraRenderType.Overlay; // 把 UI 相机设置为 Overlay Camera。
baseData.cameraStack.Clear(); // 清空旧的 Overlay 列表,避免重复加入。
baseData.cameraStack.Add(weaponOverlayCamera); // 先叠加武器相机。
baseData.cameraStack.Add(uiOverlayCamera); // 再叠加 UI 相机。
} // 方法结束。
} // 类结束。项目里怎么用
常见做法是:主相机渲染场景,武器相机只渲染手和武器,UI 相机或 Overlay 只渲染 UI 特效、小地图、后景等。每个相机用 Culling Mask 隔离 Layer,后面的相机尽量不要清颜色,否则会把前面画好的结果擦掉。
面试坑点
NOTE
多相机会增加开销,因为每个相机都可能重新做剔除和渲染。URP 官方文档也提醒,Overlay Camera 如果出现在多个 Stack 或同一个 Stack 多次,会完整重复渲染;多个 Camera Stack 重叠同一个区域也会增加像素绘制成本。所以它适合解决明确的分层需求,不适合把所有排序问题都交给多相机。
RenderTexture 如何释放?
标准答案
RenderTexture 释放要分两类:
普通 RenderTexture:自己 new RenderTexture(...) 创建的,用完后先断开引用,再 rt.Release() 释放 GPU 硬件资源,最后 Destroy(rt) 销毁 Unity 原生对象。
临时 RenderTexture:通过 RenderTexture.GetTemporary() 拿到的,必须用 RenderTexture.ReleaseTemporary(rt) 归还临时池,不要用 Destroy 当作归还。
官方文档明确说:Release() 释放的是 RenderTexture 使用的硬件资源,纹理对象本身不会被销毁,并且 RenderTexture 这类原生引擎对象不会像普通托管对象那样被 GC 自动处理。
底层原理
RenderTexture 不是普通 C# 内存,它背后通常有 Unity 原生对象和 GPU 显存资源。 所以 rt = null 只是让 C# 引用断掉,不等于 GPU 资源马上释放。
正确释放前要注意断开这些引用:
c
Camera.targetTexture = nullRenderTexture.active = null 或恢复旧的 active
c
RawImage.texture = null
Material.SetTexture("_MainTex", null)如果还有 Camera、材质、UI、后处理链引用它,就算你调用了释放,看起来也可能像“没释放干净”。
普通 RenderTexture 释放代码
c
using UnityEngine; // 引入 Unity 基础类型。
public sealed class RenderTextureOwner : MonoBehaviour // 定义一个拥有 RenderTexture 生命周期的组件。
{ // 类开始。
[SerializeField] private Camera captureCamera; // 引用要渲染到 RenderTexture 的相机。
private RenderTexture ownedTexture; // 保存运行时创建出来的 RenderTexture。
private void OnEnable() // 组件启用时创建 RenderTexture。
{ // 方法开始。
ownedTexture = new RenderTexture(512, 512, 24, RenderTextureFormat.ARGB32); // 创建一张 512 尺寸、24 位深度的 RT。
ownedTexture.Create(); // 主动创建底层 GPU 资源,避免首次使用时才创建。
captureCamera.targetTexture = ownedTexture; // 让相机渲染到这张 RT。
} // 方法结束。
private void OnDisable() // 组件禁用时释放资源。
{ // 方法开始。
ReleaseOwnedTexture(); // 调用统一释放逻辑。
} // 方法结束。
private void ReleaseOwnedTexture() // 封装普通 RenderTexture 的释放流程。
{ // 方法开始。
if (ownedTexture == null) // 如果已经释放过或者没有创建。
{ // 判断开始。
return; // 直接返回,避免重复释放。
} // 判断结束。
if (captureCamera != null && captureCamera.targetTexture == ownedTexture) // 如果相机还绑定着这张 RT。
{ // 判断开始。
captureCamera.targetTexture = null; // 先让相机不再渲染到这张 RT。
} // 判断结束。
if (RenderTexture.active == ownedTexture) // 如果当前 active RT 正是这张 RT。
{ // 判断开始。
RenderTexture.active = null; // 解除当前渲染目标,避免释放 active 目标。
} // 判断结束。
ownedTexture.Release(); // 释放底层 GPU 硬件资源。
Destroy(ownedTexture); // 销毁 Unity 原生对象。
ownedTexture = null; // 清空 C# 引用,避免悬空引用和重复释放。
} // 方法结束。
} // 类结束。临时 RenderTexture 释放代码
c
using UnityEngine; // 引入 Unity 基础类型。
public static class TemporaryRenderTextureExample // 定义临时 RT 使用示例类。
{ // 类开始。
public static void BlitWithTemp(RenderTexture source, RenderTexture destination, Material material) // 定义一次临时后处理流程。
{ // 方法开始。
RenderTexture temp = RenderTexture.GetTemporary(source.width, source.height, 0, source.format); // 从 Unity 临时 RT 池里申请一张匹配尺寸的 RT。
try // 开始保护临时资源使用过程。
{ // try 开始。
Graphics.Blit(source, temp, material); // 把 source 经过材质处理后写入 temp。
Graphics.Blit(temp, destination); // 再把 temp 写入最终目标。
} // try 结束。
finally // 无论中间是否出错,都要归还临时 RT。
{ // finally 开始。
RenderTexture.ReleaseTemporary(temp); // 把临时 RT 归还给 Unity 的临时池。
} // finally 结束。
} // 方法结束。
} // 类结束。面试加分点
IMPORTANT
GetTemporary 背后有临时池,Unity 文档也说明后续 GetTemporary 会尽量复用之前创建的临时 RT,几帧没人使用后才会销毁。
WARNING
所以面试里可以这样总结:普通 RT 是“我拥有,我释放并销毁”;临时 RT 是“我借用,我归还池”。只把变量设为 null 不够,因为真正贵的是 GPU 和 Native 资源。
Texture2D.ReadPixels 为什么贵?
标准答案
Texture2D.ReadPixels 贵,是因为它会把当前 Render Target 的像素从 GPU 读回 CPU。GPU 本来和 CPU 是异步并行工作的,但 ReadPixels 往往要求“现在立刻拿到像素结果”,所以 CPU 可能要等 GPU 把前面的渲染命令都执行完,这就形成了 GPU-CPU 同步等待。
Unity 官方文档也直接说明:ReadPixels 通常很慢,因为它会等待 GPU 完成之前的工作;如果只是 GPU 内复制,建议用 Graphics.CopyTexture、CommandBuffer.CopyTexture 或 Graphics.Blit;如果要读回 CPU,建议用 AsyncGPUReadback。
底层原理
正常渲染时:
CPU 提交渲染命令 -> GPU 慢慢执行 -> CPU 继续跑下一帧逻辑但 ReadPixels 会变成:
CPU 调 ReadPixels -> 等 GPU 渲染完 -> GPU 显存拷贝到 CPU 内存 -> CPU 才能继续所以它贵的点有三个:
第一,打断 CPU/GPU 并行流水线。
第二,全屏像素数据量很大,比如 1920x1080 的 RGBA32 大约 8MB。
第三,后面如果再 Apply(),还可能把 CPU 纹理数据重新上传到 GPU,并可能更新 mipmap。
Unity 工程实践
偶尔截图、生成头像缩略图可以用,但不要每帧全屏 ReadPixels。 如果只是把一张 GPU 纹理复制到另一张 GPU 纹理,用 Blit 或 CopyTexture。 如果确实要 CPU 拿像素,比如截图上传、AI 识别、颜色采样,优先考虑 AsyncGPUReadback,用几帧延迟换不卡主线程。
异步读回示例
c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.Rendering; // 引入 AsyncGPUReadback 所在命名空间。
public sealed class AsyncReadbackExample : MonoBehaviour // 定义一个异步 GPU 读回示例组件。
{ // 类开始。
[SerializeField] private RenderTexture sourceTexture; // 要从 GPU 读回的 RenderTexture。
private bool isReading; // 标记当前是否已经有读回请求在进行中。
public void RequestReadback() // 发起一次异步读回请求。
{ // 方法开始。
if (isReading) // 如果上一帧的读回还没有完成。
{ // 判断开始。
return; // 直接返回,避免同时发起太多请求。
} // 判断结束。
isReading = true; // 标记读回请求已经开始。
AsyncGPUReadback.Request(sourceTexture, 0, TextureFormat.RGBA32, OnReadbackComplete); // 异步请求 GPU 把纹理数据读回 CPU。
} // 方法结束。
private void OnReadbackComplete(AsyncGPUReadbackRequest request) // GPU 数据读回完成后的回调。
{ // 方法开始。
isReading = false; // 清除读回中的标记。
if (request.hasError) // 如果读回失败。
{ // 判断开始。
Debug.LogWarning("GPU readback failed."); // 输出失败日志。
return; // 结束回调逻辑。
} // 判断结束。
var pixels = request.GetData<Color32>(); // 获取 CPU 侧可读的像素数据。
Debug.Log($"Readback pixel count: {pixels.Length}"); // 示例输出像素数量,实际项目里可以编码、采样或上传。
} // 方法结束。
} // 类结束。面试加分点
IMPORTANT
如果面试官追问“那怎么优化”,我会说:少读、晚读、异步读。少读是降低分辨率或只读小区域;晚读是别在关键帧同步卡主线程;异步读是用 AsyncGPUReadback 接受几帧延迟。如果只是后处理链内部传图,绝对不要读回 CPU,直接让数据留在 GPU 上处理。
Material 和 sharedMaterial 区别是什么?
标准答案
这里通常问的是 Renderer.material 和 Renderer.sharedMaterial:
sharedMaterial 取到的是共享材质,多个 Renderer 可能都指向同一份材质资源。运行时改它,会影响所有使用这个材质的对象,甚至会改到项目里的材质设置。
material 取到的是当前 Renderer 的实例材质。如果原来是共享材质,Unity 会自动克隆一份,只影响当前对象。但这个实例材质需要你管理生命周期,不用时要销毁。
Unity 官方文档也写得很直接:material 会让材质对当前 Renderer 唯一化,并由你负责销毁;sharedMaterial 不推荐运行时修改,因为会影响所有使用者和项目材质设置。
底层原理
默认多个物体可以共用一个 Material Asset。 当你访问 renderer.sharedMaterial.color = Color.red,改的是共享材质,所以所有共用它的物体都会变红。
当你访问 renderer.material.color = Color.red,Unity 会检查这个材质是否被多个 Renderer 共用。如果是,就克隆一份实例材质给当前 Renderer,所以只当前对象变红。但如果大量对象都这样做,会产生很多材质实例,增加内存,也可能破坏合批。
代码示例:当前对象独立变色
c
using UnityEngine; // 引入 Unity 基础类型。
public sealed class InstanceMaterialColor : MonoBehaviour // 定义一个独立材质变色脚本。
{ // 类开始。
private Renderer cachedRenderer; // 缓存 Renderer,避免重复 GetComponent。
private Material instanceMaterial; // 保存 renderer.material 生成的实例材质。
private void Awake() // 初始化阶段执行。
{ // 方法开始。
cachedRenderer = GetComponent<Renderer>(); // 获取当前物体的 Renderer。
instanceMaterial = cachedRenderer.material; // 获取实例材质,必要时 Unity 会克隆共享材质。
} // 方法结束。
public void SetColor(Color color) // 设置当前物体的颜色。
{ // 方法开始。
instanceMaterial.color = color; // 只修改当前 Renderer 的实例材质。
} // 方法结束。
private void OnDestroy() // 物体销毁时执行清理。
{ // 方法开始。
if (instanceMaterial != null) // 判断实例材质是否存在。
{ // 判断开始。
Destroy(instanceMaterial); // 销毁运行时创建的材质实例,避免内存常驻。
instanceMaterial = null; // 清空引用,避免重复使用已销毁对象。
} // 判断结束。
} // 方法结束。
} // 类结束。更推荐的做法
如果只是让每个怪物颜色、闪白强度、溶解进度不同,不一定要克隆材质,可以用 MaterialPropertyBlock。它适合“多个对象用同一个材质,但每个对象有少量不同参数”的场景,官方文档也这么描述。不过新版文档也提醒:它和 SRP Batcher 不兼容,URP/HDRP 下可能影响性能,所以要结合项目管线测试。参考:MaterialPropertyBlock。
面试加分点
TIP
一句话总结:sharedMaterial 是“大家共用的原件”,material 是“当前 Renderer 的私有副本”。运行时别随便改 sharedMaterial,频繁访问 material 也要小心材质实例膨胀。只改少量 per-object 参数时,优先考虑 MaterialPropertyBlock,但在 SRP Batcher 项目里要实测。
Renderer.material 为什么可能实例化材质?
标准答案
Renderer.material 可能实例化材质,是因为 Unity 要保证你修改 material 时只影响当前这个 Renderer。 如果这个 Renderer 当前还在和其他 Renderer 共用同一个材质,Unity 会自动克隆一份私有材质实例给它。
官方文档也说明:访问 Renderer.material 时,如果材质被其他 Renderer 使用,Unity 会克隆共享材质;并且你要负责销毁这个实例材质。
底层原理
默认很多物体可能都指向同一个 Enemy.mat:
Renderer A -> Enemy.matRenderer B -> Enemy.matRenderer C -> Enemy.mat
如果你写:
c
renderer.material.color = Color.red;Unity 不能直接改 Enemy.mat,否则 A、B、C 都会变红。 所以它会让当前 Renderer 脱离共享材质,变成:
c
Renderer C -> Enemy.mat Instance之后你改 Renderer C.material,只影响 C。
注意:不是每次访问 material 都无限克隆。通常是第一次从共享材质切到实例材质时克隆;后续同一个 Renderer 再访问,会返回这个实例。但如果你对大量对象都访问 material,就会产生大量材质实例。
安全写法:确实需要独立材质时缓存并销毁
c
using UnityEngine; // 引入 Unity 基础类型。
public sealed class HitFlashByMaterial : MonoBehaviour // 定义受击闪白脚本。
{ // 类开始。
private Renderer cachedRenderer; // 缓存 Renderer,避免重复查找。
private Material instanceMaterial; // 保存运行时生成的材质实例。
private void Awake() // 初始化阶段执行。
{ // 方法开始。
cachedRenderer = GetComponent<Renderer>(); // 获取当前对象的 Renderer。
instanceMaterial = cachedRenderer.material; // 获取私有材质实例,必要时 Unity 会克隆共享材质。
} // 方法结束。
public void SetFlash(float value) // 设置闪白强度。
{ // 方法开始。
instanceMaterial.SetFloat("_Flash", value); // 修改当前对象自己的材质参数。
} // 方法结束。
private void OnDestroy() // 对象销毁时执行。
{ // 方法开始。
if (instanceMaterial != null) // 如果实例材质存在。
{ // 判断开始。
Destroy(instanceMaterial); // 销毁实例材质,避免内存常驻。
instanceMaterial = null; // 清空引用,避免重复释放。
} // 判断结束。
} // 方法结束。
} // 类结束。更推荐:只改少量参数用 MaterialPropertyBlock
c
using UnityEngine; // 引入 Unity 基础类型。
public sealed class HitFlashByPropertyBlock : MonoBehaviour // 定义使用属性块的闪白脚本。
{ // 类开始。
private static readonly int FlashId = Shader.PropertyToID("_Flash"); // 缓存 Shader 属性 ID,避免字符串查找。
private Renderer cachedRenderer; // 缓存 Renderer。
private MaterialPropertyBlock propertyBlock; // 缓存 MaterialPropertyBlock。
private void Awake() // 初始化阶段执行。
{ // 方法开始。
cachedRenderer = GetComponent<Renderer>(); // 获取当前对象的 Renderer。
propertyBlock = new MaterialPropertyBlock(); // 创建属性块。
} // 方法结束。
public void SetFlash(float value) // 设置闪白强度。
{ // 方法开始。
cachedRenderer.GetPropertyBlock(propertyBlock); // 取出当前 Renderer 已有属性块。
propertyBlock.SetFloat(FlashId, value); // 修改当前对象自己的参数。
cachedRenderer.SetPropertyBlock(propertyBlock); // 把属性块应用回 Renderer。
} // 方法结束。
} // 类结束。面试加分点
material 是“我要私有材质”的语义,不是普通 getter。 只想读取共享材质信息,用 sharedMaterial。 只想让每个对象颜色、闪白、溶解值不同,优先考虑 MaterialPropertyBlock。官方文档也说它适合多个对象用同一个材质但属性略有不同的情况;不过它不兼容 SRP Batcher,要结合项目管线测试。
MaterialPropertyBlock 有什么用?
标准答案
MaterialPropertyBlock 的作用是:在不克隆 Material、不修改共享材质资产的情况下,给某个 Renderer 单独覆盖一部分材质参数。
比如 100 个怪物共用同一个材质,但每个怪物阵营色、受击闪白强度、溶解进度不同,就很适合用 MaterialPropertyBlock。
Unity 官方也说,它适合“多个对象使用同一个材质,但只有少量属性不同”的情况,并且比每个对象创建一份完整材质更省内存。
底层原理
普通材质参数在 Material 上:
Renderer A -> Shared MaterialRenderer B -> Shared Material
如果用 renderer.material 改颜色,Unity 可能会克隆材质实例。 如果用 MaterialPropertyBlock,共享材质仍然是一份,只是在渲染当前 Renderer 时额外带上一份“属性覆盖表”。
可以理解成:
c
最终渲染参数 = sharedMaterial 默认参数 + Renderer 的 MaterialPropertyBlock 覆盖参数它能改颜色、浮点、向量、矩阵、贴图、Buffer 等参数,但不能改 Shader、Pass、Blend、ZWrite、Render Queue 这类渲染状态。官方文档也明确说它不支持 changing render state。
代码示例:怪物受击闪白
c
using UnityEngine; // 引入 Unity 基础类型。
public sealed class HitFlashByMPB : MonoBehaviour // 定义一个使用 MaterialPropertyBlock 的受击闪白脚本。
{ // 类开始。
private static readonly int FlashColorId = Shader.PropertyToID("_FlashColor"); // 缓存颜色属性 ID,避免每次用字符串查找。
private static readonly int FlashPowerId = Shader.PropertyToID("_FlashPower"); // 缓存强度属性 ID,避免每次用字符串查找。
private Renderer cachedRenderer; // 缓存 Renderer,避免重复 GetComponent。
private MaterialPropertyBlock propertyBlock; // 缓存属性块,避免频繁 new。
private void Awake() // 初始化阶段执行。
{ // 方法开始。
cachedRenderer = GetComponent<Renderer>(); // 获取当前物体的 Renderer。
propertyBlock = new MaterialPropertyBlock(); // 创建一个可复用的属性块。
} // 方法结束。
public void SetFlash(float power) // 设置受击闪白强度。
{ // 方法开始。
cachedRenderer.GetPropertyBlock(propertyBlock); // 取出当前 Renderer 已有的属性覆盖值。
propertyBlock.SetColor(FlashColorId, Color.white); // 设置当前 Renderer 的闪白颜色。
propertyBlock.SetFloat(FlashPowerId, power); // 设置当前 Renderer 的闪白强度。
cachedRenderer.SetPropertyBlock(propertyBlock); // 把属性块设置回 Renderer。
} // 方法结束。
public void ClearFlash() // 清除闪白效果。
{ // 方法开始。
cachedRenderer.GetPropertyBlock(propertyBlock); // 取出当前属性块。
propertyBlock.SetFloat(FlashPowerId, 0f); // 把闪白强度设为 0。
cachedRenderer.SetPropertyBlock(propertyBlock); // 应用修改后的属性块。
} // 方法结束。
} // 类结束。面试加分点
TIP
MaterialPropertyBlock 会被 SetPropertyBlock 复制,所以脚本里最好创建一个并复用,不要每帧 new。 它很适合 Built-in 管线里减少材质实例,但官方文档也提醒:它不兼容 SRP Batcher,在 URP/HDRP 下可能造成性能下降,所以项目里要用 Profiler 实测,不要盲目说它一定更快。
Mesh.CombineMeshes 适合什么场景?
标准答案
Mesh.CombineMeshes 适合把一批静态、不单独移动、材质相同、空间相近的小 Mesh 合并成一个大 Mesh,从而减少 MeshRenderer 数量和 DrawCall 提交次数。
Unity 官方 API 里,Mesh.CombineMeshes 的作用就是把多个 Mesh 合并到当前 Mesh;参数 mergeSubMeshes 控制是否合并成一个子网格,useMatrices 控制是否使用每个 CombineInstance 的变换矩阵。
底层原理
合并前:
石头 A Renderer -> DrawCall石头 B Renderer -> DrawCall石头 C Renderer -> DrawCall
合并后:
c
Combined Mesh Renderer -> DrawCall它减少的是 CPU 侧提交渲染命令、切换 Renderer、设置状态的成本;但顶点数量没有消失,GPU 仍然要画这些顶点。
适合场景
适合:场景里的石头、草丛、围栏、房间装饰、碎片、程序生成地形小块、地牢 Chunk、建筑静态部件。
不适合:角色、怪物、武器、可交互物体、需要单独移动或隐藏的对象、SkinnedMeshRenderer、材质很多的对象、整张大地图一次性合并。
代码示例
c
using System.Collections.Generic; // 引入 List,用来收集 CombineInstance。
using UnityEngine; // 引入 Unity 基础类型。
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] // 要求当前物体必须有 MeshFilter 和 MeshRenderer。
public sealed class StaticMeshCombiner : MonoBehaviour // 定义一个静态 Mesh 合并脚本。
{ // 类开始。
[SerializeField] private Material combinedMaterial; // 合并后使用的材质,通常要求源物体材质一致。
[SerializeField] private bool disableSourceRenderers = true; // 合并后是否关闭原始 Renderer。
private void Awake() // 初始化时执行。
{ // 方法开始。
CombineChildren(); // 合并子物体 Mesh。
} // 方法结束。
private void CombineChildren() // 定义合并逻辑。
{ // 方法开始。
MeshFilter targetFilter = GetComponent<MeshFilter>(); // 获取当前物体的目标 MeshFilter。
MeshRenderer targetRenderer = GetComponent<MeshRenderer>(); // 获取当前物体的目标 MeshRenderer。
MeshFilter[] filters = GetComponentsInChildren<MeshFilter>(); // 获取所有子物体 MeshFilter。
List<CombineInstance> combines = new List<CombineInstance>(); // 创建合并数据列表。
for (int i = 0; i < filters.Length; i++) // 遍历所有 MeshFilter。
{ // 循环开始。
MeshFilter filter = filters[i]; // 取出当前 MeshFilter。
if (filter == targetFilter || filter.sharedMesh == null) // 跳过自己和没有 Mesh 的对象。
{ // 判断开始。
continue; // 进入下一个对象。
} // 判断结束。
CombineInstance item = new CombineInstance(); // 创建一个合并项。
item.mesh = filter.sharedMesh; // 设置要合并的 Mesh。
item.transform = transform.worldToLocalMatrix * filter.transform.localToWorldMatrix; // 把子物体世界变换转成当前物体本地空间。
combines.Add(item); // 加入合并列表。
if (disableSourceRenderers) // 如果需要关闭原始 Renderer。
{ // 判断开始。
MeshRenderer sourceRenderer = filter.GetComponent<MeshRenderer>(); // 获取源物体 Renderer。
if (sourceRenderer != null) // 如果源物体存在 Renderer。
{ // 判断开始。
sourceRenderer.enabled = false; // 关闭源 Renderer,避免重复绘制。
} // 判断结束。
} // 判断结束。
} // 循环结束。
if (combines.Count == 0) // 如果没有可合并对象。
{ // 判断开始。
return; // 直接退出。
} // 判断结束。
Mesh combinedMesh = new Mesh(); // 创建合并后的新 Mesh。
combinedMesh.name = "Combined Static Mesh"; // 设置 Mesh 名称,方便调试。
combinedMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32; // 允许超过 65535 顶点的合并结果。
combinedMesh.CombineMeshes(combines.ToArray(), true, true); // 合并 Mesh,并合并子网格,使用矩阵变换。
combinedMesh.RecalculateBounds(); // 重新计算包围盒,保证视锥剔除正确。
targetFilter.sharedMesh = combinedMesh; // 把合并后的 Mesh 挂到目标 MeshFilter 上。
targetRenderer.sharedMaterial = combinedMaterial; // 设置合并后使用的共享材质。
} // 方法结束。
} // 类结束。面试加分点
NOTE
合并 Mesh 的代价是剔除粒度变粗:原来一个石头出屏幕就不画,现在合成一整块后,只要大 Mesh 的包围盒可见,整块可能都参与渲染。 所以项目里更推荐按 Chunk 合并,而不是整张地图合成一个 Mesh。
还要记住:CombineMeshes 是 CPU 处理和内存拷贝,频繁运行时合并会有开销;源 Mesh 通常需要可读;如果材质不同,即使合并成一个 Mesh,也可能仍然有多个 SubMesh 和多次绘制,收益会变小。