Skip to content

渲染管线

渲染管线大概流程是什么?

渲染管线大概流程是什么?

一句话:渲染管线就是把 3D 场景一步步变成屏幕上 2D 像素的流程。 你可以把它想成工厂流水线:CPU 先决定“画什么”,GPU 再负责“怎么画成像素”。

rendering-pipeline-flow-csharp

最简单理解

游戏里有这些东西:

模型 Mesh
材质 Material
贴图 Texture
灯光 Light
相机 Camera
Shader

渲染管线要做的事就是:

把这些 3D 数据处理成屏幕上的颜色

也就是从:

一个 3D 世界

变成:

一张屏幕图片

大概流程

1. CPU 准备场景数据

CPU 会先整理这一帧要画什么。

比如:

有哪些物体
哪些物体在相机范围内
每个物体用什么材质
每个物体用什么 Shader
哪些灯光会影响它

这个阶段还会做:

视锥剔除
遮挡剔除
渲染排序
合批
提交 DrawCall

DrawCall 可以理解成:CPU 对 GPU 说:“帮我画这个东西。”

2. GPU 接收 DrawCall

GPU 收到命令后,开始进入图形管线。

它不是一次性直接画出图片,而是分很多步骤处理:

顶点阶段
图元阶段
光栅化阶段
片元阶段
测试混合阶段
输出屏幕

3. 顶点着色器 Vertex Shader

模型本来是一堆顶点。

每个顶点一般有:

位置
法线
UV
颜色
切线
骨骼权重

顶点着色器主要负责把顶点从模型空间一路变换到屏幕相关空间。

常见变换链路是:

模型空间 -> 世界空间 -> 观察空间 -> 裁剪空间

面试里可以说:

顶点着色器负责处理每个顶点的位置变换,以及传递法线、UV 等数据给后续阶段。

4. 图元装配 Primitive Assembly

GPU 会把顶点组装成图元。

最常见的是三角形:

3 个顶点 -> 1 个三角形

因为 GPU 最擅长画三角形,所以游戏模型基本都是由大量三角形组成的。

5. 裁剪和剔除

不在相机视野里的部分不用画。

比如:

在视锥外的三角形
背对相机的三角形
被裁剪面切掉的部分

这些会被裁掉或剔除,减少后续计算。

6. 光栅化 Rasterization

这是非常关键的一步。

三角形还是几何形状,屏幕却是一个个像素。 光栅化就是判断:

这个三角形覆盖了屏幕上的哪些像素

它会把三角形转换成很多片元 Fragment

片元可以先理解成:

候选像素

注意:片元不一定最后真的能显示出来,因为后面还要经过深度测试、模板测试、混合等步骤。

7. 片元着色器 Fragment Shader

片元着色器负责算颜色。

它会根据这些信息计算最终颜色:

贴图颜色
材质参数
灯光
法线
阴影
透明度
环境光

比如一个角色的衣服为什么是红色,金属为什么有高光,大部分都和这个阶段有关。

8. 深度测试 Depth Test

如果两个物体挡在同一个屏幕像素上,谁应该显示?

答案通常是:

离相机更近的显示
离相机更远的被挡住

深度测试就是用来解决前后遮挡关系的。

9. 模板测试 Stencil Test

模板测试可以控制某些区域能不能被画。

常见用途:

描边
传送门
遮罩
特殊 UI 裁剪
角色透视效果

10. 混合 Blending

透明物体需要和背景颜色混合。

比如玻璃、火焰、粒子特效:

最终颜色 = 当前颜色和背景颜色按透明度混合

所以透明物体通常比不透明物体更麻烦,也更容易造成性能问题。

11. 写入帧缓冲 FrameBuffer

通过各种测试之后,最终颜色会写入帧缓冲。

帧缓冲可以理解成:

屏幕即将显示的那张图片

后面可能还会做后处理:

Bloom
色调映射
抗锯齿
景深
运动模糊
颜色校正

最后才显示到屏幕上。

Unity 里怎么对应

Unity 里你看到的是:

c
Camera
MeshRenderer
Material
Shader
Light
RenderPipeline

Unity 会帮你组织渲染流程。

如果是 Built-in 管线,很多流程 Unity 内部固定好了。 如果是 URP / HDRP,也就是 SRP,流程可以被 Scriptable Render Pipeline 更灵活地控制。

但底层大方向仍然是:

CPU 提交渲染命令
GPU 处理顶点
GPU 光栅化三角形
GPU 计算像素颜色
通过深度、模板、混合
输出到屏幕

C# 示例:观察 Unity 相机渲染前后时机

这个脚本不是手写整个 GPU 管线,而是帮你理解 Unity 一帧渲染时相机附近的调用顺序。

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Camera 和 Debug
[RequireComponent(typeof(Camera))] // 要求当前 GameObject 必须带有 Camera 组件
public class RenderPipelineOrderLogger : MonoBehaviour // 定义一个观察 Unity 渲染时机的脚本
{ // 类开始
    private void OnPreCull() // Unity 在相机剔除场景物体之前调用
    { // OnPreCull 函数开始
        Debug.Log("1. OnPreCull:相机准备做剔除,决定哪些物体可能可见"); // 输出剔除前阶段说明
    } // OnPreCull 函数结束
    private void OnPreRender() // Unity 在相机开始渲染之前调用
    { // OnPreRender 函数开始
        Debug.Log("2. OnPreRender:剔除基本完成,即将开始提交渲染"); // 输出渲染前阶段说明
    } // OnPreRender 函数结束
    private void OnRenderObject() // Unity 在相机渲染场景物体时调用
    { // OnRenderObject 函数开始
        Debug.Log("3. OnRenderObject:可以在这里观察或执行额外绘制逻辑"); // 输出物体渲染阶段说明
    } // OnRenderObject 函数结束
    private void OnPostRender() // Unity 在相机完成渲染之后调用
    { // OnPostRender 函数开始
        Debug.Log("4. OnPostRender:相机渲染结束,画面准备进入后续处理或显示"); // 输出渲染后阶段说明
    } // OnPostRender 函数结束
} // 类结束

面试高分回答

NOTE

渲染管线是把 3D 场景转换成屏幕像素的一套流程。大体上 CPU 先收集场景数据,做剔除、排序、合批,然后通过 DrawCall 把渲染命令提交给 GPU。GPU 先执行顶点着色器,把模型顶点从模型空间变换到裁剪空间,然后进行图元装配、裁剪、背面剔除和光栅化,把三角形转换成片元。片元着色器再根据材质、贴图、光照等计算颜色,之后经过深度测试、模板测试、混合,最终写入帧缓冲并显示到屏幕上。Unity 中 Built-in、URP、HDRP 的组织方式不同,但底层都离不开这些核心阶段。

顶点着色器做什么?

顶点着色器做什么?

一句话:顶点着色器负责处理“每一个顶点”,最核心的工作是把模型的顶点位置变换到屏幕能理解的空间。

vertex-shader-role-csharp

先理解“顶点”是什么

一个 3D 模型本质上是由很多三角形组成的。 一个三角形由 3 个顶点组成。

每个顶点通常会带很多数据:

position:顶点位置
normal:法线方向
uv:贴图坐标
color:顶点颜色
tangent:切线
boneWeight:骨骼权重

顶点着色器就是 GPU 上专门处理这些顶点的小程序。

它最核心做什么

顶点着色器最重要的任务是:坐标变换

模型里的点一开始在模型自己的局部空间里,比如角色脚底在 (0, 0, 0)。 但屏幕要显示它,必须一步步变换:

模型空间 Object Space
-> 世界空间 World Space
-> 观察空间 View Space
-> 裁剪空间 Clip Space
-> 屏幕空间 Screen Space

顶点着色器通常负责把顶点至少变换到:

裁剪空间 Clip Space

后面的 GPU 阶段才能继续做裁剪、透视除法、光栅化。

它还会传递数据

顶点着色器不只算位置,还会把一些数据传给片元着色器。

比如:

UV 传下去,片元着色器才能采样贴图
法线传下去,片元着色器才能算光照
颜色传下去,片元着色器才能混合颜色
世界坐标传下去,片元着色器才能做特效

所以可以理解成:

顶点着色器处理点的位置和顶点属性
片元着色器处理像素颜色

它能做哪些效果

顶点着色器可以做很多“顶点级”的效果:

草被风吹动
水面波浪
旗帜飘动
角色骨骼动画蒙皮
顶点挤出描边
模型整体变形

因为它改的是顶点位置,所以适合做整体形状变化。

它不能直接做什么

顶点着色器不能直接决定每个像素最终是什么颜色。 因为它处理的是顶点,不是像素。

像素颜色主要由:

Fragment Shader / Pixel Shader

来算。

顶点着色器也不知道三角形内部每一个像素的信息。 三角形内部的像素,是在后面的光栅化阶段生成的。

C# 模拟:顶点着色器做坐标变换

下面用 C# 模拟“顶点从模型空间变到裁剪空间”的过程,帮助你理解顶点着色器的核心思想。

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 Vector3、Vector4、Matrix4x4 和 Camera
public class VertexShaderCpuSimulation : MonoBehaviour // 定义一个用 CPU 模拟顶点着色器坐标变换的组件
{ // 类开始
    public Camera targetCamera; // 保存用于观察场景的相机
    public Vector3 localVertex = new Vector3(1f, 1f, 1f); // 保存一个模型空间下的顶点位置
    private void Start() // Unity 在脚本启用后第一次 Update 前调用 Start
    { // Start 函数开始
        if (targetCamera == null) // 判断相机引用是否为空
        { // if 语句开始
            targetCamera = Camera.main; // 如果没有手动指定相机,就尝试使用主相机
        } // if 语句结束
        Matrix4x4 modelMatrix = transform.localToWorldMatrix; // 获取模型矩阵,用来把模型空间变到世界空间
        Matrix4x4 viewMatrix = targetCamera.worldToCameraMatrix; // 获取观察矩阵,用来把世界空间变到相机观察空间
        Matrix4x4 projectionMatrix = targetCamera.projectionMatrix; // 获取投影矩阵,用来把观察空间变到裁剪空间
        Vector4 vertex = new Vector4(localVertex.x, localVertex.y, localVertex.z, 1f); // 把 Vector3 顶点扩展成齐次坐标 Vector4
        Vector4 worldPosition = modelMatrix * vertex; // 使用模型矩阵把顶点从模型空间变到世界空间
        Vector4 viewPosition = viewMatrix * worldPosition; // 使用观察矩阵把顶点从世界空间变到观察空间
        Vector4 clipPosition = projectionMatrix * viewPosition; // 使用投影矩阵把顶点从观察空间变到裁剪空间
        Debug.Log("World Position = " + worldPosition); // 输出世界空间中的顶点位置
        Debug.Log("View Position = " + viewPosition); // 输出观察空间中的顶点位置
        Debug.Log("Clip Position = " + clipPosition); // 输出裁剪空间中的顶点位置
    } // Start 函数结束
} // 类结束

为什么叫“逐顶点执行”

如果一个模型有 1000 个顶点,那么顶点着色器大概会对这 1000 个顶点分别执行一次。

也就是说:

顶点越多,顶点着色器执行次数越多

但 GPU 很擅长并行,所以可以同时处理大量顶点。

和片元着色器的区别

顶点着色器:

处理顶点
决定模型形状和位置
执行次数和顶点数有关

片元着色器:

处理片元/像素候选
决定颜色、光照、贴图效果
执行次数和屏幕覆盖面积有关

比如一个很大的墙面,顶点可能只有 4 个,但它覆盖了很多屏幕像素。 这种情况下,片元着色器压力可能比顶点着色器大很多。

面试高分回答

IMPORTANT

顶点着色器是 GPU 渲染管线中处理顶点的阶段,它会对每个顶点执行一次。它最核心的职责是做坐标变换,把顶点从模型空间经过世界、观察、投影变换到裁剪空间,让后续阶段能够进行裁剪、透视除法和光栅化。同时它也会把 UV、法线、顶点颜色、世界坐标等数据传递给片元着色器。顶点着色器适合做顶点动画、模型变形、骨骼蒙皮等顶点级效果,但它不直接决定最终像素颜色,像素颜色主要由片元着色器负责。

片元着色器做什么?

片元着色器做什么?

一句话:片元着色器负责处理“每一个片元”,最核心的工作是计算这个片元应该是什么颜色。

fragment-shader-role-csharp

先理解片元是什么

三角形经过光栅化之后,会被拆成很多屏幕上的候选像素。

这些候选像素就叫:

Fragment,片元

为什么不直接叫像素? 因为它还不一定真的显示到屏幕上。

它后面还要经过:

深度测试
模板测试
透明混合
写入帧缓冲

只有通过这些步骤后,它才会变成屏幕上真正看到的像素。

片元着色器的核心工作

片元着色器主要做这几件事:

采样贴图
计算颜色
计算光照
处理透明度
处理特效
输出 RGBA 颜色

比如一个角色衣服是红色,石头表面有纹理,金属有高光,火焰有透明渐变,这些很多都发生在片元着色器里。

它的输入通常有什么

片元着色器拿到的数据,很多来自顶点着色器,并且经过插值。

比如一个三角形三个顶点都有 UV:

c
左边顶点 UV = (0, 0)
右边顶点 UV = (1, 0)
上边顶点 UV = (0.5, 1)

三角形内部每个片元的 UV 会被 GPU 自动插值出来。

常见输入有:

uv:贴图坐标
normal:法线方向
worldPos:世界坐标
vertexColor:顶点颜色
depth:深度
screenPos:屏幕坐标

它的输出是什么

片元着色器通常输出颜色:

RGBA

例如:

R:红色通道
G:绿色通道
B:蓝色通道
A:透明度

但注意:片元着色器输出颜色,不代表这个颜色一定会显示。

因为后面可能被深度测试挡掉。

比如墙后面的怪物,片元着色器可能算了颜色,但深度测试发现它在墙后面,就不会写到屏幕上。

和顶点着色器的区别

顶点着色器:

处理顶点
决定模型画到哪里
执行次数和顶点数量有关

片元着色器:

处理片元
决定表面是什么颜色
执行次数和屏幕覆盖面积有关

所以一个只有 4 个顶点的大平面,如果铺满整个屏幕,顶点着色器只执行很少次,但片元着色器可能执行几十万甚至几百万次。

这也是为什么:

大面积透明特效
全屏后处理
复杂光照 Shader
大量 Overdraw

会很吃 GPU。

Unity 里怎么理解

在 Unity 里,一个材质 Material 使用一个 Shader

Shader 通常会包含:

Vertex Shader
Fragment Shader

顶点着色器负责把模型顶点变换到屏幕相关空间。 片元着色器负责给表面算颜色。

C# 示例:用脚本给片元着色器传参数

在 Unity 里,C# 不能直接替代片元着色器,但可以给 Shader 传参数。 片元着色器会用这些参数计算颜色。

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Renderer、Material 和 Color
public class FragmentShaderParameterExample : MonoBehaviour // 定义一个给材质 Shader 传参数的示例组件
{ // 类开始
    private Renderer cachedRenderer; // 缓存当前物体的 Renderer 组件
    private Material cachedMaterial; // 缓存当前物体运行时使用的材质实例
    private void Awake() // Unity 在脚本实例加载时调用 Awake
    { // Awake 函数开始
        cachedRenderer = GetComponent<Renderer>(); // 获取当前物体上的 Renderer 组件
        cachedMaterial = cachedRenderer.material; // 获取材质实例,后续修改只影响当前物体
    } // Awake 函数结束
    private void Update() // Unity 每帧调用 Update
    { // Update 函数开始
        float t = Mathf.PingPong(Time.time, 1f); // 生成一个在 0 到 1 之间来回变化的数值
        Color color = Color.Lerp(Color.red, Color.blue, t); // 根据 t 在红色和蓝色之间做线性插值
        cachedMaterial.SetColor("_BaseColor", color); // 把颜色参数传给 Shader,片元着色器会用它计算最终颜色
    } // Update 函数结束
} // 类结束

一个非常简单的片元着色器思想

虽然你现在主要写 C#,但面试里可以知道它的大概逻辑:

拿到 UV
根据 UV 采样贴图
拿到贴图颜色
结合灯光、透明度、材质参数
输出最终颜色

也就是:

颜色 = 贴图颜色 × 材质颜色 × 光照结果

性能上要注意什么

片元着色器很容易成为 GPU 瓶颈。

常见原因:

屏幕上覆盖面积太大
透明物体 Overdraw 太多
Shader 里采样贴图太多
光照计算太复杂
用了很多分支判断
全屏后处理太多

比如粒子特效、雾、透明 UI、半透明技能特效,如果一层盖一层,就会让同一个屏幕像素被反复计算很多次。

这就是 Overdraw。

面试高分回答

NOTE

片元着色器是 GPU 渲染管线中处理片元的阶段,它在光栅化之后执行。光栅化会把三角形转换成大量片元,片元着色器会对每个片元执行一次,根据插值后的 UV、法线、世界坐标、材质参数、贴图和灯光等信息计算输出颜色。它通常负责贴图采样、光照计算、透明度处理和各种表面特效。片元着色器输出的颜色还要经过深度测试、模板测试和混合,最后才可能写入帧缓冲。简单说,顶点着色器决定模型画到哪里,片元着色器决定表面显示成什么颜色。

MVP 矩阵是什么?

mvp-matrix-csharp

MVP 矩阵是什么?

一句话:MVP 矩阵就是把一个模型顶点,从模型自己的坐标系,一步步变换到相机能看到、GPU 能裁剪和投影的位置。

MVP 分别是什么意思

MVP 是三个矩阵的组合:

M = Model Matrix,模型矩阵
V = View Matrix,观察矩阵
P = Projection Matrix,投影矩阵

它们合起来做一件事:

把模型顶点变到裁剪空间

常见公式是:

clipPos = P × V × M × localPos

注意,这里虽然写成 P × V × M,但真正作用到顶点上的顺序是:

先 M
再 V
再 P

因为矩阵乘法是从右往左作用到向量上的。

M:Model Matrix 是什么

Model Matrix 负责:

模型空间 -> 世界空间

比如一个角色模型,它自己的本地坐标里,脚底可能在:

(0, 0, 0)

但放到游戏世界里,它可能在:

(10, 0, 5)

还可能旋转了 90 度,缩放了 2 倍。

这些信息就放在 Model Matrix 里:

位置 Position
旋转 Rotation
缩放 Scale

所以 M 的作用是:

把模型自己的点,放到世界里的正确位置

V:View Matrix 是什么

View Matrix 负责:

世界空间 -> 相机空间

世界里有很多物体,但屏幕显示的是“从相机角度看到的画面”。

所以 V 的作用是:

把整个世界转换到相机视角下

你可以这样理解:

不是相机真的把世界拉过来
而是数学上把世界变换成“以相机为中心”的坐标

相机在哪里、朝哪里看,都会影响 View Matrix

P:Projection Matrix 是什么

Projection Matrix 负责:

相机空间 -> 裁剪空间

它决定:

近大远小
视野范围
近裁剪面
远裁剪面
正交投影还是透视投影

如果是透视相机,就会有近大远小。 如果是正交相机,远近大小不会因为距离变化。

所以 P 的作用是:

把相机看到的 3D 空间,投影成 GPU 后续能处理的空间

为什么要组合成 MVP

因为顶点着色器里,每个顶点都要经历这些变换。

如果每次都分开写:

c
worldPos = M × localPos
viewPos = V × worldPos
clipPos = P × viewPos

也可以。

但通常可以先把矩阵合起来:

MVP = P × V × M

然后直接:

clipPos = MVP × localPos

这样更方便,也更适合 GPU 处理。

C# 示例:用 Unity 计算 MVP 变换

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Camera、Matrix4x4、Vector3、Vector4 和 Debug
public class MvpMatrixExample : MonoBehaviour // 定义一个演示 MVP 矩阵的 Unity 组件
{ // 类开始
    public Camera targetCamera; // 保存用于观察物体的相机
    public Vector3 localPoint = new Vector3(1f, 1f, 1f); // 保存一个模型空间中的点
    private void Start() // Unity 在脚本启用后第一次 Update 前调用 Start
    { // Start 函数开始
        if (targetCamera == null) // 判断是否没有手动指定相机
        { // if 语句开始
            targetCamera = Camera.main; // 如果没有指定相机,就使用场景中的主相机
        } // if 语句结束
        Matrix4x4 modelMatrix = transform.localToWorldMatrix; // 获取模型矩阵 M,用来把模型空间变到世界空间
        Matrix4x4 viewMatrix = targetCamera.worldToCameraMatrix; // 获取观察矩阵 V,用来把世界空间变到相机空间
        Matrix4x4 projectionMatrix = targetCamera.projectionMatrix; // 获取投影矩阵 P,用来把相机空间变到裁剪空间
        Matrix4x4 mvpMatrix = projectionMatrix * viewMatrix * modelMatrix; // 按 P * V * M 的顺序组合出 MVP 矩阵
        Vector4 localPosition = new Vector4(localPoint.x, localPoint.y, localPoint.z, 1f); // 把模型空间点转换成齐次坐标,w 为 1 表示这是一个位置点
        Vector4 clipPosition = mvpMatrix * localPosition; // 使用 MVP 矩阵把模型空间点直接变换到裁剪空间
        Debug.Log("Local Position = " + localPosition); // 输出模型空间坐标
        Debug.Log("Clip Position = " + clipPosition); // 输出裁剪空间坐标
    } // Start 函数结束
} // 类结束

最容易错的地方

矩阵乘法顺序不能乱。

下面这个是常见写法:

c
clipPos = P × V × M × localPos

不能随便改成:

c
clipPos = M × V × P × localPos

因为矩阵乘法一般不满足交换律:

A × B 不一定等于 B × A

所以顺序错了,模型就会飞到奇怪的位置。

MVP 只适合变换位置吗?

主要用于变换顶点位置。

如果是方向,比如法线 Normal,就不能随便用完整 MVP。 法线通常要用模型矩阵相关的法线矩阵处理,尤其遇到非等比缩放时要用逆转置矩阵。

这个点面试里说出来会加分:

位置点用 MVP
法线方向不要直接用 MVP

Unity Shader 里对应什么

Unity Shader 里常见类似:

c
UnityObjectToClipPos(vertex)

它背后做的事情,本质就是:

把模型空间顶点变到裁剪空间

也就是 MVP 变换。

面试高分回答

WARNING

MVP 矩阵是 Model、View、Projection 三个矩阵的组合。Model 矩阵把顶点从模型空间变到世界空间,View 矩阵把世界空间变到相机观察空间,Projection 矩阵把相机空间投影到裁剪空间。顶点着色器中通常会用 clipPos = P * V * M * localPos 把模型顶点变换到 GPU 后续裁剪和光栅化需要的空间。需要注意矩阵乘法顺序不能乱,实际作用顺序是先 Model,再 View,再 Projection。

世界空间、视图空间、裁剪空间分别是什么?

world-view-clip-space-csharp

世界空间、视图空间、裁剪空间分别是什么?

一句话:世界空间回答“物体在世界哪里”,视图空间回答“相机怎么看它”,裁剪空间回答“GPU 要不要把它裁掉”。

先看整体流程

一个模型顶点从模型到屏幕,大概会经历:

模型空间 -> 世界空间 -> 视图空间 -> 裁剪空间 -> NDC -> 屏幕空间

今天问的三个是中间很重要的:

世界空间 World Space
视图空间 View Space
裁剪空间 Clip Space

它们经常和 MVP 矩阵一起出现:

c
worldPos = M * localPos
viewPos = V * worldPos
clipPos = P * viewPos

世界空间是什么

世界空间就是整个游戏场景的统一坐标系。

比如 Unity 里:

transform.position

通常就是世界空间位置。

如果一个怪物的位置是:

(10, 0, 5)

意思就是它在整个游戏世界里的坐标是 (10, 0, 5)

世界空间适合做:

角色和敌人的距离判断
物体在地图中的位置
寻路目标点
物理碰撞位置
特效生成位置
音效播放位置

你可以理解成:

世界空间 = 游戏地图里的真实坐标

视图空间是什么

视图空间也叫相机空间。

它是把世界坐标转换到“以相机为中心”的坐标系里。

也就是说,进入视图空间后:

相机相当于坐标原点
物体的位置变成相对相机的位置

比如一个物体在相机前方、后方、左边、右边,就可以在视图空间里更容易判断。

视图空间适合做:

判断物体在相机前面还是后面
相机相关特效
屏幕方向相关计算
观察空间下的光照计算

Unity 里的观察矩阵是:

c
Camera.worldToCameraMatrix

它的作用就是:

世界空间 -> 视图空间

裁剪空间是什么

裁剪空间是经过投影矩阵之后的空间。

也就是:

视图空间 -> 裁剪空间

由投影矩阵 Projection Matrix 完成。

裁剪空间的重点不是“屏幕像素坐标”,而是:

GPU 用它来判断哪些顶点、三角形在可见范围内

如果一个三角形完全在可见范围外,就可以被裁掉。 如果部分在范围内、部分在范围外,就可能被裁剪成新的图元。

裁剪空间之后还要经过:

透视除法 Perspective Divide

也就是:

x / w
y / w
z / w

然后才进入:

c
NDC,Normalized Device Coordinates,归一化设备坐标

再经过视口变换,最后才变成屏幕像素坐标。

所以要记住:

裁剪空间不是屏幕空间

三者怎么联系起来

一个模型顶点一开始在模型自己的坐标里:

c
localPos

先用模型矩阵 M

c
worldPos = M * localPos

这一步得到世界空间位置。

再用观察矩阵 V

c
viewPos = V * worldPos

这一步得到相机视角下的位置。

再用投影矩阵 P

c
clipPos = P * viewPos

这一步得到裁剪空间位置。

合起来就是:

c
clipPos = P * V * M * localPos

也就是 MVP 变换。

C# 示例:在 Unity 中打印三个空间坐标

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Camera、Vector3、Vector4、Matrix4x4 和 Debug
public class SpaceTransformExample : MonoBehaviour // 定义一个演示世界空间、视图空间、裁剪空间的组件
{ // 类开始
    public Camera targetCamera; // 保存用于观察物体的相机
    public Vector3 localPoint = new Vector3(0f, 1f, 0f); // 保存一个模型空间中的点
    private void Start() // Unity 在脚本启用后第一次 Update 前调用 Start
    { // Start 函数开始
        if (targetCamera == null) // 判断是否没有手动指定相机
        { // if 语句开始
            targetCamera = Camera.main; // 如果没有指定相机,就使用场景中的主相机
        } // if 语句结束
        Matrix4x4 modelMatrix = transform.localToWorldMatrix; // 获取模型矩阵,用来把模型空间变到世界空间
        Matrix4x4 viewMatrix = targetCamera.worldToCameraMatrix; // 获取观察矩阵,用来把世界空间变到视图空间
        Matrix4x4 projectionMatrix = targetCamera.projectionMatrix; // 获取投影矩阵,用来把视图空间变到裁剪空间
        Vector4 localPosition = new Vector4(localPoint.x, localPoint.y, localPoint.z, 1f); // 把模型空间点转换成齐次坐标
        Vector4 worldPosition = modelMatrix * localPosition; // 使用模型矩阵计算世界空间坐标
        Vector4 viewPosition = viewMatrix * worldPosition; // 使用观察矩阵计算视图空间坐标
        Vector4 clipPosition = projectionMatrix * viewPosition; // 使用投影矩阵计算裁剪空间坐标
        Debug.Log("World Space = " + worldPosition); // 打印世界空间坐标
        Debug.Log("View Space = " + viewPosition); // 打印视图空间坐标
        Debug.Log("Clip Space = " + clipPosition); // 打印裁剪空间坐标
    } // Start 函数结束
} // 类结束

容易混淆的点

世界空间不是模型自己的坐标。 模型自己的坐标叫模型空间或本地空间。

视图空间不是屏幕坐标。 它只是相机视角下的 3D 坐标。

裁剪空间也不是屏幕坐标。 它还要经过透视除法和视口变换,才会变成屏幕上的像素位置。

面试高分回答

NOTE

世界空间是整个场景统一使用的坐标系,表示物体在游戏世界中的真实位置;视图空间是以相机为参考的坐标系,表示物体相对相机的位置,由 View 矩阵把世界空间转换而来;裁剪空间是经过 Projection 矩阵后的空间,GPU 会在这个空间中判断图元是否处于可见范围内,超出范围的会被裁剪。顶点通常会经过 local -> world -> view -> clip 的变换,也就是 clipPos = P * V * M * localPos

深度测试是什么?

深度测试是什么?

一句话:深度测试就是 GPU 用来判断“同一个屏幕像素上,哪个物体离相机更近”的机制。 近的留下,远的被挡住。

depth-test-csharp

先用很简单的话理解

假设屏幕上有一个像素位置。

两个物体都投影到了这个像素:

绿色方块:离相机近
红色方块:离相机远

那屏幕上应该显示谁?

答案是:

显示离相机更近的绿色方块
红色方块被挡住

这个判断过程就是深度测试

深度缓冲是什么

深度测试需要一个东西,叫:

Depth Buffer

也叫:

Z Buffer

它可以理解成一张和屏幕一样大的表。

屏幕上每个像素,都有一个对应的深度值:

像素 (100, 200) 当前最近深度 = 0.35
像素 (101, 200) 当前最近深度 = 0.42
像素 (102, 200) 当前最近深度 = 0.18

这个深度值表示:

当前这个像素上,已经画过的最近片元离相机有多远

深度测试怎么工作

当一个新的片元准备写到屏幕时,GPU 会做比较:

新片元的深度

Depth Buffer 里已有的深度

如果新片元更近:

通过深度测试
可以显示
可能更新 Depth Buffer

如果新片元更远:

深度测试失败
这个片元被丢弃
不会显示

比如:

Depth Buffer 当前记录 = 0.35
新片元 depth = 0.20

如果规则是“越小越近”,那 0.20 更近,所以通过。

再比如:

c
Depth Buffer 当前记录 = 0.35
新片元 depth = 0.70

0.70 更远,所以失败,被挡住。

ZTest 是什么

ZTest 决定比较规则。

常见规则有:

Less:新深度更小才通过
LessEqual:新深度更小或相等才通过
Greater:新深度更大才通过
Always:永远通过
Never:永远不通过

游戏里不透明物体常见默认类似:

c
ZTest LessEqual

意思是:

新片元比旧片元更近或一样近,就通过

ZWrite 是什么

ZWrite 决定:

通过深度测试后,要不要把自己的深度写进 Depth Buffer

不透明物体通常:

c
ZWrite On

因为它应该挡住后面的东西。

透明物体通常:

c
ZWrite Off

因为透明物体如果写入深度,可能会把后面的透明层错误挡掉。

所以透明物体经常需要:

从远到近排序
再进行透明混合

深度测试发生在哪里

大概流程可以理解为:

三角形光栅化
生成片元
片元准备输出颜色
进行深度测试
通过后写入颜色缓冲
必要时写入深度缓冲

但现代 GPU 还有一种优化叫:

c
Early-Z

也就是提前做深度测试。

如果 GPU 能提前判断一个片元肯定被挡住,就可以在片元着色器执行前丢掉它。

这样可以减少很多无用的片元计算。

为什么它很重要

没有深度测试,画面就会出大问题。

比如远处的墙可能盖住近处的角色。 或者后画的物体永远盖住先画的物体。

深度测试解决的是:

前后遮挡关系

让 3D 场景看起来符合真实空间。

C# 示例:用代码模拟深度测试思想

下面不是 GPU 真正的深度缓冲,而是用 C# 模拟“同一个像素上,保留最近深度”的逻辑。

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour 和 Debug
public class DepthTestSimulation : MonoBehaviour // 定义一个用 C# 模拟深度测试的组件
{ // 类开始
    private float currentDepth = 1f; // 保存当前像素已经记录的深度,1 表示很远
    private string currentColor = "Background"; // 保存当前像素显示的颜色名称
    private void Start() // Unity 在脚本启用后第一次 Update 前调用 Start
    { // Start 函数开始
        TryDrawFragment(0.70f, "Red Far Object"); // 尝试绘制一个较远的红色片元
        TryDrawFragment(0.25f, "Green Near Object"); // 尝试绘制一个较近的绿色片元
        TryDrawFragment(0.90f, "Blue Very Far Object"); // 尝试绘制一个更远的蓝色片元
        Debug.Log("Final Pixel Color = " + currentColor); // 输出最终这个像素留下来的颜色
    } // Start 函数结束
    private void TryDrawFragment(float newDepth, string newColor) // 定义尝试绘制片元的方法
    { // 方法开始
        bool passDepthTest = newDepth <= currentDepth; // 判断新片元是否比当前记录的深度更近或一样近
        if (passDepthTest == false) // 判断新片元是否没有通过深度测试
        { // if 语句开始
            Debug.Log(newColor + " failed depth test"); // 输出这个片元被深度测试丢弃
            return; // 直接返回,不更新颜色和深度
        } // if 语句结束
        currentDepth = newDepth; // 更新当前像素记录的最近深度
        currentColor = newColor; // 更新当前像素最终显示的颜色
        Debug.Log(newColor + " passed depth test"); // 输出这个片元通过了深度测试
    } // 方法结束
} // 类结束

Unity 中怎么理解

不透明物体一般会:

开启深度测试
开启深度写入
通常从前到后或按管线策略优化

透明物体一般会:

开启深度测试
关闭深度写入
从远到近排序
做透明混合

这就是为什么透明物体经常出现排序问题,比如:

玻璃叠玻璃显示不对
粒子特效互相穿插错误
半透明头发显示异常

常见面试追问

为什么透明物体通常关闭 ZWrite

因为透明物体不是完全挡住后面的东西。 如果它写入深度,后面的透明物体可能直接被深度测试丢掉,导致混合不正确。

为什么 Early-Z 能优化?

因为如果一个片元已经确定被前面的东西挡住,就没必要再执行复杂的片元着色器。 这样可以减少贴图采样、光照计算、特效计算的开销。

为什么 Overdraw 会影响性能?

因为同一个屏幕像素被很多层物体反复绘制。 每一层都可能执行片元着色器,透明特效尤其容易造成 Overdraw。

面试高分回答

WARNING

深度测试是 GPU 用来解决 3D 遮挡关系的机制。光栅化生成片元后,每个片元都会带有深度值,GPU 会把它和 Depth Buffer 中当前像素已经记录的深度进行比较。如果新片元更靠近相机,就通过深度测试,可以写入颜色缓冲,并在开启 ZWrite 时更新深度缓冲;如果更远,就会被丢弃。ZTest 控制比较规则,ZWrite 控制是否写入深度。它能保证近处物体挡住远处物体,也能通过 Early-Z 减少无效片元计算。透明物体通常关闭 ZWrite,并依赖从远到近排序和混合。

模板测试是什么?

模板测试是什么?

一句话:模板测试就是给屏幕上的像素做“标记”,之后只有符合标记规则的片元才允许继续绘制。

stencil-test-csharp

先用最简单的话理解

模板测试里的“模板”,可以想成一张和屏幕一样大的表。

屏幕上每个像素,都有一个模板值:

像素 A:Stencil = 0
像素 B:Stencil = 1
像素 C:Stencil = 1
像素 D:Stencil = 0

然后 GPU 画某个片元时,会问:

这个像素位置的 Stencil 值符合规则吗?

如果符合:

通过模板测试,继续渲染

如果不符合:

模板测试失败,这个片元被丢弃

所以模板测试本质上是:

屏幕像素级别的开关

Stencil Buffer 是什么

Stencil Buffer 叫模板缓冲。

它和 Depth Buffer 类似,都是屏幕级缓冲。

区别是:

Depth Buffer 存深度值,用来判断远近遮挡
Stencil Buffer 存整数标记,用来判断能不能画

模板缓冲通常每个像素是 8 bit,也就是可以存:

0 到 255

你可以把它理解成:

每个屏幕像素都有一个小标签

模板测试一般怎么工作

一般分两步。

第一步:先写模板值。

比如先画一个圆形遮罩区域,但这个遮罩本身不显示颜色,只是把它覆盖到的像素写成:

Stencil = 1

第二步:再画真正的物体。

画物体的时候设置规则:

只有 Stencil == 1 的地方才允许显示

于是物体就只会出现在刚才那个圆形区域里。

这就是很多遮罩、传送门、镜子效果的基础。

常见参数

模板测试里常见几个关键词:

Ref:参考值。

我要拿哪个值去和模板缓冲里的值比较

比如:

Ref = 1

Comp:比较规则。

模板缓冲里的值和 Ref 怎么比

常见规则:

Always:永远通过
Equal:相等才通过
NotEqual:不相等才通过
Less:小于才通过
Greater:大于才通过

Pass:模板测试通过后怎么处理模板值。

比如:

Keep:保持不变
Replace:替换成 Ref
Increment:加 1
Decrement:减 1

还有一些失败时的操作,比如模板测试失败、深度测试失败时怎么处理,但零基础先记住 Ref、Comp、Pass 就够了。

和深度测试有什么区别

深度测试问的是:

这个片元是不是离相机更近?

模板测试问的是:

这个片元所在像素的标记符不符合规则?

所以:

Depth Test:解决前后遮挡
Stencil Test:解决区域限制和特殊遮罩

比如一个角色站在墙后面,是否被墙挡住,这是深度测试管。 一个角色只在圆形区域里显示,这是模板测试管。

常见用途

模板测试在游戏里很常见:

UI Mask
小地图圆形遮罩
传送门
镜子
角色描边
墙后轮廓显示
区域高亮
技能范围裁剪
只让某些物体显示在指定区域

比如传送门效果可以这样理解:

先把传送门形状写入 Stencil = 1
再只在 Stencil == 1 的地方渲染另一个场景画面

C# 示例:模拟模板测试

下面用 C# 模拟一个很简单的模板测试: 先把中间一段像素写成 1,再让物体只在 Stencil == 1 的位置显示。

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour 和 Debug
public class StencilTestSimulation : MonoBehaviour // 定义一个用 C# 模拟模板测试的组件
{ // 类开始
    private int[] stencilBuffer = new int[10]; // 创建一个长度为 10 的模板缓冲,模拟屏幕上 10 个像素的模板值
    private string[] colorBuffer = new string[10]; // 创建一个长度为 10 的颜色缓冲,模拟屏幕上 10 个像素最终显示的颜色
    private void Start() // Unity 在脚本启用后第一次 Update 前调用 Start
    { // Start 函数开始
        ClearBuffers(); // 清空模板缓冲和颜色缓冲
        WriteStencilMask(3, 6, 1); // 把第 3 到第 6 个像素写成模板值 1
        DrawObjectWithStencilTest(1, "Green Object"); // 只在模板值等于 1 的地方绘制绿色物体
        PrintColorBuffer(); // 打印最终颜色缓冲结果
    } // Start 函数结束
    private void ClearBuffers() // 定义清空缓冲的方法
    { // 方法开始
        for (int i = 0; i < stencilBuffer.Length; i++) // 遍历模板缓冲中的每一个像素
        { // for 循环开始
            stencilBuffer[i] = 0; // 把当前像素的模板值清成 0
            colorBuffer[i] = "."; // 把当前像素的颜色显示成空白符号
        } // for 循环结束
    } // 方法结束
    private void WriteStencilMask(int startIndex, int endIndex, int stencilValue) // 定义写入模板遮罩的方法
    { // 方法开始
        for (int i = startIndex; i <= endIndex; i++) // 遍历遮罩覆盖到的像素范围
        { // for 循环开始
            stencilBuffer[i] = stencilValue; // 把当前像素的模板值写成指定值
        } // for 循环结束
    } // 方法结束
    private void DrawObjectWithStencilTest(int referenceValue, string colorName) // 定义带模板测试的绘制方法
    { // 方法开始
        for (int i = 0; i < colorBuffer.Length; i++) // 遍历屏幕上的每一个像素
        { // for 循环开始
            bool passStencilTest = stencilBuffer[i] == referenceValue; // 判断当前像素的模板值是否等于参考值
            if (passStencilTest == false) // 判断当前像素是否没有通过模板测试
            { // if 语句开始
                continue; // 没通过就跳过,不写颜色
            } // if 语句结束
            colorBuffer[i] = colorName; // 通过模板测试后,把颜色写入颜色缓冲
        } // for 循环结束
    } // 方法结束
    private void PrintColorBuffer() // 定义打印颜色缓冲的方法
    { // 方法开始
        string result = string.Join(" | ", colorBuffer); // 把颜色缓冲中的所有像素拼成一行字符串
        Debug.Log(result); // 输出最终屏幕像素显示结果
    } // 方法结束
} // 类结束

为什么渲染顺序很重要

模板测试通常依赖前一步已经写好了模板值。

所以顺序一般是:

先画遮罩物体,写 Stencil
再画目标物体,做 Stencil Test

如果顺序反了:

目标物体先画
遮罩还没写

那模板测试就没有正确依据,效果可能完全不对。

Unity 里怎么理解

在 Unity Shader 里,模板测试通常通过 Stencil 块控制。

核心思想类似:

c
Ref 1
Comp Equal
Pass Keep

意思是:

参考值是 1
只有当前像素模板值等于 1 才通过
通过后保持模板值不变

你不用一开始就死背语法,先把逻辑吃透:

先写标记
再按标记决定能不能画

面试高分回答

TIP

模板测试是 GPU 渲染管线中的一种像素级测试,它依赖 Stencil Buffer。Stencil Buffer 会为屏幕上的每个像素保存一个整数标记,渲染片元时可以拿这个模板值和参考值 Ref 按照比较规则 Comp 进行比较。比较通过的片元才会继续渲染,失败的会被丢弃;同时还可以通过 PassFail 等操作修改模板值。模板测试常用于 UI Mask、传送门、镜子、描边、区域裁剪等效果。它和深度测试不同,深度测试解决远近遮挡,模板测试解决区域标记和显示限制。

背面剔除是什么?

背面剔除是什么?

一句话:背面剔除就是不渲染背对相机的三角形。 因为很多模型是封闭的,背面的三角形你根本看不到,跳过它们可以减少 GPU 工作量。

backface-culling-csharp

先理解正面和背面

一个模型是由很多三角形组成的。

每个三角形都有:

正面 Front Face
背面 Back Face

如果三角形的正面朝向相机,它通常会被渲染。 如果三角形的背面朝向相机,它通常会被剔除。

这就是:

c
Backface Culling

中文叫:

背面剔除

GPU 怎么知道哪边是正面

很多时候,GPU 不是直接看“法线画哪边”,而是看三角形顶点的绕序

比如三角形三个顶点是:

A -> B -> C

在屏幕上看,它们可能是:

顺时针

逆时针

引擎或图形 API 会规定:

顺时针是正面

逆时针是正面

符合规定绕序的一面就是正面。 反过来就是背面。

所以如果模型顶点绕序反了,可能会出现:

模型从外面看不见
从里面反而能看见

这通常就是正反面判断反了。

为什么要背面剔除

因为大多数实体模型是封闭的。

比如:

角色
箱子
石头
建筑
武器
车辆

你从外面看,只能看到朝向你的那一面。 背后的三角形被前面的表面挡住了,画它们没有意义。

所以开启背面剔除可以减少:

三角形处理
光栅化压力
片元着色器计算
Overdraw

简单说:

看不到的背面,不画,省性能

Cull Back、Cull Front、Cull Off 是什么

Cull Back

剔除背面

这是最常见的模式。 大多数不透明实体模型都用它。

Cull Front

剔除正面

比较少见,常用于一些特殊效果,比如反向描边、壳体效果、某些阴影或轮廓渲染。

Cull Off

不剔除
正面和背面都画

适合薄片类模型:

树叶

纸片
布料
头发片
UI 3D 面片

因为这些东西没有真正厚度,如果只画正面,从背面看可能就消失了。

和法线有什么关系

视觉理解上,正面通常和法线方向一致。 比如一个面的法线朝外,那么外侧就是你希望看到的正面。

但严格说,背面剔除通常主要看:

三角形顶点绕序

法线更多影响:

光照
阴影
反射
表面明暗

所以要区分:

绕序决定正反面
法线决定光照方向

当然,在建模软件里,法线和绕序通常会一起保持一致,所以你会感觉它们是一回事。

C# 示例:模拟判断一个面是否朝向相机

下面用 C# 模拟“一个面的法线是否朝向相机”。 这不是 GPU 真正的绕序剔除,但能帮助你理解背面剔除的思想。

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Transform、Vector3 和 Debug
public class BackfaceCullingSimulation : MonoBehaviour // 定义一个模拟背面剔除判断的组件
{ // 类开始
    public Transform cameraTransform; // 保存相机的 Transform 引用
    public Vector3 faceNormal = Vector3.forward; // 保存当前面的本地法线方向
    private void Start() // Unity 在脚本启用后第一次 Update 前调用 Start
    { // Start 函数开始
        if (cameraTransform == null) // 判断相机引用是否为空
        { // if 语句开始
            cameraTransform = Camera.main.transform; // 如果没有手动指定相机,就使用主相机的 Transform
        } // if 语句结束
        Vector3 worldNormal = transform.TransformDirection(faceNormal).normalized; // 把面的本地法线转换成世界空间方向
        Vector3 toCamera = (cameraTransform.position - transform.position).normalized; // 计算从当前面指向相机的方向
        float dot = Vector3.Dot(worldNormal, toCamera); // 用点乘判断法线是否大致朝向相机
        bool isFrontFacing = dot > 0f; // 如果点乘大于 0,说明法线朝向相机这一侧
        if (isFrontFacing) // 判断当前面是否是朝向相机的面
        { // if 语句开始
            Debug.Log("Front Face:这个面朝向相机,通常会被渲染"); // 输出正面会被渲染的提示
        } // if 语句结束
        else // 如果当前面不是朝向相机的面
        { // else 语句开始
            Debug.Log("Back Face:这个面背对相机,开启背面剔除时通常会被跳过"); // 输出背面会被剔除的提示
        } // else 语句结束
    } // Start 函数结束
} // 类结束

Unity Shader 里怎么写

虽然你主要问 C#,但背面剔除通常在 Shader 里设置,面试知道这几个就够了:

Cull Back
Cull Front
Cull Off

含义分别是:

剔除背面
剔除正面
关闭剔除,双面都画

如果你做树叶、草、布料,经常会用:

Cull Off

如果你做普通角色、箱子、建筑,通常用:

Cull Back

常见问题

模型从某个角度突然看不见,可能是:

开了背面剔除
模型法线或绕序反了
模型是单面片
从背面看它自然不可见

树叶从背面看消失,可能是:

树叶是单面模型
Shader 开了 Cull Back

解决方式可能是:

改成双面模型
或者 Shader 使用 Cull Off
或者修正模型法线和绕序

面试高分回答

NOTE

背面剔除是渲染管线中的一种优化,它会跳过背对相机的三角形。GPU 通常根据三角形顶点在屏幕上的绕序判断正面和背面,如果开启 Cull Back,背面三角形就不会继续进入后续渲染流程,从而减少光栅化和片元着色器的开销。大多数封闭不透明模型适合开启背面剔除;但像树叶、草、布料、纸片这种双面可见的薄片模型,通常需要 Cull Off。需要注意的是,背面剔除主要和三角形绕序有关,法线更多影响光照。

Alpha Test 和 Alpha Blend 区别是什么?

Alpha Test 和 Alpha Blend 区别是什么?

一句话:Alpha Test 是“透明度不够就直接丢掉”,Alpha Blend 是“根据透明度和背景颜色混合”。 所以:树叶、草、铁丝网常用 Alpha Test;玻璃、烟雾、粒子、UI 渐隐常用 Alpha Blend。

alpha-test-vs-alpha-blend-csharp

Alpha Test 是什么

Alpha Test 也叫:

Alpha Cutout
Alpha Clip
透明裁剪

它会拿像素的 alpha 和一个阈值 cutoff 比较:

alpha >= cutoff:显示
alpha < cutoff:丢弃

它不是半透明,而是非常干脆:

要么完全显示
要么完全不显示

所以 Alpha Test 的边缘通常比较硬,像剪纸一样。

Alpha Blend 是什么

Alpha Blend 是真正的半透明混合。

它不会直接丢弃像素,而是用透明度把前景颜色和背景颜色混起来:

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

比如 alpha = 0.3

前景占 30%
背景占 70%

所以它能做玻璃、烟雾、火焰、UI 淡入淡出这种柔和透明效果。

核心区别

Alpha Test:

硬透明
不做颜色混合
可以直接丢弃片元
边缘硬
通常可以写深度
适合树叶、草、铁丝网、破洞贴图

Alpha Blend:

半透明
会和背景颜色混合
边缘柔和
通常关闭深度写入
需要排序
容易产生 Overdraw
适合玻璃、烟、火、粒子、UI

深度方面的区别很重要

Alpha Test 因为结果只有显示或丢弃,所以显示出来的部分可以像普通不透明物体一样写入深度。

所以它通常可以:

ZWrite On

Alpha Blend 因为是半透明,后面的物体也可能需要被看见。 所以透明物体通常:

ZWrite Off

但这会带来一个问题:

透明物体需要从远到近排序

否则玻璃、烟雾、粒子可能显示顺序错误。

C# 示例:模拟 Alpha Test 和 Alpha Blend

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Color、Mathf 和 Debug
public class AlphaTestBlendSimulation : MonoBehaviour // 定义一个模拟 Alpha Test 和 Alpha Blend 的 Unity 组件
{ // 类开始
    public float alpha = 0.4f; // 保存当前像素的透明度
    public float cutoff = 0.5f; // 保存 Alpha Test 使用的裁剪阈值
    private void Start() // Unity 在脚本启用后第一次 Update 前调用 Start
    { // Start 函数开始
        bool alphaTestVisible = PassAlphaTest(alpha, cutoff); // 判断当前透明度是否能通过 Alpha Test
        Color sourceColor = new Color(0f, 1f, 0f, alpha); // 创建前景颜色,绿色并带有 alpha 透明度
        Color backgroundColor = new Color(1f, 0.6f, 0f, 1f); // 创建背景颜色,橙色并且完全不透明
        Color blendedColor = AlphaBlend(sourceColor, backgroundColor); // 使用 Alpha Blend 计算混合后的颜色
        Debug.Log("Alpha Test Visible = " + alphaTestVisible); // 输出 Alpha Test 是否显示
        Debug.Log("Alpha Blend Color = " + blendedColor); // 输出 Alpha Blend 混合后的颜色
    } // Start 函数结束
    private bool PassAlphaTest(float pixelAlpha, float alphaCutoff) // 定义 Alpha Test 判断方法
    { // 方法开始
        bool visible = pixelAlpha >= alphaCutoff; // 如果 alpha 大于等于阈值,就认为这个像素可见
        return visible; // 返回这个像素是否通过 Alpha Test
    } // 方法结束
    private Color AlphaBlend(Color source, Color destination) // 定义 Alpha Blend 混合方法
    { // 方法开始
        float a = Mathf.Clamp01(source.a); // 把前景 alpha 限制在 0 到 1 之间
        float r = source.r * a + destination.r * (1f - a); // 根据 alpha 混合红色通道
        float g = source.g * a + destination.g * (1f - a); // 根据 alpha 混合绿色通道
        float b = source.b * a + destination.b * (1f - a); // 根据 alpha 混合蓝色通道
        Color result = new Color(r, g, b, 1f); // 创建最终混合后的颜色
        return result; // 返回混合结果
    } // 方法结束
} // 类结束

性能上怎么理解

Alpha Test 会 discard / clip 掉一部分片元。 它能避免被裁掉的地方继续写颜色,但过多 discard 也可能影响某些 GPU 优化。

Alpha Blend 最大的问题是 Overdraw。

比如一堆半透明烟雾叠在一起:

同一个屏幕像素可能被画很多次

每一层都要混合颜色,所以粒子、烟雾、透明 UI 多了会很吃 GPU。

面试高分回答

NOTE

Alpha Test 是通过 alpha 和 cutoff 阈值比较来决定片元是否丢弃,结果只有完全显示或完全不显示,适合树叶、草、铁丝网这类硬边透明物体,通常可以写入深度。Alpha Blend 是根据 alpha 把当前片元颜色和背景颜色进行混合,可以表现真正的半透明,适合玻璃、烟雾、粒子和 UI 渐隐,但通常需要关闭 ZWrite,并且依赖从远到近排序,容易产生 Overdraw。简单说,Alpha Test 是裁剪,Alpha Blend 是混合

Forward 和 Deferred 渲染区别是什么?

Forward 和 Deferred 渲染区别是什么?

一句话:Forward 是“画物体的时候顺便算光”,Deferred 是“先把物体信息存起来,再统一算光”。

forward-vs-deferred-rendering-csharp

Forward Rendering 是什么

Forward Rendering 叫前向渲染。

它的流程比较直接:

渲染一个物体
读取它的材质、贴图、法线
计算它受到哪些灯光影响
输出最终颜色

也就是:

物体 + 灯光 -> 最终颜色

如果场景里灯光很少,Forward 很简单、很直接、很适合。

Forward 的优点

Forward 的优点是:

流程简单
透明物体支持自然
MSAA 支持更方便
适合移动端
适合灯光少的场景
适合材质种类复杂的场景

比如:

手游
卡通渲染
透明特效多的游戏
灯光数量不多的场景

Forward 通常比较合适。

Forward 的缺点

Forward 最大的问题是:

灯光多时容易变贵

因为物体渲染时要计算灯光。

如果一个物体受到很多灯影响,计算量可能增加。

你可以粗略理解成:

成本 ≈ 物体数量 × 影响它的灯光数量

当然真实引擎会做优化,但面试里这样理解很清楚。

Deferred Rendering 是什么

Deferred Rendering 叫延迟渲染。

它分成两个大阶段。

第一阶段:几何阶段。

先不急着算最终光照,而是把物体表面信息写到几张缓冲图里。

这些缓冲图叫:

G-Buffer

G-Buffer 里通常会存:

Albedo 基础颜色
Normal 法线
Depth 深度
Metallic 金属度
Roughness 粗糙度
Specular 高光信息

第二阶段:光照阶段。

再根据 G-Buffer 和灯光,在屏幕空间统一算光。

也就是:

G-Buffer + Lights -> 最终颜色

Deferred 的优点

Deferred 最大优势是:

大量动态灯光更友好

因为光照计算可以在屏幕空间做。

尤其是很多点光源、复杂灯光场景时,Deferred 往往比传统 Forward 更有优势。

适合:

大量动态灯光
复杂不透明场景
PC / 主机
写实风格场景
夜景、霓虹灯、室内多灯场景

Deferred 的缺点

Deferred 也有代价。

第一,G-Buffer 占显存和带宽。

因为它不是只输出一张颜色图,而是要写多张图:

颜色图
法线图
深度图
材质参数图

第二,透明物体不好处理。

透明物体需要看到后面的东西,还要做混合。 但 Deferred 的 G-Buffer 更适合“不透明表面”,所以透明物体通常还是要单独走 Forward。

第三,MSAA 通常更麻烦。

因为 Deferred 在屏幕空间做光照,多采样抗锯齿处理起来比 Forward 复杂。

核心区别表

Forward:
边画物体边算光
灯光少时简单高效
透明支持更自然
移动端常用
灯光多时压力变大
Deferred:
先写 G-Buffer
再统一算光
大量动态灯光更有优势
不透明场景更适合
透明、MSAA、带宽成本更麻烦

Unity 里怎么理解

在 Unity 里,不同管线支持方式不一样。

Built-in 管线里可以选择:

Forward
Deferred

URP 也有 Forward、Deferred、Forward+ 等方案。 HDRP 默认更偏向复杂光照和延迟/混合策略。

但不管 Unity 怎么封装,底层思想都可以记成:

Forward:物体阶段算光
Deferred:G-Buffer 阶段存信息,后面再算光

C# 示例:查看当前相机渲染路径

注意:这个例子主要适合 Built-in 管线理解。 如果项目使用 URP / HDRP,实际渲染路径由 Render Pipeline Asset 控制,actualRenderingPath 不一定代表全部 SRP 细节。

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Camera、RenderingPath 和 Debug
[RequireComponent(typeof(Camera))] // 要求当前 GameObject 必须带有 Camera 组件
public class RenderingPathLogger : MonoBehaviour // 定义一个打印相机渲染路径的组件
{ // 类开始
    private Camera cachedCamera; // 缓存当前物体上的 Camera 组件
    private void Awake() // Unity 在脚本实例加载时调用 Awake
    { // Awake 函数开始
        cachedCamera = GetComponent<Camera>(); // 获取当前物体上的 Camera 组件
    } // Awake 函数结束
    private void Start() // Unity 在脚本启用后第一次 Update 前调用 Start
    { // Start 函数开始
        RenderingPath selectedPath = cachedCamera.renderingPath; // 获取相机设置里选择的渲染路径
        RenderingPath actualPath = cachedCamera.actualRenderingPath; // 获取 Unity 最终实际使用的渲染路径
        Debug.Log("Selected Rendering Path = " + selectedPath); // 输出相机设置中选择的渲染路径
        Debug.Log("Actual Rendering Path = " + actualPath); // 输出当前相机实际使用的渲染路径
    } // Start 函数结束
} // 类结束

怎么选

如果你的场景:

灯光少
透明物体多
移动端
需要 MSAA
材质变化复杂

优先考虑 Forward。

如果你的场景:

大量动态点光源
不透明物体多
PC 或主机
写实光照复杂
希望光照统一在屏幕空间处理

可以考虑 Deferred。

面试高分回答

NOTE

Forward Rendering 是前向渲染,它在渲染每个物体时直接计算该物体受到的光照,流程简单,对透明物体和 MSAA 支持更自然,适合灯光较少、移动端或透明物体较多的场景。Deferred Rendering 是延迟渲染,它先把不透明物体的颜色、法线、深度、材质参数等写入 G-Buffer,然后在屏幕空间统一计算光照,因此大量动态灯光时更有优势。但 Deferred 需要额外的 G-Buffer,显存和带宽开销更高,透明物体通常仍要走 Forward,MSAA 支持也更复杂。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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