unity常见面试题
Unity C# 基础
一、面向对象三大特性及其作用
1. 封装(Encapsulation)
封装是将数据和操作数据的方法组织在一个类型中,并通过访问修饰符、属性和公开方法隐藏内部实现,只暴露必要的使用接口。
主要作用:
- 维护对象状态的合法性,避免外部任意修改内部数据。
- 降低调用方对内部实现的依赖,便于修改和维护。
- 明确类型的职责和使用方式。
仅仅将字段改成属性并不一定实现了有效封装。如果属性拥有不受限制的公共 set,外部仍然可以写入任意值。可以使用私有设置器或公开方法进行校验:
using System;
public class Car
{
public string Model { get; private set; }
public Car(string model)
{
Rename(model);
}
public void Rename(string model)
{
if (string.IsNullOrWhiteSpace(model))
{
throw new ArgumentException("车型不能为空。", nameof(model));
}
Model = model;
}
public void StartEngine()
{
Console.WriteLine($"Engine started for {Model}.");
}
}
2. 继承(Inheritance)
继承用于表达类型之间的 is-a(是一个) 关系。派生类可以复用和扩展基类允许访问的成员,也可以重写基类中可重写的成员。
需要注意:
- C# 类只支持单继承,但一个类可以实现多个接口。
- 基类的
private成员不能由派生类直接访问。 - 构造函数不会被继承,但创建派生类时会调用基类构造函数。
- 只有标记为
virtual、abstract或override的实例成员才能被继续重写。 - 继承层次不宜过深;如果不存在稳定的 is-a 关系,通常应优先考虑组合。
using System;
public class Animal
{
public void Eat()
{
Console.WriteLine("Animal is eating.");
}
}
public class Dog : Animal
{
public void Bark()
{
Console.WriteLine("Dog is barking.");
}
}
3. 多态(Polymorphism)
多态允许使用统一的父类或接口引用操作不同的具体对象,并根据对象的实际类型执行不同实现,从而降低调用方与具体类型之间的耦合。
C# 中常见的多态包括:
- 编译时多态:方法重载,编译器根据参数列表选择调用的方法。
- 运行时多态:虚方法重写、抽象成员和接口实现,运行时根据对象的实际类型选择实现。
using System;
public abstract class Animal
{
public abstract void MakeSound();
}
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("Dog barks.");
}
}
public class Cat : Animal
{
public override void MakeSound()
{
Console.WriteLine("Cat meows.");
}
}
public static class AnimalPlayer
{
public static void PlaySound(Animal animal)
{
animal.MakeSound();
}
}
调用方只依赖 Animal,新增其他动物类型时不需要修改 PlaySound,这体现了多态的可扩展性。
二、C# 值类型和引用类型的区别
值类型和引用类型的核心区别是变量保存的内容以及复制时发生的行为,而不是简单的“值类型在栈上、引用类型在堆上”。
| 对比项 | 值类型 | 引用类型 |
|---|---|---|
| 变量保存的内容 | 数据本身 | 对对象的引用 |
| 赋值时复制的内容 | 复制整个值 | 复制引用 |
| 默认值 | 数值类型通常为 0,bool 为 false,结构体各字段取默认值 |
null |
| 常见类型 | 数值类型、bool、char、enum、struct、Nullable<T> |
class、接口、数组、委托、string |
| 内存管理 | 取决于变量所在位置 | 对象通常由 GC 管理 |
1. 存储位置不能简单按类型判断
- 局部值类型变量可能位于线程栈或寄存器中。
- 类对象中的值类型字段会作为对象的一部分存储在托管堆中。
- 值类型装箱后会产生托管堆对象。
- 引用类型对象通常位于托管堆中,但保存引用的变量可能位于栈、寄存器或另一个对象内部。
因此,不应简单断言“值类型在栈上,引用类型在堆上”。
2. 参数默认都是按值传递
C# 中不使用 ref、out 或 in 时,参数默认按值传递:
- 传递值类型时,复制数据本身。
- 传递引用类型时,复制对象引用。调用方和形参指向同一个对象,因此修改对象内容对双方可见。
- 在方法内给形参重新赋值,不会改变调用方变量保存的引用。
using System;
public class Player
{
public int Level;
}
public static class Example
{
public static void Change(Player player)
{
player.Level = 10; // 修改同一个对象,调用方可见
player = new Player(); // 只修改形参保存的引用,调用方不可见
player.Level = 99;
}
public static void Main()
{
int x = 10;
int y = x;
y = 20;
Console.WriteLine(x); // 10
var player = new Player { Level = 1 };
Change(player);
Console.WriteLine(player.Level); // 10
}
}
3. 生命周期和垃圾回收
.NET 的垃圾回收器主要通过 GC Roots 可达性分析 判断对象是否仍可访问,并不是普通的引用计数机制。
当引用类型对象不再能从 GC Roots 到达时,它只是具备了被回收的条件;具体何时回收由 GC 决定,并不会在变量离开作用域时立即销毁。
4. 结构体复制需要注意浅复制
结构体赋值会复制它的全部字段。如果结构体中包含引用类型字段,复制的是该字段中的引用,因此两个结构体仍可能通过该引用访问同一个对象。大型结构体频繁复制也可能带来性能成本,可以结合场景考虑 in、ref 或改用类。
三、ArrayList 和 List<T> 的区别
ArrayList 和 List<T> 底层都属于可扩容数组,但 ArrayList 是非泛型集合,List<T> 是泛型集合。
| 对比项 | ArrayList |
List<T> |
|---|---|---|
| 命名空间 | System.Collections |
System.Collections.Generic |
| 元素类型 | 统一按 object 存储 |
创建时指定 T |
| 类型安全 | 取出时通常需要强制转换,错误可能在运行时出现 | 编译期检查元素类型 |
| 值类型开销 | 存入值类型时发生装箱,取出并转换时发生拆箱 | 存储 T 类型的值时通常不需要装箱 |
| 使用建议 | 主要用于兼容遗留 API | 现代 C# 中优先使用 |
需要注意:
ArrayList存储引用类型时不发生装箱,但仍缺少编译期类型安全。- 即使要保存不同的具体类型,通常也应使用
List<object>、公共基类或公共接口,而不是因此选择ArrayList。 - 两者索引访问都是
O(1),尾部添加通常是均摊O(1),中间插入和删除通常是O(n)。 - 容量不足时需要创建更大的内部数组并复制元素。已知元素数量时可以指定初始容量,减少扩容开销。
using System.Collections;
using System.Collections.Generic;
var oldList = new ArrayList();
oldList.Add(10); // int 被装箱为 object
int oldValue = (int)oldList[0]; // 拆箱并转换
var numbers = new List<int>();
numbers.Add(10); // 通常不发生装箱
int value = numbers[0];
四、什么是装箱和拆箱
1. 装箱(Boxing)
装箱是将值类型转换为 object、System.ValueType 或它实现的接口类型。运行时会在托管堆上创建一个对象,并把值类型的数据复制进去。
int number = 10;
object boxed = number;
2. 拆箱(Unboxing)
拆箱是从装箱对象中提取原值类型数据。拆箱时目标类型必须与装箱前的实际值类型一致,然后再把数据复制给值类型变量。
int result = (int)boxed;
下面的代码会抛出 InvalidCastException,因为不能把装箱的 int 直接拆箱为 long:
object boxedNumber = 10;
// long value = (long)boxedNumber; // 错误
long value = (long)(int)boxedNumber; // 先拆箱为 int,再进行数值转换
装箱会产生托管堆分配,频繁装箱还会增加 GC 压力。在 Unity 的高频逻辑中,应特别留意非泛型集合、把值类型传给 object 参数、接口调用以及某些字符串格式化操作带来的潜在装箱。
五、接口和抽象类的区别
接口和抽象类都可以用于抽象和多态,但表达的设计含义不同:接口强调“具有什么能力”,抽象类强调“属于什么类型体系”。
| 对比项 | 接口 | 抽象类 |
|---|---|---|
| 实例化 | 不能直接实例化 | 不能直接实例化 |
| 继承关系 | 一个类可以实现多个接口;接口也可以继承多个接口 | 一个类只能直接继承一个基类 |
| 实例状态 | 不用于保存对象的实例状态,不能声明实例字段 | 可以包含实例字段和状态 |
| 构造函数 | 没有实例构造函数 | 可以定义构造函数,由派生类构造过程调用 |
| 成员实现 | 传统接口主要声明契约;现代 C# 支持默认接口实现 | 可以同时包含普通成员和抽象成员 |
| 访问控制 | 传统接口实例成员隐式为 public abstract |
可以使用 public、protected、private 等修饰符 |
| 适用场景 | 为无关类型定义共同能力或规范 | 为一组强相关类型复用状态和通用实现 |
需要注意:
- 抽象类可以不包含任何抽象成员;
abstract的直接含义是该类不能被直接实例化。 - 抽象成员不能提供实现,非抽象派生类必须实现尚未实现的抽象成员。
- 现代 C# 支持默认接口实现、部分访问修饰符和静态接口成员,但 Unity 是否支持具体特性取决于编辑器版本、脚本运行时和编译后端,应结合项目版本判断。
- 接口不能保存实例字段。常量和现代 C# 的静态接口成员属于语言版本相关能力,不应与实例状态混为一谈。
using System;
public interface IDamageable
{
void TakeDamage(int amount);
}
public abstract class Character
{
public string Name { get; }
public int Health { get; protected set; }
protected Character(string name, int health)
{
Name = name;
Health = health;
}
public void Move()
{
Console.WriteLine($"{Name} is moving.");
}
public abstract void UseSkill();
}
public class Player : Character, IDamageable
{
public Player(string name, int health) : base(name, health)
{
}
public override void UseSkill()
{
Console.WriteLine($"{Name} uses a skill.");
}
public void TakeDamage(int amount)
{
Health -= amount;
}
}
选择原则:需要共同状态、受保护成员和默认实现时,考虑抽象类;需要定义可由多个无关类型实现的能力,或需要一个类型具备多个契约时,考虑接口。
六、反射的实现原理
反射(Reflection)是在运行时检查类型信息,并动态创建对象、读取或修改成员、调用方法以及获取特性的机制。
1. 实现基础
C# 源代码通常会被编译为包含中间语言和元数据的程序集。元数据描述了程序集、类型、字段、属性、方法签名、事件、泛型参数和特性等信息。
运行时根据这些元数据维护类型系统,并通过以下反射对象向托管代码暴露信息:
Assembly:表示程序集。Type:表示类型。MemberInfo:字段、属性、方法和事件等成员信息的基类。FieldInfo、PropertyInfo、MethodInfo、ConstructorInfo:表示具体成员。
常见的类型获取方式:
Type compileTimeType = typeof(MyClass); // 编译时已知类型
var instance = new MyClass();
Type runtimeType = instance.GetType(); // 对象的实际运行时类型
Type namedType = Type.GetType("Namespace.MyClass"); // 根据名称查找
2. 运行过程
- 加载或取得程序集及其类型元数据。
- 通过
Type和BindingFlags查找需要的成员。 - 使用
Activator或ConstructorInfo创建实例。 - 使用
FieldInfo、PropertyInfo、MethodInfo等读取、修改或调用成员。 - 通过特性 API 获取声明在类型或成员上的特性信息。
反射调用通常还需要进行成员查找、访问检查、参数匹配、装箱拆箱和调用分派,因此比直接访问或调用慢。频繁使用时应缓存反射结果,必要时可以把 MethodInfo 转换为委托。
3. 示例
using System;
using System.Reflection;
public class MyClass
{
public string Name { get; set; } = "John";
public int Age { get; set; } = 30;
public void SayHello()
{
Console.WriteLine($"Hello, my name is {Name} and I'm {Age} years old.");
}
}
public static class ReflectionExample
{
public static void Run()
{
Type type = typeof(MyClass);
object instance = Activator.CreateInstance(type);
PropertyInfo nameProperty = type.GetProperty(nameof(MyClass.Name));
MethodInfo sayHelloMethod = type.GetMethod(nameof(MyClass.SayHello));
if (instance == null || nameProperty == null || sayHelloMethod == null)
{
throw new InvalidOperationException("未找到需要的类型或成员。");
}
nameProperty.SetValue(instance, "Alice");
sayHelloMethod.Invoke(instance, null);
}
}
GetProperty、GetMethod 和 Activator.CreateInstance 都可能失败或返回 null,实际项目中应检查结果。MethodInfo.Invoke 调用的目标方法如果抛出异常,异常通常会被包装在 TargetInvocationException 中。
4. 常见用途
- 序列化和反序列化。
- 特性驱动的框架设计。
- 编辑器工具、依赖注入和测试框架。
- 动态创建对象和插件系统。
5. Unity 中的注意事项
Unity 使用 IL2CPP 时属于 AOT 环境。反射本身仍然可以使用,但仅通过反射访问的类型或成员可能在构建时被代码裁剪。必要时应使用 [UnityEngine.Scripting.Preserve] 或 link.xml 保留相关代码。
此外,某些依赖运行时生成代码的方案在 IL2CPP 下受限。使用反射时还应避免在 Update 等高频路径中反复查找成员,并通过 Profiler 验证其分配和耗时。
七、string 和 StringBuilder 的区别与适用场景
string 和 StringBuilder 都用于处理文本,核心区别在于 string 不可变,而 StringBuilder 提供可变的字符缓冲区。
| 对比项 | string |
StringBuilder |
|---|---|---|
| 类型 | 引用类型 | 引用类型 |
| 可变性 | 不可变 | 可变 |
| 修改方式 | 修改结果通常是新的字符串 | 在内部缓冲区中追加、插入、删除或替换 |
| 适用场景 | 字符串很少修改,或只有少量、固定次数的拼接 | 循环中反复拼接,或需要大量修改文本 |
| 线程安全 | 不可变对象便于安全共享 | 实例默认不保证线程安全 |
1. string 的特点和优势
- 字符串创建后内容不能改变。所谓“修改”,实际通常是让变量引用另一个字符串。
- 不可变性使字符串便于共享,也能保证字符串作为字典键时哈希值保持稳定。
- 字符串字面量可能进入字符串驻留池,减少相同字面量的重复存储。
- 少量、固定数量的拼接通常直接使用
+、字符串插值或string.Concat即可,编译器和运行库会进行相应优化。
string firstName = "Ada";
string lastName = "Lovelace";
string fullName = firstName + " " + lastName;
string message = $"Player: {fullName}";
2. StringBuilder 的特点和优势
StringBuilder 通过内部缓冲区减少反复拼接时产生的中间字符串,适合循环或大量文本构建:
using System.Text;
var builder = new StringBuilder(128);
for (int i = 0; i < 100; i++)
{
builder.Append(i).Append(',');
}
string result = builder.ToString();
3. StringBuilder 的内部原理
StringBuilder 不会在每次 Append 时都创建一个完整的新字符串,而是把字符写入可复用的缓冲区。具体实现取决于运行库版本;现代 .NET 的常见实现使用多个 char[] 字符块组成的结构,其他版本也可能使用扩容数组并复制已有字符。
概念上的追加过程如下:
- 创建实例时准备初始字符缓冲区。
Append优先写入当前缓冲区的剩余空间。- 容量不足时分配新的字符块或更大的缓冲区。
ToString()创建最终连续的string,并复制其中的有效字符。
旧字符块 ← 旧字符块 ← 当前字符块
char[] char[] char[]
因此,准确说法是 StringBuilder 能减少中间字符串,而不是完全不产生新对象:
- 创建
StringBuilder和内部缓冲区会分配内存。 - 扩容可能分配新的字符块或数组。
ToString()必然创建最终字符串。- 传给
Append的参数可能在调用前已经创建字符串;某些格式化和重载在特定运行时中也可能产生额外分配。 Insert、Remove和Replace可能需要查找、移动或复制字符,并不都是低成本操作。
4. 容量、复用和释放
- 已知大致字符数量时,可以设置初始容量,减少扩容。
Clear()主要把逻辑长度设为0,不保证缩小Capacity或立即释放缓冲区。StringBuilder没有Dispose();它及其缓冲区不再能从 GC Roots 到达后,由 GC 负责回收。- 长期复用的实例如果偶尔增长得特别大,可能长期保留大容量。可以根据业务阈值替换为较小实例。
StringBuilder实例默认不保证线程安全,不应由多个线程无同步地同时修改。
if (builder.Capacity > 64 * 1024)
{
builder = new StringBuilder(256);
}
else
{
builder.Clear();
}
使用原则:少量、固定次数的拼接优先使用 string;循环中频繁拼接或修改大量文本时使用 StringBuilder。它通过可复用字符缓冲区减少中间字符串,但仍会产生必要的对象和缓冲区分配。
八、LinkedList<T> 和 List<T> 的区别
List<T> 是基于数组实现的可扩容顺序表,LinkedList<T> 是由 LinkedListNode<T> 组成的双向链表。
| 对比项 | List<T> |
LinkedList<T> |
|---|---|---|
| 底层结构 | 连续数组 | 双向链表节点 |
| 按索引访问 | O(1) |
不支持索引器,查找第 n 个节点为 O(n) |
| 尾部添加 | 均摊 O(1),扩容时为 O(n) |
O(1) |
| 中间插入、删除 | 需要移动后续元素,通常为 O(n) |
已持有目标节点时为 O(1) |
| 查找元素 | O(n) |
O(n) |
| 内存和缓存 | 元素紧凑,缓存局部性较好 | 每个节点包含前后引用,内存和 GC 开销更大 |
1. 链表的 O(1) 有前提
只有已经持有目标 LinkedListNode<T> 时,在该节点前后插入或删除该节点才是 O(1)。如果只有元素值或逻辑位置,需要先遍历寻找节点,查找过程是 O(n)。
例如,Remove(value) 需要先查找匹配节点,整体不是 O(1);而 Remove(node) 在节点属于该链表时可以直接修改前后链接。
using System.Collections.Generic;
var linkedList = new LinkedList<int>();
LinkedListNode<int> node = linkedList.AddLast(10);
linkedList.AddAfter(node, 20); // 已知 node,O(1)
linkedList.Remove(node); // 已知 node,O(1)
2. 选择建议
- 需要索引访问、遍历和尾部追加时,通常优先使用
List<T>。 - 已经长期持有节点,并频繁在该节点附近插入或删除时,可以考虑
LinkedList<T>。 List<T>的连续内存具有更好的缓存局部性。即使中间操作理论复杂度为O(n),在中小规模数据中也可能比链表更快。- 已知大致元素数量时,可以为
List<T>指定初始容量,减少扩容和数组复制。
链表没有数组的 Capacity 概念,但仍然受可用内存限制,不能简单描述为“不受容量限制”。
九、delegate、event、Action、Func 的区别和联系
它们都与“把方法当作值进行保存和传递”有关,但职责不同。
| 概念 | 含义 | 典型用途 |
|---|---|---|
delegate |
声明自定义委托类型的关键字 | 需要有明确业务含义的回调签名 |
System.Delegate |
所有委托类型的基类 | 一般不直接用于声明具体回调 |
event |
以委托类型为基础、限制外部操作方式的成员 | 发布订阅、状态变化通知 |
Action |
无返回值的预定义委托 | 通用回调、命令和通知 |
Func |
有返回值的预定义泛型委托 | 计算、转换、查询和策略 |
1. 自定义委托
委托是一种类型安全的方法引用。只有参数列表和返回类型兼容的方法,才能赋给对应委托变量。
using System;
public delegate void DamageHandler(int amount);
DamageHandler handler = amount => Console.WriteLine($"Damage: {amount}");
handler.Invoke(10);
当签名具有明确的业务含义,或需要在参数上声明特性时,可以定义自定义委托;普通通用回调通常可以直接使用 Action 或 Func。
2. Action 和 Func
Action表示无返回值的方法。Action可以没有参数,泛型版本最多接收 16 个输入参数。Func<TResult>表示无输入参数但有返回值的方法。Func<T1, ..., T16, TResult>最多接收 16 个输入参数,最后一个泛型参数始终表示返回类型。
Action<string> log = message => Console.WriteLine(message);
Func<int, int, int> add = (left, right) => left + right;
log("Hello");
int sum = add(2, 3);
3. event 的封装作用
普通委托变量如果公开,外部代码可以直接赋值、清空或调用。声明为事件后,外部代码通常只能使用 += 和 -= 订阅或取消订阅,只有声明事件的类型能够直接触发事件。
using System;
public class Player
{
public event Action<int> HealthChanged;
public int Health { get; private set; } = 100;
public void TakeDamage(int amount)
{
Health -= amount;
HealthChanged?.Invoke(Health);
}
}
public static class EventExample
{
public static void Run()
{
var player = new Player();
Action<int> handler = health => Console.WriteLine($"Health: {health}");
player.HealthChanged += handler;
player.TakeDamage(10);
player.HealthChanged -= handler;
}
}
4. 多播委托注意事项
- 委托可以通过
+=组合多个方法,并按照调用列表的顺序执行。 - 有返回值的多播委托最终只返回最后一个方法的结果,因此通知型多播委托通常使用
void返回值。 - 某个方法抛出未处理异常时,默认会中断调用,后续方法不会继续执行。
- 匿名 Lambda 如果没有保存到变量中,之后通常无法用一个新的同形 Lambda 正确取消订阅。
在 Unity 中,长生命周期的发布者会通过事件间接持有订阅者。应根据对象生命周期及时取消订阅,常见做法是在 OnEnable 中订阅、在 OnDisable 中取消订阅。
5. 委托实例的底层结构和不可变性
概念上,一个委托实例包含:
- 方法信息:需要调用哪个方法。
- 目标对象:实例方法需要持有对应对象;静态方法通常不需要目标对象。
- 调用列表:多播委托可以包含多个“目标对象 + 方法”的组合。
委托本身是不可变对象。使用 +=、-=、Delegate.Combine 或 Delegate.Remove 时,会生成新的委托结果,再赋回变量。
using System;
public class Counter
{
public void Print(int value)
{
Console.WriteLine(value);
}
}
var counter = new Counter();
Action<int> instanceHandler = counter.Print; // 持有 counter
Action<int> staticHandler = Console.WriteLine;
Action<int> combined = instanceHandler + staticHandler;
combined(10);
实例方法委托会持有目标对象,因此只要委托仍然可达,目标对象也可能继续存活。事件未取消订阅造成的生命周期问题,本质上也与这种引用关系有关。
6. Lambda、闭包和局部函数
Lambda 或局部函数如果捕获外部局部变量,编译器需要把这些变量提升到闭包对象或其他状态载体中。闭包可能产生托管堆分配,并延长被捕获对象的生命周期。
局部函数本身不等于委托。编译器通常将局部函数生成为普通方法;只有把它转换为委托,或捕获状态需要额外载体时,才会产生相应的委托或闭包开销。
public static Action CreateCounter()
{
int count = 0;
void Increment()
{
count++;
Console.WriteLine(count);
}
return Increment;
}
7. 常见用途和 Unity 注意事项
- 回调、完成通知和事件。
- 策略模式,例如把伤害公式或评分函数作为参数传入。
- 集合排序、筛选和 LINQ 查询。
- 延迟执行、命令队列和 UI 回调。
在 Unity 高频代码中,需要关注重复创建 Lambda、闭包捕获和反复组合委托产生的 GC Alloc。是否分配以及分配大小应以对应 Unity 版本和 Profiler 结果为准。
十、CLR、JIT、AOT、Mono 和 IL2CPP 的关系
CLR(Common Language Runtime,公共语言运行时)是 .NET 托管代码执行环境的核心概念。传统 .NET Framework 使用 CLR,现代 .NET 常见实现是 CoreCLR;Unity 则根据脚本后端使用 Mono 运行时或 IL2CPP 提供相应的托管语义支持。
1. 托管运行时的主要职责
- 代码执行:加载程序集,并通过 JIT、AOT 或对应后端执行代码。
- 类型系统:依据元数据维护类型信息,进行类型检查和方法分派。
- 内存管理:管理托管堆并通过 GC 回收不可达对象。
- 异常处理:提供跨托管调用栈的异常传播机制。
- 线程和同步:提供线程、线程池和同步原语等基础设施。
- 互操作:支持托管代码与 Native 代码交互。
- 跨语言协作:符合公共类型系统的语言可以共享程序集和类型。
托管运行时减少了手动内存管理错误,但不会自动释放文件句柄、Socket、GPU 资源等外部资源。这些内容仍需要通过 Dispose、Unity 资源 API 或明确的生命周期管理。
典型的 C# 编译流程是:
C# 源代码 → C# 编译器 → IL(中间语言)和元数据 → JIT/AOT → 本地机器代码
JIT(Just-In-Time Compilation)和 AOT(Ahead-Of-Time Compilation)的主要区别是把 IL 转换为本地代码的时机不同。
| 对比项 | JIT | AOT |
|---|---|---|
| 编译时机 | 程序运行期间,通常在方法首次执行前 | 构建、发布或运行前 |
| 启动成本 | 运行时需要承担部分编译成本 | 通常启动更快 |
| 运行时优化 | 可以根据实际平台和运行数据动态优化 | 主要依赖构建阶段能够获得的信息 |
| 构建时间 | 相对较短 | 通常更长 |
| 包体积 | 可以只在运行时编译需要的方法 | 可能生成更多本地代码,包体积可能更大 |
| 动态代码能力 | 通常更灵活 | 动态生成代码和部分反射场景受限 |
2. JIT 的特点
- 方法需要执行时,JIT 将对应 IL 编译为当前平台的机器代码。
- 可以针对真实 CPU、运行环境和热点代码进行优化。
- 首次执行方法时可能产生编译开销,并需要额外运行时内存保存编译器和本地代码。
3. AOT 的特点
- 在程序运行前生成目标平台代码,运行时不需要为普通方法执行 JIT 编译。
- 通常有利于降低启动延迟,也适用于禁止运行时生成可执行代码的平台。
- 会增加构建时间,并可能受到泛型实例化、动态代码生成和代码裁剪等限制。
- AOT 不保证运行速度一定比 JIT 快,最终性能取决于编译器、运行场景和平台。
4. Unity 中的对应关系
- Mono 后端:在平台允许时可以使用 JIT,Unity 编辑器和部分桌面平台常见。
- IL2CPP 后端:属于 AOT 流程,通常是
C# → IL → C++ → 平台本地代码。 - iOS 等平台:平台安全策略禁止运行时 JIT,通常需要使用 IL2CPP。
- WebGL:通常通过 IL2CPP 生成适用于 Web 平台的代码。
IL2CPP 在运行时仍需要支持 GC、类型元数据和异常等托管语义,但不会像普通 JIT 环境一样在运行时把方法 IL 动态编译为机器码。因此,不能把 CLR、Mono 和 IL2CPP 当作同一个具体实现。
IL2CPP 下还应特别注意代码裁剪、仅通过反射访问的成员、AOT 泛型实例化以及不受支持的运行时动态代码生成能力。
十一、foreach 和 for 遍历的区别
for 是通用循环语句,foreach 是基于枚举器模式的遍历语句。二者没有绝对的性能高低,应根据集合类型、访问方式和具体 Unity 版本判断。
| 对比项 | for |
foreach |
|---|---|---|
| 控制方式 | 手动维护初始化、条件和迭代变量 | 自动取得下一个元素 |
| 常见要求 | 通常需要索引器、长度或自行维护状态 | 类型满足枚举器模式 |
| 访问位置 | 可以按索引跳跃、倒序或分段访问 | 通常按枚举器定义的顺序访问 |
| 可读性 | 控制更灵活,但代码更长 | 顺序遍历时通常更简洁 |
| 修改元素 | 可以通过索引修改可写集合元素 | 普通迭代变量通常是只读的当前值 |
1. foreach 的实现基础
编译器会寻找 GetEnumerator(),并通过枚举器的 MoveNext() 和 Current 完成遍历。类型不一定必须显式实现 IEnumerable;只要满足编译器认可的枚举器模式,也可能使用 foreach。
如果枚举器需要释放资源,foreach 会在结束或异常退出时调用相应的 Dispose()。
2. 遍历期间修改集合
遍历 List<T>、Dictionary<TKey, TValue> 等集合时,如果修改集合结构,例如添加、删除或清空元素,枚举器通常会检测到版本变化并抛出 InvalidOperationException。
using System.Collections.Generic;
var numbers = new List<int> { 1, 2, 3 };
foreach (int number in numbers)
{
// numbers.Add(4); // 通常会导致 InvalidOperationException
}
如果集合元素是引用类型,可以修改当前对象的内部状态,因为迭代变量保存的是对象引用;但不能因此认为可以安全修改集合本身的结构。
值类型元素通常会被复制到迭代变量中,修改这个副本不会自动写回集合:
using System.Collections.Generic;
public struct Point
{
public int X;
}
var points = new List<Point> { new Point { X = 1 } };
Point copy = points[0];
copy.X = 10; // 修改副本
points[0] = copy; // 需要显式写回
3. 性能和 GC
- 数组和具体类型的
List<T>遍历通常能得到良好优化,不能简单认为for一定比foreach快。 List<T>.Enumerator是结构体。直接遍历具体的List<T>时,通常不会因为枚举器本身产生 GC Alloc。- 通过非泛型
IEnumerable、某些接口或迭代器方法遍历时,可能发生装箱或额外分配。 - 对自定义集合而言,
Count、索引器和枚举器的实现成本可能不同。
Unity 中应使用 Profiler 对目标平台和构建后端进行验证,而不是把某一种循环固定认为是更快的选择。
十二、Dictionary<TKey, TValue> 的内部实现原理
Dictionary<TKey, TValue> 基于哈希表实现。不同 .NET 和 Unity 运行时版本的内部细节可能略有差异,但常见实现由桶数组和条目数组组成,并通过条目索引形成哈希冲突链。
1. 核心数据结构
buckets桶数组:每个桶保存对应冲突链的起始条目索引。具体实现中可能使用“索引加一”等编码方式表示空桶。entries条目数组:连续保存哈希码、下一个条目索引、键和值。
条目结构可以概念化为:
private struct Entry<TKey, TValue>
{
public int HashCode;
public int Next;
public TKey Key;
public TValue Value;
}
这里的 Next 是条目数组中的索引,不一定是真正的链表节点引用。因此,常见的 .NET Dictionary 并不会在冲突严重时转换为红黑树,也不是同时使用开放定址法和链地址法。
2. 查找过程
查找一个键时,大致会经历以下步骤:
- 使用
IEqualityComparer<TKey>获取键的哈希码。 - 根据哈希码计算桶位置。
- 从桶记录的条目开始,沿
Next索引检查冲突链。 - 先比较哈希码,再通过比较器的
Equals判断键是否相等。 - 找到匹配键后返回对应值;到达链尾仍未找到则表示键不存在。
using System;
using System.Collections.Generic;
var scores = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
scores["Player"] = 100;
Console.WriteLine(scores["player"]); // 100
3. 哈希冲突和复杂度
不同键可能得到相同桶位置,这就是哈希冲突。冲突键会通过条目中的 Next 索引组织成链。
- 平均查找、插入和删除复杂度为
O(1)。 - 哈希分布很差或大量冲突时,最坏可能退化为
O(n)。 - 好的哈希函数和正确的相等性实现对性能非常重要。
4. 扩容和删除
容量不足时,字典会创建更大的桶数组和条目数组,复制有效条目,并重新建立条目与桶的对应关系。扩容是 O(n),因此添加操作的平均复杂度通常按均摊 O(1) 理解。
删除条目后,内部实现通常会维护空闲条目链表,以便后续添加时复用对应位置。具体字段名称和实现方式取决于运行时版本。
5. 键类型的要求
- 自定义键应保证
Equals和GetHashCode的规则一致:相等对象必须产生相同哈希码。 - 键加入字典后,不应修改会影响相等性或哈希码的字段,否则之后可能无法正常找到该键。
- 可以通过构造函数传入自定义
IEqualityComparer<TKey>。 Dictionary<TKey, TValue>不允许使用null作为键。- 不应把字典枚举顺序当作稳定的业务协议;应根据需要显式排序。
6. Unity 中的注意事项
Unity 的内置序列化系统通常不能直接在 Inspector 中序列化普通 Dictionary<TKey, TValue>。需要显示或持久化字典时,可以使用可序列化键值列表、实现 ISerializationCallbackReceiver,或采用适合项目的第三方序列化方案。
十三、int? 和 int 有什么区别
int 是不可空值类型,int? 是 Nullable<int> 的简写,可以表示一个整数或“没有值”。
| 对比项 | int |
int? / Nullable<int> |
|---|---|---|
| 类型 | 值类型 | 可空值类型,本身也是结构体 |
| 可表示的状态 | 一个 32 位有符号整数 | 一个 int 或 null |
default 结果 |
0 |
null |
| 常见场景 | 必须有数值的计数和计算 | 数据库可空字段、尚未设置或缺失的数值 |
1. 默认值需要区分字段和局部变量
- 字段、数组元素以及
default(int)的默认值是0。 - 字段、数组元素以及
default(int?)的默认值是null。 - 局部变量无论声明为
int还是int?,读取前都必须明确赋值,不能直接使用一个尚未赋值的局部变量。
2. 常用成员和空值处理
Nullable<T> 主要提供 HasValue、Value 和 GetValueOrDefault()。当可空值没有值时访问 Value,会抛出 InvalidOperationException。
using System;
int? number = null;
bool hasValue = number.HasValue;
int value1 = number.GetValueOrDefault(); // 没有值时返回 0
int value2 = number ?? 0; // 空合并运算符
if (number is int actualValue)
{
Console.WriteLine(actualValue);
}
int 可以隐式转换为 int?;从 int? 取得 int 时必须处理空值,例如使用模式匹配、??、GetValueOrDefault() 或在确认 HasValue 后访问 Value。
3. 提升运算符
值类型运算符通常会被提升到对应的可空值类型。对于大多数算术运算,只要任意操作数为 null,结果就是 null。
int? left = 10;
int? right = null;
int? result = left + right; // null
可空值类型的相等和关系运算需要分别理解。例如两个空的 int? 使用 == 比较结果为 true,而 <、> 等关系比较只要涉及 null,结果通常为 false。
4. 装箱行为
- 有值的
int?装箱时,会装箱它内部的int,箱中对象的运行时类型是System.Int32。 - 没有值的
int?装箱结果是null。
int? hasNumber = 10;
int? noNumber = null;
object boxedValue = hasNumber; // 装箱为 System.Int32
object boxedNull = noNumber; // null
int? 是实际存在于运行时的 Nullable<int> 值类型;它与可空引用类型标注(例如 string?)不是同一种机制。
Unity 基础
一、四元数和欧拉角的优缺点
Unity 使用欧拉角方便人类编辑和查看旋转,但 Transform 内部使用四元数保存旋转。二者是同一个旋转的不同表示方式。
| 对比项 | 欧拉角 | 四元数 |
|---|---|---|
| Unity 常用类型 | Vector3 |
Quaternion |
| 数据量 | 3 个 float,通常为 12 字节 |
4 个 float,通常为 16 字节 |
| 可读性 | 直观,便于 Inspector 编辑 | 不直观,不应直接修改分量 |
| 万向节锁 | 按顺序组合旋转时可能发生 | 表示和组合旋转时不会发生 |
| 插值 | 容易受角度环绕和多解影响 | 适合 Lerp、Slerp 等旋转插值 |
| 旋转组合 | 需要明确欧拉角顺序 | 使用四元数乘法组合 |
1. 欧拉角的特点
欧拉角使用三个角度描述旋转,适合在 Inspector 中编辑,也便于表达“绕某个轴旋转多少度”。
主要问题:
- 旋转结果依赖旋转顺序。
- 可能发生万向节锁。
- 同一个旋转可能对应多组欧拉角,例如
0°与360°。 - 在
0°/360°附近直接插值可能绕远路或产生跳变。
2. 四元数的特点
四元数适合保存、组合和插值旋转。Unity 常用 Quaternion.Euler 从欧拉角创建四元数,使用 Quaternion.Slerp 或 Quaternion.Lerp 插值。
using UnityEngine;
public class RotationExample : MonoBehaviour
{
public Quaternion TargetRotation { get; set; }
private void Start()
{
TargetRotation = Quaternion.Euler(0f, 90f, 0f);
}
private void Update()
{
transform.rotation = Quaternion.Slerp(
transform.rotation,
TargetRotation,
5f * Time.deltaTime);
}
}
需要注意:
Quaternion.x/y/z/w不是欧拉角,不能把它们当作角度直接修改。q和-q可以表示同一个旋转。- 四元数应保持单位长度,Unity 的常用旋转 API 通常会处理归一化。
- 四元数本身不会发生万向节锁,但把它转换成欧拉角再反复修改,仍可能遇到欧拉角的多解和奇异点。
二、万向节锁是如何导致的
万向节锁(Gimbal Lock)发生在使用三个有顺序的轴旋转表示姿态时。当中间一次旋转使第一根旋转轴与第三根旋转轴重合或平行,这两个旋转就无法继续独立控制,系统从三个旋转自由度退化为两个。
1. 产生原因
以 Unity 使用的 Z-X-Y 欧拉角顺序为例:
- 先绕 Z 轴旋转。
- 再绕 X 轴旋转。
- 最后绕 Y 轴旋转。
当中间的 X 轴旋转到特定奇异角度(典型情况是接近 ±90°)时,第一次和第三次旋转所对应的轴可能重合。此时改变其中一个角度会产生与另一个角度相同或相关的旋转效果,因此丢失一个独立自由度。
2. 常见轴含义
在常见的 Unity 角色或相机语境中,通常约定:
- X:Pitch,俯仰。
- Y:Yaw,偏航。
- Z:Roll,翻滚。
Roll、Pitch、Yaw 的具体轴含义依赖坐标系和项目约定,使用前应先明确采用的约定。
3. 奇异点与附近区域
- 到达奇异角度时,会真正丢失一个自由度。
- 接近奇异角度时,欧拉角转换可能变得敏感,出现数值跳变或某个角度突然大幅变化。
- 这不代表物体不能继续旋转,而是当前欧拉角参数无法稳定、唯一地描述该旋转。
四元数表示和组合旋转时没有这个奇异点,因此 Unity 内部使用四元数保存 Transform.rotation。但如果每帧把四元数转换成欧拉角并修改,仍然会重新引入欧拉角的问题。
三、Unity 中欧拉角的旋转顺序是什么
Unity 使用欧拉角创建旋转时,通常按照以下顺序依次应用:
Z → X → Y
例如 Quaternion.Euler(x, y, z) 表示先应用 Z 轴旋转,再应用 X 轴旋转,最后应用 Y 轴旋转。旋转顺序不同,即使三个角度数值相同,最终姿态也可能不同。
1. Space.Self 和 Space.World 不是旋转顺序
Transform.Rotate(Vector3 eulerAngles, Space relativeTo) 中的 relativeTo 只决定旋转轴参考哪个坐标系:
Space.Self:相对于物体当前局部坐标轴旋转。Space.World:相对于世界坐标轴旋转。
它不能把 Unity 默认的欧拉角顺序改成任意顺序。
transform.Rotate(new Vector3(0f, 90f, 0f), Space.Self);
transform.Rotate(Vector3.up, 90f, Space.World);
2. 自定义旋转组合顺序
需要明确控制顺序时,可以分别创建绕各轴的四元数,再按期望顺序相乘。四元数乘法不满足交换律,并且右侧旋转通常先作用。
Quaternion rotateX = Quaternion.AngleAxis(x, Vector3.right);
Quaternion rotateY = Quaternion.AngleAxis(y, Vector3.up);
Quaternion rotateZ = Quaternion.AngleAxis(z, Vector3.forward);
// 对向量的实际作用顺序:先 Z,再 X,最后 Y。
Quaternion result = rotateY * rotateX * rotateZ;
transform.rotation = result;
3. Transform 的存储方式
Transform.rotation和localRotation使用四元数。- Inspector 和
transform.eulerAngles提供欧拉角表示,方便阅读和编辑。 - 同一个四元数可能转换成不同但等价的欧拉角,所以读取值不一定等于之前写入的值。
- 不建议每帧读取
eulerAngles、修改单个分量后再写回。持续旋转优先使用Transform.Rotate或四元数乘法。
四、Unity 动态加载资源的几种方式
Unity 常见的运行时资源加载方式包括 Resources、AssetBundle、Addressables、StreamingAssets 和 UnityWebRequest。选择时不仅要考虑如何加载,还要考虑依赖、异步执行、远程更新和资源释放。
| 方式 | 主要特点 | 适用场景 |
|---|---|---|
Resources |
使用简单,资源随包构建 | 小型项目、少量内置资源 |
| AssetBundle | 底层资源包,可本地或远程加载 | 自定义热更新和资源管理系统 |
| Addressables | 高层地址系统,管理依赖和引用计数 | 中大型项目,通常优先考虑 |
| StreamingAssets | 原样随包保存文件 | 配置、视频、数据库或自定义数据 |
UnityWebRequest |
负责下载网络数据 | 远程文本、纹理、音频和 AssetBundle |
1. Resources
资源放在任意名为 Resources 的目录中,加载路径相对于该目录且不包含文件扩展名。
GameObject prefab = Resources.Load<GameObject>("Prefabs/MyPrefab");
ResourceRequest request = Resources.LoadAsync<GameObject>("Prefabs/MyPrefab");
需要注意:
Resources.Load是同步 API,复杂资源可能造成主线程卡顿。Resources中的资源会被打入安装包,资源量过大时会增加构建和管理成本。- 它缺少完善的依赖、版本和远程内容管理能力。
- 单个资源可以使用
Resources.UnloadAsset卸载;Resources.UnloadUnusedAssets会查找未使用资源,但本身可能带来明显开销。
2. AssetBundle
AssetBundle 是平台相关的资源包。通常先加载 Bundle,再从 Bundle 中加载具体资源。
AssetBundle bundle = AssetBundle.LoadFromFile(bundlePath);
GameObject prefab = bundle.LoadAsset<GameObject>("Player");
bundle.Unload(false);
需要管理:
- Bundle 之间的依赖关系和加载顺序。
- 同步与异步 API,例如
LoadFromFileAsync、LoadAssetAsync。 - 不同平台对应的构建产物。
- Bundle 和已加载资源的生命周期。
Unload(false) 卸载 Bundle 容器,但保留已经加载的资源;Unload(true) 会尝试同时卸载从该 Bundle 加载的资源,仍在使用的对象可能失效。
3. Addressables
Addressables 是建立在 AssetBundle 等机制之上的高层资源系统,可以管理地址、依赖、异步加载、本地或远程内容、引用计数和内容更新。
using System.Collections;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class AddressablesExample : MonoBehaviour
{
private IEnumerator LoadPlayer()
{
AsyncOperationHandle<GameObject> handle =
Addressables.LoadAssetAsync<GameObject>("Player");
yield return handle;
if (handle.Status == AsyncOperationStatus.Succeeded)
{
GameObject prefab = handle.Result;
Debug.Log(prefab.name);
}
Addressables.Release(handle);
}
}
加载完成后必须根据使用方式调用 Addressables.Release 或 Addressables.ReleaseInstance。忘记释放会使引用计数无法归零,资源可能长期留在内存中。
4. StreamingAssets
StreamingAssets 中的文件通常会以原始形式随包发布,适合存放视频、数据库、配置或由业务代码解析的数据。
不同平台访问方式不同:桌面平台可能可以直接使用文件 API,而 Android、WebGL 等平台通常需要通过 UnityWebRequest 读取,不能假设始终存在普通文件路径。
5. UnityWebRequest
UnityWebRequest 主要负责下载数据,不会自动把任意文件转换成 Unity 序列化资源。常用接口包括:
UnityWebRequestTexture.GetTexture:下载纹理。UnityWebRequestMultimedia.GetAudioClip:下载音频。UnityWebRequestAssetBundle.GetAssetBundle:下载 AssetBundle。DownloadHandlerBuffer:取得文本或字节数据。
资源加载优化不仅是“异步”,还要避免同一帧集中实例化、反序列化和上传 GPU,并建立明确的加载、缓存、引用和释放策略。
五、MonoBehaviour 生命周期
Unity 通过 Player Loop 在不同阶段调用 MonoBehaviour 的消息函数。常见时序可以简化为:
Awake → OnEnable → Start
↓
FixedUpdate(可能 0 次、1 次或多次)
↓
Update → LateUpdate
↓
OnDisable → OnDestroy
1. 初始化阶段
| 方法 | 调用特点 | 常见用途 |
|---|---|---|
Awake |
每个实例通常只调用一次,早于该实例的 Start |
初始化自身状态、缓存组件引用 |
OnEnable |
组件启用且 GameObject 激活时调用,可以多次调用 | 订阅事件、注册回调 |
Start |
脚本启用时,在第一次帧更新前调用一次 | 依赖其他对象完成 Awake 后的初始化 |
场景初始对象通常会先执行所有 Awake 和 OnEnable,再进入 Start 阶段。但运行时动态实例化对象时,调用可能插入当前帧流程,因此不应依赖不同 GameObject 之间未明确配置的执行先后顺序。
2. 更新阶段
FixedUpdate
FixedUpdate 使用固定时间步长调度,主要服务于物理模拟。由于渲染帧率和固定时间步长不同,一个渲染帧内可能执行零次、一次或多次 FixedUpdate。
适合:
- 使用
Rigidbody.AddForce等物理 API。 - 固定时间步长的物理控制逻辑。
Update
脚本启用且 GameObject 激活时,通常每个渲染帧调用一次。适合处理输入、普通游戏逻辑和非物理移动。
LateUpdate
在当前帧的 Update 阶段之后调用,适合依赖其他对象已经完成更新的逻辑,例如第三人称相机跟随。
3. 禁用和销毁阶段
| 方法 | 典型触发条件 | 常见用途 |
|---|---|---|
OnDisable |
组件禁用、GameObject 失活、销毁前或脚本重载等 | 取消事件订阅、停止临时行为 |
OnDestroy |
对象被销毁或场景卸载 | 最终清理、释放明确拥有的资源 |
取消订阅通常优先与订阅阶段对称,例如在 OnEnable 订阅、在 OnDisable 取消订阅,而不是只依赖 OnDestroy。
4. 执行顺序注意事项
- 不同对象上同一种消息函数的默认先后顺序不应作为业务逻辑依据。
- 可以通过 Project Settings 中的 Script Execution Order 或
[DefaultExecutionOrder]调整脚本类型顺序。 OnCollisionEnter、OnTriggerEnter等属于物理消息回调,不属于狭义的初始化和销毁生命周期。OnApplicationFocus、OnApplicationPause、OnApplicationQuit用于应用焦点、暂停和退出状态。
生命周期函数由 Unity 按名称识别和调用,不应手动调用 Awake、Start 或 Update 来模拟引擎阶段。
5. 底层调用原理
MonoBehaviour 是托管脚本对象,同时与 Unity Native 层中的对象关联。Unity 会识别脚本类型声明了哪些消息函数,并在对象满足激活、启用等条件时,将相应实例纳入更新和消息调度。
引擎主循环通过 Player Loop 在固定阶段调度这些消息。例如,普通 Update 位于脚本行为更新阶段,常可在 Player Loop 中看到 ScriptRunBehaviourUpdate;物理、动画、渲染和延迟销毁等工作位于其他阶段。
需要注意:
- Unity 消息不是 C# 事件,也不要求把方法声明为
virtual或override;引擎主要按约定的方法名和签名识别它们。 - Unity 不会在每一帧通过
MethodInfo.Invoke反射调用所有Update。类型识别可能使用元数据,但高频调用会使用经过优化的 Native 与 Managed 调度路径。 - Mono 和 IL2CPP 后端的底层调用方式不同。Mono 可能使用运行时调用桩和 Native/Managed 转换机制;IL2CPP 会生成对应的 C++ 包装及调用代码。不能把这些过程统一描述为普通的 P/Invoke。
- 可以把引擎维护更新列表或调度结构作为概念模型,但具体容器和实现属于 Unity 版本相关的内部细节。
- 大量只有空
Update或很少执行逻辑的组件仍会增加调度及 Native/Managed 边界成本。极端场景可以考虑集中式 Update Manager,但应先通过 Profiler 验证。
六、常见游戏动画类型及其原理
动画可以按数据表现方式、模型变形方式或运行时生成方式分类。这些类型并不互斥,例如骨骼动画通常也会使用关键帧记录骨骼变换。
| 类型 | 核心原理 | 常见用途 |
|---|---|---|
| 属性关键帧动画 | 记录属性曲线并在关键帧之间插值 | Transform、颜色、材质和 UI 动画 |
| 骨骼蒙皮动画 | 骨骼矩阵按权重影响网格顶点 | 人物、动物和复杂角色 |
| Blend Shape | 混合预先保存的顶点形变 | 表情、口型和局部变形 |
| Sprite 序列帧 | 按时间切换 Sprite | 2D 角色和特效 |
| 程序化动画 | 运行时通过算法计算姿态 | IK、瞄准、脚部贴地 |
| 物理动画 | 使用物理引擎求解运动 | 布娃娃、布料和破碎效果 |
1. 属性关键帧动画
AnimationClip 保存一组随时间变化的动画曲线。每条曲线对应某个对象属性,例如位置、旋转、缩放、颜色或材质参数,播放时在关键帧之间进行插值。
2. 骨骼蒙皮动画
骨骼组成层级结构,动画曲线驱动骨骼的局部变换。网格顶点保存所受骨骼的索引和权重,运行时根据骨骼矩阵和权重计算顶点最终位置。
概念上可以表示为:
最终顶点位置 = Σ(骨骼变换矩阵 × 顶点位置 × 对应权重)
因此骨骼动画不是把一个矩阵应用到整个模型,而是多个骨骼共同影响不同顶点。
3. Blend Shape / Morph Target
Blend Shape 保存顶点相对基础网格的位移,通过权重混合不同形变。它常用于面部表情、口型和肌肉变化,也可以与骨骼动画同时使用。
4. Sprite 序列帧动画
按照时间切换不同 Sprite,可以通过 AnimationClip、Animator 或代码实现。适合 2D 角色、UI 和逐帧特效,但大量高分辨率帧可能增加纹理内存。
5. 程序化动画和 IK
程序化动画在运行时根据目标、地形或状态计算姿态,例如:
- 手部抓取和瞄准约束。
- 脚部 IK 和地面贴合。
- 呼吸、摆动和受击反馈。
- 根据速度生成步态。
6. 物理动画
物理动画通过 Rigidbody、Joint、Cloth 等系统计算结果,例如布娃娃、布料、头发和破碎物。项目中经常把物理结果与关键帧动画混合,而不是只选择一种方式。
7. Unity 动画系统常见概念
AnimationClip:保存动画曲线和事件。- Animator Controller:通过状态、参数和过渡组织动画。
- Blend Tree:根据速度或方向等参数混合多个动作。
- Animation Layer 和 Avatar Mask:分层混合并限制影响的骨骼范围。
- Avatar/Retargeting:在人形骨骼之间重定向动画。
- Root Motion:使用动画根节点的位移和旋转驱动角色移动。
说明渲染技术分类时,应先明确采用的分类维度,再解释这些技术可以组合使用。
七、矩阵相乘的意义及注意点
矩阵可以表示和组合缩放、旋转、平移、投影等变换。矩阵相乘的核心意义是把多个坐标变换合成为一个变换,并在不同坐标空间之间转换数据。
1. 基本规则
- 左矩阵的列数必须等于右矩阵的行数。
A * B通常不等于B * A,矩阵乘法不满足交换律。- 矩阵乘法满足结合律:
(A * B) * C = A * (B * C)。 - 单位矩阵是乘法单位元,矩阵与单位矩阵相乘保持不变。
2. Unity 中的变换应用顺序
Unity 常用以下形式变换向量:
result = Matrix * vector
在这种约定下,表达式右侧的变换先作用。如果组合矩阵为:
Matrix4x4 translation = Matrix4x4.Translate(position);
Matrix4x4 rotation = Matrix4x4.Rotate(rotationValue);
Matrix4x4 scale = Matrix4x4.Scale(scaleValue);
Matrix4x4 matrix = translation * rotation * scale;
那么它对点的实际作用顺序是:
先缩放 → 再旋转 → 最后平移
也可以使用:
Matrix4x4 matrix = Matrix4x4.TRS(position, rotationValue, scaleValue);
Vector3 worldPoint = matrix.MultiplyPoint3x4(localPoint);
3. 点、方向和齐次坐标
图形学使用四维齐次坐标统一表示平移等变换:
| 数据 | 齐次分量 | 是否受平移影响 | Unity 常用方法 |
|---|---|---|---|
| 点 | w = 1 |
是 | MultiplyPoint、MultiplyPoint3x4 |
| 方向向量 | w = 0 |
否 | MultiplyVector |
MultiplyPoint3x4 适用于不包含投影的普通仿射变换;涉及投影和齐次除法时,需要使用适合完整 4×4 变换的方法。
4. 常见坐标空间
矩阵经常用于以下转换:
模型空间 → 世界空间 → 观察空间 → 裁剪空间 → 屏幕空间
Unity 常见矩阵包括:
Transform.localToWorldMatrix:局部空间到世界空间。Transform.worldToLocalMatrix:世界空间到局部空间。- 相机观察矩阵:世界空间到相机空间。
- 投影矩阵:相机空间到裁剪空间。
5. 其他注意事项
- 逆矩阵用于执行反向坐标变换,但不可逆矩阵不存在有效逆矩阵。
- 非均匀缩放与旋转组合可能产生剪切或使法线变换不能直接使用原矩阵。
- 法线通常需要使用模型矩阵逆矩阵的转置进行变换,尤其是在存在非均匀缩放时。
Matrix4x4的内存布局和数学乘法约定不是同一个问题;Shader 中mul的书写方向应结合具体 API 和着色器约定判断。
关键结论是:矩阵乘法顺序不可交换,并且在 Unity 常见的 M * v 写法中,靠右的变换先作用。
八、Unity 协程、线程和 async/await 的区别
Unity 协程是 C# 迭代器状态机 与 Unity Player Loop 调度 的结合。它允许一段逻辑在遇到 yield 时暂停,并在满足对应条件后继续执行。
协程通常运行在 Unity 主线程上,不等于后台线程,也不会自动把耗时计算分摊到多个帧。
1. C# 语言基础:迭代器状态机
包含 yield return 的方法会被编译器转换为实现 IEnumerator 的状态机。直接调用该方法时,只会创建并返回迭代器,方法主体不会像普通方法一样立即完整执行。
private IEnumerator ExampleCoroutine()
{
Debug.Log("开始");
yield return null;
Debug.Log("下一帧继续");
}
IEnumerator iterator = ExampleCoroutine(); // 只取得迭代器
Coroutine coroutine = StartCoroutine(iterator); // Unity 开始驱动 MoveNext
StartCoroutine 会让 Unity 驱动迭代器,代码通常会先执行到第一次 yield,之后再根据 yield 对象决定恢复时机。编译器只会把跨 yield 仍需要保留的状态提升为状态机字段,不是机械地保存所有局部变量。
2. 常见 yield 指令和恢复时机
协程没有统一的“都在 Update 后、LateUpdate 前恢复”的规则,恢复阶段取决于等待对象。
| 写法 | 含义 |
|---|---|
yield return null |
通常在下一帧继续 |
yield return new WaitForSeconds(t) |
等待受 Time.timeScale 影响的时间 |
yield return new WaitForSecondsRealtime(t) |
等待真实时间,不受 timeScale 影响 |
yield return new WaitForFixedUpdate() |
在后续固定更新阶段继续 |
yield return new WaitForEndOfFrame() |
在本帧渲染接近结束时继续 |
yield return new WaitUntil(predicate) |
条件为真后继续,条件检查通常位于 Update 与 LateUpdate 之间 |
yield return anotherIEnumerator |
等待嵌套迭代器完成 |
WaitForSeconds 不是精确计时器。它通常在时间满足后的某一帧恢复,实际时刻还受帧率和当前帧耗时影响。
3. 分帧不等于自动非阻塞
只有执行到 yield 时,协程才会交还主线程控制权。两个 yield 之间的代码仍然在当前帧同步执行,并可能造成卡顿。
错误示例:
private IEnumerator HeavyWork()
{
DoVeryExpensiveWork(); // 仍然会阻塞当前帧
yield return null;
}
主动分批示例:
private IEnumerator ProcessItems(List<Item> items)
{
const int batchSize = 100;
for (int i = 0; i < items.Count; i++)
{
Process(items[i]);
if ((i + 1) % batchSize == 0)
{
yield return null;
}
}
}
如果单个任务无法安全拆分,并且不调用只能在主线程使用的 Unity API,可以考虑 Job System、Burst、线程或 Task.Run 等方案。
4. 生命周期和停止方式
StopCoroutine可以停止指定协程。StopAllCoroutines只停止当前MonoBehaviour启动的协程。- 将
MonoBehaviour.enabled设置为false,通常不会自动停止它已经启动的协程。 - 将 GameObject 设为不激活或销毁对象,会停止其相关协程。
- 协程不会自动理解业务取消条件,应明确设计停止、超时和清理逻辑。
调用 StopCoroutine 时应保留 Coroutine 或原始 IEnumerator 引用,避免启动和停止时使用不同的迭代器实例。
5. 内存分配
协程可能产生以下托管分配:
- 编译器生成的迭代器状态机对象。
new WaitForSeconds(...)等等待对象。WaitUntil、WaitWhile使用的委托和闭包。- 嵌套迭代器和临时集合。
是否需要缓存等待对象取决于参数是否固定、Unity 版本和具体使用方式,应使用 Profiler 验证,而不是盲目缓存所有对象。
6. 与线程和 async/await 的区别
| 对比项 | Unity 协程 | 线程 | async/await |
|---|---|---|---|
| 基础 | IEnumerator 状态机 |
操作系统执行流 | Task、ValueTask 或自定义 Awaitable 状态机 |
| 常见调度 | Unity Player Loop | 操作系统抢占调度 | 由等待对象和同步上下文决定 |
| 是否自动并行 | 否 | 多核环境中可以并行 | 否,只有 Task.Run 等操作通常进入线程池 |
| Unity API | 位于主线程时可使用大多数 API | 后台线程不能直接调用大多数场景对象 API | 取决于续体恢复线程 |
| 适合场景 | 分帧逻辑和等待 Unity 阶段 | 可并行 CPU 计算、后台数据处理 | 网络、文件等异步 I/O 和任务组合 |
| 返回值与异常 | 需要自行设计 | 需要自行在线程边界处理 | 支持返回值、异常传播和取消模式 |
线程是实际执行流,但线程数量不是越多越好。多核设备上不同线程可能并行,核心不足时则主要表现为并发切换;共享数据还需要处理竞态、锁、原子操作和内存可见性。
Task 表示一项可能尚未完成的工作,不等于一个独立线程。async/await 本身只负责状态机和续体,等待真正的异步 I/O 时不一定占用工作线程;Task.Run 才通常把 CPU 工作提交到线程池。
后台线程不能直接操作大多数 GameObject、Transform 和 MonoBehaviour API。工作完成后,需要把结果安全地交回 Unity 主线程,并处理取消、异常和对象生命周期。
7. 选择建议
- 等待帧、时间、动画阶段或把工作主动拆到多帧:使用协程。
- 网络、文件等具有异步 API 的 I/O:优先使用
async/await。 - 可安全并行的 CPU 密集型数据计算:考虑 Job System、Burst、线程池或
Task.Run。
总结:协程是主线程上的可暂停分段执行机制,线程是操作系统执行流,async/await 是组织异步状态机和续体的语法。三者都不意味着代码会自动变快,应根据工作类型和 Unity 主线程限制选择。
九、纹理加载后占用内存如何计算
纹理文件的磁盘大小不等于运行时内存。PNG、JPG 和 Crunch 等主要影响资源文件、构建包或下载体积;纹理进入内存后的主要占用取决于目标平台实际使用的纹理格式、尺寸、Mipmap 和是否保留 CPU 副本。
1. 未压缩纹理
未压缩二维纹理的基础层可以按下面的公式估算:
基础层字节数 = 宽度 × 高度 × 每像素字节数
常见格式示例:
| 格式 | 每像素大小 |
|---|---|
RGBA32 |
4 字节 |
RGB24 |
3 字节 |
RGB565 |
2 字节 |
Alpha8 |
1 字节 |
例如,1024 × 1024 的 RGBA32 纹理:
1024 × 1024 × 4 = 4,194,304 字节 = 4 MiB
这里使用的是二进制单位 MiB,1 MiB = 1024 × 1024 字节。
2. 块压缩纹理
BC/DXT、ETC 和 ASTC 等格式按像素块存储,应该按块计算,而不能只用平均每像素位数处理所有尺寸。
字节数 = ceil(宽度 / 块宽) × ceil(高度 / 块高) × 每块字节数
以常见 BC 格式为例:
- BC1/DXT1:每个
4×4块占 8 字节。 - BC3/DXT5:每个
4×4块占 16 字节。
1024 × 1024 的 BC1 纹理基础层为:
256 × 256 × 8 = 524,288 字节 = 0.5 MiB
块压缩需要对块数量向上取整,因此极小纹理和非块尺寸整数倍纹理会与简单的“平均每像素字节数”估算存在偏差。
3. Mipmap
每级 Mipmap 的宽高通常缩小为上一层的一半,直到 1×1。对于足够大的普通二维纹理,完整 Mipmap 链的总大小接近基础层的 4/3:
总大小 ≈ 基础层 × 4 / 3
例如,4 MiB 的 RGBA32 基础层加完整 Mipmap 后约为 5.33 MiB。块压缩、NPOT 纹理和小尺寸层需要按每一级实际尺寸及块对齐计算,不能保证严格等于 4/3。
4. CPU 和 GPU 副本
纹理内存可能同时存在于 CPU 和 GPU:
- GPU 需要保存用于采样的纹理资源。
- 开启 Texture Import Settings 中的
Read/Write Enabled时,Unity 通常还会保留可供脚本读取或修改的 CPU 侧副本,从而增加内存占用。 - 关闭
Read/Write Enabled可以在纹理上传完成后释放不需要的 CPU 数据,但之后不能再使用依赖可读性的 API。 - Crunch 是磁盘压缩方案。加载后仍要解压为目标 GPU 纹理格式,因此不能用 Crunch 文件大小代表显存占用。
5. 其他纹理类型
- Cubemap 通常需要计算 6 个面。
Texture2DArray需要乘数组层数。Texture3D需要考虑深度及每级 Mipmap 的三维尺寸。- RenderTexture 还可能包含颜色缓冲、深度/模板缓冲、MSAA 多重采样以及临时或双缓冲资源。
- GPU 行对齐、驱动元数据和平台实现也可能产生理论公式之外的额外开销。
实际项目中应先确认目标平台的导入格式,再使用 Unity Profiler、Memory Profiler 和平台 GPU 工具测量。理论公式适合估算,不应替代真机数据。
十、材质、贴图、纹理和 Shader 的关系
这几个概念处在不同层次。Shader 定义如何计算渲染结果,Material 为 Shader 提供参数,Texture 是可被采样的数据资源,“贴图”则通常描述纹理在某个材质属性中的用途。
| 概念 | Unity 中的含义 | 示例 |
|---|---|---|
| Shader | 定义顶点处理、表面着色、光照和混合等渲染算法及可用属性 | Lit、Unlit、自定义 Shader |
| Material | 选择一个 Shader,并保存其属性值、纹理引用、关键字和部分渲染状态 | 角色皮肤材质、金属材质 |
| Texture | 可供 CPU 或 GPU 使用和采样的数据资源 | Texture2D、Cubemap、Texture3D、RenderTexture |
| Map/贴图 | 纹理承担的语义角色,不是独立的 Unity 资源基类 | Albedo Map、Normal Map、AO Map |
1. 纹理不只是一张二维图片
PNG、JPG、TGA 等源文件可以被 Unity 导入为 Texture2D,但 Unity 的纹理还包括 Cubemap、Texture2DArray、Texture3D、RenderTexture 等。纹理可以保存颜色,也可以保存法线、深度、遮罩、查找表或任意供 Shader 使用的数据。
2. 贴图表示用途
同一个 Texture 可以根据它被接入的 Shader 属性承担不同角色。常见贴图包括:
- Base Color/Albedo:基础颜色和可能的透明度。
- Normal Map:修改光照计算使用的表面法线,不会直接增加模型几何细节。
- Metallic 或 Specular Map:描述金属度工作流或高光工作流中的反射属性。
- Smoothness/Roughness Map:控制高光范围和表面微观粗糙程度;Unity 常用 Smoothness,通常与 Roughness 呈反向含义。
- Ambient Occlusion Map:描述局部遮蔽程度。
- Emission Map:描述自发光颜色和强度。
多个属性可以打包进一张纹理的不同颜色通道,以减少纹理采样和资源数量。具体通道规则取决于 Shader 和渲染管线,不能假设所有项目都相同。
3. 材质不是贴图集合
Material 的核心是“Shader 加一组参数”。这些参数既可以是纹理,也可以是颜色、浮点数、向量和开关。材质可以引用多张纹理,也可以完全不使用纹理。
关系可以概括为:
Renderer → Material → Shader
├─ 数值、颜色、向量、关键字
└─ Texture 引用 → 在某个槽位中承担贴图用途
最终显示效果还会受到模型顶点数据、灯光、相机、渲染管线、全局 Shader 参数和渲染状态等因素影响,不是只由材质决定。
4. Unity 中的材质实例
renderer.sharedMaterial返回共享材质引用,修改它会影响所有使用该材质的对象,并可能修改编辑器中的资源状态。- 访问
renderer.material时,Unity 可能为该 Renderer 创建独立材质实例,频繁使用可能增加材质数量和内存占用。 - 如果对象只需要少量属性差异,可以考虑
MaterialPropertyBlock,避免为每个对象复制完整材质,同时仍需结合 SRP Batcher 和实际批处理规则验证性能。
总结:Shader 定义算法,Material 保存 Shader 的配置,Texture 提供可采样数据,贴图是 Texture 在某个材质属性中的具体用途。
十一、修改 Time.timeScale 会影响什么
Time.timeScale 是 Unity 的全局缩放时间因子,默认值为 1。它影响使用“缩放时间”的系统,但不会暂停整个 Player Loop,也不会自动影响所有系统。
timeScale |
效果 |
|---|---|
1 |
正常速度 |
0 < timeScale < 1 |
慢动作 |
timeScale > 1 |
加速 |
0 |
缩放时间停止,但渲染、输入和未缩放时间仍继续 |
1. Update 和时间属性
Time.deltaTime和Time.time等缩放时间会受timeScale影响。Time.unscaledDeltaTime、Time.unscaledTime和实时计时不受影响。- 当
timeScale = 0时,Update通常仍会每帧调用,只是Time.deltaTime为0。 - 渲染、输入检测以及使用未缩放时间的 UI 逻辑仍可以继续运行。
因此,暂停菜单仍可以在 timeScale = 0 时响应输入和播放使用未缩放时间的动画。
2. 物理和 FixedUpdate
物理模拟使用固定的游戏时间步长 Time.fixedDeltaTime。修改 timeScale 不会自动修改 Time.fixedDeltaTime 属性本身,但会改变固定更新相对于真实时间的执行频率。
timeScale变小时,物理世界相对于真实时间变慢。timeScale = 0时,FixedUpdate和自动物理模拟通常停止推进。- 慢动作时如果希望维持近似的真实时间固定更新频率并提高平滑度,可以同步缩小
fixedDeltaTime。
private float baseFixedDeltaTime;
private void Awake()
{
baseFixedDeltaTime = Time.fixedDeltaTime;
}
private void SetTimeScale(float scale)
{
Time.timeScale = scale;
Time.fixedDeltaTime = scale > 0f
? baseFixedDeltaTime * scale
: baseFixedDeltaTime;
}
这种调整不是 Unity 自动完成的。暂停时无需把 fixedDeltaTime 设为 0,因为 timeScale = 0 已会停止固定时间推进。项目应根据物理精度和 CPU 开销决定是否同步调整。
3. 协程、延迟调用和计时
常见的缩放时间操作包括:
WaitForSeconds:受timeScale影响;timeScale = 0时等待通常不会完成。WaitForSecondsRealtime:使用真实时间,不受影响。Invoke、InvokeRepeating:通常使用缩放时间。Destroy(object, delay):延迟时间使用缩放时间。yield return null:等待下一帧,因此即使timeScale = 0仍可继续恢复。
4. 动画、粒子、UI 和 Tween
- Animator 默认使用缩放时间,也可以配置为 Unscaled Time。
- 粒子系统是否使用未缩放时间可以通过相应配置控制。
- UI 动画和 Tween 是否暂停,取决于它们使用
deltaTime还是unscaledDeltaTime,以及第三方库的更新模式。 - 不能假设所有动画系统都会自动跟随
timeScale。
5. 音频
音频播放默认不受 Time.timeScale 影响。AudioSource.ignoreListenerPause 控制的是该音源是否忽略 AudioListener.pause,与 timeScale 没有直接关系。
实现暂停时通常需要显式设置 AudioListener.pause、暂停指定 AudioSource,或者根据设计保留 UI 音效和背景音乐。
6. 完整暂停需要额外状态管理
Time.timeScale = 0 主要停止缩放时间和自动物理推进,不等于冻结整个游戏。完整暂停通常还要处理:
- 玩家输入和游戏逻辑是否继续接收。
- 音频是否暂停。
- UI 是否使用未缩放时间。
- 网络消息和后台任务是否继续。
- 暂停前的
timeScale与fixedDeltaTime如何保存和恢复。
总结:timeScale 控制缩放时间,不控制真实时间和整个引擎循环;暂停功能需要分别管理物理、音频、输入、UI 和异步逻辑。
十二、Camera Clear Flags 各选项的作用
在 Built-in Render Pipeline 中,Camera.clearFlags 决定相机开始渲染前如何处理当前渲染目标的颜色缓冲和深度缓冲。清除行为会影响多相机叠加、背景显示和深度遮挡。
| 选项 | 颜色缓冲 | 深度缓冲 | 常见用途 |
|---|---|---|---|
| Skybox | 清除后绘制天空盒 | 清除 | 普通 3D 场景背景 |
| Solid Color | 清除为 backgroundColor |
清除 | 纯色背景、2D 场景 |
| Depth Only | 保留已有颜色 | 清除 | 多相机分层渲染 |
| Don’t Clear | 保留 | 保留 | 特殊累积效果或自定义渲染流程 |
1. Skybox
相机清除颜色和深度缓冲,并在背景区域渲染天空盒。天空盒由场景 Lighting Settings 或相机上的 Skybox 组件等配置决定;如果没有可用的天空盒,通常会退回到相机的背景颜色。
2. Solid Color
相机用 Camera.backgroundColor 清除颜色缓冲,同时清除深度缓冲,然后正常渲染场景物体。它适合不需要天空盒的纯色背景、2D 场景和部分离屏渲染。
3. Depth Only
相机只清除深度缓冲,但保留颜色缓冲中已有的画面。相机随后仍会正常渲染自己的彩色物体,并不是“只输出深度信息”。
它常用于多个相机依次绘制:前一个相机先渲染背景或主场景,后一个相机保留颜色、清除深度后再渲染武器、角色特写或指定 Layer。
4. Don’t Clear
相机既不清除颜色缓冲,也不清除深度缓冲,而是在渲染目标现有内容上继续绘制。现有内容可能来自同一帧中更早执行的相机,也可能是保留下来的缓冲内容,不能笼统认为一定是完整的上一帧。
如果没有明确控制渲染目标和相机顺序,可能出现拖影、残留画面或深度遮挡异常,因此通常只用于有意设计的累积效果、多相机组合或自定义渲染流程。
5. 渲染管线差异
上述选项主要对应 Built-in Render Pipeline。URP 通常通过 Base/Overlay Camera Stack、Background Type 和 Clear Depth 等配置控制清屏;HDRP 和其他 SRP 也可能使用各自的相机设置,因此需要先明确所讨论的渲染管线。
UI 系统
一、Image 和 RawImage 的区别
Image 和 RawImage 都是 UGUI 中用于显示图形的组件,并且都继承自 MaskableGraphic。二者的核心区别是资源类型和可用的显示方式不同。
| 对比项 | Image |
RawImage |
|---|---|---|
| 主要资源 | Sprite |
Texture |
| 常见属性 | Sprite Type、Fill、Preserve Aspect | Texture、UV Rect |
| 九宫格 | 支持 Sliced 和 Tiled | 不直接支持 Sprite Border 九宫格 |
| 图集 | 可配合 Sprite Atlas | 直接引用纹理 |
| 常见用途 | 图标、按钮、面板、进度条 | RenderTexture、视频、摄像头和动态纹理 |
1. Image
Image 通过 sprite 属性显示 Sprite。Sprite 除了引用纹理区域,还可以包含 Pivot、Border、Pixels Per Unit 和渲染网格等信息,因此 Image 支持多种显示模式:
- Simple:普通拉伸显示。
- Sliced:根据 Sprite Border 进行九宫格缩放。
- Tiled:根据边框和中心区域进行平铺。
- Filled:按水平、垂直或圆形方式显示填充比例,常用于进度条和技能冷却。
Image 还支持 preserveAspect、SetNativeSize() 和 Sprite Atlas 等功能,适合大多数普通 UI 图片。
2. RawImage
RawImage 通过 texture 属性直接显示 Texture,因此可以使用 Texture2D、RenderTexture、视频纹理、WebCamTexture 或运行时下载的纹理,不需要先创建 Sprite。
它的 uvRect 属性可以控制采样纹理的 UV 范围,例如截取纹理的一部分或实现滚动效果。RawImage 不提供 Image.Type 的 Sliced、Tiled 和 Filled 等 Sprite 专用功能。
3. 共同点和选择建议
- 两者都支持颜色叠加、材质、Mask/RectMask2D 和
raycastTarget。 - 是否能够批处理取决于 Canvas、材质、纹理或图集、层级和裁剪状态等条件,不能只按组件类型判断。
- 普通 UI 图片优先使用
Image;需要直接显示 Texture 或控制 UV 时使用RawImage。
image.sprite = iconSprite;
rawImage.texture = cameraRenderTexture;
rawImage.uvRect = new Rect(0f, 0f, 1f, 1f);
二、Canvas 的三种渲染模式
Canvas Render Mode 决定 UI 使用屏幕空间还是世界空间渲染。RectTransform 的最终大小并不一定等于原始屏幕像素,还会受到 CanvasScaler、锚点、父级缩放和参考分辨率等因素影响。
| 模式 | 是否需要 Render Camera | 与场景深度的关系 | 常见用途 |
|---|---|---|---|
| Screen Space - Overlay | 否 | 通常绘制在场景之上 | HUD、菜单、弹窗 |
| Screen Space - Camera | 是 | 参与相机投影、裁剪和排序 | 需要相机控制的屏幕 UI |
| World Space | 需要场景相机观察 | 作为场景对象参与遮挡 | 头顶血条、世界面板、VR/AR UI |
1. Screen Space - Overlay
Canvas 直接对应屏幕显示区域,不需要指定渲染相机。UI 通常在场景画面之上绘制,不会被普通 3D 几何体遮挡。
多个 Canvas 之间仍会受到 Sorting Layer、Order in Layer、Override Sorting 和层级关系影响。它适合固定在屏幕上的 HUD、菜单和提示信息。
2. Screen Space - Camera
Canvas 需要指定 Render Camera,并位于相机前方由 Plane Distance 决定的平面上。UI 会受到相机投影、裁剪范围、排序以及具体渲染管线的影响。
这种模式适合需要配合指定相机、相机堆叠、特殊材质或渲染顺序的屏幕 UI。它并不天然比 Overlay 更适合点击交互;UI 是否能够交互主要由 EventSystem、Input Module 和 GraphicRaycaster 决定。
3. World Space
Canvas 是场景中的空间对象,位置、旋转和缩放由 Transform/RectTransform 控制。它会受到观察距离、透视和场景遮挡影响,适合角色头顶 UI、场景操作面板和 XR 界面。
World Space Canvas 需要合理设置尺寸和缩放。进行指针交互时,还应为 GraphicRaycaster 配置正确的 Event Camera,并确保场景中存在 EventSystem 和适合当前输入系统的 Input Module。
4. CanvasScaler
CanvasScaler 决定屏幕空间 UI 如何适配不同分辨率:
- Constant Pixel Size:通过 Scale Factor 统一缩放。
- Scale With Screen Size:使用 Reference Resolution,并通过 Match 调节更偏向宽度还是高度。
- Constant Physical Size:按物理单位缩放,结果依赖设备报告的 DPI,实际项目中需要真机验证。
Canvas 渲染模式解决“在哪里以及通过什么方式渲染”,CanvasScaler 解决“不同屏幕尺寸下如何缩放”,二者不能混为一谈。
三、Texture Type 中 Default 和 Sprite (2D and UI) 的区别
现代 Unity 的 Texture Importer 通常把普通纹理类型称为 Default。旧版本或旧资料中可能出现 Texture 的叫法。二者都会导入纹理像素数据,但 Sprite 模式还会创建供 2D 和 UI 系统使用的 Sprite 对象及相关元数据。
| 对比项 | Default | Sprite (2D and UI) |
|---|---|---|
| 主要结果 | 普通纹理资源 | 纹理数据以及一个或多个 Sprite |
| 常见用途 | Material、Shader、RawImage、数据纹理 | SpriteRenderer、UI Image、Sprite Atlas |
| 区域划分 | 通常使用完整纹理 | 支持 Single、Multiple 和 Sprite Editor |
| 专用元数据 | 以普通纹理导入设置为主 | Pivot、Pixels Per Unit、Border、Mesh Type 等 |
1. Default
Default 适合把图片作为普通 Texture 使用,例如赋给材质或 Shader、通过 RawImage 显示,或者作为运行时采样的数据纹理。透明通道并不是 Sprite 独有能力;只要源文件和导入格式支持,Default 纹理同样可以保留和采样 Alpha。
2. Sprite (2D and UI)
Sprite 模式会在纹理数据之上生成 Sprite 资源。Sprite Mode 可以选择:
- Single:整张图片对应一个 Sprite。
- Multiple:通过 Sprite Editor 把图集或序列帧切分成多个 Sprite。
还可以设置 Pivot、Pixels Per Unit、Border 和 Mesh Type,并将 Sprite 用于 SpriteRenderer、UGUI Image 或 Sprite Atlas。
3. 透明区域和渲染网格
Sprite 模式不会无条件裁掉图片的透明边界。Sprite Rect 仍由导入区域或 Sprite Editor 中的切片决定。Mesh Type = Tight 时,Unity 可以参考透明区域生成更贴合可见像素的渲染网格,但这不等于自动修改 Sprite Rect 或源图片尺寸。
4. 九宫格
Unity 不会根据图片内容自动推断九宫格边界。需要在 Sprite Editor 中手动设置 Border,然后在 UGUI Image 中把 Type 设为 Sliced 或 Tiled。
因此,选择原则是:需要普通纹理采样或 RawImage 时使用 Default;需要 SpriteRenderer、UI Image、切片、Pivot、PPU 或九宫格时使用 Sprite (2D and UI)。
物理系统
一、两个物体发生碰撞的必要条件
需要先区分三个概念:物理引擎是否建立接触并产生阻挡、脚本是否收到 OnCollision... 消息,以及 Collider 是否作为 Trigger 产生重叠消息。三者的条件并不完全相同。
1. Collider 和基础过滤条件
普通 3D 物理碰撞首先需要两个有效的碰撞形状,例如 BoxCollider、SphereCollider、CapsuleCollider、MeshCollider 或 TerrainCollider。Collider 可以位于挂载 Rigidbody 的物体上,也可以作为子物体组成 Compound Collider。
碰撞对还必须满足:
- Collider 和所在 GameObject 已启用、激活。
- 两个 Layer 在 Physics Settings 的 Layer Collision Matrix 中允许碰撞。
- 没有通过
Physics.IgnoreCollision或Physics.IgnoreLayerCollision忽略该碰撞对。 - 两个 Collider 属于同一个有效 Physics Scene。
- 形状在物理时间步内确实接触、相交或发生扫掠碰撞。
2. Static、Dynamic 和 Kinematic
| 类型 | 常见组成 | 行为 |
|---|---|---|
| Static Collider | Collider,没有关联 Rigidbody | 作为静态环境参与碰撞 |
| Dynamic Rigidbody | Collider + isKinematic = false 的 Rigidbody |
受力、重力和碰撞求解器驱动 |
| Kinematic Rigidbody | Collider + isKinematic = true 的 Rigidbody |
由代码或动画驱动,不被普通力推动 |
两个 Static Collider 即使发生几何重叠,通常也不会形成需要求解的动态碰撞对。常见有效组合是 Dynamic 与 Static、Dynamic 与 Dynamic、Dynamic 与 Kinematic。
传统 OnCollisionEnter/Stay/Exit 通常要求碰撞对中至少一方关联非运动学 Rigidbody。Static、Dynamic 和 Kinematic 的具体消息及求解行为还应结合 Unity 版本对应的 Collision Action Matrix 判断,不能简单概括成“Rigidbody 可选”。
3. 碰撞回调不是物理碰撞的必要条件
没有任何脚本时,物理引擎仍可以计算阻挡、摩擦、反弹和速度变化。只有业务代码需要响应碰撞时,才需要声明 Unity Message:
private void OnCollisionEnter(Collision collision)
{
Debug.Log($"Hit: {collision.collider.name}");
}
OnCollisionEnter、OnCollisionStay 和 OnCollisionExit 是 Unity 按约定识别的消息方法,不是需要实现的 C# 接口。
4. Trigger 条件
当任意一方的 Collider.isTrigger 为 true 时,该 Collider 通常只检测重叠,不产生普通的物理阻挡和接触响应,应使用:
private void OnTriggerEnter(Collider other)
{
Debug.Log($"Entered: {other.name}");
}
Trigger 消息通常要求碰撞对中至少一方关联 Rigidbody,运动学 Rigidbody 也常用于触发器。实际是否发送消息仍取决于 Collider、Rigidbody 类型、Layer 和 Unity 的碰撞行为矩阵。
5. 3D 和 2D 物理系统相互独立
| 3D 物理 | 2D 物理 |
|---|---|
Collider |
Collider2D |
Rigidbody |
Rigidbody2D |
Physics |
Physics2D |
OnCollisionEnter |
OnCollisionEnter2D |
OnTriggerEnter |
OnTriggerEnter2D |
3D Collider 不会与 2D Collider 发生碰撞,因此需要先明确讨论的是 3D 还是 2D 物理系统。
二、CharacterController 和 Rigidbody 的区别
CharacterController 和 Rigidbody 都可以实现角色移动,但前者强调由代码直接控制的稳定游戏手感,后者强调由物理求解器驱动的动力学交互。第一人称或第三人称视角并不是选择它们的决定条件。
| 对比项 | CharacterController |
Rigidbody |
|---|---|---|
| 驱动方式 | 调用 Move 或 SimpleMove |
力、速度、重力或 MovePosition 等 |
| 碰撞检测 | 会检测 Collider 并限制移动 | 由刚体碰撞和求解器处理 |
| 重力 | Move 不自动应用,需要自行实现 |
useGravity 开启时自动应用 |
| 受力和惯性 | 不会自动受到力、冲量和关节影响 | Dynamic Rigidbody 会受到动力学影响 |
| 实体碰撞消息 | OnControllerColliderHit |
OnCollisionEnter/Stay/Exit |
| 常见用途 | 精确角色控制、台阶和坡度处理 | 物理角色、载具、可推动物体和布娃娃 |
1. CharacterController
CharacterController 是一种面向角色运动的胶囊控制器。它会使用物理系统进行碰撞查询和穿透处理,因此不能说它“不依赖物理引擎”或“不受碰撞影响”。
它的主要特点是:
- 只有调用
Move或SimpleMove时才会移动。 Move不自动应用重力,跳跃、下落速度和空中控制通常由代码维护。SimpleMove使用速度移动并自动应用重力,但不会提供与Move完全相同的控制方式。- 不会像 Dynamic Rigidbody 一样自动受到
AddForce、爆炸力、摩擦、反弹或 Joint 影响。 - 提供
slopeLimit、stepOffset、skinWidth、height、radius和center等角色专用参数。 isGrounded表示最近一次移动完成后的接地状态,不是独立于移动调用持续更新的地面查询。
实体碰撞通常通过下面的消息处理:
private void OnControllerColliderHit(ControllerColliderHit hit)
{
Debug.Log($"Controller hit: {hit.collider.name}");
}
CharacterController 也可以参与触发器交互,具体消息行为应结合 Collider、Rigidbody、Layer 和项目使用的 Unity 版本验证,不能笼统认为它不会产生任何物理消息。需要推动 Dynamic Rigidbody 时,通常还要在 OnControllerColliderHit 中自行编写施力逻辑。
2. Rigidbody
Rigidbody 将物体交给物理求解器管理。Dynamic Rigidbody 可以受到重力、力、冲量、摩擦、碰撞反作用和关节约束,也能自然影响其他物理物体。
常见控制方式包括:
- 在
FixedUpdate中使用AddForce、AddTorque。 - 根据设计设置线速度或角速度。
- 对 Kinematic Rigidbody 使用
MovePosition、MoveRotation。 - 使用 Constraints 限制不需要的平移或旋转自由度。
不建议通过每帧直接修改 Transform 来驱动普通 Dynamic Rigidbody,这可能绕过预期的物理插值和碰撞流程。需要快速移动时还应根据场景选择合适的 Collision Detection Mode,避免穿透高速或较薄的 Collider。
3. 选择建议
- 需要稳定、精确、可预测的移动,并重点处理台阶和坡度时,考虑 CharacterController。
- 角色需要自然受力、推动物体、使用关节或参与完整物理互动时,考虑 Rigidbody。
- 第一人称和第三人称都可以使用任一方案,应根据移动手感和物理需求选择。
- CharacterController 属于 3D 系统;2D 项目通常使用
Rigidbody2D、Collider2D或自定义 2D 控制器。
三、Raycast、Shape Cast 和 Overlap 查询的原理与区别
Raycast 是对物理场景执行的一次几何查询:从起点沿指定方向,在最大距离内查找与 Collider 的交点。它不会推动物体、产生碰撞反作用,也不会自动触发 OnCollisionEnter。
1. 内部查询过程
物理引擎通常不会逐个遍历场景中的所有 Collider,而是通过空间加速结构完成查询:
起点、方向和最大距离
↓
LayerMask 与 Trigger 过滤
↓
Broad Phase 空间结构筛选候选 Collider
↓
Narrow Phase 计算射线与具体形状的交点
↓
生成 RaycastHit 结果
Broad Phase 常使用包围盒树或类似结构排除不可能命中的对象;Narrow Phase 再根据 Box、Sphere、Capsule、Mesh 等 Collider 类型执行精确相交测试。
2. Raycast、Shape Cast 和 Overlap 的区别
| 查询类型 | 检测方式 | 常见用途 |
|---|---|---|
| Raycast | 一条没有体积的射线沿方向检测 | 瞄准、视线和精确点击 |
| SphereCast | 球体沿路径扫掠 | 宽容的地面检测、摄像机避障 |
| CapsuleCast | 胶囊体沿路径扫掠 | 角色移动和头顶空间检测 |
| BoxCast | 盒体沿路径扫掠 | 箱体移动、近战范围和体积预测 |
| Overlap | 检查指定形状当前位置与哪些 Collider 重叠 | 范围攻击、感知和出生点检查 |
| Check | 只返回是否存在重叠 | 只需要布尔结果的快速判断 |
Shape Cast 回答“具有体积的形状沿路径移动会撞到什么”,通常返回命中点、法线和距离。Overlap 回答“当前位置有哪些 Collider 与形状重叠”,没有运动路径和首次命中时间。
常见选择包括:
- 精确视线和武器射击:Raycast。
- 角色接地:Raycast 简单,SphereCast 或 CapsuleCast 对台阶、斜坡和边缘更宽容。
- 角色移动前预测:CapsuleCast。
- 相机避障:SphereCast。
- 爆炸和范围技能:OverlapSphere,再根据距离和遮挡二次过滤。
这些查询只返回几何结果,不会自动推动 Rigidbody、产生碰撞反作用或触发 OnCollisionEnter。
3. 常用 API
| API | 结果 | 分配和注意事项 |
|---|---|---|
Physics.Raycast |
返回是否命中,并可取得最近的一个 RaycastHit |
单次查询常用 |
Physics.RaycastAll |
返回全部命中结果 | 会创建结果数组,顺序不保证按距离排列 |
Physics.RaycastNonAlloc |
把结果写入调用方数组 | 减少 GC Alloc,但缓冲区不足时结果可能被截断 |
PhysicsScene.Raycast |
查询指定 3D Physics Scene | 多物理场景时使用 |
Physics2D.Raycast |
查询 2D 物理场景 | 与 3D Physics 相互独立 |
4. 使用示例
using UnityEngine;
public class RaycastExample : MonoBehaviour
{
[SerializeField] private LayerMask targetLayers;
private void Update()
{
Ray ray = new Ray(transform.position, transform.forward);
if (Physics.Raycast(
ray,
out RaycastHit hit,
100f,
targetLayers,
QueryTriggerInteraction.Ignore))
{
Debug.Log($"Hit {hit.collider.name} at {hit.point}");
}
}
}
LayerMask 是位掩码,不是直接把 Layer 编号当作掩码传入。QueryTriggerInteraction 可以选择使用全局设置、忽略 Trigger 或明确命中 Trigger。
5. RaycastHit 中的常用信息
collider:命中的 Collider。rigidbody:命中对象关联的 Rigidbody,可能为null。transform:命中对象的 Transform。point:世界空间交点。normal:交点处表面法线。distance:起点到交点的距离。triangleIndex、barycentricCoordinate、textureCoord:主要用于支持这些信息的 MeshCollider 等场景。
6. 常见陷阱
- 3D Raycast 的起点位于某个 Collider 内部时,可能检测不到该 Collider。
- 通过 Transform 直接移动 Collider 后,物理世界的空间数据可能尚未同步。应优先通过物理 API 移动物体;确有需要时可以调用
Physics.SyncTransforms(),但它可能带来额外开销。 Debug.DrawRay只负责调试绘制,不会执行物理查询。- 射线只能检测 Collider,目标只有 Renderer 或 MeshFilter 时不会被命中。
CastAll、Overlap等返回新数组的版本可能产生托管分配;NonAlloc 缓冲区不足时会截断结果。- 多命中结果通常不保证按距离排序,需要业务代码自行排序。
- 查询起始形状已经与 Collider 重叠时,不同 API 对命中点和法线的处理可能不同,应单独处理初始穿透。
- Shape Cast 的形状、旋转和尺寸应与实际 Collider 匹配,否则可能出现视觉上能通过但查询认为被阻挡的情况。
大量独立查询可能成为性能热点。高频批量射线可以考虑 RaycastCommand、Job System、合理的 LayerMask 和查询频率,并通过 Profiler 在目标平台验证。
四、Rigidbody 有哪些移动方式,应该如何选择
Rigidbody 的控制方式决定物体是否遵循物理求解、碰撞响应和插值。不能把修改 Transform、设置位置、设置速度和施加力视为完全等价的操作。
| 方式 | 主要特点 | 常见用途 |
|---|---|---|
| 修改 Transform | 直接改变场景变换,物理系统之后再同步 | 瞬移、初始化或非物理对象 |
| 设置 Rigidbody 位置 | 直接设置物理姿态,效果接近传送 | 重置位置、出生点和纠错 |
MovePosition / MoveRotation |
让 Rigidbody 在物理步中移动到目标姿态,并配合插值 | Kinematic Rigidbody 的连续移动 |
| 设置线速度或角速度 | 直接控制运动状态 | 精确速度控制、跳跃和特殊角色方案 |
AddForce / AddTorque |
由质量、时间步和 ForceMode 决定速度变化 | 受力、推进、爆炸和车辆 |
1. 直接修改 Transform
对普通 Dynamic Rigidbody 每帧修改 transform.position 或 transform.rotation,可能绕过预期的速度积分、插值和连续碰撞流程,并造成穿透、抖动或物理世界同步成本。
它适合初始化、传送或明确不需要连续碰撞轨迹的情况。传送后如果马上执行物理查询,还应注意 Transform 与物理场景是否已经同步。
2. MovePosition 和 MoveRotation
它们常用于由代码驱动的 Kinematic Rigidbody,使移动参与物理时间步并能够与 Rigidbody Interpolation 配合。目标位置通常在 FixedUpdate 中计算。
Kinematic Rigidbody 不会像 Dynamic Rigidbody 一样被普通力推动,但它移动时仍可以影响动态刚体和触发碰撞或 Trigger 交互。具体碰撞消息取决于双方的刚体类型和 Unity 版本对应的行为矩阵。
3. 速度和施力
设置速度适合需要直接控制运动结果的场景,但每个物理步强行覆盖速度可能抵消重力、摩擦和碰撞求解器刚刚产生的变化。应只修改需要控制的分量,并保留其他物理效果。
AddForce 和 AddTorque 更适合自然动力学。Unity 新旧版本可能分别使用 linearVelocity 或 velocity 表示线速度,应结合项目版本使用。
4. 更新阶段
通常在 Update 中读取输入,在 FixedUpdate 中应用需要与物理步同步的速度、力或移动命令。GetKeyDown 等只在单个渲染帧成立的输入如果只在 FixedUpdate 读取,可能被遗漏,可以先缓存输入状态。
总结:传送使用直接设置姿态,Kinematic 连续移动优先使用 MovePosition,直接速度用于精确控制,真实受力使用 AddForce。普通 Dynamic Rigidbody 不应长期由 Transform 每帧驱动。
五、ForceMode 四种模式有什么区别
Rigidbody.AddForce 的 ForceMode 决定输入值是持续作用还是瞬时作用,以及是否受刚体质量影响。
| 模式 | 是否持续积分 | 是否考虑质量 | 概念含义 |
|---|---|---|---|
Force |
是 | 是 | 持续施加普通力 |
Acceleration |
是 | 否 | 持续施加指定加速度 |
Impulse |
否,当前物理步立即改变速度 | 是 | 瞬时冲量 |
VelocityChange |
否,当前物理步立即改变速度 | 否 | 直接增加指定速度量 |
1. 持续模式
Force 和 Acceleration 通常连续多个物理步调用,例如推进器、风力和持续加速。物理引擎会根据固定时间步完成积分,一般不应再机械地把传入值乘一次 Time.fixedDeltaTime,否则会重复缩放时间。
Force 对质量大的刚体产生较小加速度;Acceleration 对不同质量的刚体产生相同加速度。
2. 瞬时模式
Impulse 和 VelocityChange 通常只调用一次,例如跳跃、爆炸冲击和受击击退。
Impulse 会考虑质量,相同冲量对轻物体影响更大;VelocityChange 忽略质量,适合明确要求所有目标增加相同速度的游戏化效果。
3. 其他施力方式
AddForceAtPosition在指定世界位置施力,同时可能产生线速度和扭矩。AddRelativeForce按刚体局部坐标轴施力。AddTorque和AddRelativeTorque用于旋转。
施力通常会唤醒休眠刚体。若刚体是 Kinematic、被冻结了对应轴,或受其他约束限制,最终结果可能与输入力不同。
可以记为:Force 和 Impulse 考虑质量;Acceleration 和 VelocityChange 不考虑质量。持续模式用于每个物理步,瞬时模式用于一次性速度变化。
六、碰撞检测模式有什么区别,为什么会发生高速穿透
离散碰撞检测通常只比较相邻物理步中的物体姿态。如果物体在一个时间步内从薄墙一侧直接移动到另一侧,中间没有任何离散姿态与墙相交,就可能发生 Tunneling(隧穿)。
| 模式 | 基本思路 | 常见用途 |
|---|---|---|
| Discrete | 在离散物理步中检测接触 | 普通速度物体,成本较低 |
| Continuous | 对运动路径执行连续检测,主要防止撞穿静态物体 | 高速动态物体 |
| Continuous Dynamic | 对更多动态组合执行连续检测 | 高速且需要互相碰撞的关键动态物体 |
| Continuous Speculative | 根据预测运动扩展接触范围 | 动态或 Kinematic 物体,成本相对灵活 |
不同模式之间哪些组合能够执行连续检测,取决于 Unity 版本和 PhysX 的 Collision Detection Matrix,不能只看单个 Rigidbody 的设置。
1. Continuous CCD
基于 Sweep 的连续检测会沿物体运动轨迹寻找最早碰撞时间,通常比 Discrete 更昂贵。它主要适合子弹、快速移动角色和必须避免穿透的重要物体,不应对所有刚体无条件开启。
快速旋转、复杂形状、直接修改 Transform 或一次性传送仍可能绕开预期的连续轨迹,CCD 也不是所有穿透问题的绝对保证。
2. Speculative CCD
Speculative 模式会预测下一步可能覆盖的范围并提前生成接触,通常能够用于 Kinematic Rigidbody,也可能比 Sweep CCD 更容易扩展到不同碰撞组合。
它可能根据不准确的接触法线产生 Ghost Contact,例如物体在细小凸起附近提前被抬起或减速,因此需要根据具体场景测试。
3. 其他防穿透手段
- 减小
fixedDeltaTime可以缩短单步移动距离,但会增加整体物理 CPU 成本。 - 增加关键障碍物厚度或使用更适合的 Collider。
- 射弹可以使用 Raycast 或 Shape Cast 检查上一位置到当前位置之间的路径。
- 避免用 Transform 瞬移需要连续碰撞的动态物体。
总结:高速穿透源于离散采样错过中间接触。连续模式能改善关键物体的检测,但会增加成本并有适用矩阵,仍需结合时间步、形状和移动方式处理。
七、FixedUpdate、物理时间步和渲染帧是什么关系
Unity 的渲染更新和物理更新使用不同的时间节奏。Update 通常每个渲染帧执行一次,而物理系统按照 Time.fixedDeltaTime 表示的固定游戏时间步推进。
1. 为什么一帧可能执行多次 FixedUpdate
Unity 会累计尚未模拟的游戏时间:
- 渲染帧很快时,累计时间可能不足一个固定步,本帧执行零次
FixedUpdate。 - 累计时间达到一个固定步时,执行一次。
- 某帧耗时过长时,可能连续执行多次固定更新来追赶游戏时间。
因此,不能假设一次 Update 必然对应一次 FixedUpdate,也不能用 FixedUpdate 次数直接代表渲染帧率。
2. fixedDeltaTime 的取舍
较小的 fixedDeltaTime 表示每秒模拟次数更多,可以改善快速运动、关节和控制响应,但会提高物理、脚本和碰撞检测成本。较大的时间步成本较低,但运动更离散,可能增加穿透、抖动和控制误差。
Maximum Allowed Timestep 用于限制单帧允许用于补做物理模拟的时间,避免严重卡顿后不断补算形成恶性循环。达到限制时,物理时间相对于真实时间可能暂时变慢。
3. 输入与物理控制
短时输入通常在 Update 中读取并缓存,再由后续 FixedUpdate 消费。持续按住状态可以跨多个固定步使用,但一次性按下事件应明确记录和清除,避免丢失或重复触发。
网络 Tick、动画更新和物理 Tick 也不一定使用相同频率,应通过缓冲、插值和状态同步连接,而不是假设它们天然一一对应。
4. timeScale
物理使用缩放后的游戏时间。降低 Time.timeScale 会让固定更新相对于真实时间减少;是否同步调整 fixedDeltaTime 取决于项目希望保持的真实时间物理频率、精度和 CPU 预算。
总结:物理使用独立固定步,单个渲染帧可能执行零次或多次 FixedUpdate。减小步长提高时间分辨率但增加成本,输入应在渲染更新中采集并正确传递给物理更新。
八、Rigidbody Interpolation 和 Extrapolation 的区别
物理姿态只在固定时间步更新,而画面可能以更高或不稳定的帧率渲染。如果直接显示最近一次物理姿态,物体可能呈现阶梯式移动和视觉抖动。
| 模式 | 处理方式 | 主要特点 |
|---|---|---|
| None | 直接显示当前物理姿态 | 无额外平滑,可能抖动 |
| Interpolate | 在前后两个已知物理姿态之间插值 | 稳定,但视觉通常落后一个物理步 |
| Extrapolate | 根据当前速度预测下一个姿态 | 延迟较低,但预测错误时会回弹 |
1. Interpolate
插值使用已经由物理系统确认的姿态,因此通常比较稳定,适合玩家角色、相机跟随目标和画面中重要的移动刚体。代价是需要保存额外状态,并引入大约一个固定时间步的显示延迟。
2. Extrapolate
外推根据速度预测物体即将到达的位置。当物体突然碰撞、停止或改变速度时,预测可能错误,随后会被真实物理结果纠正,从而产生穿插或回弹。
3. 使用注意事项
- 平滑主要影响渲染所见 Transform,不会提高物理模拟频率或碰撞精度。
- 直接修改 Transform 可能破坏 Rigidbody 的插值历史,应让物理系统控制需要插值的对象。
- 并非所有刚体都需要开启。大量非关键物体开启插值会增加状态和更新成本。
- 相机跟随通常放在
LateUpdate,并跟随已经由插值更新的目标姿态,但具体相机系统仍应在实际帧率下验证。
总结:Interpolation 使用已知状态,稳定但有一拍延迟;Extrapolation 预测未来,响应快但可能纠正跳变。两者只平滑显示,不改变物理求解本身。
九、Physics Material(物理材质)的摩擦和弹性如何计算
物理材质用于配置 Collider 接触时的摩擦和反弹,不是 Renderer 使用的视觉 Material。
1. 主要属性
- Static Friction:物体尚未相对滑动时抵抗开始滑动的摩擦。
- Dynamic Friction:物体已经滑动时的摩擦。
- Bounciness:碰撞后的弹性恢复程度。
- Friction Combine:双方摩擦系数如何组合。
- Bounce Combine:双方弹性系数如何组合。
常见 Combine Mode 包括 Average、Minimum、Maximum 和 Multiply。两个 Collider 使用不同模式时,Unity 会按照运行时定义的优先规则选择组合方式,具体优先级和行为应结合项目版本验证。
2. 摩擦和反弹不是绝对结果
最终运动还会受到质量、速度、接触法线、重力、Solver Iterations、时间步和其他约束影响。即使 Bounciness 很高,低于 Bounce Threshold 的低速碰撞也可能不产生明显反弹,以避免静止物体持续抖动。
Trigger 不执行普通接触求解,因此它的物理材质不会产生常规摩擦和反弹效果。
3. 常见用途
- 冰面:较低摩擦。
- 橡胶球:较高弹性。
- 角色控制器辅助 Collider:根据移动方案选择低摩擦,避免贴墙或坡面时产生不需要的阻力。
物理材质通常是共享资源。运行时直接修改共享资源会影响所有引用它的 Collider;需要独立参数时应建立明确的材质实例或资源配置策略。
十、Primitive、Compound 和 MeshCollider 如何选择
Collider 的形状复杂度会影响碰撞检测、内存、烘焙时间和接触稳定性。
| 类型 | 特点 | 常见用途 |
|---|---|---|
| Primitive Collider | Box、Sphere、Capsule,计算简单稳定 | 角色、道具和大多数动态物体 |
| Compound Collider | 多个子 Primitive Collider 共同属于一个 Rigidbody | 车辆、复杂道具和近似角色形状 |
| Convex MeshCollider | 使用凸包近似网格 | 需要较复杂外形的动态物体 |
| Non-Convex MeshCollider | 保留凹形三角网格 | 静态场景地形、建筑和固定环境 |
1. Primitive Collider
Primitive Collider 通常拥有专门且高效的相交算法,性能和稳定性都较好。实际游戏碰撞不必与美术网格完全一致,适度简化通常还能改善角色移动和堆叠稳定性。
非均匀缩放、负缩放和复杂父级变换可能让碰撞形状与预期不一致,应尽量在合理的局部缩放下配置 Collider。
2. Compound Collider
可以在 Rigidbody 根节点或其子节点上放置多个 Collider,由同一个 Rigidbody 统一驱动。它能用多个简单形状近似复杂物体,通常比动态的高精度 MeshCollider 更容易控制。
Collider 数量过多仍会增加 Broad Phase、接触生成和内存成本。应在近似精度和形状数量之间平衡。
3. MeshCollider
Convex MeshCollider 会把形状转换为凸包,凹陷部分不会保留,并受到 PhysX 对凸形状复杂度的限制。动态 Rigidbody 使用 MeshCollider 时通常需要启用 Convex。
非凸 MeshCollider 适合静态环境。让复杂非凸网格频繁移动会增加物理结构更新成本,并且某些刚体组合不会产生普通碰撞。
导入网格时的可读性、Mesh Cooking Options、重复顶点和退化三角形也可能影响碰撞网格生成,应根据目标平台和实际查询进行验证。
总结:动态物体优先 Primitive 或 Compound;需要复杂动态外形时考虑 Convex;静态且必须保留凹形细节时使用 Non-Convex MeshCollider。
Unity 网络
一、Unity 中 TCP 黏包与拆包如何处理
TCP 向应用层提供连续、有序的字节流,但不保留每次 Send 的消息边界。一次 Receive 可能只得到半条消息,也可能同时得到多条消息,因此不能把“一次发送”当成“一次接收”。
常见分帧方式:
- 固定长度:实现简单,但长度变化大时浪费空间。
- 分隔符:适合文本协议,消息内容需要处理转义。
- 长度前缀:头部记录消息体长度,适合二进制协议。
- 使用已有的消息协议或网络库,由框架处理分帧。
Unity 客户端应维护跨多次接收的缓冲区:收到数据后追加到缓冲区,循环取出所有完整消息,不足一条的剩余数据留到下一次接收。长度字段应统一字节序,并限制最大消息长度,避免异常数据造成超大内存分配。
UDP 保留数据报边界,不存在 TCP 式黏包,但仍可能丢包、乱序、重复或因数据报过大而分片。
二、Unity 网络中 TCP 和 UDP 如何选择
| 对比项 | TCP | UDP |
|---|---|---|
| 数据形式 | 连续字节流 | 独立数据报 |
| 可靠性 | 可靠、按序、去重 | 默认不保证送达、顺序或去重 |
| 消息边界 | 不保留 | 保留 |
| 延迟特征 | 丢包时可能发生队头阻塞 | 应用可选择丢弃旧数据或自行重传 |
| 常见用途 | 登录、聊天、背包、交易、低频指令 | 高频状态、输入、语音和实时动作 |
不能简单认为 UDP 一定比 TCP 快。UDP 只是把可靠性、顺序、重传和拥塞控制等选择交给应用或上层网络库。实时游戏通常按消息性质选择:
- 必须完整且按序到达的数据,可以使用 TCP、可靠 UDP 通道或可靠消息流。
- 高频位置和朝向通常更关心“最新状态”,旧包迟到后可以直接丢弃。
- 登录、配置和资源下载通常使用 HTTP/HTTPS。
- 大厅和浏览器实时消息常使用 WebSocket。
同一项目可以组合多种协议。使用 Unity Transport、Netcode for GameObjects、Netcode for Entities 或第三方网络库时,也应理解其底层通道提供的是可靠、有序还是不可靠语义。
三、Unity 长连接的心跳、超时与断线重连
连接仍处于建立状态,不代表对端进程和业务会话一定健康。断网、移动网络切换、NAT 映射过期、应用进入后台或服务器异常,都可能让客户端暂时无法立即发现连接失效。
应用层心跳通常使用 Ping/Pong,并可携带序列号、时间戳或服务器 Tick。心跳间隔过短会增加包量、耗电和后台唤醒;过长则会延迟故障发现。
应区分连接超时、请求超时、空闲超时、心跳超时和包含重试在内的整体截止时间。重连通常采用指数退避并加入随机抖动,避免大量客户端同时重试。
网络切换、退出登录和应用生命周期变化时,应取消旧连接任务,并为每次连接分配新的连接代次,防止旧异步回调写入当前会话。重新建立 Socket 后还需要重新鉴权,并根据最后确认的消息序号、服务器 Tick 或快照版本恢复游戏状态。
四、Unity 中如何使用异步 Socket 并切回主线程
网络 I/O 可以使用 ConnectAsync、ReceiveAsync、SendAsync 等异步 API。异步不等于创建专用线程;等待期间通常由操作系统的 I/O 完成机制负责通知。
实现时必须处理 TCP 的部分读写、有序写队列、取消、超时、主动关闭、对端关闭、协议错误和连接代次。
大多数 GameObject、Transform、MonoBehaviour 和场景 API 只能在 Unity 主线程调用。常见流程是:
异步接收字节
↓
后台解析为纯数据消息
↓
写入线程安全队列
↓
主线程在 Update 或 Player Loop 中取出
↓
修改场景对象和 UI
从 Unity 主线程开始的 await 常会通过 Unity 的同步上下文恢复,但 Task.Run、第三方库、ConfigureAwait(false) 和对象销毁都可能改变执行条件。应用消息前仍要确认线程、场景和对象生命周期有效。
WebGL 运行在浏览器沙箱中,不能把桌面端的普通原生 Socket 和 UDP 用法原样搬过去,应根据平台选择 WebSocket、WebRTC 或网络库提供的 WebGL 传输实现。
五、Unity 游戏应用层协议如何设计
协议至少需要解决消息边界、消息类型、版本、请求匹配、资源限制和错误恢复。一个常见的二进制消息结构是:
固定头部
├─ 协议标识与版本
├─ 标志位
├─ 消息类型
├─ Sequence / Request ID
├─ Payload Length
└─ 消息体 Payload
设计时应明确字节序、字符串编码、时间单位、浮点或量化规则、消息长度上限和版本兼容方式。不要直接把 C# 结构体的内存布局复制到网络,因为字段对齐、Padding、平台和运行时实现可能不同。
JSON 易于调试,但体积和解析分配通常较大;Protobuf 等 Schema 格式便于跨语言和字段演进;自定义二进制协议能精确控制带宽,但维护成本更高。
客户端数据不能被服务器直接信任。移动、伤害、物品和付费等关键逻辑应由服务器校验或权威计算。需要加密、身份认证和完整性保护时,应使用 TLS、DTLS 或 QUIC 等成熟方案。
六、Unity UDP 通信中的 MTU 与分片
MTU 表示链路能够承载的最大 IP 数据包大小。以太网常见 MTU 为 1500 字节,但移动网络、VPN、隧道、Relay 和加密头部会降低可用载荷,不能把 1500 当作所有玩家网络的固定值。
UDP 数据报过大时可能发生 IP 分片。任意一个分片丢失,整个数据报通常都无法重组。实时游戏应让单个 UDP 包低于保守的路径上限,并为 IP、UDP、加密和自定义协议头预留空间。
必须发送大消息时,可以在应用层分片,并限制并发重组数量、总内存、最大原始长度和等待时间。大型可靠数据通常更适合通过 HTTP、TCP 或网络库的可靠通道传输。高频状态应通过量化、增量编码和兴趣管理减少包体。
七、Unity 项目如何选择 HTTP、WebSocket、TCP/UDP 和 QUIC
| 技术 | 通信特点 | Unity 中的常见用途 |
|---|---|---|
| HTTP/HTTPS | 请求响应,生态成熟 | 登录、配置、排行榜、商城、资源下载 |
| WebSocket | 基于 TCP 的全双工消息连接 | 大厅、聊天、通知、WebGL 实时通信 |
| TCP | 可靠有序字节流 | 自定义长连接、可靠业务消息 |
| UDP | 不可靠数据报 | 高频状态、输入、语音和低延迟数据 |
| QUIC | 基于 UDP,提供加密、可靠流等能力 | HTTP/3、支持该协议栈的现代实时传输 |
Unity 中的普通 HTTP 请求通常使用 UnityWebRequest。WebSocket 保留消息帧语义,但底层 TCP 仍有连接级队头阻塞。QUIC 并不是“不可靠 UDP”,它通常提供加密、拥塞控制、丢包恢复和相互独立的可靠流。
选择协议时还要检查目标平台能力、移动网络限制、WebGL 浏览器约束、证书配置、IPv6、Relay/NAT 穿透和服务端实现,不能只根据桌面编辑器中的测试结果决定最终方案。
八、状态同步、帧同步和快照同步
| 方案 | 主要传输内容 | 优点 | 主要难点 |
|---|---|---|---|
| 状态同步 | 权威状态或状态变化 | 对确定性要求较低 | 带宽、插值和纠错 |
| 帧同步 / Lockstep | 每个 Tick 的输入或命令 | 单位多时带宽较低 | 确定性、迟到输入和重演 |
| 快照同步 | 周期性完整或增量状态 | 适合动作游戏和插值显示 | 基线、压缩和丢包恢复 |
状态同步通常由客户端发送输入,服务器模拟并广播结果。大量实体需要兴趣管理、量化、增量编码和更新频率分级。
帧同步要求各端在相同逻辑 Tick 执行相同输入。浮点计算、容器遍历顺序、随机数和 Unity 物理引擎都可能破坏跨平台确定性,因此不能直接假设 PhysX 场景适合严格 Lockstep。
快照同步由服务器按 Tick 发送世界快照,客户端保留快照缓冲并在已知状态间插值。增量快照需要记录基线和确认关系;基线不可用时,应等待有效基线或请求完整状态。
竞技游戏通常采用服务器权威,客户端负责输入和表现,服务器决定移动、命中和资源变化。
九、客户端预测、插值、外推、回滚和延迟补偿
| 技术 | 使用的数据 | 作用 |
|---|---|---|
| 插值 | 两个已收到的历史状态 | 平滑显示远端对象 |
| 外推 | 最新状态和速度等信息 | 数据暂缺时短暂预测未来 |
| 客户端预测 | 尚未确认的本地输入 | 降低本地操作延迟 |
| 回滚 | 历史状态和迟到输入 | 恢复历史并重新模拟 |
| 延迟补偿 | 服务器保存的历史状态 | 按玩家看到的时刻验证命中 |
插值会故意显示稍早的服务器时间,以吸收抖动;外推时间应受限,收到权威状态后再根据误差立即或平滑校正。
客户端预测会立即执行本地输入,同时为输入编号并发送服务器。服务器返回权威状态和最后处理的输入序号后,客户端恢复到权威状态,删除已确认输入,再重演剩余输入。服务器仍必须校验速度、碰撞和技能条件。
回滚需要保存最近若干 Tick 的状态,因此要求逻辑足够确定、状态快照成本可控,并避免音效和特效在重演时重复触发。延迟补偿由服务器回溯历史位置进行命中检测,最大回溯时间需要限制。
这些技术通常组合使用,并以服务器权威状态为最终依据。
性能优化
一、如何分析和优化 Unity 内存
Unity 内存优化不能只理解为“压缩资源”或“少创建对象”。首先应确认是哪一种内存、在哪个平台、哪个阶段达到峰值,再针对实际占用处理。
1. 区分不同类型的内存
| 类型 | 常见内容 | 主要管理方式 |
|---|---|---|
| 托管内存 | C# 对象、数组、字符串、集合和委托等 | 减少无效分配,控制引用关系,由 GC 回收 |
| Unity Native 内存 | GameObject、Component、Texture、Mesh、AudioClip 等引擎对象的 Native 数据 | Destroy、资源卸载和资源系统的释放 API |
| GPU 内存 | 纹理、网格缓冲、RenderTexture、阴影贴图等 | 资源导入设置、分辨率、格式和渲染配置 |
| 系统与第三方内存 | 图形驱动、音频、网络、原生插件和线程栈等 | 平台工具、插件接口和明确的生命周期管理 |
托管对象不再从 GC Roots 可达后,只是具备被 GC 回收的条件;Unity 资源的 Managed Wrapper 不可达,也不代表其 Native 或 GPU 数据一定已经按业务需要及时释放。因此,应区分托管对象回收、Unity 资源卸载和非托管资源释放。
2. 先测量,再优化
- 使用 Unity Profiler 的 Memory 模块观察总量、分类、变化趋势和加载峰值。
- 使用 Memory Profiler 获取并比较快照,查找大对象、重复资源和引用链。
- 使用 CPU Profiler 的
GC.Alloc定位托管分配来源。 - GPU 内存统计在不同平台上的完整程度不同,必要时结合 RenderDoc、Xcode、Android GPU Inspector 或厂商工具。
- 应在目标设备和代表性场景中测试。编辑器本身会引入额外对象和内存,不能只根据 Editor 数据下结论。
需要同时关注稳定运行时占用和瞬时峰值。例如场景切换时,新旧场景、加载缓冲区和资源上传数据可能短时间同时存在,即使最终稳定值不高,也可能触发系统杀进程或内存不足。
3. 优化资源本身
纹理
- 根据实际显示尺寸降低不必要的分辨率,并为不同平台设置合适的 Override。
- 使用目标平台支持的纹理格式,例如 BC、ETC、ASTC 等。GPU 纹理压缩可以减少运行时纹理内存,但画质、压缩率和设备支持需要权衡。
- PNG、JPG 和 Crunch 主要影响源文件、包体或下载大小。加载后仍会转换为目标运行时纹理格式,不能直接用磁盘文件大小估算内存。
- 不需要脚本读取像素时关闭
Read/Write Enabled,避免长期保留 CPU 侧副本。 - 根据观察距离决定是否生成 Mipmap。完整 Mipmap 链通常会增加约三分之一的基础纹理内存,但能改善远距离采样质量和缓存效率。
- 大型开放场景可以根据项目条件考虑 Mipmap Streaming,降低不需要的高分辨率 Mip 常驻量。
网格、动画和音频
- 删除模型中不需要的顶点属性、Blend Shape、骨骼和动画曲线,控制顶点数、索引数和骨骼权重。
- 不需要在运行时读取或修改 Mesh 时,关闭对应的
Read/Write Enabled,减少 CPU 侧数据保留。 - AudioClip 的
Decompress On Load、Compressed In Memory和Streaming在内存、CPU 与 I/O 之间有不同取舍。短音效和长音乐不应使用完全相同的加载策略。 - RenderTexture、阴影贴图和 MSAA 缓冲区可能占用大量 GPU 内存,应控制分辨率、格式、数量和生命周期,并及时释放临时渲染目标。
4. 管理加载和释放生命周期
- 不要一次加载所有资源。可以按场景、关卡或功能分组,异步加载,并控制同一时刻的加载峰值。
- Addressables 加载获得的 Handle 应与
Addressables.Release配对;通过 Addressables 实例化的对象通常使用Addressables.ReleaseInstance。引用计数归零后,相关资源才具备卸载条件。 - AssetBundle 的依赖关系和生命周期需要统一管理。
Unload(false)只卸载 Bundle 容器,已加载资源仍会保留;Unload(true)还会卸载已加载资源,使用不当可能使正在使用的对象失效。 Resources.UnloadUnusedAssets会查找当前未使用的资源,可能产生明显耗时,应安排在加载界面等合适阶段,而不是高频调用。- 运行时创建的 GameObject、Material、Texture 和 RenderTexture 等不再需要时,应根据所有权调用
Destroy、Release或对应释放 API。 - 清理静态集合、缓存、事件订阅和长期对象字段中已经失效的引用,否则资源可能因为仍然可达而无法释放。
5. 对象池和缓存需要设置上限
对象池通过保留对象减少反复实例化、销毁和托管分配,适合子弹、特效和短时间内频繁复用的对象。但它本质上是用常驻内存换取 CPU 和 GC 稳定性:
- 池容量过大会提高常驻内存。
- 池中对象引用的纹理、网格和材质也可能被一起保留。
- 很少复用、体积很大或生命周期很长的对象不一定适合池化。
- 应设置容量上限,并在场景切换或长期空闲时缩减不再需要的池。
集合和缓冲区也可以复用,例如预估 List<T>、Dictionary<TKey, TValue> 或 StringBuilder 的容量并使用 Clear()。但容量设置过大同样会保留内存,应根据真实峰值调整。
6. 图集和共享资源的取舍
纹理图集主要用于减少纹理或材质切换,并不保证降低内存。如果只使用一个小图标却必须加载整张大图集,内存反而可能增加。图集应尽量按照界面、场景和生命周期分组,同时处理 Mipmap 边缘和平台最大纹理尺寸等问题。
还应避免无意创建重复资源。例如访问 renderer.material 可能为 Renderer 创建独立材质实例;只需要修改少量实例属性时,可以考虑共享材质和 MaterialPropertyBlock,并结合实际渲染管线与批处理规则验证。
总结:先在目标平台测量并区分托管、Native 和 GPU 内存,再从资源规格、加载峰值、引用关系和释放策略入手。资源压缩、图集和对象池都有适用条件,不是无条件降低内存的方案。
二、GC 为什么执行,如何减少 GC Alloc 和卡顿
回答 GC 问题时应区分三个概念:
- 托管分配:代码在托管堆上创建对象,例如
new数组、字符串或类实例。Profiler 中的GC.Alloc通常表示发生了托管分配,不表示此时已经执行 GC。 - 垃圾对象:对象不再能从 GC Roots 到达时,才成为可以回收的垃圾。
- 垃圾回收:运行时在分配压力、堆阈值、低内存信号或显式请求等条件下执行可达性检查并回收垃圾,不是每创建或“销毁”一个对象就立即执行。
GC 回收的是托管内存。即使垃圾已经被回收,托管堆保留的内存也可能先供后续分配复用,而不是立即全部归还操作系统。
1. Unity 中常见的托管分配来源
- 在
Update、FixedUpdate等高频路径中反复new类、数组或集合。 List<T>、Dictionary<TKey, TValue>、字符串缓冲区等容量不足时扩容。- 字符串拼接、插值、格式化、日志和频繁更新 UI 文本。
- 值类型转换为
object、非泛型接口或某些接口类型时发生装箱。 - 捕获局部变量的 Lambda、匿名方法、局部函数和闭包。
- 反复创建、组合委托,或没有正确取消长期事件订阅。
- LINQ、迭代器方法、反射调用参数数组以及部分枚举路径。
- 协程状态机、等待对象、
WaitUntil或WaitWhile使用的委托。 - 返回新数组的 Unity API,例如部分
GetComponents、Physics.RaycastAll或资源查询接口。
具体 API 是否分配会受到 Unity 版本、调用重载、脚本后端和编译器优化影响,应以 Profiler 结果为准。例如直接遍历具体的 List<T> 通常不会仅因为 foreach 枚举器产生 GC Alloc,不能机械地把所有 foreach 或所有 Lambda 都视为分配。
2. 减少高频分配
- 使用 CPU Profiler 的 Timeline、
GC.Alloc和 Allocation Call Stacks 定位分配调用栈,并在 Development Build 的目标设备上复测。Deep Profile 会显著改变性能数据,只应在需要时使用。 - 避免在每帧逻辑中创建临时数组、集合和字符串。复用调用方提供的缓冲区,并为集合设置合理的初始容量。
- 使用
Clear()复用集合,但注意它通常不会缩小 Capacity。偶尔增长得特别大的缓存可以按阈值替换或主动缩减。 - 优先使用合适的非分配重载,例如
Physics.RaycastNonAlloc、GetComponents(List<T>)等,同时正确处理缓冲区不足和结果截断。 - 循环构建大量文本时可以复用
StringBuilder;高频 UI 数字更新应选择目标 UI 系统支持的低分配格式化方式。 - 避免不必要的装箱,优先使用泛型集合和类型明确的 API。
- 避免在高频路径中反复创建捕获闭包。可以缓存稳定的委托,但不要为了“零分配”引入难以维护的全局状态。
- 对确实频繁创建和释放的对象使用有上限的对象池。对象池减少分配,但会增加常驻内存,必须结合峰值和复用率决定。
优化目标通常是让正常帧中的托管分配足够低且稳定,而不是要求所有代码在任何阶段都保持 0 B。初始化、场景加载和低频操作可以接受经过评估的分配,应重点避免不可控的每帧垃圾和集中回收尖峰。
3. 正确处理引用和资源
普通局部变量通常不需要在使用结束后手动赋值为 null。只有对象被长生命周期的字段、静态集合、缓存、事件发布者或其他 GC Roots 间接引用时,主动移除引用才有意义。
常见处理包括:
- 从静态集合和缓存中删除已经失效的条目。
- 按生命周期取消事件订阅,避免发布者长期持有订阅者。
- 停止不再需要的协程、异步任务和定时回调,并处理其捕获状态。
- 释放 Addressables Handle、AssetBundle、RenderTexture 和 Native 容器等明确拥有的资源。
- 对文件、Socket 和其他实现
IDisposable的资源使用Dispose或using。
Dispose 和 Unity 资源释放 API 主要用于释放 Native、操作系统或资源系统拥有的内容,并不等于执行 GC。Destroy(gameObject) 通常在帧末销毁 Unity Native 对象,相关托管包装对象仍由 GC 在之后回收。
4. 增量 GC 和手动 GC
支持并启用 Incremental GC 时,Unity 可以把部分标记工作分散到多个帧,从而降低单次停顿,但它不会减少垃圾数量,也不一定减少总回收工作。最根本的优化仍是降低分配率和存活对象数量。
通常不应在普通游戏循环中频繁调用 GC.Collect()。强制完整回收可能直接造成明显卡顿。只有在加载界面、场景切换或大批临时对象确实已失效之后,才可以根据目标设备上的 Profiler 结果评估是否主动安排一次回收。
禁用 GC 会让不可达对象持续占用托管堆,并可能最终导致内存不足,只适用于严格控制分配量和持续时间的特殊流程,不能作为常规优化方案。
总结:托管分配不等于立即执行 GC,不可达对象才是垃圾。应通过 Profiler 找出高频 GC.Alloc,减少临时对象、装箱、字符串、闭包和数组返回,合理复用集合与对象池,并只在生命周期结束时断开长期引用。增量 GC 能分摊停顿,但不能替代减少分配。
三、如何使用 Unity Profiler 定位性能瓶颈
性能优化的第一步不是直接修改代码,而是确定目标设备、目标帧率和可复现的测试场景,然后通过工具找到真正占用帧时间或内存的部分。
例如,目标为 60 FPS 时,每帧预算约为:
1000 ms / 60 ≈ 16.67 ms
目标为 30 FPS 时约为 33.33 ms。但 CPU 和 GPU 通常会流水执行,不能简单把所有线程与 GPU 时间直接相加作为一帧耗时。
1. 常用 Profiler 模块
| 模块 | 主要观察内容 |
|---|---|
| CPU Usage | 主线程、渲染线程、Job、脚本、物理、动画和 GC 耗时 |
| GPU Usage | GPU 各渲染阶段耗时;支持程度取决于平台和图形 API |
| Rendering | Batches、SetPass Calls、三角形、顶点和阴影等统计 |
| Memory | 托管、Native、纹理、网格、音频等内存趋势 |
| Physics | 物理模拟、碰撞体和查询相关统计 |
| UI / UI Details | Canvas、Batch、顶点和 UI 重建等信息,具体能力取决于 Unity 版本 |
CPU Profiler 常用两种视图:
- Timeline:查看不同线程在一帧中的执行顺序、并行关系、等待和尖峰。
- Hierarchy:按照调用层级聚合耗时,重点区分
Total和Self。Total包含子调用,Self只表示当前采样本身。
2. 正确的分析流程
- 在目标平台创建 Development Build,并连接 Profiler。Editor 中的资源数据库、Inspector 和编辑器脚本会引入额外开销。
- 固定测试场景、相机位置、角色数量和操作流程,先预热一段时间再采样。
- 确认问题是持续超预算、周期性尖峰,还是加载或切换时的一次性卡顿。
- 找到耗时最大的线程和 Marker,再展开调用栈定位业务代码或引擎子系统。
- 每次只修改一个主要变量,使用相同流程重新采样,并记录优化前后的帧时间、内存和画质变化。
可以使用 ProfilerMarker 给自己的系统添加标记,帮助在 Timeline 中区分业务阶段:
using Unity.Profiling;
private static readonly ProfilerMarker UpdateEnemiesMarker =
new ProfilerMarker("Game.UpdateEnemies");
private void UpdateEnemies()
{
using (UpdateEnemiesMarker.Auto())
{
// 需要测量的逻辑
}
}
3. 配合其他工具
- Profile Analyzer:比较多帧或两组采样,避免只根据某一帧做判断。
- Frame Debugger:逐步查看一帧的渲染事件、材质、Pass 和批处理结果,但它不是 GPU 时间分析器。
- Memory Profiler:获取内存快照,分析对象、重复资源和引用链。
- RenderDoc、Xcode、Android GPU Inspector 和厂商工具:分析 GPU Pass、带宽、纹理、Shader 和平台级性能。
Deep Profile 会插入大量额外采样,可能显著改变运行性能和调用时序。它适合短时间定位缺少 Marker 的脚本调用,不应把开启 Deep Profile 后的绝对耗时直接当作正式性能结果。
4. 等待 Marker 不一定是根因
WaitForTargetFPS、Gfx.WaitForPresentOnGfxThread、Semaphore.WaitForSignal 等 Marker 表示当前线程正在等待。原因可能是 VSync、目标帧率限制、GPU 尚未完成、另一个线程没有提交工作或 Job 依赖未完成。
因此,看到等待时间很长时,应继续检查被等待的一方,而不是直接优化等待函数本身。
总结:先在目标设备建立稳定的复现场景,通过 Timeline 确认 CPU、GPU、线程等待或内存问题,再展开具体 Marker。Profiler 数据必须结合帧预算、线程关系和平台工具解释。
四、如何判断是 CPU 瓶颈还是 GPU 瓶颈
CPU Bound 表示 CPU 无法及时完成脚本、物理、动画、剔除或渲染命令提交;GPU Bound 表示 GPU 在顶点、像素、带宽、阴影或后处理等阶段耗时过长。一个项目也可能在不同场景、设备或帧中出现不同瓶颈。
1. 主要判断方法
- 同时查看 CPU Profiler 和 GPU Profiler 的帧耗时,并确认主线程、渲染线程和 GPU 谁最晚完成。
- 在相同场景中明显降低渲染分辨率。如果帧时间大幅下降,通常说明像素填充、后处理或带宽等 GPU 成本占比较高;如果变化很小,更可能受 CPU 或顶点等成本限制。
- 暂时降低阴影、后处理、透明特效和 Shader 复杂度,观察 GPU 时间是否下降。
- 暂时减少 AI、物理对象、Animator 和脚本更新数量,观察 CPU 时间是否下降。
- 使用目标平台的 GPU 工具验证。Editor 或不支持 GPU Profiler 的平台只能提供有限线索。
2. 常见 CPU 瓶颈
- 主线程脚本、序列化、资源加载和对象实例化。
- 物理模拟、碰撞查询和过高的固定更新频率。
- 大量 Animator、骨骼、粒子或 UI 重建。
- Draw Call 和渲染状态设置过多,导致主线程或渲染线程提交成本过高。
- Job 没有并行起来、依赖链过长,或主线程过早调用
Complete()等待结果。
常见优化方向包括降低更新频率、减少无效工作、改善算法和数据布局、使用对象池、Job System 与 Burst,以及减少渲染提交成本。
3. 常见 GPU 瓶颈
- 分辨率过高、Overdraw 严重或大量全屏后处理。
- Shader 纹理采样、分支和数学计算复杂。
- 实时灯光、阴影、反射和多 Pass 渲染过多。
- 顶点数、骨骼蒙皮或细分过高。
- RenderTexture、HDR、MSAA 和高精度格式带来的显存带宽压力。
常见优化方向包括动态分辨率、降低 Shader 和后处理复杂度、减少透明覆盖、调整阴影与灯光、使用 LOD,以及降低渲染目标精度或尺寸。
4. 注意帧率限制和同步等待
开启 VSync 或设置目标帧率后,CPU 可能主动等待下一次显示时机。此时 Profiler 中出现较长等待不代表性能不足。如果关闭帧率限制进行测试,应注意设备发热和测试条件,并在完成后恢复项目设置。
Gfx.WaitForPresentOnGfxThread 可能表示 GPU 或显示同步限制,也可能反映渲染线程与主线程之间的流水关系,不能仅凭一个 Marker 判断 GPU Bound。
总结:结合 CPU/GPU 时间、分辨率缩放实验和目标平台工具判断瓶颈。降低分辨率明显变快通常指向 GPU 像素成本,但最终仍应以完整的线程和 GPU 采样为准。
五、Draw Call、Batch 和 SetPass Call 有什么区别
这三个指标都与渲染提交有关,但含义并不相同。
| 概念 | 含义 | 主要成本 |
|---|---|---|
| Draw Call | CPU 向图形 API 提交一次绘制命令 | CPU/驱动提交,以及对应 GPU 绘制工作 |
| Batch | Unity 统计的一次绘制批次,通常对应一次实际绘制提交 | 受批处理、Pass、阴影和渲染管线影响 |
| SetPass Call | 切换或设置 Shader Pass、材质和渲染状态 | 常带来较明显的 CPU 与驱动状态切换成本 |
一个 Renderer 不一定只产生一次 Draw Call。例如多 Pass Shader、额外灯光、阴影投射和深度 Pass 都可能产生额外绘制。相反,多个 Renderer 也可能通过批处理或实例化由较少的绘制命令处理。
1. 为什么 SetPass 通常值得关注
GPU 绘制前需要设置 Shader、纹理、混合、深度和光栅化状态。频繁切换材质、Shader Keyword、Pass 或渲染队列,会增加状态设置和驱动验证成本。
共享材质和兼容的 Shader 状态通常有利于减少 SetPass,但材质相同并不保证一定能够合批。光照数据、Lightmap、阴影状态、渲染层、顶点布局和 MaterialPropertyBlock 等差异都可能影响结果。
2. Draw Call 少不代表一定快
- 一个 Draw Call 可以绘制大量顶点,仍然可能受到顶点处理限制。
- 一个全屏透明 Pass 可能只有一次 Draw Call,却造成严重 Overdraw 和像素开销。
- 为了强行合并网格,可能增加内存、破坏剔除粒度,导致不可见几何也被提交。
- 在现代图形 API 和高性能设备上,可承受的 Draw Call 数量与旧移动设备差异很大。
3. 如何分析
- 使用 Game 视图 Stats 或 Rendering Profiler 观察趋势,不把单个数字当作跨设备标准。
- 使用 Frame Debugger 逐条查看绘制事件、Pass、材质和批处理失败原因。
- 使用 GPU 工具确认真正耗时的是命令提交、顶点、像素、带宽还是其他阶段。
总结:Draw Call 是绘制提交,Batch 是 Unity 统计的绘制批次,SetPass 是渲染状态和 Shader Pass 设置。减少它们主要优化 CPU 提交成本,但必须同时关注 GPU 工作量、内存和剔除效果。
六、Static Batching、Dynamic Batching、GPU Instancing 和 SRP Batcher 的区别
这些技术都可能降低渲染的 CPU 成本,但实现方式和适用条件不同。
| 技术 | 核心方式 | 适用场景 | 主要代价或限制 |
|---|---|---|---|
| Static Batching | 预先合并标记为静态的几何数据 | 不移动且材质兼容的场景物体 | 可能增加网格内存,物体不能自由移动 |
| Dynamic Batching | CPU 在运行时合并满足限制的小网格 | 少量低顶点动态物体及特定平台 | 有严格限制和 CPU 转换成本,现代平台未必划算 |
| GPU Instancing | 一次或少量命令绘制相同 Mesh、Material 的多个实例 | 大量相同树木、道具、弹壳等 | Shader 和材质需支持实例化,实例差异受限 |
| SRP Batcher | 缓存并优化兼容 Shader 的常量数据设置 | URP、HDRP 和兼容的自定义 SRP Shader | 主要减少状态设置成本,不保证减少 Draw Call |
1. Static Batching
Static Batching 适合不会移动、旋转或缩放的场景几何。Unity 可以把兼容对象的顶点转换到统一空间并组织成较大的批次,从而减少运行时提交开销。
它可能复制或扩展网格数据,增加构建后和运行时内存。合并粒度过大还可能使剔除不够精细,因此不应把所有场景对象无条件标记为 Batching Static。
2. Dynamic Batching
Dynamic Batching 需要 CPU 每帧处理顶点,并且对顶点数量、属性、缩放、材质和渲染 Pass 等存在限制。在现代图形 API 上,CPU 合并成本可能接近或超过节省的 Draw Call 成本。
是否启用以及是否真正生效应根据 Unity 版本、渲染管线和目标平台的 Profiler 数据决定。
3. GPU Instancing
GPU Instancing 让多个对象共享 Mesh 和 Material,并通过每实例数据提供位置、颜色等差异。它适合数量较多、几何相同的对象。
不同 Mesh、材质实例、Shader Variant、Pass 或不兼容的实例属性会拆分批次。透明对象还需要考虑排序正确性,不能只为了实例化而破坏显示结果。
4. SRP Batcher
SRP Batcher 通过让兼容 Shader 使用固定的常量缓冲布局,减少材质切换时的数据准备成本。它即使没有把多个对象合成一个 Draw Call,也可能显著降低 CPU 渲染线程耗时。
Shader 是否兼容 SRP Batcher 可以在 Inspector 或 Frame Debugger 中检查。MaterialPropertyBlock、特殊 Shader 写法和不同 Unity 版本可能影响兼容性,应在项目使用的渲染管线中验证。
5. 选择原则
- 静态且材质兼容的环境物体:考虑 Static Batching。
- 大量相同 Mesh 和 Material 的动态或静态物体:考虑 GPU Instancing。
- 使用 URP/HDRP:优先保证 Shader 兼容 SRP Batcher,再判断是否需要实例化。
- Dynamic Batching:仅在目标平台测量后决定,不应默认认为开启一定更快。
这些方案优化的层次不同,也可能相互影响。最终应使用 Frame Debugger 和 Profiler 确认实际批处理结果,而不是只根据 Inspector 开关判断。
七、什么是 Overdraw,如何优化
Overdraw 指同一个屏幕像素在一帧内被多个物体反复绘制。前面的结果如果最终被后面的物体完全覆盖,相关像素计算和带宽可能成为无效开销。
1. 常见来源
- 大面积半透明 UI、多个全屏面板和渐变背景。
- 粒子、烟雾、光效、植被和头发等透明材质。
- 多层后处理或多个全屏 Blit。
- 使用大矩形网格承载只有中间少量可见像素的 Sprite 或粒子。
- 相机范围内重叠严重的复杂几何。
不透明物体通常可以借助深度测试、Early-Z 或深度预处理减少部分无效像素着色;透明物体为了正确混合,通常按从后向前排序且不写入深度,因此更容易产生 Overdraw。
2. 如何分析
- Scene 视图的 Overdraw 模式可以观察重复覆盖的大致区域,但颜色只是近似提示,不代表实际 Shader 成本。
- Frame Debugger 可以检查透明对象和全屏 Pass 的执行顺序。
- GPU Profiler 和平台 GPU 工具可以确认 Fragment、带宽和 Render Pass 的真实耗时。
3. 常见优化方式
- 减少透明层数、粒子数量、粒子尺寸和屏幕覆盖面积。
- 为 Sprite、粒子和特效使用更贴合可见区域的网格,避免巨大透明空白区域。
- 合并或移除不必要的全屏效果,并降低高成本后处理的分辨率。
- UI 页面不可见时真正禁用对应对象或 Canvas,不要只依赖完全透明的颜色。
- 对能够使用不透明或裁剪材质的对象评估改用 Opaque 或 Alpha Clip,但
discard、MSAA 和移动端 Tile GPU 上的成本也需要实测。 - 根据设备性能使用动态分辨率或降低渲染比例,减少需要处理的像素数量。
减少 Overdraw 主要改善 GPU 像素和带宽成本。如果当前项目是 CPU Bound,单独降低 Overdraw 可能不会提高帧率,但仍可能降低功耗和发热。
八、LOD、视锥剔除和遮挡剔除的区别
三者都用于减少不必要的渲染工作,但处理的问题不同。
| 技术 | 作用 | 典型依据 |
|---|---|---|
| Frustum Culling | 不渲染相机视锥之外的对象 | Renderer Bounds 是否与视锥相交 |
| Occlusion Culling | 不渲染被其他物体完全遮挡的对象 | 预计算或运行时可见性数据 |
| LOD | 距离较远时改用更低成本的模型或材质 | 对象在屏幕中的相对高度或距离 |
1. Frustum Culling
Unity 通常会自动对 Renderer 执行视锥剔除。Bounds 设置过大、粒子范围过宽或 SkinnedMeshRenderer Bounds 不准确,都会让本应不可见的对象继续参与渲染。
手动合并大量相距很远的网格可能形成一个巨大的 Bounds。只要其中一小部分进入视锥,整个合并网格都可能被提交,从而破坏剔除粒度。
2. Occlusion Culling
遮挡剔除适合房间、走廊、城市街区等存在稳定大型遮挡物的场景。它需要生成和保存可见性数据,并在运行时执行查询,因此存在烘焙时间、内存和 CPU 成本。
开放地形、遮挡关系变化剧烈、大量小物体或完全动态的场景可能收益有限。Occluder 和 Occludee 的选择、最小遮挡尺寸和相机移动方式都需要根据场景验证。
3. LOD
LODGroup 可以在对象远离相机时切换到较低顶点数、较简单 Shader,最终也可以进入 Culled 状态。切换阈值通常基于物体在屏幕中的相对高度,而不只是固定世界距离。
LOD 会增加多套 Mesh 和可能的材质资源,因此不一定降低内存。Cross Fade 在过渡期间可能同时绘制两个 LOD,也会带来额外成本。应在画面稳定性、GPU 节省和资源内存之间权衡。
4. 选择建议
- 所有普通 Renderer 都应保持合理 Bounds,以便视锥剔除正常工作。
- 大型静态、遮挡明确的场景可以评估 Occlusion Culling。
- 中远距离仍可见且模型复杂的对象适合 LOD。
- 大规模草木、单位或特效还可以结合距离分级、
CullingGroup、GPU Instancing 和业务层停更策略。
总结:视锥剔除解决“相机外”,遮挡剔除解决“被挡住”,LOD 解决“虽然可见但没必要保持最高精度”。
九、UGUI 常见性能问题及优化方式
UGUI 的主要成本不仅是 Draw Call,还包括 Canvas 重建、布局计算、网格生成、射线检测和透明像素填充。
1. Canvas 重建
Canvas 中的 UI 元素发生位置、尺寸、文本、材质或层级变化时,可能触发布局、Graphic 或 Batch 重建。一个频繁变化的小元素如果与大量静态 UI 位于同一个 Canvas,可能使更大的区域重复处理。
常见做法是把长期静态 UI、频繁变化 UI 和独立弹窗拆分到不同 Canvas。但 Canvas 本身也有排序、渲染和管理成本,不能为每个小元素创建一个 Canvas。
2. 布局系统
- 嵌套的
HorizontalLayoutGroup、VerticalLayoutGroup、GridLayoutGroup和ContentSizeFitter可能引起多轮布局计算。 - 避免每帧修改会触发布局的 RectTransform、文本或子节点数量。
- 对只需初始化一次的布局,可以在计算完成后关闭不再需要的 Layout 组件,或改为业务代码直接设置位置。
- 不要高频调用
Canvas.ForceUpdateCanvases()或LayoutRebuilder.ForceRebuildLayoutImmediate(),除非确实需要立即取得布局结果。
3. Batch 和材质
UI 的层级顺序决定绘制顺序。不同纹理、材质、Mask、裁剪状态和穿插的文字可能打断 Batch。Sprite Atlas 可以减少纹理切换,但应按页面和生命周期分组,避免加载无关的大图集。
Mask 常使用模板缓冲并产生额外绘制,RectMask2D 更适合矩形裁剪,但具体效果仍取决于层级、材质和平台。嵌套 Mask 应谨慎使用。
4. 射线检测
- 对纯装饰的
Image、Text和 TextMeshProUGUI 关闭raycastTarget。 - 减少不必要的 GraphicRaycaster 和复杂的 Blocking Objects 检测。
- 隐藏界面时同步处理
interactable、blocksRaycasts和激活状态,避免不可见 UI 继续参与输入。
5. Overdraw 和文本
- 避免大量全屏半透明 Image、层层叠加的面板和透明空白区域。
- 频繁修改文本会触发网格和可能的布局更新,应只在内容变化时更新。
- TextMeshPro 动态字体图集可能在运行时扩张,应准备合理的字符集、Atlas 大小和回退字体。
- 长列表应使用虚拟化或对象池,只创建可见区域附近的条目。
UGUI 优化应结合 UI Profiler、Frame Debugger 和 GPU Overdraw 分析。减少 Canvas 数量、减少 Draw Call 或关闭某个组件都不是单独适用于所有界面的固定答案。
十、Unity 物理系统优化的分析流程
物理章节已经分别说明了时间步、Collider、碰撞检测和查询 API;优化时不应再次孤立地套用这些规则,而应先确认成本来自模拟、接触求解还是查询。
1. 定位成本
- 使用 CPU Profiler 和 Physics 模块观察每帧物理步数、模拟耗时、Collider、接触对、约束和查询数量。
- 区分稳定的持续成本与某帧集中创建 Rigidbody、移动 Static Collider 或补做多次固定更新造成的尖峰。
- 在目标设备复现相同的刚体数量、场景区域和交互流程,Editor 数据只用于初步定位。
2. 减少参与规模
- 用 Layer Collision Matrix 排除永远不需要碰撞的组合。
- 让远距离、停用和不再交互的物体退出模拟,并允许静止刚体正常 Sleep。
- 保持 Collider 足够简单,同时避免为了减少 Collider 数量而使用代价更高的大型复杂网格。
- 通过空间分区和激活范围控制同时存在的物理对象,而不是只优化单次碰撞。
3. 只为关键对象提高精度
- 根据游戏需求设置
fixedDeltaTime,并监控卡顿帧是否补做过多物理步。 - 只给高速且不能穿透的关键对象启用连续检测。
- 只为确实存在关节或堆叠不稳定的对象提高 Solver Iterations。
- 只给画面中需要平滑显示的 Rigidbody 开启 Interpolation。
4. 控制查询调度
- 使用准确的 LayerMask、距离、形状和 Trigger 策略减少候选。
- 降低不需要每帧执行的感知和范围查询频率,并把大量查询分散到不同帧。
- NonAlloc API 只解决托管分配,查询本身的 Broad Phase 和 Narrow Phase 成本仍然存在。
- 大量独立射线可以评估
RaycastCommand和 Job System,同时避免高频Physics.SyncTransforms()。
总结:先用 Profiler 判断是步数、对象规模、求解还是查询导致瓶颈,再缩小参与范围并只为关键对象保留高精度。物理优化不是统一降低参数,而是在稳定性和 CPU 预算之间分级配置。
十一、为什么异步加载仍然可能造成卡顿
异步加载通常只能把部分文件读取、解压或资源准备工作移出当前帧,并不保证整个流程都在后台线程完成。Unity 对象创建、场景集成和许多引擎 API 仍然需要主线程参与。
1. 常见卡顿阶段
- 文件读取和 AssetBundle 解压。
- 资源反序列化、依赖解析和 Managed Wrapper 创建。
- 大量
Instantiate,以及随之执行的Awake、OnEnable和注册逻辑。 - Mesh、Texture 上传 GPU,RenderTexture 创建和显存分配。
- Shader Variant、图形管线状态或材质首次使用时的准备。
- 场景激活时一次性启用大量 Renderer、Collider、Animator 和 UI。
- 卸载资源、执行
Resources.UnloadUnusedAssets或集中 GC。
因此,LoadSceneAsync、Addressables.LoadAssetAsync 或 AssetBundle.LoadAssetAsync 完成得很平滑,也不代表后续实例化和激活不会产生尖峰。
2. 常见优化方式
- 把资源读取、实例化、初始化和激活拆成不同阶段,并限制每帧处理数量或时间预算。
- 提前加载即将使用的资源,避免在战斗或镜头切换的关键帧首次加载。
- 对频繁出现的对象使用有上限的对象池,并在加载界面分批预热。
- 控制同时发起的加载数量。过高并发可能造成磁盘争用、解压 CPU 峰值和更大的临时内存。
- 根据平台和 Unity 版本配置异步纹理或网格上传的时间片与缓冲区,并使用 Profiler 验证主线程和 GPU 上传峰值。
- 对实际会使用的 Shader Variant 或管线状态进行适量预热,同时做好 Variant 剥离,避免预热集合过大。
- 大型场景使用 Additive Scene、分区或 Addressables 分组时,建立清晰的依赖和释放规则,防止重复加载与长期引用。
3. 场景切换的内存峰值
异步切换时,新场景资源、旧场景资源、下载或解压缓冲区可能同时存在。可以先释放确定不再需要的对象,分批加载新内容,再在合适阶段卸载旧资源;但过早卸载也可能造成黑屏或重复加载。
应同时采集 CPU Timeline 和 Memory Profiler 数据,观察峰值发生在读取、实例化、GPU 上传、场景激活还是资源卸载阶段。
4. 资源压缩的取舍
例如 AssetBundle 使用更强的整体压缩可能减小下载体积,但加载时需要更多解压时间和临时内存;分块压缩通常更利于随机读取,但文件可能更大。应根据本地、远程、磁盘速度和更新粒度选择,而不是只追求最小包体。
总结:异步 API 不等于所有阶段都离开主线程。真正的优化是把读取、反序列化、实例化、激活和 GPU 上传分阶段执行,并控制每帧工作量、并发数和内存峰值。
十二、大量 Update 为什么有性能开销,如何优化
Unity 会把声明了 Update、FixedUpdate 或 LateUpdate 等消息的活动组件加入对应调度。单个空或简单 Update 的成本通常很小,但当数量达到数千甚至更多时,Native/Managed 调度、函数调用和其中的重复轮询会累积成明显开销。
1. 常见问题
- 大量组件每帧检查一个很少发生的条件。
- 所有远距离、不可见或暂停对象仍然执行完整逻辑。
- 在每个
Update中重复查找组件、遍历场景或执行相同的全局计算。 - 非物理逻辑放在
FixedUpdate中,而一个渲染帧可能执行多次固定更新。 - 多个系统在不同组件中重复计算距离、时间、输入或目标列表。
2. 优化方式
- 删除真正不需要的空消息方法;组件没有声明对应消息时,Unity 无需把它加入该更新列表。
- 只在对象活跃期间启用脚本,进入休眠、死亡或池中时关闭不需要的组件。
- 用事件响应低频状态变化,避免每帧轮询。但高频事件和复杂订阅关系也有成本与维护风险。
- 把不需要每帧更新的逻辑改为定时、分帧或按距离分级更新。例如把 AI 感知分散到多个帧,而不是同一帧更新全部单位。
- 缓存稳定的组件和数据引用,避免在高频路径重复执行场景搜索和层级遍历。
- 使用
CullingGroup、距离分区、空间结构或可见性系统,让远距离对象降低更新频率或停止更新。 - 大量同构数据计算可以改为连续数组、Job System 和 Burst,减少分散的对象调用并改善并行度和缓存局部性。
3. 集中式 Update Manager
当项目确实有大量小型更新对象时,可以由一个管理器维护活动对象列表并统一调用:
public interface IUpdatable
{
void Tick(float deltaTime);
}
集中调度可以减少 Unity 消息数量,并方便实现分组、时间预算和不同更新频率。但它也会带来注册、注销、列表修改、异常隔离和生命周期管理问题。
对于几十或几百个普通组件,集中式管理器未必能带来可测收益,反而会增加架构复杂度。只有 Profiler 明确显示脚本调度或大量小调用成为热点时,才值得引入。
4. 更新频率必须符合业务语义
- 输入一般在
Update读取。 - 物理操作通常与
FixedUpdate配合。 - 相机跟随等依赖其他对象完成更新的逻辑通常放在
LateUpdate。 - 降低 AI、UI 或网络表现层更新频率时,应使用正确的时间累计方式,避免逻辑速度随帧率变化。
总结:大量 Update 的问题来自调度和每帧重复工作累积。优先停用无效组件、改用事件或降频更新、缓存数据,并在规模足够大时考虑集中调度或 Job/Burst;是否值得优化必须由 Profiler 证明。
更多推荐



所有评论(0)