Skip to content

图形 / 引擎必背

点乘和叉乘

math-dot-cross-product

一句话理解

点乘回答:两个向量“方向像不像”。 叉乘回答:两个向量“往哪边转”,以及它们张开的面积和垂直方向。

点乘是什么

公式:

c
A · B = Ax * Bx + Ay * By + Az * Bz
A · B = |A| * |B| * cosθ

如果 AB 都是单位向量,那么:

A · B = cosθ

所以点乘结果可以判断角度关系:

dot > 0:夹角小于 90°,大概同方向
dot = 0:夹角等于 90°,互相垂直
dot < 0:夹角大于 90°,大概反方向

游戏里常用点乘做:

判断敌人是否在前方
判断目标是否在视野扇形内
计算光照里的漫反射强度
计算一个向量在另一个方向上的投影

叉乘是什么

公式含义:

A × B 得到一个新向量
这个新向量垂直于 A 和 B 所在平面
|A × B| = |A| * |B| * sinθ

叉乘可以判断“左右侧”。 在普通 2D 坐标里:

c
crossZ = Ax * By - Ay * Bx
crossZ > 0:B 在 A 的左侧
crossZ < 0:B 在 A 的右侧

在 Unity 的 XZ 地面平面里,经常看 Vector3.Cross(forward, dir).y。 注意:左右符号和坐标系、叉乘顺序有关,顺序反过来符号也会反。

Unity C# 示例

c
using UnityEngine; // 引入 UnityEngine,使用 Transform、Vector3、Mathf 和 MonoBehaviour
public sealed class DotCrossDemo : MonoBehaviour // 定义点乘和叉乘演示脚本
{ // 类开始
    public Transform target; // 目标对象,用来判断它在不在视野内以及在左边还是右边
    public float viewAngle = 60f; // 角色视野角度,例如 60 度
    private void Update() // Unity 每帧调用 Update
    { // Update 开始
        if (target == null) // 如果没有目标对象
        { // if 开始
            return; // 直接返回,避免空引用
        } // if 结束
        Vector3 toTarget = target.position - transform.position; // 计算从自己指向目标的向量
        Vector3 dir = toTarget.normalized; // 把方向向量归一化,方便点乘直接表示 cos 值
        float dot = Vector3.Dot(transform.forward, dir); // 点乘:判断目标方向和自己正前方有多接近
        float halfAngle = viewAngle * 0.5f; // 视野半角,因为左右各占一半
        float limit = Mathf.Cos(halfAngle * Mathf.Deg2Rad); // 把半角转成 cos 阈值
        bool inView = dot >= limit; // dot 大于阈值,说明目标在视野扇形内
        Vector3 cross = Vector3.Cross(transform.forward, dir); // 叉乘:判断目标在自己左侧还是右侧
        bool targetOnRight = cross.y > 0f; // 在 Unity XZ 平面中,这个顺序下 y 大于 0 通常表示目标在右侧
        Debug.Log($"inView = {inView}, targetOnRight = {targetOnRight}"); // 输出判断结果
    } // Update 结束
} // 类结束

面试高分回答

NOTE

点乘得到标量,核心看 cosθ,适合判断两个方向夹角、前后关系、视野范围和投影。叉乘得到向量,核心看垂直方向和 sinθ,适合判断左右、旋转方向、面积和法线。游戏开发里,点乘常用于视野检测和光照,叉乘常用于转向、法线计算、三角形朝向判断。

MVP 矩阵

graphics-mvp-matrix

标准答案

MVP 矩阵就是 Model * View * Projection 三个变换的组合,用来把模型顶点从本地空间变换到裁剪空间。

更准确地说:

c
clipPos = Projection * View * Model * localPos

它对应的空间变化是:

模型本地空间 -> 世界空间 -> 相机空间 -> 裁剪空间

三个矩阵分别做什么

Model 矩阵:把模型从自己的本地坐标变到世界坐标。 比如一个角色模型的顶点原本在模型内部坐标系里,通过 Model 矩阵后,才知道它在场景里的位置、旋转和缩放。

View 矩阵:把世界坐标变到相机坐标。 可以理解成:世界不动,但从相机的角度重新描述所有物体的位置。它通常和相机 Transform 的逆矩阵有关。

Projection 矩阵:把相机空间变到裁剪空间。 透视投影会产生近大远小,正交投影不会近大远小。视野角、宽高比、近裁剪面、远裁剪面都会影响投影矩阵。

底层流程

顶点先经过 MVP 变成裁剪空间坐标:

c
clipPos = MVP * vertex

然后 GPU 会继续做:

透视除法:NDC = clipPos.xyz / clipPos.w
视口映射:NDC -> 屏幕像素坐标
光栅化:三角形 -> 像素片元

所以 MVP 主要发生在顶点阶段,它决定一个顶点最终会被投到屏幕哪里。

Unity Shader 示例

c
struct appdata // 定义顶点输入结构
{ // 结构体开始
    float4 vertex : POSITION; // 模型本地空间下的顶点坐标
}; // 结构体结束
struct v2f // 定义顶点着色器输出结构
{ // 结构体开始
    float4 positionCS : SV_POSITION; // 裁剪空间坐标,给 GPU 后续光栅化使用
}; // 结构体结束
v2f vert(appdata v) // 定义顶点着色器函数
{ // 函数开始
    v2f o; // 创建输出变量
    o.positionCS = UnityObjectToClipPos(v.vertex); // Unity 内部把对象空间顶点乘到裁剪空间
    return o; // 返回顶点着色器输出
} // 函数结束

面试高分回答

NOTE

MVP 的核心作用是完成顶点坐标的空间变换。M 把模型放进世界,V 把世界转换到相机视角,P 把相机看到的空间投影到裁剪空间。Unity 里我们通常不会手写完整 MVP,而是用 UnityObjectToClipPos 或 URP 的 TransformObjectToHClip。面试时要注意补一句:MVP 之后还不是屏幕坐标,还要经过透视除法和视口映射。

坐标空间转换

graphics-coordinate-space-conversion

标准答案

坐标空间转换,本质是把“同一个点或方向”,换一个参照系来描述。

比如一个角色手上武器的挂点,在武器模型里可能是:

c
local = (0, 1, 0)

但放到场景里以后,它就会变成世界坐标:

c
world = (10, 2, 5)

完整渲染链路通常是:

c
Local/Object Space -> World Space -> View Space -> Clip Space -> NDC -> Screen Space

底层原理

Local Space 是物体自己的坐标系。 World Space 是整个场景的统一坐标系。 View Space 是以相机为原点的坐标系。 Clip Space 是投影矩阵变换后的裁剪空间。 NDC 是透视除法之后的标准化设备坐标。 Screen Space 是最终屏幕像素坐标。

从矩阵角度看:

c
world = ModelMatrix * local
view = ViewMatrix * world
clip = ProjectionMatrix * view

也就是常说的:

c
clip = P * V * M * local

Unity 工程实践

Unity 里最常用的是这几类:

c
transform.TransformPoint(localPoint)
本地坐标 -> 世界坐标

transform.InverseTransformPoint(worldPoint)
世界坐标 -> 本地坐标

camera.WorldToScreenPoint(worldPoint)
世界坐标 -> 屏幕坐标

camera.ScreenPointToRay(Input.mousePosition)
屏幕点击 -> 世界射线

这里有个很重要的坑: 点和方向不一样。

点有位置,所以会受位移、旋转、缩放影响:

c
TransformPoint

方向没有位置,不应该受位移影响:

c
TransformDirection

代码示例

c
using UnityEngine; // 引入 UnityEngine,使用 Transform、Camera、Vector3、RaycastHit 等类型
public sealed class CoordinateSpaceDemo : MonoBehaviour // 定义坐标空间转换演示脚本
{ // 类开始
    public Transform target; // 目标物体,用来演示世界坐标和本地坐标转换
    public Camera mainCamera; // 摄像机,用来演示世界坐标和屏幕坐标转换
    public Vector3 localOffset = new Vector3(0f, 1f, 0f); // 定义一个本地空间偏移点
    private void Update() // Unity 每帧调用 Update
    { // Update 开始
        if (mainCamera == null || target == null) return; // 如果摄像机或目标为空,就直接返回
        Vector3 worldPoint = transform.TransformPoint(localOffset); // 把本地坐标点转换成世界坐标点
        Vector3 localPoint = transform.InverseTransformPoint(target.position); // 把目标世界坐标转换成当前物体的本地坐标
        Vector3 worldDir = transform.TransformDirection(Vector3.forward); // 把本地方向转换成世界方向,不受位移影响
        Vector3 screenPoint = mainCamera.WorldToScreenPoint(target.position); // 把目标世界坐标转换成屏幕像素坐标
        bool behindCamera = screenPoint.z < 0f; // 判断目标是否在相机背后
        Debug.Log($"worldPoint = {worldPoint}"); // 输出本地坐标转世界坐标的结果
        Debug.Log($"localPoint = {localPoint}"); // 输出世界坐标转本地坐标的结果
        Debug.Log($"worldDir = {worldDir}"); // 输出本地方向转世界方向的结果
        Debug.Log($"screenPoint = {screenPoint}, behind = {behindCamera}"); // 输出屏幕坐标和是否在相机背后
        if (Input.GetMouseButtonDown(0)) // 如果鼠标左键按下
        { // if 开始
            Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition); // 把屏幕点击位置转换成一条世界射线
            if (Physics.Raycast(ray, out RaycastHit hit, 1000f)) // 用射线检测世界中的碰撞点
            { // if 开始
                Debug.Log($"click world position = {hit.point}"); // 输出点击到的世界坐标
            } // if 结束
        } // if 结束
    } // Update 结束
} // 类结束

常见追问

WorldToScreenPoint 的返回值里,xy 是屏幕像素坐标,z 是点到相机的深度。如果 z < 0,说明点在相机背后,UI 跟随时要特殊处理。

ScreenToWorldPoint 不能只给鼠标 x/y 就完事,因为一个屏幕点对应的是一条射线,不是唯一世界点。必须提供深度,或者用 ScreenPointToRay 打到地面、碰撞体上。

面试高分回答

TIP

坐标空间转换的核心是参照系变化。游戏逻辑里常做 Local 和 World 的互转,比如挂点、子弹发射点、角色相对位置;UI 表现里常做 World 到 Screen,比如血条跟随;点击地面移动一般是 Screen 到 Ray,再 Raycast 到世界。渲染管线里则是 Object 到 World、View、Clip,再经过透视除法和视口映射到屏幕。关键坑点是点和方向不能混用,TransformPoint 会受位移影响,TransformDirection 不受位移影响。

深度测试

graphics-depth-test

标准答案

深度测试就是 GPU 在画一个片元之前,先看它离相机远不远。如果当前片元比深度缓冲里记录的片元更近,就允许它显示;如果更远,就丢弃它。

简单说就是:同一个屏幕像素上,谁离相机近,谁挡住谁。

底层原理

渲染时 GPU 不只是写颜色,还会维护一张深度缓冲,也叫 Depth Buffer / Z Buffer。

流程大概是:

  1. 顶点经过 MVP 变换后进入裁剪空间。
  2. 光栅化后生成片元。
  3. 每个片元都有一个深度值。
  4. GPU 拿这个深度值和深度缓冲里的旧值比较。
  5. 如果通过 ZTest,才可能写入颜色缓冲。
  6. 如果 ZWrite On,还会把新的深度写回深度缓冲。

常见默认规则是:

c
ZTest LEqual

意思是:新片元深度小于等于旧深度,说明更近或一样近,可以通过。

Unity 工程实践

不透明物体通常:

c
ZTest LEqual
ZWrite On

这样前面的物体会挡住后面的物体,而且可以利用 Early-Z 提前丢弃看不见的片元,减少片元着色器开销。

透明物体通常:

c
ZTest LEqual
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha

因为透明物体需要看到后面的颜色,如果它写入深度,就可能把后面的透明物体错误挡掉。所以透明物体一般关闭深度写入,但仍然保留深度测试。

Shader 示例

c
Shader "Interview/DepthTestDemo" // 定义一个用于演示深度测试的 Shader
{ // Shader 开始
    SubShader // SubShader 开始
    { // SubShader 内容开始
        Tags { "Queue"="Geometry" } // 放在不透明队列中渲染
        Pass // 定义一个渲染 Pass
        { // Pass 内容开始
            ZTest LEqual // 当前片元深度小于等于已有深度时通过测试
            ZWrite On // 通过深度测试后把深度写入深度缓冲
            ColorMask RGBA // 允许写入颜色缓冲的 RGBA 通道
        } // Pass 内容结束
    } // SubShader 内容结束
} // Shader 结束

面试高分回答

IMPORTANT

我理解深度测试的核心是解决遮挡关系。GPU 会为屏幕上的每个像素维护一个深度值,新片元来的时候用 ZTest 和旧深度比较,通过后才写颜色;如果 ZWrite 开启,还会更新深度缓冲。项目里不透明物体一般开 ZWrite,透明物体一般关 ZWrite 并放到透明队列排序,否则容易出现透明遮挡错误。另外深度测试还能配合 Early-Z 减少无效片元计算,是移动端优化里很重要的一环。

Alpha Blend 和 Alpha Test

graphics-alpha-blend-test

标准答案

Alpha Test 是透明裁剪,也叫 Cutout。它不是半透明,而是根据 alpha 值和阈值判断:够大就显示,不够就直接丢弃。结果只有两种:画,或者不画。

Alpha Blend 是透明混合。它会把当前物体颜色和背景颜色按 alpha 比例混在一起,所以能做玻璃、烟雾、水、UI 渐隐这种真正的半透明效果。

一句话记忆:Alpha Test 是“裁掉”,Alpha Blend 是“混合”。

底层原理

Alpha Test 常见逻辑是:

c
if alpha < cutoff:
    discard
else:
    draw

在 Shader 里通常用 clip()。被 clip 掉的片元不会写颜色,也不会写深度;没被裁掉的部分可以像不透明物体一样参与深度写入。

Alpha Blend 常见公式是:

最终颜色 = 前景颜色 * alpha + 背景颜色 * (1 - alpha)

比如一个蓝色玻璃 alpha 是 0.3,它不是直接盖住后面的东西,而是把自己的颜色和后面的颜色混起来。

Unity 工程实践

Alpha Test 适合树叶、草、栅栏、破洞布料这种“有镂空,但不是半透明”的东西。它通常可以放在 AlphaTest 或不透明相关队列里,并且可以 ZWrite On,所以遮挡关系比较稳定。

Alpha Blend 适合玻璃、烟雾、特效、UI 半透明、水面等。它通常放在 Transparent 队列,常见设置是 ZWrite Off,否则透明物体会把后面的透明物体错误挡住。但因为它不写深度,所以很依赖排序,一般要从远到近渲染。

代码示例

c
float alpha = tex2D(_MainTex, i.uv).a; // 从贴图中采样 alpha 通道
clip(alpha - _Cutoff); // 如果 alpha 小于阈值,就丢弃当前片元
float4 color = tex2D(_MainTex, i.uv); // 采样当前像素的颜色
return color; // 返回颜色,表示通过裁剪的部分正常显示
Tags { "Queue"="Transparent" } // 把物体放入透明渲染队列
ZWrite Off // 关闭深度写入,避免透明物体错误挡住后面的透明物体
ZTest LEqual // 保留深度测试,让被不透明物体挡住的透明片元不显示
Blend SrcAlpha OneMinusSrcAlpha // 使用常见 alpha 混合公式

面试高分回答

NOTE

我会这样答:Alpha Test 本质是根据 alpha 阈值做片元裁剪,适合草、树叶、铁丝网这种镂空材质;它不是半透明,通过的部分通常还能写深度。Alpha Blend 才是真正的半透明,它会把前景和背景按 alpha 混合,适合玻璃、烟雾和 UI。但 Blend 通常关闭 ZWrite,需要透明排序,而且 Overdraw 会比较重。项目里我会根据需求选择:需要硬边镂空用 Alpha Test,需要柔和透明用 Alpha Blend。

Forward 和 Deferred

graphics-forward-deferred

标准答案

Forward Rendering 是物体在渲染时直接计算光照,然后输出最终颜色。可以理解成:每画一个物体,就把灯光一起算完。

Deferred Rendering 是先不急着算最终光照,而是先把每个像素的基础信息写到 GBuffer,比如颜色、法线、深度、金属度、粗糙度等,然后再用这些屏幕空间数据统一计算光照。

一句话记忆:Forward 是“边画物体边算光”,Deferred 是“先存材质信息,再统一算光”。

底层原理

Forward 的流程大概是:

物体 -> 顶点着色器 -> 片元着色器 -> 计算光照 -> 输出颜色

如果场景里有很多灯光,同一个物体可能要被多个光源影响。这样每个物体、每盏灯都可能增加计算量,所以 Forward 在大量动态光源场景下压力会变大。

Deferred 的流程大概是:

几何阶段 -> 写 GBuffer -> 光照阶段 -> 输出最终颜色

它先把屏幕上每个像素的信息存起来,之后光照阶段只针对屏幕像素算光。这样大量动态光源时通常更划算。

Unity 工程实践

Forward 更适合:

  • 灯光数量不多的场景。
  • 移动端项目。
  • 透明物体较多的场景。
  • 需要 MSAA 的场景。
  • 简单、稳定、兼容性优先的项目。

Deferred 更适合:

  • 大量动态光源。
  • 复杂室内灯光。
  • PC、主机或高性能设备。
  • 不透明物体为主的场景。

但 Deferred 有明显代价:GBuffer 会占显存和带宽,移动端可能比较贵;而且透明物体通常不能很好地走 Deferred 光照,很多透明物体还是要走 Forward。

代码示例

下面这个只适合 Built-in Render Pipeline 演示。URP/HDRP 通常是在管线资源或 Renderer 设置里配置。

c
using UnityEngine; // 引入 UnityEngine 命名空间
public class RenderingPathSetter : MonoBehaviour // 定义一个脚本,用来演示 Built-in 管线下切换渲染路径
{ // 类开始
    [SerializeField] private Camera targetCamera; // 在 Inspector 中指定要修改的相机
    [SerializeField] private bool useDeferred; // 是否使用 Deferred 渲染路径
    private void Awake() // Awake 会在脚本初始化时调用
    { // 方法开始
        if (targetCamera == null) // 如果没有手动绑定相机
        { // if 代码块开始
            targetCamera = Camera.main; // 尝试获取场景中的 Main Camera
        } // if 代码块结束
        targetCamera.renderingPath = useDeferred ? RenderingPath.DeferredShading : RenderingPath.Forward; // 根据开关设置 Forward 或 Deferred
    } // 方法结束
} // 类结束

面试高分回答

IMPORTANT

我会这样答:Forward 是每个物体渲染时直接算光,流程简单,透明和 MSAA 友好,适合移动端和灯光较少的场景。Deferred 是先写 GBuffer,再统一做光照,它在大量动态光源场景下更有优势,但会增加显存和带宽压力,而且透明物体处理麻烦。所以不能简单说 Deferred 一定更快,要看平台、光源数量、透明物体比例和带宽压力。

法线贴图

graphics-normal-map-principle

标准答案

法线贴图 Normal Map 是一张特殊贴图,它用 RGB 颜色保存“每个像素的法线方向”。它不会真的改变模型形状,也不会改变碰撞体和轮廓,只是让 Shader 在计算光照时使用贴图里的法线,所以低面数模型也能看起来有凹凸细节。

一句话:法线贴图是“用贴图骗光照”,让平面看起来像有细节。

底层原理

普通模型每个顶点有法线,光照插值后,一个大平面看起来比较平。法线贴图把法线精度提升到“像素级”:每个片元采样一次法线贴图,然后用采样出来的法线去做 N dot L 光照计算。

RGB 颜色本来是 0 到 1,但法线方向需要 -1 到 1,所以 Shader 会做一次解码:

normal = rgb * 2 - 1

法线贴图通常偏蓝,是因为默认切线空间法线大概是:

(0, 0, 1)

编码成颜色后就是:

(0.5, 0.5, 1)

蓝色通道很高,所以看起来偏蓝紫色。

Unity 工程实践

Unity 里法线贴图通常要把 Texture Type 设置成 Normal map,否则 Unity 会把它当普通颜色贴图处理,采样出来的方向可能不对。材质里把它放进 Normal Map 槽,Shader 再通过 UnpackNormal 还原法线。

项目里常见用途是:砖墙、石头、皮肤纹理、角色装备、地面细节。它的好处是不用增加大量顶点,也能让光照产生细节;代价是多一次贴图采样、多一张贴图内存,还需要模型有正确的 Tangent 数据。

常见坑:绿通道方向反了会导致凹凸反过来;模型切线不对会出现接缝;法线贴图不会改变轮廓,所以侧面看还是平的。

代码示例

c
Shader "Interview/SimpleNormalMap" // 定义一个演示法线贴图的 Shader
{ // Shader 开始
    Properties // 定义材质面板属性
    { // 属性块开始
        _MainTex ("Albedo", 2D) = "white" {} // 主贴图,用来提供基础颜色
        _BumpMap ("Normal Map", 2D) = "bump" {} // 法线贴图,用来提供像素级法线
    } // 属性块结束
    SubShader // SubShader 开始
    { // SubShader 内容开始
        Tags { "RenderType"="Opaque" } // 声明这是不透明物体
        Pass // Pass 开始
        { // Pass 内容开始
            CGPROGRAM // 开始写 CG/HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数
            #pragma fragment frag // 指定片元着色器函数
            #include "UnityCG.cginc" // 引入 Unity 常用工具函数
            #include "Lighting.cginc" // 引入 Unity 光照相关变量
            sampler2D _MainTex; // 声明主贴图采样器
            sampler2D _BumpMap; // 声明法线贴图采样器
            float4 _MainTex_ST; // 声明主贴图的 Tiling 和 Offset
            struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; float4 tangent : TANGENT; }; // 顶点输入包含位置、UV、法线、切线
            struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 n : TEXCOORD1; float3 t : TEXCOORD2; float3 b : TEXCOORD3; }; // 顶点输出包含裁剪坐标、UV、TBN 三个方向
            v2f vert(appdata v) // 顶点着色器开始
            { // 顶点函数体开始
                v2f o; // 创建输出结构体
                o.pos = UnityObjectToClipPos(v.vertex); // 把模型顶点从对象空间变到裁剪空间
                o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 应用主贴图的 Tiling 和 Offset
                o.n = UnityObjectToWorldNormal(v.normal); // 把模型法线转到世界空间
                o.t = UnityObjectToWorldDir(v.tangent.xyz); // 把模型切线转到世界空间
                o.b = cross(o.n, o.t) * v.tangent.w; // 用法线和切线叉乘得到副切线
                return o; // 返回顶点输出
            } // 顶点函数体结束
            fixed4 frag(v2f i) : SV_Target // 片元着色器开始
            { // 片元函数体开始
                float3 tn = UnpackNormal(tex2D(_BumpMap, i.uv)); // 采样并解码切线空间法线
                float3 n = normalize(tn.x * normalize(i.t) + tn.y * normalize(i.b) + tn.z * normalize(i.n)); // 用 TBN 把法线转到世界空间
                float3 l = normalize(_WorldSpaceLightPos0.xyz); // 获取主方向光方向
                float ndotl = saturate(dot(n, l)); // 用新法线和光照方向做点乘
                fixed4 albedo = tex2D(_MainTex, i.uv); // 采样基础颜色
                return fixed4(albedo.rgb * ndotl, albedo.a); // 返回带法线贴图光照效果的颜色
            } // 片元函数体结束
            ENDCG // 结束 CG/HLSL 代码
        } // Pass 内容结束
    } // SubShader 内容结束
} // Shader 结束

面试高分回答

CAUTION

我会这样说:法线贴图本质是把高模表面的法线信息烘焙到一张 RGB 贴图里,低模渲染时每个像素从贴图里取法线,再通过 TBN 矩阵从切线空间转到世界空间参与光照。它不改变几何、碰撞和轮廓,只改变光照结果,所以能用较低面数表现丰富细节。项目里要注意贴图导入类型、切线数据、绿通道方向、压缩格式和采样开销。

PBR

graphics-pbr-principle

标准答案

PBRPhysically Based Rendering,中文叫基于物理的渲染。它不是某一个固定 Shader,而是一套材质和光照模型,用更接近真实世界的规则来描述物体表面如何反射光。

简单说:传统材质更多靠调参“看起来像”,PBR 希望材质参数有明确物理意义,比如基础色、金属度、粗糙度、法线、AO、环境反射等。这样同一个材质放在不同光照环境下,也能保持比较稳定、可信的效果。

底层原理

PBR 常见核心有三个:

  1. 能量守恒:反射出去的光不能比打到表面的光还多。
  2. 微表面模型:物体表面不是绝对光滑,而是由很多微小面片组成。
  3. BRDF:描述光从某个方向照进来,又有多少光反射到观察方向。

常见 PBR 高光模型会拆成:

c
Specular = D * F * G / denominator

D 表示微表面法线分布,粗糙度越高,高光越散。 F 表示菲涅尔,视角越贴近表面,反射越强。 G 表示几何遮蔽,模拟微表面之间互相遮挡。

Unity 工程实践

Unity 里常见的是 Metallic / Smoothness 工作流。注意 Unity 里很多地方叫 Smoothness,它和 Roughness 是反着来的:

c
Smoothness = 1 - Roughness

金属材质通常反射强,反射颜色带有金属本身的颜色,漫反射很弱。非金属材质有明显漫反射,高光颜色通常接近白色,常见 F0 大概在 0.04 附近。

项目里 PBR 经常配合:

  • BaseMap / Albedo:基础色,不应该画死阴影和高光。
  • Metallic:控制是不是金属。
  • Smoothness / Roughness:控制高光集中还是扩散。
  • Normal Map:增加光照细节。
  • AO:压暗缝隙和遮蔽区域。
  • Reflection Probe / Skybox:提供环境反射,也就是 IBL。

简化代码思路

下面不是完整 Unity Lit Shader,只是把 PBR 里最核心的计算关系写出来:

c
float3 N = normalize(normalWS); // 归一化世界空间法线
float3 V = normalize(viewDirWS); // 归一化观察方向
float3 L = normalize(lightDirWS); // 归一化光照方向
float3 H = normalize(L + V); // 计算半角向量
float NdotL = saturate(dot(N, L)); // 计算法线和光照方向夹角
float NdotV = saturate(dot(N, V)); // 计算法线和观察方向夹角
float NdotH = saturate(dot(N, H)); // 计算法线和半角向量夹角
float VdotH = saturate(dot(V, H)); // 计算观察方向和半角向量夹角
float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic); // 非金属用固定反射率,金属用自身颜色
float3 F = FresnelSchlick(VdotH, F0); // 计算菲涅尔反射项
float D = DistributionGGX(NdotH, roughness); // 计算微表面法线分布项
float G = GeometrySmith(NdotV, NdotL, roughness); // 计算几何遮蔽项
float3 specular = D * G * F / max(4.0 * NdotV * NdotL, 0.001); // 计算镜面反射
float3 diffuse = albedo * (1.0 - metallic) / 3.14159; // 金属减少漫反射,非金属保留漫反射
float3 color = (diffuse + specular) * lightColor * NdotL; // 合并漫反射和高光得到直接光结果

面试高分回答

CAUTION

我会这样答:PBR 是一套基于物理规律的材质和光照模型,核心是能量守恒、微表面 BRDF 和环境光照。它用 Albedo、Metallic、Roughness 或 Smoothness、Normal、AO 等参数描述材质,而不是随便调一个高光颜色。粗糙度会影响微表面分布,决定高光是集中还是发散;金属度会影响漫反射和反射颜色;IBL 和 Reflection Probe 则让环境也参与反射。项目里我会注意贴图规范、颜色空间、反射探针、Shader Variant 和移动端采样成本。

Shadow Map

graphics-shadow-map-principle

标准答案

Shadow Map 就是阴影贴图。它的核心思想是:先从光源的角度渲染一遍场景,只记录深度;然后从相机角度正常渲染时,把当前像素点再投影到光源空间,和 Shadow Map 里的深度做比较。

如果当前点比 Shadow Map 里记录的最近深度更远,说明它前面有东西挡住了光,所以这个点在阴影里。

一句话:Shadow Map 是“光源看到的深度图”,不是一张真正的阴影颜色图。

底层原理

它通常分两步:

第一步,从光源视角渲染:

光源 -> 看场景 -> 记录每个方向最近的深度 -> 得到 Shadow Map

第二步,从相机视角渲染:

相机 -> 看见某个点 -> 把点变到光源空间 -> 查 Shadow Map -> 比较深度

判断逻辑大概是:

c
currentDepth > shadowMapDepth + bias

如果成立,说明这个点比光源当时看到的最近点更远,被挡住了,所以进入阴影。

Unity 工程实践

在 Unity 里,实时阴影背后通常就是 Shadow Map。方向光一般会用正交投影生成阴影图;聚光灯类似相机透视投影;点光源要向多个方向生成阴影,常见是立方体贴图或者多次渲染,所以点光源阴影更贵。

项目里常调这些参数:

  • Shadow Distance:阴影距离越远,覆盖范围越大,但精度更差、性能更贵。
  • Shadow Resolution:分辨率越高,阴影越清晰,但显存和采样成本更高。
  • Bias / Normal Bias:用来缓解阴影痤疮,但太大会产生 Peter Panning。
  • Cascade Shadow Map:方向光常用,把视锥分段,近处高精度,远处低精度。
  • PCF:多采样附近 Shadow Map,让阴影边缘更柔和。

移动端一般会减少实时阴影,优先考虑烘焙阴影、假阴影、Blob Shadow、降低阴影距离、降低分辨率、减少投影光源数量。

伪代码理解

c
float4 lightClipPos = mul(_LightViewProj, float4(worldPos, 1.0)); // 把当前世界坐标点变换到光源裁剪空间
float3 lightNdcPos = lightClipPos.xyz / lightClipPos.w; // 做透视除法,得到光源视角下的归一化坐标
float2 shadowUV = lightNdcPos.xy * 0.5 + 0.5; // 把坐标从 -1 到 1 转成 0 到 1,用来采样阴影图
float currentDepth = lightNdcPos.z; // 当前点在光源视角下的深度
float mapDepth = SAMPLE_TEXTURE2D(_ShadowMap, sampler_ShadowMap, shadowUV).r; // 从 Shadow Map 里取出光源记录的最近深度
float bias = 0.002; // 加一个小偏移,减少深度精度误差导致的阴影痤疮
float shadow = currentDepth - bias > mapDepth ? 0.0 : 1.0; // 如果当前点更远,就认为被遮挡,阴影值为 0
float3 finalColor = baseColor * shadow; // 用阴影系数压暗最终颜色

面试高分回答

NOTE

我会这样答:Shadow Map 的本质是从光源视角生成一张深度纹理,记录光能看到的最近表面;相机渲染时把片元位置投影到光源空间,再和这张深度图比较,如果当前深度更大,就说明被别的物体挡住了光。它的优点是适合实时阴影,缺点是有分辨率和精度问题,会出现阴影痤疮、Peter Panning、锯齿和级联接缝。Unity 里优化时我会控制阴影距离、分辨率、级联数量、Bias,并且在移动端尽量用烘焙阴影或假阴影替代实时阴影。

后处理

graphics-post-processing-principle

标准答案

后处理 Post Processing,就是场景已经渲染成一张屏幕图像之后,再对这张图做二次处理。

它处理的不是某个模型,而是整张屏幕结果。比如 Bloom、颜色校正、景深、运动模糊、SSAO、抗锯齿、暗角、色调映射,都属于后处理。

一句话:后处理就是“把渲染好的画面当作一张纹理,再用 Shader 加工”。

底层原理

普通渲染流程大概是:

模型 -> 光照 -> 阴影 -> 颜色缓冲 -> 屏幕

加了后处理以后,流程变成:

模型 -> 光照 -> 阴影 -> RenderTexture -> 后处理 Shader -> 屏幕

后处理通常会做一个全屏 Pass。也就是画一个覆盖整个屏幕的三角形或四边形,然后片元着色器对每个屏幕像素采样、计算、输出新颜色。

有些效果只需要颜色图,比如灰度、调色、暗角。 有些效果还需要深度图,比如景深、雾、SSAO。 有些效果需要运动向量,比如 Motion Blur、TAA。

Unity 工程实践

Unity 里后处理通常通过 Volume 系统控制参数。URP 和 HDRP 都有自己的后处理流程,Built-in 以前常用 Post Processing Stack。

项目里常见做法是:

  • Bloom 用来让高亮区域扩散发光。
  • Color Grading 用来统一画面色调。
  • Depth of Field 用深度图模拟景深。
  • SSAO 用深度和法线估算屏幕空间遮蔽。
  • FXAA / TAA 用来做抗锯齿。
  • Tone Mapping 把 HDR 亮度压回屏幕可显示范围。

移动端要特别小心。因为后处理经常是全屏计算,分辨率越高,像素越多,带宽压力越大。Bloom、景深、SSAO、Motion Blur 这种多次采样效果尤其贵。

简单灰度后处理代码

c
float4 Frag(Varyings input) : SV_Target // 定义全屏后处理片元函数
{ // 函数体开始
    float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv); // 从屏幕颜色纹理采样当前像素
    float gray = dot(color.rgb, float3(0.299, 0.587, 0.114)); // 按人眼敏感度计算灰度值
    color.rgb = float3(gray, gray, gray); // 把 RGB 都设置成灰度值
    return color; // 输出处理后的颜色
} // 函数体结束

常见坑

后处理的顺序会影响结果,比如先 Bloom 再 Tone Mapping,和先 Tone Mapping 再 Bloom,视觉结果不一样。

临时 RenderTexture 会增加显存和峰值内存。如果频繁创建释放,还可能带来性能抖动。工程里一般要复用临时 RT,或者使用管线提供的 RT 管理方式。

UI 是否参与后处理也要注意。有些项目希望 UI 不被景深、模糊影响,就要让 UI 走 Overlay Camera 或单独渲染层级。

面试高分回答

NOTE

我会这样说:后处理是在场景渲染到颜色缓冲之后,对整张屏幕图像做全屏处理。它本质上是屏幕空间 Shader,输入通常是颜色纹理,也可能需要深度、法线、运动向量等缓冲。它能做 Bloom、SSAO、景深、色彩校正、抗锯齿等效果,但代价是全屏采样和 RenderTexture 读写,移动端尤其要关注带宽、分辨率和临时 RT 数量。

Game Loop

game-loop-principle

标准答案

Game Loop 就是游戏主循环。游戏不是执行一次就结束,而是不断重复一帧一帧的流程:处理输入、更新游戏逻辑、模拟物理、更新动画和相机、渲染画面、提交到屏幕。

一句话:Game Loop 是驱动游戏世界持续运行的“心跳”。

底层原理

一个最简单的游戏循环可以理解成:

while 游戏运行:
    计算这一帧经过了多久
    读取输入
    更新游戏状态
    模拟物理
    渲染画面
    显示到屏幕

这里最关键的是时间。因为不同机器帧率不一样,所以移动、计时、动画不能简单写死“每帧移动 1 米”,而应该乘 deltaTime

物理通常不跟渲染帧完全绑定,而是用固定时间步,比如 Unity 默认 fixedDeltaTime = 0.02,也就是一秒 50 次物理更新。

Unity 工程实践

Unity 里我们平时没有自己写 while 主循环,因为引擎内部已经有 PlayerLoop。我们的 AwakeStartUpdateFixedUpdateLateUpdate,本质上都是挂在 PlayerLoop 不同阶段被引擎调用。

常见理解是:

Update 每个渲染帧调用,适合输入、普通逻辑、非物理移动。

FixedUpdate 按固定时间步调用,适合 Rigidbody、碰撞、物理力。

LateUpdate 在 Update 之后调用,适合相机跟随、依赖角色已经更新完的位置。

渲染阶段会做可见性判断、排序、合批、提交 DrawCall,然后 GPU 渲染,最后 Present 到屏幕。

简化代码理解

c
while (isRunning) // 只要游戏还在运行,就持续执行主循环
{ // 主循环开始
    float deltaTime = GetFrameDeltaTime(); // 计算当前帧距离上一帧经过了多久
    ReadInput(); // 读取键盘、鼠标、手柄或触摸输入
    UpdateGameLogic(deltaTime); // 根据 deltaTime 更新角色、AI、技能、任务等逻辑
    accumulator += deltaTime; // 把当前帧时间累加到物理时间池里
    while (accumulator >= fixedDeltaTime) // 如果累积时间足够,就执行一次或多次固定物理步
    { // 固定物理循环开始
        FixedPhysicsStep(fixedDeltaTime); // 用固定时间步模拟碰撞、刚体和物理约束
        accumulator -= fixedDeltaTime; // 扣掉已经消耗的一次固定物理时间
    } // 固定物理循环结束
    LateUpdateCamera(); // 在逻辑更新后更新相机,避免跟随抖动
    RenderFrame(); // 收集渲染对象并提交渲染命令
    PresentFrame(); // 把最终画面显示到屏幕
} // 主循环结束

常见坑

不要把所有逻辑都塞进 Update。大量对象每帧 Update 会导致 BehaviourUpdate 占用高,项目里常用 Update Manager、分帧、事件驱动来优化。

不要在 FixedUpdate 里读瞬时输入,比如 GetKeyDown,因为物理帧和渲染帧频率不同,可能漏掉输入。通常在 Update 读输入,在 FixedUpdate 执行物理。

不要以为帧率越高逻辑就越快。正确逻辑应该基于时间推进,而不是基于帧数推进。

面试高分回答

TIP

我会这样答:Game Loop 是游戏运行的主循环,每一帧会处理输入、更新逻辑、执行固定时间步物理、更新动画相机、渲染并提交屏幕。Unity 里这个循环由引擎内部的 PlayerLoop 管理,我们写的 Update、FixedUpdate、LateUpdate 都是在不同阶段被调用。普通逻辑用 deltaTime 适配不同帧率,物理用 fixedDeltaTime 保持稳定。项目优化时我会关注每帧 Update 数量、分帧执行、主线程耗时、渲染提交和物理模拟,避免单帧时间超过预算。

ECS

architecture-ecs-principle

标准答案

ECSEntity Component System,也就是实体、组件、系统。

Entity 是实体,通常只是一个 ID,比如角色 1001、怪物 1002。 Component 是组件,只存数据,比如位置、速度、血量。 System 是系统,只写逻辑,比如移动系统批量处理所有有 Position + Velocity 的实体。

一句话:ECS 是把“对象 = 数据 + 行为”的写法拆开,变成“实体是 ID、组件是数据、系统批量处理逻辑”。

底层原理

传统 OOP 里,一个角色对象可能长这样:

c
Player
- position
- velocity
- hp
- Move()
- Attack()
- TakeDamage()

ECS 会拆成:

c
Entity 1001
PositionComponent
VelocityComponent
HealthComponent
MoveSystem
DamageSystem

这样做的好处是数据可以按组件类型集中存储,比如所有 Position 放在一块连续数组里。CPU 遍历时缓存命中率更高,也更适合 Job、多线程和 SIMD/Burst 优化。

Unity 工程实践

在 Unity 里,普通 MonoBehaviour 更像面向对象组件模式,适合业务逻辑、UI、普通玩法。Unity DOTS/ECS 更偏数据导向,适合大量实体批量更新,比如大量怪物、大量子弹、大规模寻路、大量采集单位、模拟类玩法。

但不是所有系统都要用 ECS。小项目或者对象数量不多时,强行 ECS 会增加抽象复杂度,调试也更绕。面试里最好说:我会在“大量同类对象批量更新”的地方考虑 ECS 思想,而不是全项目硬套。

简化代码

c
using System.Collections.Generic; // 引入 Dictionary,用来按实体 ID 存组件数据
public struct Entity { public int Id; } // Entity 只保存一个 ID,不保存复杂逻辑
public struct Position { public float X; public float Y; } // Position 组件只保存位置数据
public struct Velocity { public float X; public float Y; } // Velocity 组件只保存速度数据
public class World // World 负责统一管理实体和组件
{ // World 类开始
    private int nextId = 1; // 记录下一个可用实体 ID
    public Dictionary<int, Position> Positions = new Dictionary<int, Position>(); // 保存所有实体的位置组件
    public Dictionary<int, Velocity> Velocities = new Dictionary<int, Velocity>(); // 保存所有实体的速度组件
    public Entity CreateEntity() // 创建一个新实体
    { // 创建实体方法开始
        Entity entity = new Entity { Id = nextId }; // 用当前 ID 创建实体
        nextId++; // ID 自增,避免下一个实体重复
        return entity; // 返回新实体
    } // 创建实体方法结束
} // World 类结束
public class MoveSystem // MoveSystem 负责处理移动逻辑
{ // MoveSystem 类开始
    public void Update(World world, float deltaTime) // 每帧更新所有可移动实体
    { // Update 方法开始
        foreach (var pair in world.Velocities) // 遍历所有拥有速度组件的实体
        { // foreach 循环开始
            int entityId = pair.Key; // 取出当前实体 ID
            if (!world.Positions.ContainsKey(entityId)) continue; // 如果没有位置组件,就不能移动
            Position pos = world.Positions[entityId]; // 取出当前位置
            Velocity vel = pair.Value; // 取出当前速度
            pos.X += vel.X * deltaTime; // 根据速度和时间更新 X 坐标
            pos.Y += vel.Y * deltaTime; // 根据速度和时间更新 Y 坐标
            world.Positions[entityId] = pos; // 把更新后的位置写回组件表
        } // foreach 循环结束
    } // Update 方法结束
} // MoveSystem 类结束

面试高分回答

TIP

我会这样答:ECS 的核心不是简单换一种写类方式,而是数据和逻辑分离。Entity 只表示一个身份,Component 是纯数据,System 根据组件组合查询实体并批量处理。这样数据更连续,CPU 缓存更友好,也更适合多线程 Job 和 Burst 优化。Unity 项目里我不会所有地方都强行 ECS,而是会在大量同类对象、子弹、怪物 AI、模拟单位这类批处理场景使用 ECS 或借鉴 ECS 思想。

组件系统

architecture-component-system-principle

标准答案

组件系统是一种用“组合”来组织游戏对象的架构。对象本身不做成一个巨大继承类,而是作为一个容器,通过挂不同组件获得不同能力。

比如一个玩家对象可以挂:

c
Transform
MoveComponent
HealthComponent
AnimatorComponent
ColliderComponent
AttackComponent

这样它就拥有位置、移动、血量、动画、碰撞、攻击等能力。

一句话:组件系统就是“对象是容器,组件是能力模块”。

底层原理

传统继承可能会变成:

c
Character
Enemy
BossEnemy
FlyingBossEnemy
FireFlyingBossEnemy

继承层级越来越深,很难复用。组件系统会改成:

c
GameObject + FlyComponent + FireAttackComponent + HealthComponent

这样一个对象想飞,就挂飞行组件;想攻击,就挂攻击组件;想有血量,就挂血量组件。

Unity 的 GameObject + Component 就是典型组件系统。GameObject 管理名字、层级、激活状态,Component 提供具体功能。引擎在内部 PlayerLoop 中遍历启用的组件,调用它们的生命周期函数,比如 AwakeOnEnableUpdateOnDisableOnDestroy

Unity 工程实践

组件系统最大的好处是灵活。比如同一个 HealthComponent 可以给玩家、怪物、可破坏箱子复用。MoveComponent 可以给角色,也可以给机关平台。

但组件系统也有坑:组件之间如果到处 GetComponent、互相强依赖,就会变得很乱。工程里我会尽量让组件职责单一,通过初始化注入、事件、接口或上层控制器来协调,而不是让组件互相乱找。

还要注意性能:大量对象都有 Update 会造成调度开销,项目里常用 Update Manager、事件驱动、分帧更新来优化。

简化代码

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class HealthComponent : MonoBehaviour // 定义血量组件,让对象拥有生命值能力
{ // 类开始
    [SerializeField] private int maxHp = 100; // 最大生命值,可以在 Inspector 配置
    private int currentHp; // 当前生命值,只在组件内部维护
    private void Awake() // 组件初始化时调用
    { // 方法开始
        currentHp = maxHp; // 初始化当前生命值为最大生命值
    } // 方法结束
    public void TakeDamage(int damage) // 对外提供受伤接口
    { // 方法开始
        currentHp -= damage; // 扣除伤害值
        currentHp = Mathf.Max(currentHp, 0); // 保证生命值不会低于 0
        if (currentHp == 0) // 如果生命值归零
        { // if 开始
            Die(); // 执行死亡逻辑
        } // if 结束
    } // 方法结束
    private void Die() // 处理死亡逻辑
    { // 方法开始
        gameObject.SetActive(false); // 简化处理:死亡后禁用对象
    } // 方法结束
} // 类结束
using UnityEngine; // 引入 Unity 引擎命名空间
public class MoveComponent : MonoBehaviour // 定义移动组件,让对象拥有移动能力
{ // 类开始
    [SerializeField] private float speed = 5f; // 移动速度,可以在 Inspector 配置
    private void Update() // 每帧调用,用于处理普通移动逻辑
    { // 方法开始
        float x = Input.GetAxisRaw("Horizontal"); // 读取水平输入
        float z = Input.GetAxisRaw("Vertical"); // 读取垂直输入
        Vector3 dir = new Vector3(x, 0f, z).normalized; // 把输入转换成方向向量
        transform.position += dir * speed * Time.deltaTime; // 按速度和 deltaTime 移动对象
    } // 方法结束
} // 类结束

面试高分回答

IMPORTANT

组件系统的核心是组合优于继承。GameObject 更像一个容器,具体能力由不同 Component 提供,比如移动、血量、动画、碰撞、攻击都可以拆成独立组件。Unity 的 MonoBehaviour 就是典型组件模型,引擎通过 PlayerLoop 调度组件生命周期。项目里我会让组件职责单一,避免组件之间循环依赖;大量 Update 时会用 Update Manager、事件驱动或分帧优化。它和 ECS 的区别是:普通组件通常既有数据也有行为,而 ECS 的 Component 更偏纯数据,System 批量处理逻辑。

资源管理器

architecture-resource-manager-principle

标准答案

资源管理器 ResourceManager 是客户端统一管理资源加载、缓存、依赖、引用计数和卸载的模块。业务层不应该到处直接调用 Resources.LoadAddressables.LoadAssetAsyncAssetBundle.LoadAsset,而应该统一走资源管理器。

它解决的问题是:避免重复加载、避免卸载混乱、避免资源泄漏、统一异步加载流程、统一错误处理和性能统计。

核心设计

一个比较完整的资源管理器通常包含:

  • 统一加载接口:LoadAsync<T>(key)
  • 缓存表:同一个资源只加载一份。
  • 引用计数:谁使用资源,谁增加引用;不用了就释放。
  • 依赖管理:Prefab 可能依赖材质,材质依赖贴图,不能只管主资源。
  • 异步加载:避免加载大资源时卡住主线程。
  • 卸载策略:引用为 0 后进入可卸载队列,在安全时机释放。
  • 错误处理:加载失败要有日志、兜底资源、重试或降级。
  • 统计监控:记录加载耗时、资源大小、引用状态,方便排查泄漏。

Unity 工程实践

如果是小 Demo,可以用 Resources 简化。 如果是正式项目,更常见的是 AssetBundleAddressables

Resources 的问题是:打包不可控、资源容易常驻、依赖和卸载不好管理。 AssetBundle 更底层,需要自己管理依赖、版本、Hash、卸载。 Addressables 是 Unity 封装好的资源系统,提供 key 加载、依赖管理、异步句柄和释放接口。

面试里可以说:我会让业务层只关心资源 key,不关心底层来自本地包、热更包还是远程 CDN。

简化代码

c
using System.Collections.Generic; // 引入 Dictionary,用来保存资源缓存表
using System.Threading.Tasks; // 引入 Task,用来支持 async/await 异步加载
using UnityEngine; // 引入 UnityEngine,使用 Object 和 Debug
using UnityEngine.AddressableAssets; // 引入 Addressables,用来按 key 加载资源
using UnityEngine.ResourceManagement.AsyncOperations; // 引入异步操作句柄类型
public sealed class SimpleResourceManager // 定义一个简化版资源管理器
{ // 类开始
    private sealed class Record // 定义资源缓存记录
    { // 记录类开始
        public AsyncOperationHandle Handle; // 保存 Addressables 返回的句柄,释放时要用
        public Object Asset; // 保存已经加载出来的资源对象
        public int RefCount; // 保存当前资源被多少地方引用
    } // 记录类结束
    private readonly Dictionary<string, Record> cache = new Dictionary<string, Record>(); // 用 key 保存资源缓存
    public async Task<T> LoadAsync<T>(string key) where T : Object // 定义异步加载接口
    { // 加载方法开始
        if (cache.TryGetValue(key, out Record record)) // 如果缓存里已经有这个资源
        { // 缓存命中分支开始
            record.RefCount++; // 引用计数加一
            return record.Asset as T; // 直接返回缓存资源
        } // 缓存命中分支结束
        AsyncOperationHandle<T> handle = Addressables.LoadAssetAsync<T>(key); // 通过 Addressables 异步加载资源
        await handle.Task; // 等待异步加载完成
        if (handle.Status != AsyncOperationStatus.Succeeded) // 如果加载失败
        { // 加载失败分支开始
            Addressables.Release(handle); // 释放失败的句柄,避免句柄泄漏
            Debug.LogError($"Load asset failed: {key}"); // 输出错误日志,方便定位资源问题
            return null; // 返回空,交给上层做兜底处理
        } // 加载失败分支结束
        T asset = handle.Result; // 取出加载成功的资源对象
        cache[key] = new Record { Handle = handle, Asset = asset, RefCount = 1 }; // 写入缓存,并设置引用计数为一
        return asset; // 返回加载出来的资源
    } // 加载方法结束
    public void Release(string key) // 定义释放资源接口
    { // 释放方法开始
        if (!cache.TryGetValue(key, out Record record)) // 如果缓存里没有这个资源
        { // 未找到分支开始
            Debug.LogWarning($"Release missing asset: {key}"); // 输出警告,说明释放了不存在的资源
            return; // 直接返回,避免继续执行
        } // 未找到分支结束
        record.RefCount--; // 引用计数减一
        if (record.RefCount > 0) // 如果还有其他地方在使用
        { // 仍被引用分支开始
            return; // 不真正卸载资源
        } // 仍被引用分支结束
        cache.Remove(key); // 从缓存表移除资源记录
        Addressables.Release(record.Handle); // 释放 Addressables 句柄,让底层资源可以被卸载
    } // 释放方法结束
} // 类结束

面试高分回答

NOTE

资源管理器的核心不是简单封装一个 Load,而是把资源生命周期统一收口。业务只传 key,资源管理器负责查缓存、处理依赖、异步加载、引用计数、失败兜底和释放。资源被多个系统使用时不能谁不用了就直接卸载,而是引用计数归零后再进入可卸载流程。正式项目里我会优先用 Addressables 或 AssetBundle,并配合加载耗时统计、资源引用检查、Memory Profiler 和日志,避免重复加载、峰值内存过高和资源泄漏。

场景管理

architecture-scene-manager-principle

标准答案

场景管理器 SceneManager 的作用,不只是封装一句 LoadScene,而是统一管理场景切换流程:加载界面、异步进度、资源预加载、场景激活、旧场景卸载、常驻对象、事件清理和防重复切换。

一句话:场景管理器负责让“从 A 场景到 B 场景”的过程稳定、不卡、可控、可清理。

核心流程

一次完整切场景通常是:

请求切场景
-> 防重复检查
-> 显示 Loading
-> 保存必要数据
-> 预加载资源
-> 异步加载新场景
-> 等待进度到 0.9
-> 允许激活场景
-> 设置 ActiveScene
-> 卸载旧场景
-> 清理旧资源
-> 隐藏 Loading

这里的重点是:切场景不是瞬间发生的,它是一个异步流程,期间要保证玩家体验、数据一致性和资源生命周期。

Unity 工程实践

Unity 常见有两种加载模式:

Single:加载新场景时替换旧场景,适合普通关卡切换。

Additive:新场景叠加到当前场景,适合大世界分块、常驻 UI、公共管理器场景、战斗子场景。

LoadSceneAsync 的一个经典坑是:如果 allowSceneActivation = false,加载进度通常会停在 0.9,只有设置为 true 后才会真正激活场景。所以进度条通常会把 0 到 0.9 映射成 0% 到 100%

简化代码

c
using System.Collections; // 引入 IEnumerator,用来写协程加载流程
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Debug
using UnityEngine.SceneManagement; // 引入场景管理 API
public sealed class SimpleSceneLoadManager : MonoBehaviour // 定义一个简化版场景加载管理器
{ // 类开始
    [SerializeField] private CanvasGroup loadingMask; // Loading 遮罩,用来挡住切场景过程
    private bool isLoading; // 标记当前是否正在加载,防止重复切场景
    public void LoadScene(string sceneName) // 对外提供统一切场景入口
    { // 方法开始
        if (isLoading) return; // 如果正在加载,就直接忽略重复请求
        StartCoroutine(LoadSceneRoutine(sceneName)); // 启动协程执行异步切场景流程
    } // 方法结束
    private IEnumerator LoadSceneRoutine(string sceneName) // 定义真正的异步加载协程
    { // 协程开始
        isLoading = true; // 标记进入加载状态
        SetLoadingVisible(true); // 显示 Loading 遮罩
        yield return null; // 等一帧,让 Loading UI 有机会先显示出来
        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single); // 异步加载目标场景
        operation.allowSceneActivation = false; // 先不立刻激活场景,方便控制进度条和过渡
        while (operation.progress < 0.9f) // Unity 异步加载通常会在 0.9 等待激活
        { // while 开始
            float progress = operation.progress / 0.9f; // 把 0 到 0.9 映射成 0 到 1
            Debug.Log($"Loading progress: {progress:P0}"); // 输出加载进度,正式项目可更新进度条
            yield return null; // 等待下一帧继续检查进度
        } // while 结束
        operation.allowSceneActivation = true; // 允许 Unity 激活新场景
        while (!operation.isDone) // 等待场景真正激活完成
        { // while 开始
            yield return null; // 每帧等待一次
        } // while 结束
        SetLoadingVisible(false); // 隐藏 Loading 遮罩
        isLoading = false; // 标记加载流程结束
    } // 协程结束
    private void SetLoadingVisible(bool visible) // 控制 Loading 遮罩显示隐藏
    { // 方法开始
        if (loadingMask == null) return; // 如果没有绑定遮罩,就直接返回
        loadingMask.alpha = visible ? 1f : 0f; // 根据 visible 设置透明度
        loadingMask.blocksRaycasts = visible; // 显示时拦截点击,避免玩家误操作
        loadingMask.interactable = visible; // 显示时允许遮罩参与交互状态
    } // 方法结束
} // 类结束

常见坑

不要重复创建常驻管理器。DontDestroyOnLoad 对象切场景不会销毁,如果新场景里又放了一个同名管理器,就会出现重复单例、重复事件监听、重复播放音乐。

不要忘记退订事件。旧场景对象如果订阅了全局事件但没有取消订阅,就可能导致 Missing Reference、空引用、旧逻辑被触发。

不要只卸载场景不管资源。UnloadSceneAsync 会卸载场景对象,但不代表所有资源立刻释放。资源引用还在、静态变量还引用着、Addressables 句柄没 Release,内存还是下不来。

面试高分回答

TIP

我会这样答:场景管理器的核心是把切场景流程统一收口。业务层只发起“切到哪个场景”,管理器负责防重复请求、显示 Loading、保存状态、预加载资源、异步加载场景、控制 allowSceneActivation、设置 ActiveScene、卸载旧场景、释放资源引用和清理事件。普通关卡切换用 Single,大世界或常驻 UI 可以用 Additive。项目里我会重点处理进度卡 0.9、重复常驻对象、旧事件未退订、资源没释放、黑屏时间过长这些问题。

编辑器扩展

unity-editor-extension-principle

标准答案

Unity 编辑器扩展就是在 Unity Editor 里写工具,给开发团队用,不是给玩家运行。它常用于批量处理资源、检查规范、生成配置、自定义 Inspector、做策划配置工具、构建前检查等。

一句话:编辑器扩展的价值是把重复、容易出错、依赖人工的流程自动化。

常见类型

EditorWindow:做独立工具窗口,比如资源检查工具、关卡编辑工具、配置表导入工具。

CustomEditor:重写某个组件的 Inspector,让配置更直观。

PropertyDrawer:重写某个字段或 Attribute 的显示方式,比如范围、下拉框、只读字段。

MenuItem:给 Unity 菜单栏加按钮,比如一键打包、一键生成配置。

AssetPostprocessor:资源导入时自动处理,比如贴图压缩、模型规范、音频格式检查。

底层原理

Unity 编辑器扩展运行在 Editor 进程里,依赖 UnityEditor 命名空间。它可以通过 AssetDatabase 查找和修改项目资源,通过 SerializedObjectSerializedProperty 安全编辑 Inspector 数据。

重点是:编辑器代码不能进玩家包。通常放在 Editor 文件夹,或者放到只给 Editor 使用的 Assembly Definition 里。

代码示例

c
using UnityEditor; // 引入 UnityEditor,只有编辑器代码可以使用
using UnityEngine; // 引入 UnityEngine,用来输出日志和使用基础类型
public sealed class TextureRuleWindow : EditorWindow // 定义一个贴图规则检查窗口
{ // 类开始
    private string folder = "Assets"; // 保存要检查的资源目录
    [MenuItem("Tools/Texture Rule Checker")] // 在 Unity 菜单栏添加工具入口
    private static void Open() // 定义打开窗口的方法
    { // 方法开始
        GetWindow<TextureRuleWindow>("Texture Checker"); // 创建或显示这个编辑器窗口
    } // 方法结束
    private void OnGUI() // 绘制编辑器窗口界面
    { // 方法开始
        folder = EditorGUILayout.TextField("Folder", folder); // 绘制目录输入框
        if (GUILayout.Button("Check Textures")) // 绘制检查按钮并判断是否点击
        { // if 开始
            CheckTextures(); // 点击按钮后执行贴图检查
        } // if 结束
    } // 方法结束
    private void CheckTextures() // 定义贴图检查逻辑
    { // 方法开始
        string[] guids = AssetDatabase.FindAssets("t:Texture", new[] { folder }); // 在指定目录查找所有贴图资源
        foreach (string guid in guids) // 遍历每一个贴图 GUID
        { // foreach 开始
            string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转成资源路径
            TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; // 获取贴图导入器
            if (importer == null) // 如果当前资源不是有效贴图导入器
            { // if 开始
                continue; // 跳过当前资源
            } // if 结束
            if (importer.maxTextureSize > 1024) // 如果贴图最大尺寸超过规范
            { // if 开始
                Debug.LogWarning($"Texture too large: {path}"); // 输出警告,提示贴图过大
            } // if 结束
        } // foreach 结束
        Debug.Log("Texture check finished."); // 输出检查完成日志
    } // 方法结束
} // 类结束

工程实践

编辑器扩展一定要注意和运行时代码隔离。不要在普通运行时代码里 using UnityEditor,否则打包会报错。

好的编辑器工具不只是能跑,还要有清晰错误提示、可撤销操作、进度条、日志、批处理确认、异常保护。比如批量改资源前最好弹确认框,修改资源后调用 AssetDatabase.SaveAssets(),涉及对象修改时可以接入 Undo.RecordObject()

面试高分回答

IMPORTANT

我会这样答:编辑器扩展是 Unity 客户端工具链的重要部分,主要服务开发效率和资源质量。常用方式包括 EditorWindow 做独立窗口,CustomEditor 改 Inspector,PropertyDrawer 改字段显示,MenuItem 添加菜单命令,AssetPostprocessor 做资源导入检查。项目里我会把编辑器代码放在 Editor 目录或 Editor-only asmdef,避免污染运行时,并通过自动化检查资源命名、贴图压缩、配置生成和构建前校验,减少人工错误。

序列化

unity-serialization-principle

标准答案

序列化就是把对象的状态转换成可以保存、传输、还原的数据格式。反序列化就是把这些数据读回来,重新恢复成对象状态。

在 Unity 里,Inspector 能显示字段、Prefab 能保存修改、Scene 能保存对象层级,本质上都依赖 Unity 的序列化系统。

一句话:序列化保存的是对象状态,不保存运行逻辑。

Unity 中常见序列化

Unity 默认会序列化:

  • public 字段。
  • [SerializeField] private 字段。
  • 基本类型,比如 intfloatboolstring
  • Unity 类型,比如 Vector3ColorAnimationCurve
  • 继承 UnityEngine.Object 的引用,比如 TextureMaterialPrefab
  • [System.Serializable] 的普通类或结构体。

Unity 通常不会直接序列化:

  • 属性 Property
  • 方法。
  • 静态字段。
  • 大多数泛型字典 Dictionary
  • 没有标记 [Serializable] 的普通自定义类。

底层原理

Unity 保存场景和 Prefab 时,会把对象、组件和字段写成可持久化数据。资源引用不是直接保存内存地址,而是通过 GUIDfileID 等信息关联到资源文件,所以 .meta 文件很重要。

如果删了 .meta,资源 GUID 变了,原来场景和 Prefab 里的引用就可能丢失,出现 Missing Reference。

代码示例

c
using System; // 引入 Serializable 特性所在命名空间
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 JsonUtility
[Serializable] // 标记这个类可以被 Unity 序列化
public class PlayerSaveData // 定义玩家存档数据类
{ // 类开始
    public int level; // 保存玩家等级
    public int hp; // 保存玩家当前血量
    public string weaponId; // 保存玩家武器 ID
} // 类结束
public class SaveDemo : MonoBehaviour // 定义一个用于演示序列化的组件
{ // 类开始
    [SerializeField] private int level = 1; // 私有字段加 SerializeField 后可以显示并保存到 Inspector
    [SerializeField] private int hp = 100; // 保存玩家血量,并允许 Inspector 配置
    [SerializeField] private string weaponId = "sword_001"; // 保存武器 ID
    public string SaveToJson() // 把当前对象状态保存成 JSON 字符串
    { // 方法开始
        PlayerSaveData data = new PlayerSaveData(); // 创建一个纯数据对象
        data.level = level; // 把组件里的等级写入存档数据
        data.hp = hp; // 把组件里的血量写入存档数据
        data.weaponId = weaponId; // 把组件里的武器 ID 写入存档数据
        string json = JsonUtility.ToJson(data); // 把数据对象序列化成 JSON 字符串
        return json; // 返回 JSON 字符串
    } // 方法结束
    public void LoadFromJson(string json) // 从 JSON 字符串恢复对象状态
    { // 方法开始
        PlayerSaveData data = JsonUtility.FromJson<PlayerSaveData>(json); // 把 JSON 反序列化成数据对象
        level = data.level; // 恢复等级字段
        hp = data.hp; // 恢复血量字段
        weaponId = data.weaponId; // 恢复武器 ID 字段
    } // 方法结束
} // 类结束

常见坑

字段改名会影响旧数据。Unity 里可以用 [FormerlySerializedAs] 保留旧字段名映射,避免 Prefab 或 Scene 里的旧数据丢失。

序列化不是万能存档系统。真正项目里还要考虑版本号、默认值、数据校验、加密、防篡改、兼容旧版本。

JsonUtility 很快但功能比较弱,比如对字典、多态支持不强。如果项目需要复杂 JSON,可能会用 Newtonsoft Json 或自定义序列化方案。

面试高分回答

CAUTION

我会这样答:序列化是把对象状态转成可保存和传输的数据,Unity 的 Inspector、Scene、Prefab、ScriptableObject 都依赖序列化。Unity 主要序列化字段,不序列化属性和方法;私有字段需要 [SerializeField],自定义类需要 [Serializable]。资源引用不是保存内存地址,而是依赖 GUID 和 fileID,所以 meta 文件非常关键。项目里做存档或配置时,我会关注版本兼容、字段改名、默认值、数据校验和防篡改。

内存池

architecture-memory-pool-principle

标准答案

内存池 Memory Pool 是一种内存管理技术:程序启动或某个模块初始化时,先申请一大块内存,然后切成很多固定大小的小块。后面需要对象时,不再频繁 malloc/new,而是从池里拿一块;不用时也不还给系统堆,而是放回池里等待复用。

一句话:内存池是“提前申请,重复使用”。

底层原理

最常见的固定块内存池会维护一个空闲链表 free list

初始化时:

大块内存 -> 切成 N 个小块 -> 小块串成空闲链表

分配时:

从 free list 头部取出一块

释放时:

把这块重新插回 free list 头部

所以分配和释放都很快,通常接近 O(1)

为什么游戏引擎常用

游戏里有很多生命周期很短、数量很多的对象,比如子弹、粒子、特效实例、网络消息、战斗事件、寻路节点。如果频繁 new/deletemalloc/free,会带来堆管理开销、内存碎片和性能抖动。

内存池可以减少这些问题:

  • 减少频繁向系统堆申请内存。
  • 减少外部碎片。
  • 分配释放时间更稳定。
  • 数据块更集中,缓存友好。
  • 方便统计和定位模块内存峰值。

C++ 简化实现

c
#include <cstddef> // 引入 size_t 类型
#include <new> // 引入 placement new 需要的头文件
#include <stdexcept> // 引入异常类型
class FixedMemoryPool // 定义一个固定大小块的内存池
{ // 类开始
private: // 私有成员开始
    struct FreeNode // 定义空闲链表节点
    { // 节点结构开始
        FreeNode* next; // 指向下一个空闲块
    }; // 节点结构结束
    char* memory; // 保存整块预分配内存的起始地址
    FreeNode* freeList; // 保存空闲链表头指针
    std::size_t blockSize; // 保存每个内存块的大小
    std::size_t blockCount; // 保存内存块数量
public: // 公有成员开始
    FixedMemoryPool(std::size_t inputBlockSize, std::size_t inputBlockCount) // 构造函数,传入块大小和块数量
        : memory(nullptr), freeList(nullptr), blockSize(inputBlockSize), blockCount(inputBlockCount) // 初始化成员变量
    { // 构造函数开始
        if (blockSize < sizeof(FreeNode)) // 如果块太小,连一个链表节点都放不下
        { // if 开始
            blockSize = sizeof(FreeNode); // 把块大小至少调整到 FreeNode 大小
        } // if 结束
        memory = new char[blockSize * blockCount]; // 一次性申请整块连续内存
        for (std::size_t i = 0; i < blockCount; ++i) // 遍历每一个块
        { // for 开始
            char* blockAddress = memory + i * blockSize; // 计算当前块的起始地址
            FreeNode* node = reinterpret_cast<FreeNode*>(blockAddress); // 把当前块临时当作空闲链表节点
            node->next = freeList; // 当前节点指向旧的链表头
            freeList = node; // 把当前节点插到空闲链表头部
        } // for 结束
    } // 构造函数结束
    ~FixedMemoryPool() // 析构函数
    { // 析构函数开始
        delete[] memory; // 释放整块预分配内存
        memory = nullptr; // 把内存指针置空,避免悬空指针
        freeList = nullptr; // 把空闲链表置空
    } // 析构函数结束
    void* Allocate() // 从内存池分配一个块
    { // 分配函数开始
        if (freeList == nullptr) // 如果空闲链表为空
        { // if 开始
            throw std::bad_alloc(); // 抛出分配失败异常
        } // if 结束
        FreeNode* node = freeList; // 取出链表头节点
        freeList = freeList->next; // 链表头移动到下一个空闲块
        return node; // 返回这块内存给调用者
    } // 分配函数结束
    void Free(void* ptr) // 把一个块归还给内存池
    { // 释放函数开始
        if (ptr == nullptr) // 如果传入空指针
        { // if 开始
            return; // 空指针不需要处理
        } // if 结束
        FreeNode* node = reinterpret_cast<FreeNode*>(ptr); // 把归还的内存块重新当作链表节点
        node->next = freeList; // 让当前块指向旧的空闲链表头
        freeList = node; // 把当前块放回空闲链表头部
    } // 释放函数结束
}; // 类结束

常见坑

内存池不是没有释放,而是释放回池里。池本身什么时候销毁,才会把整块内存还给系统。

固定块内存池适合同类大小对象。如果对象大小差异很大,一个固定块池会浪费空间。工程里通常会做多个池,比如 32B、64B、128B、256B 不同规格。

一定要防重复释放、越界写、释放非本池指针、线程并发竞争。正式项目里会加调试标记、红区检查、分配统计、线程锁或线程局部池。

面试高分回答

NOTE

我会这样答:内存池的核心是预分配和复用。初始化时申请一大块内存并切成固定大小块,用空闲链表管理。分配时从链表头取一块,释放时放回链表头,所以分配释放接近 O(1),能减少堆分配开销和内存碎片。游戏引擎里它常用于子弹、粒子、网络包、组件等大量短生命周期对象。它的代价是固定块可能浪费空间,并且要严格处理重复释放、越界、线程安全和池生命周期。

Job System

architecture-job-system-principle

Job System 是什么?

Job System 可以理解成:Unity 提供的一套“受管控的多线程任务调度系统”。它不是让你随便 new Thread,而是把大量可并行的计算拆成一个个 Job,交给 Unity 的 Worker Threads 执行,最后主线程通过 JobHandle.Complete() 等待结果。

面试标准回答

Job System 主要解决的是:主线程压力过大

比如一帧里要更新 10000 个单位的位置、AI 评分、视野检测、寻路预处理,如果都放在 Update() 里做,主线程会卡。Job System 可以把这些“彼此独立、适合批量计算”的任务分发到多个 CPU 核心上。

核心流程是:

  1. 主线程准备数据。
  2. 创建 Job。
  3. 调用 Schedule() 提交任务。
  4. Worker Thread 并行执行。
  5. 主线程用 JobHandle 管理依赖。
  6. 需要结果时调用 Complete()
  7. 主线程读取结果并应用到 Unity 对象上。

底层原理

Job System 的本质是:任务队列 + 工作线程池 + 依赖关系调度

主线程不会直接执行所有逻辑,而是把任务放进 Job 队列。Unity 后台有一组 Worker Threads,会从队列里取任务执行。多个 Job 之间可以通过 JobHandle 表达依赖关系,比如:

JobA -> JobB -> JobC

意思是:JobB 必须等 JobA 完成,JobC 必须等 JobB 完成。

这样 Unity 可以在保证顺序正确的前提下,尽量并行执行任务。

为什么 Job 里不能随便操作 Unity API?

因为大部分 Unity 对象不是线程安全的。

例如:

c
transform.position
gameObject.SetActive()
GetComponent<T>()
Instantiate()
Destroy()

这些通常只能在主线程调用。

所以 Job 里一般只处理纯数据,例如:

c
NativeArray<Vector3>
NativeArray<float>
NativeArray<int>

Job 算完之后,再回到主线程把结果应用到 TransformAnimatorGameObject 上。

常见 Job 类型

IJob:适合单个任务。

IJobParallelFor:适合对数组里的每个元素做同样处理,比如批量移动、批量计算距离。

IJobEntity:DOTS/ECS 中常见,用来批量处理 Entity。

JobHandle:表示一个 Job 的执行状态,也用来串联依赖。

NativeArray:Job 中常用的 Native 容器,可以被多线程安全访问。

Burst:把 Job 编译成更高性能的原生代码,常用于数学密集型计算。

代码示例:批量移动 1000 个位置

c
using Unity.Burst; // 引入 Burst,用来让 Job 有机会被编译成更快的原生代码
using Unity.Collections; // 引入 NativeArray、Allocator、ReadOnly 等 Job 容器相关类型
using Unity.Jobs; // 引入 IJobParallelFor 和 JobHandle
using UnityEngine; // 引入 MonoBehaviour、Vector3、Time 等 Unity 类型

[BurstCompile] // 标记这个 Job 可以被 Burst 编译优化
public struct MoveJob : IJobParallelFor // 定义一个并行 Job,每个 index 处理一个元素
{ // 结构体开始
    public float deltaTime; // 保存当前帧间隔时间,用来保证移动不受帧率影响

    public NativeArray<Vector3> positions; // 保存所有角色的位置,Job 会修改这个数组

    [ReadOnly] public NativeArray<Vector3> velocities; // 保存所有角色的速度,只读可以减少数据竞争风险

    public void Execute(int index) // 每个元素都会调用一次 Execute,index 表示当前处理哪个元素
    { // Execute 方法开始
        positions[index] += velocities[index] * deltaTime; // 根据速度和时间更新当前位置
    } // Execute 方法结束
} // MoveJob 结构体结束

public class JobSystemDemo : MonoBehaviour // 定义一个 Unity 脚本,用来演示如何调度 Job
{ // 类开始
    private NativeArray<Vector3> positions; // 保存位置数组,使用 NativeArray 才能给 Job 使用

    private NativeArray<Vector3> velocities; // 保存速度数组,使用 NativeArray 才能给 Job 使用

    private void Start() // Start 在脚本第一次启用后、第一帧 Update 前调用
    { // Start 方法开始
        positions = new NativeArray<Vector3>(1000, Allocator.Persistent); // 创建 1000 个位置数据,Persistent 表示跨帧保存

        velocities = new NativeArray<Vector3>(1000, Allocator.Persistent); // 创建 1000 个速度数据,Persistent 表示跨帧保存

        for (int i = 0; i < velocities.Length; i++) // 遍历所有速度元素
        { // for 循环开始
            velocities[i] = new Vector3(1f, 0f, 0f); // 给每个对象设置一个向右移动的速度
        } // for 循环结束
    } // Start 方法结束

    private void Update() // Update 每帧调用一次,适合调度每帧计算任务
    { // Update 方法开始
        MoveJob job = new MoveJob // 创建一个 MoveJob 实例
        { // 初始化 Job 字段开始
            deltaTime = Time.deltaTime, // 把当前帧时间传进 Job
            positions = positions, // 把位置数组传进 Job
            velocities = velocities // 把速度数组传进 Job
        }; // 初始化 Job 字段结束

        JobHandle handle = job.Schedule(positions.Length, 64); // 调度 Job,64 表示每批处理 64 个元素

        handle.Complete(); // 等待 Job 完成,完成后主线程才能安全读取 positions
    } // Update 方法结束

    private void OnDestroy() // 对象销毁时调用,用来释放 NativeArray
    { // OnDestroy 方法开始
        if (positions.IsCreated) positions.Dispose(); // 如果位置数组已经创建,就释放 Native 内存

        if (velocities.IsCreated) velocities.Dispose(); // 如果速度数组已经创建,就释放 Native 内存
    } // OnDestroy 方法结束
} // JobSystemDemo 类结束

面试高分说法

IMPORTANT

Job System 不是“开线程”这么简单,它更像是 Unity 提供的数据并行计算框架。真正高效的写法不是 Schedule() 后马上 Complete(),而是尽量先调度 Job,然后主线程继续做别的工作,最后在必须用结果之前再 Complete(),这样才能把主线程和 Worker Thread 的时间重叠起来。

常见坑

CAUTION

Job 里直接访问 TransformGameObjectAnimator,容易出问题。

Job 任务太小,调度成本可能比计算本身还高。

NativeArray 忘记 Dispose() 会造成 Native Memory 泄漏。

Complete() 调得太早,会把异步并行变成同步等待。

所以一句话记住:Job System 是把批量计算拆给工作线程做,用 JobHandle 管依赖,用 NativeArray 管数据安全,最后主线程收结果。

脚本系统

architecture-script-system-principle

标准答案

脚本系统就是让玩法逻辑可以用脚本来写,而不是全部写死在引擎底层。它负责创建脚本实例、绑定引擎对象、调用生命周期函数、处理脚本异常、支持热重载或热更新,并控制脚本能访问哪些引擎能力。

一句话:脚本系统把“变化快的玩法逻辑”放到脚本层,把“稳定的底层能力”留在引擎层。

核心结构

脚本系统一般分几层:

  • 脚本层:写任务、AI、技能、UI、剧情等业务逻辑。
  • 绑定层:把引擎 API 暴露给脚本,比如 Transform、资源、音频、UI。
  • 引擎层:提供真正的对象、渲染、物理、资源、网络等能力。
  • 调度层:统一调用脚本的 AwakeStartUpdateOnDestroy
  • 安全层:捕获异常、限制访问、避免脚本错误拖垮整个引擎。

Unity 的 MonoBehaviour 可以理解成一种 C# 脚本系统;Lua 热更方案则是另一种脚本系统,Lua VM 执行业务逻辑,通过绑定层访问 Unity/C# 能力。

底层原理

脚本系统最重要的是“调用桥”。脚本想移动一个角色,本质不是脚本直接改底层内存,而是通过绑定接口调用引擎对象:

脚本调用 transform.position
-> 绑定层找到对应 C# / C++ 对象
-> 参数转换
-> 调用引擎 API
-> 返回结果给脚本

如果是 Lua,通常会有 Lua 虚拟机、C# 绑定代码、对象引用映射表。 如果是 C#,Unity 会通过 Mono 或 IL2CPP 执行托管代码,再由引擎 PlayerLoop 调度生命周期。

简化代码

c
using System; // 引入 Exception,用来捕获脚本异常
using System.Collections.Generic; // 引入 List,用来保存脚本实例列表
public interface IScriptBehaviour // 定义脚本行为接口
{ // 接口开始
    void Awake(); // 脚本初始化时调用
    void Update(float deltaTime); // 每帧更新时调用
    void OnDestroy(); // 脚本销毁时调用
} // 接口结束
public sealed class ScriptSystem // 定义一个简化版脚本系统
{ // 类开始
    private readonly List<IScriptBehaviour> scripts = new List<IScriptBehaviour>(); // 保存所有脚本实例
    public void AddScript(IScriptBehaviour script) // 添加一个脚本实例
    { // 方法开始
        scripts.Add(script); // 把脚本加入管理列表
        SafeCall(script.Awake); // 安全调用脚本 Awake,避免异常中断系统
    } // 方法结束
    public void Update(float deltaTime) // 每帧由引擎主循环调用
    { // 方法开始
        for (int i = 0; i < scripts.Count; i++) // 遍历所有脚本实例
        { // for 开始
            IScriptBehaviour script = scripts[i]; // 取出当前脚本
            SafeCall(() => script.Update(deltaTime)); // 安全调用脚本 Update
        } // for 结束
    } // 方法结束
    public void RemoveScript(IScriptBehaviour script) // 移除一个脚本实例
    { // 方法开始
        if (!scripts.Remove(script)) // 如果列表里没有这个脚本
        { // if 开始
            return; // 直接返回,避免重复移除
        } // if 结束
        SafeCall(script.OnDestroy); // 安全调用脚本销毁逻辑
    } // 方法结束
    private void SafeCall(Action action) // 封装安全调用函数
    { // 方法开始
        try // 尝试执行脚本回调
        { // try 开始
            action(); // 执行传入的脚本函数
        } // try 结束
        catch (Exception e) // 捕获脚本异常
        { // catch 开始
            Console.WriteLine(e.Message); // 输出错误信息,真实项目应接入日志系统
        } // catch 结束
    } // 方法结束
} // 类结束

工程实践

设计脚本系统时,我会重点考虑这些点:

  • 生命周期:脚本什么时候创建、启用、更新、暂停、销毁。
  • 对象绑定:脚本对象和引擎对象怎么互相引用。
  • 错误隔离:单个脚本报错不能让整个客户端崩掉。
  • 性能边界:跨语言调用、反射调用、装箱和字符串查找不能太频繁。
  • 热重载:更新脚本后怎么保留状态,失败后怎么回滚。
  • 安全限制:脚本不能随便访问文件、网络、敏感接口。
  • 调试能力:脚本错误要能定位到文件、行号、对象和调用链。

面试高分回答

TIP

我会这样答:脚本系统的核心是把玩法逻辑和引擎底层解耦。脚本层负责变化快的业务,绑定层负责把引擎 API 暴露出去,引擎层提供稳定能力,调度层统一调用生命周期。Unity 的 MonoBehaviour 是 C# 脚本系统,Lua 热更则是 Lua VM 加绑定层的脚本系统。项目里我会重点处理生命周期、对象引用、异常隔离、跨语言调用性能、热重载状态恢复和安全边界。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0