从 Unity C# 参考源码理解 Unity 引擎架构
基于 UnityCsReference 6000.7.0a4 的源码阅读笔记,适合想理解 Unity C# API、C# / C++ 绑定和运行时模型的开发者。
关键词: Unity、UnityCsReference、C#、C++、Binding、Job System、NativeArray、协程、异步
前言
Unity 的核心引擎主要由 C++ 实现,但开发者日常写的几乎全是 C#。这中间隔着一层怎样的桥梁?UnityCsReference 这个仓库把 C# 世界完整摊开在我们面前------类型定义、公开接口、托管逻辑、原生绑定声明,以及部分编辑器和模块代码。
这篇文章不逐文件罗列代码,而是通过几条代表性源码线索,建立一张架构地图:C# API 如何连接 C++,Unity 对象为什么有托管包装和原生实体两层,协程与 async/await 如何暂停和恢复,以及 Task、Job System 和 NativeArray 各自负责什么。
说明:
UnityCsReference主要公开 Unity 的 C# 类型、API、托管侧逻辑和绑定声明;完整的 C++ 引擎实现并不在本仓库中。因此阅读重点是 C# API 设计、对象表示、C# 如何调用原生引擎,以及 Unity 的组件和运行时模型。
一、阅读范围与方法
本文围绕四条主线展开:
API 设计:公开 API、泛型、重载、Property、Attribute
引擎边界:C# 包装对象、C++ 原生实体、绑定和参数封送
运行时模型:GameObject、Component、Scene、资源、协程和异步
并发模型:Task、线程池、Job System、NativeArray 和安全句柄
阅读具体源码时,可以反复问自己三个问题:这段代码是在 C# 托管侧执行,还是通过绑定进入 C++?它操作的是托管对象、原生内存,还是 Unity 原生实体?它运行在主线程、协程调度流程,还是 Job 工作线程?
二、C# API:连接用户代码与引擎
典型调用链是这样的:游戏脚本调用 C# 公开 API,经过 C# 包装逻辑,再通过绑定桥接进入 C++ 引擎。C# 层不只是"传参数",它还负责稳定的调用契约、重载、默认值、类型检查和部分纯 C# 逻辑。普通有方法体的 C# 方法由托管运行时执行,而 extern 方法没有 C# 方法体,通常由绑定系统转到原生实现。
泛型约束是 C# API 设计中常见的手段,比如 AddComponent<T>:
public T AddComponent<T>() where T : Component
{
return AddComponent(typeof(T)) as T;
}
where T : Component 使错误类型在编译期就被拒绝。泛型入口提供类型安全,Type 入口则保留运行时动态调用能力。默认值和重载的设计原则也类似------常用场景少写参数,特殊场景仍可完整控制,所有入口最终复用统一实现:
public static void Destroy(Object obj)
{
Destroy(obj, 0.0F);
}
需要注意的是,System.Type 是运行时类型描述对象,不是 Unity Object 的基类。而 Internal_ 通常只是内部命名约定,不等于 C++;是否跨入原生层,重点看 extern 和绑定特性。
三、C# 与 C++ 绑定
绑定声明通常长这样:
[FreeFunction(Name = "MonoAddComponentWithType", HasExplicitThis = true)]
private extern Component Internal_AddComponentWithType(Type type);
extern 表示 C# 侧没有方法体;FreeFunction 声明对应的原生自由函数名称和调用方式;NativeMethod 则描述原生方法名称、异常行为等。
整个绑定系统由特性、生成工具、桥接代码和运行时支持共同组成。构建时建立 C# 方法与 C++ 函数的对应关系,调用时完成参数封送、原生调用和返回值封送。
四、Unity 对象的双层结构
继承 UnityEngine.Object 的对象通常可以理解为两层:C# 包装对象提供 API、保存身份和托管引用;C++ 原生实体保存引擎状态并执行底层计算。
IntPtr m_CachedPtr;
private EntityId m_EntityId;
传入 C++ 的不是完整 C# 对象副本,而是能定位原生实体的指针、ID 或句柄。Destroy 通常先销毁 C++ 实体;若其他代码仍引用 C# 包装对象,它会暂时留在托管堆,最终由 GC 回收。此时 Unity 重载的 == null 可能返回 true,因为它检查的是原生实体是否仍有效,而不是 C# 引用是否为空。
五、参数封送与异常
参数封送就是 C# 与 C++ 之间转换参数、返回值和异常。不同类型的处理方式各有特点:
| 类型 | 常见处理方式 |
|---|---|
int、float、bool、enum |
直接或按底层数值传递 |
Vector3、Quaternion |
按固定字段布局复制 |
UnityEngine.Object |
传指针、ID 或句柄 |
string |
转换为原生字符串 |
| 数组、集合 | 缓冲区、指针和长度 |
| 原生容器 | 传递原生内存描述 |
| 委托 | 建立可被原生侧调用的回调入口 |
以 Vector3 为例,它通过 StructLayout 保证字段布局与 C++ 侧一致:
[StructLayout(LayoutKind.Sequential)]
public struct Vector3
{
public float x, y, z;
}
C# 能判断的参数错误通常先在托管侧检查;引擎状态错误由 C++ 产生,再由绑定层转换为 ArgumentException、InvalidOperationException 或 MissingReferenceException 等 C# 异常。
六、Unity 基础对象模型
Unity 的核心对象关系可以概括为:GameObject 是容器,Component 是能力单元,Transform 负责位置、旋转、缩放和父子层级,Behaviour 是带 enabled 开关的组件,MonoBehaviour 则是用户脚本基类。
public sealed partial class GameObject : Object { }
public partial class Component : Object { }
public class Behaviour : Component { }
public class MonoBehaviour : Behaviour { }
Unity 的设计哲学是"组合能力优先",但不是完全放弃继承------继承表达类型关系,组件组合表达对象拥有什么能力。
对象激活通常同时受自身、父级和脚本开关影响。activeSelf 只看自身,activeInHierarchy 还看父级;脚本一般需要对象在层级中激活且 enabled == true 才会正常执行。常见生命周期顺序是 Awake -> OnEnable -> Start -> Update,其中 Awake、Start 通常一次执行,OnEnable 可重复执行,Update 在满足运行条件时逐帧执行。
七、Scene、资源与数据驱动
Scene 是包含 GameObject 层级、资源引用和运行时状态的复杂引擎容器。Single 模式替换场景,Additive 模式追加场景,异步加载则由引擎跨帧推进。
资源管理方面,Resources 简单但缺少精细管理;AssetBundle 提供打包和按包加载,控制力强但需要自行处理依赖与释放;Addressables 在 AssetBundle 之上提供地址、依赖、异步加载和句柄释放管理。
ScriptableObject 是不依附 GameObject 的可序列化数据资产。定义类型后可创建 .asset 实例,多个对象可以引用同一份配置------角色定义、技能数值、物品配置等静态数据放在 ScriptableObject 中,MonoBehaviour 读取配置并执行移动、战斗、交互等运行时行为。这体现了数据驱动设计:静态定义与运行时行为分离,数据可以复用;共享资产在运行时被修改时,所有引用者都可能看到变化。
名称说明:
SO是ScriptableObject的简称,不是文件后缀。Unity 中它的资产实例通常使用.asset后缀;.so通常指 Linux 下的原生共享库(Shared Object),与 ScriptableObject 无关。
八、C# 源码组织与元数据
partial 只在编译期把多个文件合并为一个类型,常用于分离绑定声明、正常 API 和废弃 API,它本身不负责废弃机制。真正标记废弃的是 Obsolete 特性:
[Obsolete("Use NewMethod instead", false)]
public void OldMethod() { }
Obsolete 是 Attribute 而非关键字,false 产生警告,true 产生编译错误。保留旧 API 可以避免旧项目直接编译失败。
Property 是带访问逻辑的读写入口,Field 是直接数据成员;对外接口常用 Property,内部序列化和简单缓存常用 Field。Attribute 则是附加在代码元素上的元数据,编译器把 C# 源码编译为 IL 时,也会把类型、方法签名、继承关系和 Attribute 写入程序集元数据,反射通过 Type、MethodInfo 等 API 读取这些信息。
C# 源码 -> 编译器 -> IL + 元数据 -> CLR/Unity -> Reflection API
CLR 是 Common Language Runtime(公共语言运行时)。程序集中的元数据可以理解为一组结构化表,记录类型、方法、字段、继承关系和 Attribute 等信息。程序集加载后,typeof、GetType、GetMethod 等 Reflection API 会查询这些元数据表并返回描述对象;反射不是重新读取 .cs 源码。
九、运算符、遍历与协程
Unity 重载了 Object 的比较和布尔转换,因此 if (gameObject) 可能检查的是原生实体是否有效,而不只是 C# 引用是否为空。
foreach 依赖 GetEnumerator()、Current 和 MoveNext()。Unity 的 Transform 遍历器通过 childCount 和 GetChild(index) 访问原生层级,隐藏内部存储结构:
public IEnumerator GetEnumerator() => new Transform.Enumerator(this);
协程同样使用 IEnumerator。编译器把包含 yield 的方法生成状态机,Unity 的调度器每帧调用 MoveNext(),根据 Current 判断等待条件。一句话概括:foreach 用迭代器遍历数据,协程用迭代器把一段逻辑拆到多帧执行。
协程中的状态机与迭代器
两者分工不同。状态机保存"执行到哪里"和局部变量;IEnumerator 暴露 MoveNext()、Current,让外部逐步推进状态机;Unity 调度器每帧调用 MoveNext(),决定何时再次推进。
IEnumerator WaitAndPrint()
{
Debug.Log("A");
yield return new WaitForSeconds(1);
Debug.Log("B");
}
编译器在编译阶段就会把整个协程方法改造成一个隐藏状态机,并不会等运行到 yield 才临时生成。状态机通常按"可暂停位置"划分状态,而不是每一行代码一个状态。第一次 MoveNext() 执行 A 并暂停,Current 暴露等待对象;等待条件满足后,Unity 再次调用 MoveNext(),状态机从暂停位置继续并执行 B。因此,迭代器是"推进接口",状态机是"保存执行状态的实现"。
十、Delegate、Action、Func 与 event
delegate 不是修饰函数,而是定义一种"方法签名类型":
delegate void DamageHandler(int amount);
DamageHandler handler = TakeDamage;
Action 和 Func 是 .NET 预定义的委托类型,Action<T> 无返回值,Func<T1, TResult> 的最后一个泛型参数是返回值:
Action<int> log = value => Debug.Log(value);
Func<int, int> square = value => value * value;
event 是对委托的访问限制:外部只能 += 订阅和 -= 退订,只有声明类内部可以触发事件。
public event Action<int> Damaged;
private void Hit(int value)
{
Damaged?.Invoke(value);
}
它们底层都保存方法入口,可以绑定实例方法、静态方法或 lambda,并支持多播调用链。若把委托直接作为 public 字段暴露,外部既能调用,也能用 = 覆盖整个调用列表;event 保留同样的订阅能力,但外部只能使用 += 和 -=,不能覆盖或触发它。Action 和 Func 的主要区别是返回值约定,不是"比 delegate 更安全"------它们只是 .NET 预定义的泛型委托类型,省去了重复声明自定义 delegate 的工作。
十一、Lambda 与匿名函数
Lambda 是创建委托实例的简洁语法,本质仍然是一个可以被调用的方法:
Action<int> print = value => Debug.Log(value);
Func<int, int> square = value => value * value;
等价的普通方法写法是定义一个 Square 方法再赋值给 Func<int, int>。Lambda 可以捕获外部变量,此时编译器会生成一个隐藏对象保存这些变量,这种对象称为闭包:
int offset = 10;
Func<int, int> addOffset = value => value + offset;
Unity 中常见的事件注册、排序、集合筛选和异步回调都大量使用 Lambda:
button.onClick.AddListener(() => OpenPanel("Inventory"));
需要注意的是,Lambda 捕获的是变量,而不一定是当时变量的值;循环中创建回调时尤其要注意闭包变量的生命周期和最终取值。
十二、async/await 与 Unity Awaitable
async/await 也是"暂停后继续",但它不是每帧主动轮询协程,而是等待一个可等待对象完成:
async Awaitable LoadScene()
{
await SceneManager.LoadSceneAsync("Battle");
Debug.Log("加载完成");
}
编译器同样会把 async 方法改造成状态机。遇到 await 时,如果等待对象尚未完成,方法保存状态并返回;对象完成后,awaiter 触发 continuation,状态机从暂停位置继续。
一个类型只要提供 GetAwaiter(),并且返回的 awaiter 具备 IsCompleted、OnCompleted/UnsafeOnCompleted 和 GetResult(),就可以被 await:
public Awaiter GetAwaiter() => new Awaiter(this);
Unity 的 Awaitable 是专门适配引擎异步操作的类型,源码位于 Runtime/Export/Scripting/Awaitable*.cs。它通过绑定特性把原生异步操作的完成状态、异常和取消传回 C#,并使用 AsyncMethodBuilder 与编译器生成的状态机协作。
协程和 async/await 的核心区别在于:
| 维度 | 协程 | async/await |
|---|---|---|
| 核心机制 | IEnumerator + Unity 调度器 + yield | Awaiter + continuation |
| 推进方式 | 每帧主动调用 MoveNext() | 等待对象完成后触发回调 |
| 典型场景 | 按帧推进逻辑、延时、序列动画 | 等待异步操作完成(加载、IO、网络) |
| 异常处理 | 较难在协程内统一捕获 | 可使用 try/catch 自然处理 |
Awaitable 通常面向 Unity 主线程和原生异步操作;具体是否切换线程,要看等待对象和 API 的实现,不能把 async 自动等同于后台线程。
十三、Task、线程池与 Unity 主线程
Task 是 .NET 的通用任务抽象,await 只暂停当前异步方法,不自动创建线程;Task.Run 通常会把工作提交给线程池:
var result = await Task.Run(() => PureCSharpCalculation());
线程池适合纯 C# 计算、数据解析和部分 I/O,不适合直接操作 GameObject、Transform 等 Unity 对象。常见结构是后台线程计算数据,完成后回到 Unity 主线程修改场景对象。Task 偏向通用异步和并发,Job System 偏向 Unity 数据的批量并行计算。异步不等于多线程,并发也不必然等于并行。
十四、Job System:IJob、并行 Job 与依赖
IJob 表示一次执行的任务:
public struct AddJob : IJob
{
public NativeArray<int> result;
public void Execute()
{
result[0] = 1 + 2;
}
}
IJobParallelFor 表示对一组索引执行相同逻辑:
public struct MoveJob : IJobParallelFor
{
public NativeArray<Vector3> positions;
public void Execute(int index)
{
positions[index] += Vector3.forward;
}
}
调度并行 Job 时,第二个参数 64 是批次大小,不是线程数量,Unity 会根据工作线程和负载分配这些索引:
JobHandle handle = job.Schedule(positions.Length, 64);
handle.Complete();
Job 可以声明依赖,second 必须等待 first 完成:
JobHandle first = firstJob.Schedule();
JobHandle second = secondJob.Schedule(first);
second.Complete();
JobHandle 是任务依赖和完成状态的句柄,不是线程本身。Job 尽量只处理 NativeArray 等原生容器中的值类型数据,计算结果由主线程应用回 GameObject 和 Transform。
NativeArray 与 AtomicSafetyHandle
NativeArray<T> 内部保存原生缓冲区、长度、分配器和安全句柄:
internal void* m_Buffer;
internal int m_Length;
internal AtomicSafetyHandle m_Safety;
internal Allocator m_AllocatorLabel;
创建时通过 UnsafeUtility.MallocTracked 分配原生内存,并创建 AtomicSafetyHandle。数组读写会调用 CheckReadAndThrow 或 CheckWriteAndThrow,释放时检查句柄和正在运行的 Job,避免提前释放仍被使用的内存。
AtomicSafetyHandle 是 Job System 的安全检查机制,不是锁。它记录某块 NativeContainer 的读、写、释放状态,并检查重复释放以及 Job 尚未完成时的读写冲突。相关检查通常受 ENABLE_UNITY_COLLECTIONS_CHECKS 控制,发布版本可以关闭部分检查以减少开销。
先更新到这里,很快更新完毕,其实目前的内容显然以及超出了这份源码本身,但是能多了解一点是一点。