Appearance
题海扩展:图形与 Shader 题海
什么是 Clip Space?
一句话定义 Clip Space 是顶点经过 Model -> View -> Projection 变换之后、还没做 除以 w 之前的齐次裁剪空间。
底层原理 一个顶点会经历:
c
Object Space -> World Space -> View Space -> Clip Space -> NDC -> Screen Space其中 Clip Space 的坐标是:
c
clipPos = ProjectionMatrix * ViewMatrix * ModelMatrix * objectPos
clipPos = (x, y, z, w)注意,它还是 float4,还没有变成真正屏幕坐标。GPU 会先用它做裁剪,比如:
c
-w <= x <= w
-w <= y <= w
z 范围和图形 API 有关通过裁剪后,GPU 再做透视除法:
c
ndc = clipPos.xyz / clipPos.w这一步之后才进入 NDC,也就是归一化设备坐标。
为什么叫 Clip Space 因为 GPU 会在这个空间里做 clipping,也就是把视野外的三角形裁掉。 比如一个三角形一半在屏幕里、一半在屏幕外,GPU 会在 Clip Space 里把屏幕外的部分裁掉,只留下可见部分进入光栅化。
Unity 里怎么看到它 在 Unity Shader 里,顶点着色器输出的 SV_POSITION 通常就是 Clip Space 坐标。Built-in 管线常见写法是 UnityObjectToClipPos,URP/HDRP 常见叫 TransformObjectToHClip,这里的 HClip 就是 Homogeneous Clip Space。
代码示例
c
struct appdata // 定义从模型传入顶点着色器的数据结构。
{ // appdata 结构体开始。
float4 vertex : POSITION; // 模型空间下的顶点坐标。
}; // appdata 结构体结束。
struct v2f // 定义从顶点着色器传给后续阶段的数据结构。
{ // v2f 结构体开始。
float4 positionCS : SV_POSITION; // 裁剪空间坐标,GPU 后续会用它做裁剪和透视除法。
}; // v2f 结构体结束。
v2f vert(appdata v) // 定义顶点着色器函数。
{ // 顶点着色器函数开始。
v2f o; // 创建输出结构体。
o.positionCS = UnityObjectToClipPos(v.vertex); // 把模型空间顶点变换到 Clip Space。
return o; // 返回给 GPU 后续管线。
} // 顶点着色器函数结束。常见坑 不要把 Clip Space 当成 World Space 用。 也不要在顶点着色器里手动 clip.xyz / clip.w 后再塞给 SV_POSITION,因为 GPU 后面还会做透视除法,容易变成重复除以 w。
什么是 NDC?
一句话定义 NDC 是 Normalized Device Coordinates,也就是 Clip Space 做完 除以 w 之后、还没变成屏幕像素之前的归一化坐标空间。
底层流程
c
Object Space
-> World Space
-> View Space
-> Clip Space
-> NDC
-> Screen Space从 Clip Space 到 NDC 的核心公式是:
c
ndc = clipPos.xyz / clipPos.w也就是:
c
ndc.x = clip.x / clip.w
ndc.y = clip.y / clip.w
ndc.z = clip.z / clip.w坐标范围
x 通常是 -1 到 1: -1 表示屏幕最左边,1 表示屏幕最右边。
y 通常是 -1 到 1: -1 表示屏幕下边,1 表示屏幕上边。
z 表示深度: Direct3D 常见是 0 到 1,OpenGL 常见是 -1 到 1,Unity 会根据平台做适配。
和 Clip Space、Screen Space 的区别
Clip Space:还没除以 w,是 float4(x, y, z, w)。 NDC:已经除以 w,是标准化坐标。 Screen Space:NDC 再经过 Viewport Transform,变成真正的像素坐标。
代码理解
c
float4 clipPos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点变到 Clip Space。
float3 ndcPos = clipPos.xyz / clipPos.w; // 手动演示透视除法,得到 NDC。
float2 screen01 = ndcPos.xy * 0.5 + 0.5; // 把 -1 到 1 转成 0 到 1 的屏幕比例坐标。面试关键词 NDC 是“除以 w 后、Viewport Transform 前”的空间。它不是世界坐标,也不是像素坐标。
Viewport Transform 做什么?
一句话定义 Viewport Transform 就是把 NDC 的 -1 到 1 标准坐标,按相机视口矩形转换成真正的屏幕像素坐标。
底层流程
c
Clip Space
-> 透视除法
-> NDC
-> Viewport Transform
-> Screen SpaceNDC 里点的范围通常是:
c
x: -1 到 1
y: -1 到 1Viewport Transform 会先把它变成 0 到 1:
c
u = ndc.x * 0.5 + 0.5
v = ndc.y * 0.5 + 0.5再映射到视口像素:
c
screenX = viewport.x + u * viewport.width
screenY = viewport.y + v * viewport.heightUnity 里怎么理解
Camera.rect 是归一化视口区域,比如小地图相机只占屏幕右上角。 WorldToViewportPoint 得到的是 0 到 1 的视口坐标。 WorldToScreenPoint 得到的是屏幕像素坐标。
C# 示例
c
using UnityEngine; // 引入 Unity 引擎 API。
public static class ViewportTransformDemo // 定义一个视口变换演示工具类。
{ // 类开始。
public static Vector2 NdcToScreen(Vector2 ndc, Rect pixelRect) // 把 NDC 坐标转换成屏幕像素坐标。
{ // 方法开始。
float u = ndc.x * 0.5f + 0.5f; // 把 NDC 的 x 从 -1 到 1 转成 0 到 1。
float v = ndc.y * 0.5f + 0.5f; // 把 NDC 的 y 从 -1 到 1 转成 0 到 1。
float screenX = pixelRect.x + u * pixelRect.width; // 根据视口 x 偏移和宽度计算屏幕 x。
float screenY = pixelRect.y + v * pixelRect.height; // 根据视口 y 偏移和高度计算屏幕 y。
return new Vector2(screenX, screenY); // 返回最终屏幕像素坐标。
} // 方法结束。
} // 类结束。常见坑 Viewport Transform 不负责投影,投影已经在 Clip Space 之前完成。它只做“缩放 + 偏移”。另外要注意不同图形 API 的 y 原点、深度范围可能不同,Unity 通常会帮我们做平台适配。
深度缓冲精度为什么近处更高?
一句话回答 因为透视投影后的深度值不是线性存距离,而是近似按 1 / z 分布:越靠近相机,深度值变化越快,所以深度缓冲分配到近处的有效精度更高。
底层原理 透视投影后,深度大概是这种形式:
c
depth = A + B / viewZ所以它不是“距离相机 1 米、2 米、3 米”这样平均分布。 近处 viewZ 小,1 / viewZ 变化很剧烈;远处 viewZ 大,变化会越来越平。
结果就是:
近处两个物体只差一点距离,深度值也能分开。 远处两个物体差了不少距离,深度值可能还是很接近。 这就是远处更容易出现 Z-Fighting 的原因。
C# 简单模拟
c
using UnityEngine; // 引入 Unity 数学 API。
public static class DepthPrecisionDemo // 定义深度精度演示类。
{ // 类开始。
public static float StandardDepth(float eyeZ, float near, float far) // 计算标准透视深度。
{ // 方法开始。
eyeZ = Mathf.Max(eyeZ, near); // 保证距离不会小于 near 裁剪面。
float a = far / (far - near); // 计算透视深度公式中的常量 A。
float b = -far * near / (far - near); // 计算透视深度公式中的常量 B。
return a + b / eyeZ; // 返回非线性深度值。
} // 方法结束。
} // 类结束。Unity 工程实践
Camera.nearClipPlane 不要设得太小,比如 0.001 很危险。 Camera.farClipPlane 不要设得没必要地远。 far / near 比例越夸张,深度精度越容易崩。
优化方向:提高 near、降低 far、使用更高精度深度格式、必要时用 Reverse-Z。 补一句:正交相机深度通常更接近线性,这个“近处精度更高”的问题主要针对透视相机。
反向 Z 是什么?
一句话定义 反向 Z 就是把深度范围反过来:传统是 near = 0, far = 1,反向 Z 是 near = 1, far = 0,并配合 Greater 深度测试来提高远处深度精度。
底层原理 普通透视深度里,远处深度值会挤在接近 1 的区域,远处两个物体深度差很小,就容易 Z-Fighting。
反向 Z 把远处深度放到接近 0 的区域。浮点数在 0 附近可表示的值更密,所以远处深度有效精度会变好。
它改了什么
传统 Z:
c
near = 0
far = 1
ClearDepth = 1
ZTest Less / LEqual反向 Z:
c
near = 1
far = 0
ClearDepth = 0
ZTest Greater / GEqualUnity 里注意
Unity 在一些现代图形 API 和渲染管线里会使用反向 Z。写 Shader 时不要自己硬猜“深度 0 一定是近处”,应该用 Unity 提供的深度宏和线性化函数。
c
float GetEyeDepth(float rawDepth) // 定义把原始深度转成线性视空间深度的函数。
{ // 函数开始。
#if defined(UNITY_REVERSED_Z) // 判断当前平台或管线是否使用反向 Z。
float deviceDepth = rawDepth; // 反向 Z 下 rawDepth 的含义由 Unity 深度参数处理。
#else // 如果不是反向 Z。
float deviceDepth = rawDepth; // 普通 Z 下同样保留原始深度值。
#endif // 结束宏判断。
return LinearEyeDepth(deviceDepth, _ZBufferParams); // 使用 Unity 参数把深度转成线性 Eye Depth。
} // 函数结束。面试加分说法 反向 Z 不是单纯把比较符号换一下,它通常要配合投影矩阵、清深度值、深度比较函数和浮点深度缓冲一起使用。它主要解决大场景、远裁剪面很远时的远处深度精度问题。
Early-Z 是什么?
一句话定义 Early-Z 就是在 Pixel Shader 执行之前先做深度测试,把已经被前面物体挡住的片元提前丢掉,避免浪费昂贵的片元计算。
底层原理 正常渲染时,一个三角形光栅化后会产生很多片元。 如果这些片元其实被前面的物体挡住,最终不会显示出来。Early-Z 的作用就是:
先深度测试
如果被挡住,直接丢弃
如果没被挡住,再执行 Pixel Shader这样可以减少 Overdraw 带来的 GPU 片元压力。
什么时候收益明显
不透明物体多、遮挡多、Pixel Shader 很贵时收益明显。 比如角色、建筑、地形互相遮挡,而且 Shader 里有多张贴图采样、多光源计算、PBR 计算,Early-Z 可以少跑很多无效片元。
哪些写法容易破坏 Early-Z
discard / clip、Alpha Test、手动写深度 SV_Depth、透明混合 ZWrite Off 都可能削弱或破坏 Early-Z。 因为 GPU 必须等 Pixel Shader 执行完,才知道这个片元到底要不要存在,或者最终深度是多少。
Shader 示例
c
Shader "Demo/OpaqueEarlyZFriendly" // 定义一个适合 Early-Z 的不透明 Shader。
{ // Shader 开始。
SubShader // 定义子着色器。
{ // SubShader 开始。
Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 放在不透明队列,方便深度写入和 Early-Z。
ZWrite On // 开启深度写入,让后面的物体可以被深度挡掉。
ZTest LEqual // 使用常规深度测试,离相机更近或相等的片元通过。
Pass // 定义一个渲染 Pass。
{ // Pass 开始。
CGPROGRAM // 开始 CG/HLSL 代码。
#pragma vertex vert // 指定顶点着色器入口。
#pragma fragment frag // 指定片元着色器入口。
#include "UnityCG.cginc" // 引入 Unity 常用 Shader 函数。
struct appdata { float4 vertex : POSITION; }; // 输入模型空间顶点。
struct v2f { float4 pos : SV_POSITION; }; // 输出裁剪空间位置。
v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); return o; } // 顶点变换到裁剪空间。
fixed4 frag(v2f i) : SV_Target { return fixed4(1, 1, 1, 1); } // 不 discard、不写深度,Early-Z 更容易生效。
ENDCG // 结束 CG/HLSL 代码。
} // Pass 结束。
} // SubShader 结束。
} // Shader 结束。面试加分说法 Early-Z 优化的是“被遮挡片元的 Pixel Shader 成本”,不是减少 DrawCall,也不是减少三角形数量。要配合不透明排序、ZWrite On、减少 discard,必要时做 Depth Prepass。
Alpha Blend 为什么可能破坏 Early-Z?
一句话回答 Alpha Blend 可能破坏或削弱 Early-Z,是因为透明物体通常 ZWrite Off,不会把自己的深度写进深度缓冲;而且它的颜色要和后面的颜色混合,不能像不透明物体那样直接把后方片元提前丢掉。
核心原理
不透明物体通常:
ZWrite On
先写深度
后面的片元如果被挡住,Early-Z 可以直接丢掉Alpha Blend 通常:
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
透明层要按顺序混合
多个透明片元都可能影响最终颜色所以透明物体之间不能很好地互相用深度剔除。即使某个透明片元在另一个透明片元后面,它也可能还要参与最终颜色混合。
注意这个细节 Alpha Blend 不一定完全禁止 Early-Z。 如果透明片元在不透明物体后面,仍然可能被已有深度挡掉。 但透明物体自己通常不写深度,所以它对后面的透明层没有很好的遮挡作用,透明 Overdraw 仍然很贵。
Unity Shader 示例
c
Shader "Demo/AlphaBlend" // 定义一个半透明 Shader。
{ // Shader 开始。
SubShader // 定义子着色器。
{ // SubShader 开始。
Tags { "Queue"="Transparent" "RenderType"="Transparent" } // 放进透明队列。
ZWrite Off // 关闭深度写入,避免透明物体错误挡住后面的透明层。
Blend SrcAlpha OneMinusSrcAlpha // 开启标准 Alpha 混合。
Pass // 定义渲染 Pass。
{ // Pass 开始。
} // Pass 结束。
} // SubShader 结束。
} // Shader 结束。面试加分说法 Alpha Blend 的问题不是“完全不能深度测试”,而是“通常不能写深度,所以透明层之间无法很好地 Early-Z 剔除”。透明物体多时,瓶颈常常是 Overdraw 和 Pixel Shader 成本。
优化方向 减少透明面积和层数;能用 Alpha Test/Cutout 就少用 Blend;粒子和 UI 控制屏幕覆盖面积;复杂透明效果可以考虑深度预处理、低分辨率渲染或分层方案。
双面渲染有什么代价?
一句话回答 双面渲染通常就是 Cull Off,让正面和背面都参与渲染。它适合树叶、草、布料、纸片这类薄片,但代价是片元计算、Overdraw、透明排序、法线光照和阴影成本都会增加。
核心代价
- 片元成本增加:背面不再被剔除,也可能进入光栅化和 Pixel Shader。
- Overdraw 增加:薄片、草、树叶、透明材质叠在一起时更明显。
- 透明更贵:双面透明会出现前后面都 Blend,排序和填充率压力更大。
- 光照可能不对:背面法线方向和正面相反,可能需要根据正反面翻转法线。
- 阴影成本增加:如果 ShadowCaster 也双面,阴影贴图渲染压力会上升。
- 不一定多 DrawCall:单 Pass
Cull Off通常不是多一次 DrawCall;但如果用正反面两个 Pass,就可能接近翻倍。
Shader 示例
c
Shader "Demo/DoubleSided" // 定义一个双面渲染 Shader。
{ // Shader 开始。
SubShader // 定义子着色器。
{ // SubShader 开始。
Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 设置为不透明几何队列。
Cull Off // 关闭背面剔除,让正面和背面都可见。
ZWrite On // 开启深度写入,适合不透明双面薄片。
Pass // 定义一个渲染 Pass。
{ // Pass 开始。
} // Pass 结束。
} // SubShader 结束。
} // Shader 结束。面试加分说法 背面剔除通常发生在顶点着色之后、光栅化之前,所以双面渲染主要增加的是 Raster 和 Fragment 阶段压力,不是简单地说“顶点计算翻倍”。如果是两 Pass 双面实现,那 DrawCall 和部分阶段才可能翻倍。
项目建议 只给真正需要的薄片材质开双面。封闭模型、角色身体、建筑墙体默认不要开。移动端尤其要谨慎,因为双面透明草、树叶、特效很容易把填充率打爆。
顶点色可以用来做什么?
一句话定义
顶点色就是 Mesh 每个顶点自带的一份 RGBA 数据,Shader 可以拿它做颜色、遮罩、权重、区域标记、特效强度等低成本控制。
底层原理
GPU 顶点数据里除了 position / normal / uv,还可以有 color。 光栅化时,三角形三个顶点的颜色会被自动插值到每个片元,所以片元着色器拿到的不是单个顶点色,而是平滑过渡后的颜色。
常见用途
顶点色常用于:角色换色、受击变红、模型区域遮罩、地形材质混合、AO 烘焙、风力强度、溶解边缘、流光强度、调试区域显示。 优点是省贴图、省采样、数据跟模型走;缺点是精度受顶点密度影响,顶点太少时颜色变化会很粗。
Unity 例子
c
Shader "Interview/VertexColorUnlit" // 定义一个使用顶点色的 Unlit Shader
{ // Shader 开始
Properties // 暴露给 Inspector 的属性
{ // 属性块开始
_MainTex ("Main Tex", 2D) = "white" {} // 主贴图,默认白色
} // 属性块结束
SubShader // 子着色器开始
{ // SubShader 开始
Pass // 一个渲染 Pass
{ // Pass 开始
CGPROGRAM // 开始 CG/HLSL 代码
#pragma vertex vert // 指定顶点着色器函数
#pragma fragment frag // 指定片元着色器函数
#include "UnityCG.cginc" // 引入 Unity 常用宏和函数
sampler2D _MainTex; // 声明主贴图采样器
struct appdata // 顶点输入结构
{ // appdata 开始
float4 vertex : POSITION; // 顶点位置
float2 uv : TEXCOORD0; // 顶点 UV
fixed4 color : COLOR; // 顶点色
}; // appdata 结束
struct v2f // 顶点到片元的数据结构
{ // v2f 开始
float4 pos : SV_POSITION; // 裁剪空间位置
float2 uv : TEXCOORD0; // 传给片元的 UV
fixed4 color : COLOR; // 传给片元的顶点色
}; // v2f 结束
v2f vert(appdata v) // 顶点着色器
{ // vert 开始
v2f o; // 创建输出数据
o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点变到裁剪空间
o.uv = v.uv; // 传递 UV
o.color = v.color; // 传递顶点色
return o; // 返回给光栅化阶段
} // vert 结束
fixed4 frag(v2f i) : SV_Target // 片元着色器
{ // frag 开始
fixed4 tex = tex2D(_MainTex, i.uv); // 采样主贴图
return tex * i.color; // 用顶点色给贴图染色或做遮罩
} // frag 结束
ENDCG // 结束 CG/HLSL 代码
} // Pass 结束
} // SubShader 结束
} // Shader 结束面试补一句
项目里我一般会把顶点色当成“便宜的数据通道”:能不用额外贴图采样就不用贴图,但如果需要很精细的像素级遮罩,就会改用 Mask 贴图。
UV2 常用来存什么?
标准答案
UV2 通常用来存第二套纹理坐标,最常见用途是 Lightmap UV,也就是烘焙光照贴图用的 UV。 但它本质上是 Mesh 的第二个 TEXCOORD 顶点通道,所以项目里也经常拿它存一些自定义数据,比如 AO、遮罩、溶解权重、风动强度、区域 ID、材质混合权重等。
底层原理
一个 Mesh 顶点不只可以有位置、法线、UV0,还可以有多套 UV:
UV0:通常采样主贴图,比如 Albedo、Normal、Mask。UV2:Unity 里对应mesh.uv2,Shader 里通常对应TEXCOORD1。- 数据会随顶点进入 GPU,然后在光栅化阶段插值到片元。
所以 UV2 不一定非得表示“坐标”,它也可以当成每个顶点携带的额外数据。
Unity 项目里常见用途
- 静态物体烘焙光照:存 Lightmap UV,要求不重叠、留边距。
- 存 AO 或污渍强度:减少额外贴图采样。
- 存特效控制参数:比如溶解范围、流光强度、风摆权重。
- 存材质混合权重:比如地形、角色服装、场景模型混合不同材质区域。
注意坑点
如果模型导入设置里开启了 Generate Lightmap UVs,Unity 可能会生成或覆盖 UV2。 所以如果你用 UV2 存自定义数据,要和美术导出、模型导入、烘焙流程约定清楚,否则线上可能出现 Shader 参数错乱。
Unity 写入 UV2 示例
c
using UnityEngine; // 引入 UnityEngine 命名空间
using System.Collections.Generic; // 引入 List 集合命名空间
public class UV2Example : MonoBehaviour // 定义一个演示 UV2 的组件
{ // 类开始
private void Start() // Unity 在对象启用后的第一帧前调用 Start
{ // Start 方法开始
MeshFilter meshFilter = GetComponent<MeshFilter>(); // 获取当前物体上的 MeshFilter
Mesh mesh = meshFilter.mesh; // 获取实例化后的 Mesh,避免直接改共享资源
List<Vector2> uv2List = new List<Vector2>(); // 创建一组 UV2 数据
for (int i = 0; i < mesh.vertexCount; i++) // 遍历 Mesh 的每个顶点
{ // for 循环开始
uv2List.Add(new Vector2(1.0f, 0.5f)); // 给每个顶点写入自定义 UV2 数据
} // for 循环结束
mesh.SetUVs(1, uv2List); // 写入第 1 号 UV 通道,也就是 UV2 / TEXCOORD1
} // Start 方法结束
} // 类结束面试一句话
UV2 最经典是给 Lightmap 用,但从底层看它就是 TEXCOORD1 顶点通道,也能用来存各种低成本的 per-vertex 自定义数据。
Lightmap UV 有什么要求?
标准答案
Lightmap UV 的核心要求是:不能重叠、要留 Padding、通常在 0 到 1 范围内、展开失真要小、面积密度要合理。 因为 Lightmap 本质是一张烘焙出来的光照贴图,如果两个面共用同一块 UV 区域,它们就会共用同一块光照像素,容易出现串光、脏边、漏光、阴影错乱。
底层原理
普通贴图 UV 可以重叠,比如左右对称模型共用一块贴图,这样能省贴图空间。 但 Lightmap UV 不行,因为每个表面的光照结果通常不一样:
- 左墙和右墙看到的光不同。
- 地面和天花板受光不同。
- 阴影区域和亮面区域不同。
所以 Lightmap UV 要让每个面在光照贴图里有自己独立的位置。
Unity 里重点记这几个要求
- 不重叠:不同 UV Island 不要压在一起。
- 留 Padding:UV 岛之间要留边距,避免采样过滤、mipmap、压缩导致串色。
- 尽量在 0 到 1 内:方便 Lightmap Atlas 打包。
- 面积比例合理:玩家常看的大墙面、地面要给足像素密度。
- 低拉伸:UV 展开太扭曲,烘焙阴影会变形。
- 不要太碎:UV 岛太多会浪费 Padding 空间,也会降低利用率。
Unity 工程坑点
Generate Lightmap UVs 可以自动生成 UV2,但它可能覆盖原来的 mesh.uv2。 如果你用 UV2 存了自定义数据,比如风力、溶解权重、材质混合参数,就要小心导入设置把它改掉。
简单检查脚本
c
#if UNITY_EDITOR // 只在 Unity 编辑器环境下编译这段代码
using UnityEngine; // 引入 UnityEngine 基础 API
using UnityEditor; // 引入 UnityEditor 编辑器 API
public static class LightmapUvChecker // 定义一个静态工具类
{ // 工具类开始
[MenuItem("Tools/Check Selected Lightmap UV2")] // 在 Unity 菜单栏添加一个检查按钮
private static void CheckSelected() // 定义菜单点击后执行的方法
{ // 方法开始
foreach (GameObject go in Selection.gameObjects) // 遍历当前选中的所有 GameObject
{ // foreach 开始
MeshFilter meshFilter = go.GetComponent<MeshFilter>(); // 获取物体上的 MeshFilter 组件
if (meshFilter == null || meshFilter.sharedMesh == null) // 判断是否没有 MeshFilter 或没有 Mesh
{ // if 开始
continue; // 跳过没有 Mesh 的对象
} // if 结束
Mesh mesh = meshFilter.sharedMesh; // 取出共享 Mesh 资源
Vector2[] uv2 = mesh.uv2; // 读取 Mesh 的 UV2 数据
if (uv2 == null || uv2.Length != mesh.vertexCount) // 判断 UV2 是否缺失或数量不匹配
{ // if 开始
Debug.LogWarning($"{go.name}: 缺少 UV2,或 UV2 数量和顶点数不一致。", go); // 输出警告日志
continue; // 跳过当前对象
} // if 结束
for (int i = 0; i < uv2.Length; i++) // 遍历每一个 UV2 坐标
{ // for 开始
Vector2 uv = uv2[i]; // 取出当前顶点的 UV2
if (uv.x < 0f || uv.x > 1f || uv.y < 0f || uv.y > 1f) // 判断 UV2 是否超出 0 到 1 范围
{ // if 开始
Debug.LogWarning($"{go.name}: 第 {i} 个顶点 UV2 超出 0 到 1,值为 {uv}。", go); // 输出超范围警告
break; // 找到一个问题后停止检查当前 Mesh
} // if 结束
} // for 结束
} // foreach 结束
} // 方法结束
} // 工具类结束
#endif // 结束编辑器条件编译这段脚本只能做基础巡检,真正的 UV 重叠、Padding 是否足够、密度是否合理,还要结合 DCC 工具、Unity 导入设置和烘焙结果来看。
面试一句话
Lightmap UV 不是普通贴图 UV,它要服务于光照烘焙,所以重点是“不重叠、留边距、低失真、密度合理”,否则就会出现串光、脏边、漏光和阴影错误。
切线空间由哪些向量组成?
标准答案
切线空间由三个基向量组成,也叫 TBN:
Tangent:切线T,通常沿着模型表面的U方向。Bitangent:副切线 / 双切线B,通常沿着模型表面的V方向。Normal:法线N,垂直于模型表面。
这三个方向共同组成一个贴在模型表面上的局部坐标系。
底层原理
普通模型顶点有法线 N,但法线贴图里的 RGB 表示的是“局部方向”,不是世界空间方向。 所以 Shader 需要用 TBN 矩阵,把法线贴图采样出来的切线空间法线转换到世界空间或视图空间:
c
normalWS = TBN * normalTS其中:
normalTS:法线贴图解码后的切线空间法线。normalWS:转换后的世界空间法线。TBN:由Tangent / Bitangent / Normal组成的矩阵。
Unity 里的重点
Unity 的 Mesh 通常存:
normal:法线Ntangent.xyz:切线Ttangent.w:切线空间的左右手性,用来还原B
所以副切线一般这样算:
c
float3 N = normalize(normalWS); // 把世界空间法线归一化
float3 T = normalize(tangentWS); // 把世界空间切线归一化
float sign = tangentW; // tangent.w 表示切线空间左右手性
float3 B = normalize(cross(N, T) * sign); // 用 N 和 T 叉乘还原副切线 B
float3x3 TBN = float3x3(T, B, N); // 组成 TBN 矩阵在 Unity 里还要注意负缩放,很多内置写法会把 unity_WorldTransformParams.w 也乘进去,用来修正镜像变换导致的方向反转。
常见坑点
如果切线不对,法线贴图就会出现:
- 高光方向错误。
- 接缝明显。
- 镜像 UV 一边正常,一边反了。
- 法线贴图看起来像“凹凸方向反了”。
所以项目里遇到法线贴图异常,我会先检查:模型有没有切线、法线贴图类型是否正确、TBN 计算是否匹配 Unity 的约定、是否有镜像 UV 或负缩放。
面试一句话
切线空间由 Tangent、Bitangent、Normal 三个向量组成,Unity 里通常存 T 和 N,再通过 cross(N, T) * tangent.w 还原 B。
TBN 矩阵是什么?
标准答案
TBN 矩阵就是由 Tangent、Bitangent、Normal 三个方向组成的空间变换矩阵:
c
TBN = [ T B N ]它主要用于 法线贴图:把法线贴图里采样出来的 normalTS,从切线空间转换到世界空间 normalWS,然后才能和世界空间灯光方向做光照计算。
底层原理
法线贴图里的 RGB 不是世界方向,而是“相对于模型表面”的局部方向:
x表示沿Tangent偏多少。y表示沿Bitangent偏多少。z表示沿Normal偏多少。
所以要用 TBN 把它转换出去:
c
normalWS = TBN * normalTS实际 Shader 里因为行列主序和 mul 写法不同,可能写成 mul(TBN, normalTS) 或 mul(normalTS, TBN),关键是 T、B、N 的排列和乘法方向要一致。
Unity 里怎么构造
Unity 通常存 normal 和 tangent,副切线 B 常通过叉乘还原:
c
float3 normalTS = UnpackNormal(tex2D(_BumpMap, i.uv)); // 从法线贴图采样并解码出切线空间法线
float3 N = normalize(i.normalWS); // 归一化世界空间法线 N
float3 T = normalize(i.tangentWS); // 归一化世界空间切线 T
float sign = i.tangentSign; // 读取 tangent.w 或修正后的切线空间手性
float3 B = normalize(cross(N, T) * sign); // 用 N 和 T 叉乘还原副切线 B
float3x3 TBN = float3x3(T, B, N); // 用 T、B、N 组成 TBN 矩阵
float3 normalWS = normalize(mul(normalTS, TBN)); // 把切线空间法线转换到世界空间项目坑点
TBN 错了,法线贴图最常见的问题就是高光方向反、接缝明显、凹凸反了。 我一般会检查:模型有没有 tangent、法线贴图导入类型是否是 Normal Map、tangent.w 有没有乘、负缩放和镜像 UV 有没有处理。
面试一句话
TBN 是把切线空间映射到世界空间的基变换矩阵,法线贴图采样出来的方向必须经过 TBN 转换,才能参与正常的光照计算。
法线贴图为什么不能直接当世界法线用?
标准答案
法线贴图不能直接当世界法线用,因为它通常存的是 切线空间法线,也就是“相对于当前模型表面”的局部方向,不是固定的世界方向。
同一张法线贴图贴到地面、墙面、斜坡上时,贴图里的 (0.5, 0.5, 1) 都表示“沿当前表面法线方向”,但这些表面在世界里的朝向是不一样的。
底层原理
法线贴图采样出来的是:
normalTS其中 TS 是 Tangent Space,切线空间。
它需要通过 TBN 矩阵转换成世界空间:
normalWS = TBN * normalTS然后才能和世界空间灯光方向做点乘:
c
diffuse = dot(normalWS, lightDirWS)如果直接把 normalTS 当 normalWS,就等于不管模型表面怎么转,法线方向都固定在世界里,结果就是高光方向不对、凹凸漂浮、接缝明显。
Unity Shader 里正确写法
c
float3 normalTS = UnpackNormal(tex2D(_NormalMap, i.uv)); // 从法线贴图采样,并解码成切线空间法线
float3 T = normalize(i.tangentWS); // 获取并归一化世界空间切线 T
float3 N = normalize(i.normalWS); // 获取并归一化世界空间法线 N
float sign = i.tangentSign; // 获取 tangent.w 或修正后的左右手性符号
float3 B = normalize(cross(N, T) * sign); // 用 N 和 T 叉乘还原世界空间副切线 B
float3x3 TBN = float3x3(T, B, N); // 用 T、B、N 组成 TBN 矩阵
float3 normalWS = normalize(mul(normalTS, TBN)); // 把切线空间法线转换成世界空间法线
float ndotl = saturate(dot(normalWS, lightDirWS)); // 用世界空间法线和世界空间灯光方向做光照点乘面试补充
严格说,如果一张法线贴图本来就是世界空间法线图,那可以直接当世界方向用。 但游戏里常见的 Normal Map 基本都是 切线空间法线贴图,因为它可以复用在不同模型、不同朝向、不同动画变形的表面上。
面试一句话
法线贴图存的是局部凹凸方向,不是世界方向;必须乘 TBN 转成 normalWS,才能和世界空间灯光正确计算。
高光锯齿如何处理?
标准答案
高光锯齿通常叫 Specular Aliasing,不是单纯模型边缘锯齿。它常见原因是:高光太尖、法线贴图太高频、粗糙度太低、远距离 mip/filter 不合适,导致一个像素里高光变化特别剧烈,镜头一动就闪。
处理思路
- 材质侧:不要让
Smoothness无限高,给Roughness一个下限。 - 法线贴图侧:开启合适的 mipmap 和过滤,降低过强的 Normal Strength。
- Shader 侧:根据法线变化增加有效粗糙度,也就是 Specular AA / Toksvig 类思路。
- 屏幕抗锯齿侧:TAA 对高光闪烁通常比 MSAA 更有效,因为 MSAA 主要处理几何边缘,不一定解决 Shader 内部高光闪烁。
- 美术侧:避免超高频噪声法线、过亮金属高光、过低 roughness 堆在一起。
底层原理
高光公式对法线很敏感。法线只要一点点变化,N dot H 的结果就可能差很多。 如果 roughness 很低,高光会非常窄,可能小于一个像素。GPU 采样时一帧采到亮点,下一帧没采到,就会出现闪烁、断裂、锯齿。
简化 Shader 思路
c
float roughness = max(_Roughness, 0.08); // 给粗糙度设置一个下限,避免高光过尖
float normalVariance = length(fwidth(normalWS)); // 用屏幕空间导数估算法线变化幅度
float specularAA = saturate(normalVariance * _SpecularAAStrength); // 把法线变化转换成抗锯齿强度
roughness = saturate(roughness + specularAA); // 法线变化越剧烈,就让高光越宽
float smoothness = 1.0 - roughness; // 如果管线使用 smoothness,就把 roughness 转回 smoothness这段只是思路示例,真实 PBR 里要结合 BRDF、法线贴图 mip、IBL 预过滤和管线约定一起处理。
面试一句话
高光锯齿不能只说“开抗锯齿”,要从 粗糙度下限、法线贴图 mip/filter、降低高频法线、Specular AA、TAA 多层一起处理。
法线贴图压缩有什么问题?
标准答案
法线贴图压缩最大的问题是:它压的不是普通颜色,而是方向向量。 RGB 只要偏一点,解码出来的法线方向就会偏,最后会表现成高光块状、接缝、闪烁、凹凸变脏、斜面反光异常。
底层原理
普通颜色贴图压缩后只是颜色略有误差,但法线贴图的 RGB 会被解码成方向:
normal = rgb * 2 - 1如果压缩让 R/G/B 改变了,法线方向就改变了。 尤其高光对法线非常敏感,所以压缩误差在金属、湿地、皮革、车漆这类材质上会特别明显。
常见问题
- 块状痕迹:BC、ETC、ASTC 都是块压缩,低质量会让法线出现方块感。
- 高光破碎:法线方向被量化后,高光不连续。
- 接缝明显:UV 接缝、镜像 UV、切线不一致时更明显。
- Mip 后变软:法线 mipmap 平均后长度变短,凹凸和高光都会变弱。
- 格式错误:把 Normal Map 当普通 sRGB 颜色图压缩,会导致解码不正确。
Unity 里怎么处理
PC 上法线贴图常优先考虑 BC5,因为它适合存 X/Y 两个通道,再重建 Z。 移动端常用 ASTC,块大小越小质量越好,比如 ASTC 4x4 质量高但占内存大,6x6 / 8x8 更省但更容易有瑕疵。 导入时要设置成 Normal Map,不要当普通颜色贴图处理,通常也不要对重要法线贴图用太狠的 Crunch。
Unity 导入设置示例
c
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 基础 API
public static class NormalMapImportTool // 定义法线贴图导入工具类
{ // 类开始
[MenuItem("Tools/Set Selected Textures As Normal Map")] // 添加菜单按钮
private static void SetSelectedAsNormalMap() // 定义菜单执行方法
{ // 方法开始
foreach (Object obj in Selection.objects) // 遍历当前选中的资源
{ // 循环开始
string path = AssetDatabase.GetAssetPath(obj); // 获取资源路径
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; // 获取贴图导入器
if (importer == null) // 判断资源是否不是贴图
{ // if 开始
continue; // 跳过非贴图资源
} // if 结束
importer.textureType = TextureImporterType.NormalMap; // 设置贴图类型为法线贴图
importer.sRGBTexture = false; // 关闭 sRGB,让法线按线性数据处理
importer.mipmapEnabled = true; // 开启 mipmap,减少远处闪烁
importer.textureCompression = TextureImporterCompression.CompressedHQ; // 使用较高质量压缩
importer.SaveAndReimport(); // 保存设置并重新导入贴图
} // 循环结束
} // 方法结束
} // 类结束面试一句话
法线贴图压缩要保护“方向精度”,不能只看图片像不像;格式、sRGB、mipmap、压缩质量、Normal Strength 和平台格式都要一起考虑。
Mipmap 有什么作用?
标准答案
Mipmap 的作用是:给同一张纹理预生成多级小图,让物体离相机越远时,GPU 采样越低分辨率的纹理层级。 它主要解决 远处纹理闪烁、锯齿、摩尔纹、采样带宽浪费 的问题。
底层原理
一张 1024x1024 的纹理会生成:
1024x1024 -> 512x512 -> 256x256 -> 128x128 -> ...远处物体在屏幕上可能只占几十个像素,如果还硬采 1024x1024 原图,就会把大量高频细节压进少量屏幕像素里,结果就是闪烁和噪点。 Mipmap 相当于提前把远处纹理“低通滤波”好了。
优点和代价
优点:
- 减少远处纹理闪烁。
- 降低摩尔纹和采样噪声。
- 提高纹理缓存命中率。
- 降低纹理带宽压力。
代价:
- 完整 Mipmap 链大约多占 33% 内存。
- 纹理导入和构建包会更慢一点。
- UI、字体、图标、像素风贴图开 Mipmap 可能会变糊。
Unity 使用建议
3D 场景贴图,比如地面、墙面、角色、道具、法线贴图,通常建议开 Mipmap。 UI、字体、小图标、像素风精灵通常关闭 Mipmap。 如果是倾斜角度很大的地面,还可以配合 Anisotropic Filtering,否则远处斜面仍然可能糊。
Unity 导入设置示例
c
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 基础 API
public static class MipmapImportTool // 定义一个 Mipmap 导入设置工具类
{ // 类开始
[MenuItem("Tools/Enable Mipmap For Selected Textures")] // 在 Unity 菜单栏添加一个按钮
private static void EnableMipmap() // 定义菜单点击后执行的方法
{ // 方法开始
foreach (Object obj in Selection.objects) // 遍历当前选中的所有资源
{ // 循环开始
string path = AssetDatabase.GetAssetPath(obj); // 获取资源路径
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; // 获取纹理导入器
if (importer == null) // 如果当前资源不是纹理
{ // if 开始
continue; // 跳过这个资源
} // if 结束
importer.mipmapEnabled = true; // 开启 Mipmap
importer.filterMode = FilterMode.Trilinear; // 使用三线性过滤,减少 mip 层级切换突变
importer.anisoLevel = 4; // 设置各向异性过滤等级,改善斜面纹理清晰度
importer.SaveAndReimport(); // 保存设置并重新导入纹理
} // 循环结束
} // 方法结束
} // 类结束常见坑点
图集开 Mipmap 时要留足 Padding,否则低 mip 会采到旁边图片的颜色。 移动端不要无脑开高质量过滤,要结合内存、带宽、发热一起看。 面试里可以总结成一句:Mipmap 是用约 33% 额外内存,换远处纹理稳定性和采样效率。
Anisotropic Filtering 是什么?
标准答案
Anisotropic Filtering,中文叫 各向异性过滤,主要用来改善 斜视角纹理采样。 比如地面、道路、跑道、墙面这种纹理,当相机斜着看时,远处很容易糊成一片。开启各向异性过滤后,纹理在斜视角下会更清晰。
底层原理
普通 Mipmap 主要解决“远处纹理缩小”的问题,但它默认更像按一个接近圆形的采样范围去取纹理。 可是斜着看地面时,一个屏幕像素投影到纹理上,不是圆形,而是一个 细长的椭圆区域。
各向异性过滤会沿着这个细长方向取更多样本,所以远处斜面纹理不会被过度平均。
和 Mipmap 的关系
它不是替代 Mipmap,而是 Mipmap 的补充:
Mipmap:解决远处纹理缩小时的闪烁和锯齿。Trilinear:在两个 mip 层之间插值,减少 mip 切换突变。Anisotropic Filtering:解决斜视角下纹理被拉长采样导致的模糊。
Unity 使用建议
地面、道路、墙面、跑道、地砖这类纹理很适合开。 UI、图标、角色小贴图、没有明显斜视角的贴图,收益就不大。 移动端不要无脑开 16x,一般可以从 2x 或 4x 开始测,重点看带宽、发热和帧率。
Unity 设置示例
c
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 基础 API
public static class AnisoImportTool // 定义各向异性过滤导入工具类
{ // 类开始
[MenuItem("Tools/Set Selected Textures Aniso 4")] // 在菜单栏添加一个工具按钮
private static void SetAnisoLevel() // 定义菜单点击后执行的方法
{ // 方法开始
foreach (Object obj in Selection.objects) // 遍历当前选中的资源
{ // 循环开始
string path = AssetDatabase.GetAssetPath(obj); // 获取资源路径
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; // 获取纹理导入器
if (importer == null) // 如果不是纹理资源
{ // if 开始
continue; // 跳过当前资源
} // if 结束
importer.mipmapEnabled = true; // 开启 Mipmap,各向异性过滤通常配合 Mipmap 使用
importer.anisoLevel = 4; // 设置各向异性过滤等级为 4
importer.filterMode = FilterMode.Trilinear; // 使用三线性过滤,减少 mip 层级切换突变
importer.SaveAndReimport(); // 保存设置并重新导入纹理
} // 循环结束
} // 方法结束
} // 类结束面试一句话
各向异性过滤就是在斜视角下,根据纹理投影的拉伸方向多采样,改善地面、道路这类远处斜面纹理发糊的问题,但代价是更多采样和纹理带宽。
Texture Streaming 是什么?
标准答案
Texture Streaming 在 Unity 里通常指 Texture Mipmap Streaming: 它不是简单地异步加载整张贴图,而是根据相机距离、物体屏幕占比、内存预算,动态决定一张纹理需要加载到哪一级 mip。
简单说就是:
- 近处物体:加载高清 mip,保证清晰。
- 远处物体:只加载低清 mip,节省内存。
- 内存紧张:降低低优先级纹理的 mip 清晰度。
底层原理
一张纹理本来有很多 mip 层,比如:
4096 -> 2048 -> 1024 -> 512 -> 256不开 Texture Streaming 时,这些 mip 往往会一起占用显存或纹理内存。 开启后,Unity 会根据当前相机看到的大小,判断这张贴图实际需要多清晰。比如远处一个房子只占屏幕很小一块,就没必要加载 4096 的最高 mip。
Unity 里适合用在哪
很适合:
- 开放世界。
- 大场景。
- 高分辨率场景贴图。
- 地形、建筑、道路、岩石、植被。
- 低端机内存压力大的项目。
不太适合:
- UI 图标。
- 字体。
- 必须立刻高清显示的关键界面。
- 很小且常驻的贴图。
常见坑点
Texture Streaming 会节省内存,但可能带来 先糊后清 的现象。 比如镜头快速切换、玩家高速移动、刚进场景时,高清 mip 还没加载上来,画面会短暂模糊。 所以项目里要配合:预加载、合理预算、贴图优先级、关键资源锁定高清 mip。
Unity 设置示例
c
#if UNITY_EDITOR // 只在 Unity 编辑器环境下编译
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 基础 API
public static class TextureStreamingImportTool // 定义纹理流送导入工具类
{ // 类开始
[MenuItem("Tools/Enable Texture Streaming For Selected")] // 在 Unity 菜单栏添加按钮
private static void EnableTextureStreaming() // 定义菜单执行方法
{ // 方法开始
foreach (Object obj in Selection.objects) // 遍历当前选中的资源
{ // 循环开始
string path = AssetDatabase.GetAssetPath(obj); // 获取资源路径
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; // 获取纹理导入器
if (importer == null) // 判断当前资源是否不是纹理
{ // if 开始
continue; // 跳过非纹理资源
} // if 结束
importer.mipmapEnabled = true; // 开启 Mipmap,因为 Streaming 依赖 mip 链
importer.streamingMipmaps = true; // 开启 Streaming Mip Maps
importer.streamingMipmapsPriority = 0; // 设置默认流送优先级
importer.SaveAndReimport(); // 保存设置并重新导入纹理
} // 循环结束
} // 方法结束
} // 类结束
#endif // 结束编辑器条件编译面试一句话
Texture Streaming 是按需流送纹理 mip,用“近处清晰、远处省内存”的策略控制纹理内存,适合大场景和高分辨率贴图,但要注意预算过低和镜头快速移动导致的先糊后清。
LOD Bias 是什么?
标准答案
LOD Bias 是一个偏置系数,用来控制系统更偏向 画质 还是 性能。
在 Unity 里要区分两种常见语境:
- 模型 LOD Bias:通常指
QualitySettings.lodBias,影响LODGroup的切换距离。 - 纹理 Mip Bias:比如
Texture.mipMapBias,影响采样时选择更高清还是更低清的 mip。
模型 LOD Bias
模型 LOD 是根据物体在屏幕上的大小切换的:
LOD0 高模 -> LOD1 中模 -> LOD2 低模 -> CullLOD Bias 越大,越倾向于保持高质量 LOD:
LOD Bias > 1:更晚切到低模,画质更好,但渲染压力更高。LOD Bias = 1:正常切换。LOD Bias < 1:更早切到低模,性能更好,但远处可能更粗糙。
纹理 Mip Bias
纹理里的 bias 是影响 mip 选择:
- 负 mip bias:更倾向采高清 mip,看起来更锐,但更容易闪烁、更费带宽。
- 正 mip bias:更倾向采低清 mip,看起来更糊,但更省性能和内存带宽。
所以面试里说 LOD Bias 时,最好先说明:如果指 Unity QualitySettings,那主要是模型 LOD;如果是纹理采样,那是 mip bias。
Unity 示例
c
using UnityEngine; // 引入 Unity 基础 API
public class QualityLodBiasExample : MonoBehaviour // 定义一个画质档 LOD Bias 示例组件
{ // 类开始
public void SetHighQuality() // 设置高画质档
{ // 方法开始
QualitySettings.lodBias = 2.0f; // 调大模型 LOD Bias,让高精度 LOD 保持更远
QualitySettings.maximumLODLevel = 0; // 允许使用最高质量 LOD
} // 方法结束
public void SetLowQuality() // 设置低端机画质档
{ // 方法开始
QualitySettings.lodBias = 0.5f; // 调小模型 LOD Bias,让物体更早切到低模
QualitySettings.maximumLODLevel = 1; // 限制最高 LOD,跳过最贵的 LOD0
} // 方法结束
public void SetTextureMipBias(Texture texture) // 设置纹理 mip bias
{ // 方法开始
texture.mipMapBias = 0.5f; // 正 bias 更偏向低清 mip,能省带宽但会更糊
} // 方法结束
} // 类结束常见坑点
LOD Bias 调太大,远处模型细节更多,可能增加顶点数、DrawCall、阴影成本。 调太小,远处会明显弹 LOD,甚至出现模型穿帮、剪影变化太明显。 纹理 mip bias 负值用多了会更锐,但远处更容易闪烁。
面试一句话
模型 LOD Bias 控制 LOD 切换更偏向画质还是性能;纹理 mip bias 控制采样更偏向高清 mip 还是低清 mip,二者名字像,但作用对象不同。
透明排序为什么困难?
标准答案
透明排序困难,是因为透明混合结果依赖 绘制顺序。 不透明物体可以靠深度缓冲解决遮挡:谁离相机近,谁通过 ZTest 留下来。 但半透明物体需要把颜色和背景混合:
c
out = src * alpha + dst * (1 - alpha)这里的 dst 是已经画在屏幕上的颜色,所以 先画谁、后画谁会影响最终结果。
底层原理
透明物体通常:
ZWrite Off:不写深度,避免把后面的透明物体直接挡掉。ZTest On:仍然会被不透明物体遮挡。Blend On:和当前颜色缓冲混合。- 通常按 从远到近 绘制。
难点在于:透明物体经常互相穿插、嵌套,甚至同一个 Mesh 内部三角形也可能前后交错。 这时按“物体中心点”排序就不够了,因为局部区域可能 A 在 B 前面,另一个区域又是 B 在 A 前面。
Unity 工程里怎么处理
常见做法是:不透明先画,透明后画;透明队列里按距离排序。 如果出问题,可以:
- 拆分透明 Mesh,减少交叉。
- 调整
Render Queue或Sorting Order。 - 能用
Alpha Test / Clip就不用半透明混合。 - 对头发、玻璃、粒子做专门排序或分层。
- 必要时用 Depth Prepass、Dither、OIT 等方案,但都有代价。
Shader 示例
c
Tags { "Queue" = "Transparent" } // 把材质放进透明渲染队列
Tags { "RenderType" = "Transparent" } // 标记当前 Shader 是透明类型
ZWrite Off // 关闭深度写入,避免挡住后续透明物体
ZTest LEqual // 保留深度测试,让透明物体仍然被不透明物体遮挡
Blend SrcAlpha OneMinusSrcAlpha // 使用常见 Alpha 混合公式
Cull Back // 默认剔除背面,减少不必要的透明绘制面试一句话
透明排序难在:透明混合依赖绘制顺序,但透明物体经常交叉、嵌套、同 Mesh 内部也没有单一正确顺序,所以只能通过排序规则和工程规避做近似。
半透明阴影如何处理?
标准答案
半透明阴影不能简单按普通透明混合来做,因为常规 Shadow Map 主要记录的是 深度,它天然表达的是“这条光线有没有被挡住”,不是“被挡住 30%”。
所以项目里一般按材质类型处理:
- 叶子、铁网、栅栏:用
Alpha Test / Clip投硬裁剪阴影。 - 玻璃、半透明材质:很多项目直接不投真实阴影,或者用淡色假阴影。
- 烟雾、技能特效:通常用贴花、投影、预烘焙或特效自带阴影感。
- 需要半透明感:用
Dither点阵近似,让覆盖率模拟透明度。
底层原理
普通阴影流程是:
从灯光视角渲染深度 -> 得到 Shadow Map -> 主渲染时比较深度这个过程只关心最近深度,不关心透明物体的颜色和 alpha 混合。 所以半透明物体如果直接写入 Shadow Map,通常会变成“完全遮光”;如果不写,又完全没有阴影。
Shader 思路:Alpha Clip 阴影
c
Pass // 定义一个 ShadowCaster Pass
{ // Pass 开始
Tags { "LightMode" = "ShadowCaster" } // 告诉 Unity 这是投射阴影用的 Pass
ZWrite On // 阴影图需要写入深度
ZTest LEqual // 使用正常深度测试
CGPROGRAM // 开始 CG/HLSL 代码
#pragma vertex vert // 指定顶点着色器
#pragma fragment frag // 指定片元着色器
#include "UnityCG.cginc" // 引入 Unity 常用函数
sampler2D _MainTex; // 声明主贴图
float _Cutoff; // 声明 Alpha 裁剪阈值
struct appdata // 顶点输入结构
{ // appdata 开始
float4 vertex : POSITION; // 顶点位置
float2 uv : TEXCOORD0; // 顶点 UV
}; // appdata 结束
struct v2f // 顶点到片元结构
{ // v2f 开始
float4 pos : SV_POSITION; // 裁剪空间位置
float2 uv : TEXCOORD0; // 传递 UV
}; // v2f 结束
v2f vert(appdata v) // 顶点着色器
{ // vert 开始
v2f o; // 创建输出结构
o.pos = UnityObjectToClipPos(v.vertex); // 转换到裁剪空间
o.uv = v.uv; // 传递 UV
return o; // 返回输出
} // vert 结束
fixed4 frag(v2f i) : SV_Target // 片元着色器
{ // frag 开始
fixed alpha = tex2D(_MainTex, i.uv).a; // 采样贴图 alpha
clip(alpha - _Cutoff); // alpha 小于阈值就丢弃,不写入阴影图
return 0; // 通过裁剪的像素写入阴影深度
} // frag 结束
ENDCG // 结束 CG/HLSL
} // Pass 结束面试一句话
半透明阴影一般不是真把透明混合写进 Shadow Map,而是按效果取舍:叶子用 Alpha Clip,半透明感用 Dither,玻璃和特效常用假阴影或自定义方案。
深度预写入有什么用?
标准答案
深度预写入,也叫 Depth Prepass / Z Prepass,作用是:先用一个很便宜的 Pass 把场景深度写好,再让后面的复杂渲染 Pass 通过 Early-Z 提前丢弃被遮挡的片元。
它主要是为了减少 Overdraw 和昂贵片元着色的浪费。
底层原理
普通渲染里,如果一个很贵的 Shader 先执行了,但后面又被前面的物体挡住,这些片元计算就浪费了。 深度预写入会先执行:
只写深度,不写颜色然后主渲染时,GPU 可以先查深度,如果发现这个像素已经被更近物体挡住,就直接丢弃,不再跑复杂 Fragment Shader。
常见 Shader 写法
c
Pass // 定义深度预写入 Pass
{ // Pass 开始
ZWrite On // 开启深度写入
ZTest LEqual // 使用常规深度测试
ColorMask 0 // 不写颜色,只写深度
} // Pass 结束主 Pass 通常继续正常渲染:
c
Pass // 定义主渲染 Pass
{ // Pass 开始
ZWrite Off // 深度已经在预写入阶段写过,这里可以不再写
ZTest LEqual // 让被遮挡片元通过深度测试提前剔除
// 这里执行贴图采样、PBR、光照等昂贵计算 // 主渲染逻辑说明
} // Pass 结束什么时候有用
适合:
- 场景遮挡很多。
- Shader 很贵,比如复杂 PBR、多贴图、多光源。
- Overdraw 高。
- 需要深度图给 SSAO、景深、软粒子、后处理使用。
- URP/HDRP 里类似
Depth Priming / Depth Prepass的优化路径。
不一定适合:
- 场景很简单,遮挡少。
- 顶点数很多,多一遍几何 Pass 反而贵。
- 移动端 Tile-Based GPU,要实测。
- 大量透明、Alpha Clip、
discard材质,Early-Z 可能受影响。
面试一句话
深度预写入是用一个便宜 Pass 先建立深度,让后续昂贵 Shader 借助 Early-Z 少算被遮挡像素;它用额外 Pass 换片元计算节省,是否划算要看 Overdraw、Shader 成本和平台。
GrabPass 为什么贵?
标准答案
GrabPass 贵,是因为它不是普通“采样一张贴图”,而是要把当前屏幕颜色从 framebuffer 额外拷贝成一张纹理,后面的透明 Shader 才能采样它。这个过程会增加显存带宽、可能打断 GPU 渲染流水线,在移动端 Tile-Based GPU 上还可能触发 Tile Resolve,所以很容易变成性能热点。
底层原理
正常渲染时,GPU 正在往颜色缓冲写结果。GrabPass 需要把“当前已经画好的屏幕”保存出来:
c
GrabPass { "_GrabTexture" } // 把当前屏幕颜色抓到同名纹理,多个物体可以尽量复用一次抓屏。
sampler2D _GrabTexture; // 声明 GrabPass 生成的屏幕纹理采样器。
float4 grabUV = ComputeGrabScreenPos(o.pos); // 把顶点裁剪空间坐标转换成屏幕采样坐标。
fixed4 bg = tex2Dproj(_GrabTexture, grabUV); // 从屏幕纹理里采样背景颜色。
return bg; // 输出采样到的背景颜色,可用于玻璃、折射、扭曲效果。贵的点主要有四个:
- 屏幕拷贝成本高:分辨率越高,颜色缓冲越大。
- 带宽压力大:移动端带宽比桌面端更宝贵。
- 可能造成同步或 Tile Resolve:Tile GPU 本来想在片上缓存里处理,GrabPass 可能迫使它提前写回内存。
- 多个物体可能重复抓屏:
GrabPass { }未命名时,多个物体可能多次抓屏。
Unity 工程实践
Built-in 管线里,GrabPass 常用于玻璃、水面折射、热扭曲、屏幕模糊。但项目里我会尽量避免大量使用,尤其是移动端。
更推荐的做法是:
- 用命名 GrabPass:
GrabPass { "_GrabTexture" },避免每个物体都抓一次。 - 用低分辨率 RenderTexture 做统一扭曲或模糊。
- 在 URP 里用
_CameraOpaqueTexture/ Scene Color。 - 用自定义 Render Pass 统一拷贝一次颜色缓冲。
- 控制透明物体面积,减少 Overdraw。
一句面试话术
GrabPass 贵的本质是:它打破了正常的顺序渲染流程,需要把当前屏幕颜色额外拷贝成纹理,带来带宽、同步和重复采样成本;移动端尤其要谨慎。
屏幕空间描边怎么做?
标准答案
屏幕空间描边通常是一个后处理效果:先让相机拿到深度、法线或对象 Mask,再在全屏 Pass 里采样当前像素周围的像素;如果深度、法线或 ID 差异很大,就认为这里是边缘,然后把边缘像素合成成描边颜色。
底层原理
它不是把模型真的放大一圈,而是在屏幕二维图像上找“突变”:
- 深度突变:物体和背景交界处深度差很大。
- 法线突变:同一个物体表面转折处法线变化很大。
- Mask 突变:只给目标物体写一个 Mask,Mask 边界就是描边位置。
核心片元逻辑大概是这样:
c
float4 _OutlineColor; // 描边颜色,例如黑色、白色、角色阵营色。
float _DepthSensitivity; // 深度差异权重,用来控制深度边缘强度。
float _Threshold; // 边缘阈值,超过这个值才认为是描边。
float2 _TexelSize; // 单个屏幕像素的 UV 大小,通常来自 1 / 屏幕宽高。
float SampleDepth01(float2 uv) // 采样深度并转换到 0 到 1 范围。
{ // 函数开始。
float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, uv); // 读取相机深度纹理。
return Linear01Depth(rawDepth, _ZBufferParams); // 把硬件深度转换成线性深度。
} // 函数结束。
float Edge(float2 uv) // 计算当前像素是不是边缘。
{ // 函数开始。
float c = SampleDepth01(uv); // 采样中心像素深度。
float l = SampleDepth01(uv + float2(-_TexelSize.x, 0)); // 采样左边像素深度。
float r = SampleDepth01(uv + float2(_TexelSize.x, 0)); // 采样右边像素深度。
float u = SampleDepth01(uv + float2(0, _TexelSize.y)); // 采样上边像素深度。
float d = SampleDepth01(uv + float2(0, -_TexelSize.y)); // 采样下边像素深度。
float diff = abs(c - l) + abs(c - r) + abs(c - u) + abs(c - d); // 累加中心和邻居的深度差。
return smoothstep(_Threshold, _Threshold + 0.01, diff * _DepthSensitivity); // 根据阈值得到平滑边缘强度。
} // 函数结束。
float4 Frag(Varyings i) : SV_Target // 后处理片元函数。
{ // 函数开始。
float4 sceneColor = SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, i.uv); // 采样原始画面颜色。
float edge = Edge(i.uv); // 计算当前像素边缘强度。
return lerp(sceneColor, _OutlineColor, edge * _OutlineColor.a); // 边缘处混合描边色,非边缘保持原图。
} // 函数结束。Unity 工程实践
在 URP 里一般用 RendererFeature 或 Full Screen Pass 做。需要让相机提供 Depth Texture;如果要法线描边,还需要 DepthNormals Pass 或自己额外渲染一张法线纹理。只给选中目标描边时,可以先把目标写进一张 Mask RT,再对 Mask 做边缘检测。
优点是描边宽度稳定,和模型拓扑关系不大;缺点是它是全屏采样,分辨率越高越贵,而且透明物体可能不写深度,容易描不出来。
模型空间描边怎么做?
标准答案
模型空间描边最常见做法叫 Inverted Hull 外壳描边:额外渲染一遍模型,把顶点沿模型空间法线外扩一点,只画外扩模型的背面,再让原模型盖住中间,最后露出来的外圈就是描边。
底层原理
它不是后处理找边缘,而是真的让模型几何体“变大一圈”:
- 多一个描边 Pass。
- 顶点位置沿
normal外扩。 Cull Front剔除正面,只画背面外壳。- 正常模型再渲染,把中间盖住。
- 外壳比原模型大,边缘露出来,所以形成描边。
核心代码大概这样:
c
Pass // 描边 Pass:负责画外扩后的模型外壳。
{ // Pass 开始。
Cull Front // 剔除正面,只显示外扩模型的背面。
ZWrite Off // 描边不写深度,避免影响后续深度结果。
ZTest LEqual // 仍然遵守深度测试,被前方物体挡住时不显示描边。
CGPROGRAM // 开始 CG/HLSL 代码。
#pragma vertex vert // 指定顶点着色器函数。
#pragma fragment frag // 指定片元着色器函数。
#include "UnityCG.cginc" // 引入 Unity 常用矩阵和工具函数。
float _OutlineWidth; // 描边宽度,控制顶点外扩距离。
fixed4 _OutlineColor; // 描边颜色。
struct appdata // 顶点输入结构。
{ // 结构开始。
float4 vertex : POSITION; // 模型空间顶点坐标。
float3 normal : NORMAL; // 模型空间顶点法线。
}; // 结构结束。
struct v2f // 顶点到片元的数据结构。
{ // 结构开始。
float4 pos : SV_POSITION; // 裁剪空间坐标。
}; // 结构结束。
v2f vert(appdata v) // 顶点着色器入口。
{ // 函数开始。
v2f o; // 创建输出数据。
float3 n = normalize(v.normal); // 归一化模型空间法线。
v.vertex.xyz += n * _OutlineWidth; // 顶点沿法线方向外扩。
o.pos = UnityObjectToClipPos(v.vertex); // 把外扩后的顶点转换到裁剪空间。
return o; // 返回顶点处理结果。
} // 函数结束。
fixed4 frag(v2f i) : SV_Target // 片元着色器入口。
{ // 函数开始。
return _OutlineColor; // 输出纯色描边。
} // 函数结束。
ENDCG // 结束 CG/HLSL 代码。
} // Pass 结束。Unity 工程实践
这种描边适合卡通角色、选中高亮、敌人轮廓、二次元渲染。优点是实现简单,不需要全屏后处理;缺点是会多一次绘制,而且描边宽度会受模型缩放、距离、法线方向影响。
常见坑点是:硬法线、UV 缝、镜像模型可能导致描边裂开;非等比缩放会让描边粗细不一致。项目里常用“平滑法线”写入顶点色或切线,再用平滑法线外扩,效果会稳定很多。
一句面试话术
模型空间描边的核心是:多画一遍模型,把顶点沿模型法线外扩,用 Cull Front 只显示背面外壳,再用原模型盖住中间,露出的外圈就是描边。
溶解边缘发光怎么做?
标准答案
溶解边缘发光一般这样做:用一张噪声图控制溶解顺序,把噪声值低于溶解阈值的像素 clip 掉;然后把“刚好接近阈值”的一小段区域算成边缘带,让它输出高亮 Emission,最后开启 HDR 和 Bloom,让高亮边缘产生真正的光晕。
底层原理
每个像素都有一个噪声值:
c
sampler2D _MainTex; // 主贴图,用来采样模型原本颜色。
sampler2D _DissolveTex; // 溶解噪声图,用来决定像素消失顺序。
float _DissolveAmount; // 溶解进度,0 表示不溶解,1 表示几乎全溶解。
float _EdgeWidth; // 发光边缘宽度,控制边缘带有多厚。
float _EdgeIntensity; // 发光强度,数值越大越容易触发 Bloom。
fixed4 _EdgeColor; // 边缘发光颜色。
fixed4 frag(v2f i) : SV_Target // 片元着色器入口。
{ // 函数开始。
fixed4 baseColor = tex2D(_MainTex, i.uv); // 采样原始颜色。
float noise = tex2D(_DissolveTex, i.uv).r; // 采样噪声值,范围通常是 0 到 1。
float alive = noise - _DissolveAmount; // 计算当前像素距离溶解阈值还有多远。
clip(alive); // 如果 alive 小于 0,直接丢弃这个像素。
float edge = 1.0 - smoothstep(0.0, _EdgeWidth, alive); // 越靠近裁剪边界,edge 越接近 1。
fixed3 emission = _EdgeColor.rgb * edge * _EdgeIntensity; // 用边缘强度生成自发光颜色。
fixed3 finalColor = baseColor.rgb + emission; // 把自发光叠加到原始颜色上。
return fixed4(finalColor, baseColor.a); // 输出最终颜色。
} // 函数结束。关键点是:clip 负责“消失”,smoothstep 负责“边缘带”,Emission 负责“变亮”,Bloom 负责“看起来真的发光”。
Unity 工程实践
如果用 URP Shader Graph,可以用 Noise Texture -> Sample Texture2D -> Alpha Clip Threshold 做溶解,再用 Smoothstep 算边缘,把结果乘上 EdgeColor * Intensity 接到 Emission。
注意:只写很亮的颜色不一定会有光晕,必须开启 HDR 和 Bloom。移动端要控制 Bloom 强度和范围,不然会增加后处理成本。
常见坑
噪声图压缩太差,边缘会脏;EdgeWidth 太小会锯齿明显,太大会像一整片发亮;如果材质是透明混合,还要注意排序问题。一般角色消失、怪物死亡、传送、技能烧蚀效果都很常用。
水面反射有哪些方案?
标准答案
水面反射常见有 5 种方案:
- 平面反射 Planar Reflection:镜像一个相机,把场景再渲染到
RenderTexture,水面 Shader 采样这张 RT。质量最好,适合湖面、镜面水、地面积水,但成本高,几乎等于多渲染一遍场景。 - SSR 屏幕空间反射:在屏幕空间沿反射方向做 ray march,查找已经渲染出来的颜色。动态效果好,但屏幕外、被遮挡的东西反射不到。
- Cubemap / Reflection Probe:用环境立方体贴图模拟反射。便宜稳定,适合天空、远景、粗糙水面,但近处动态物体不准确。
- 假反射:用天空盒颜色、渐变、法线扰动、Fresnel 混合假出反射。移动端很常用,性能最好。
- 光追反射:真实发射射线求交。质量最高,但平台限制和性能成本都很高,多见于 HDRP 高端项目。
底层原理
水面反射的基础是反射向量:
c
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - worldPos); // 从水面像素指向相机,表示观察方向。
float3 normal = normalize(worldNormal); // 水面世界空间法线,通常会被法线贴图扰动。
float3 reflectDir = reflect(-viewDir, normal); // 根据视线方向和法线计算反射方向。
float fresnel = pow(1.0 - saturate(dot(viewDir, normal)), 5.0); // 计算 Fresnel,视角越斜反射越强。
float3 reflection = SAMPLE_TEXTURECUBE(_ReflectionCube, reflectDir).rgb; // 用反射方向采样 Cubemap。
float3 waterColor = lerp(_WaterColor.rgb, reflection, fresnel); // 用 Fresnel 在水色和反射之间混合。
return float4(waterColor, 1.0); // 输出最终水面颜色。Unity 工程实践
我会按平台和画质选方案:高质量 PC 或主机水面用 Planar Reflection + 法线扰动 + Fresnel;动态屏幕反射可以加 SSR;移动端一般用 Reflection Probe / Cubemap + 假反射,再用法线贴图让水面动起来。
平面反射要优化:RT 降分辨率、只渲染需要反射的 Layer、降低更新频率、远处水面不实时更新。SSR 要注意边缘断裂和屏幕外缺失。Probe 方案要注意动态物体不准确,通常只作为环境反射。
一句面试话术
水面反射不是只有一种做法:高质量平面水用 Planar,动态屏幕效果用 SSR,移动端常用 Cubemap 或假反射,最后都需要 Fresnel 和法线扰动把反射融合得自然。
平面反射有什么代价?
标准答案
平面反射最大的代价是:它通常要额外用一个镜像相机,把水面上方或需要反射的物体再渲染到一张 RenderTexture。所以它不是“采样一张贴图”这么简单,而是接近 多渲染一遍场景。
主要成本有:
- CPU 成本:多一次相机剔除、排序、渲染命令提交。
- GPU 成本:多一次几何绘制、像素填充、可能还有阴影和光照。
- 显存成本:需要一张反射
RenderTexture。 - 带宽成本:GPU 要写入 RT,水面 Shader 又要采样 RT。
- 移动端成本更明显:Tile-Based GPU 对 RT 切换、写回、带宽很敏感。
底层原理
平面反射通常是这样:
c
reflectionCamera.enabled = false; // 反射相机平时不自动渲染,避免每帧重复不可控开销。
reflectionCamera.cullingMask = reflectionLayerMask; // 只渲染需要出现在反射里的物体,减少 DrawCall。
reflectionCamera.targetTexture = reflectionRT; // 把反射结果写入 RenderTexture。
reflectionCamera.Render(); // 手动触发反射相机渲染一次。
waterMaterial.SetTexture("_ReflectionTex", reflectionRT); // 水面材质采样这张反射图。真正贵的是 reflectionCamera.Render() 这一句,因为它会走一遍相机渲染流程:剔除、排序、提交、绘制、写入 RT。场景越复杂,反射相机看到的东西越多,成本越高。
Unity 工程实践
项目里我一般这样优化平面反射:
- 反射 RT 用半分辨率或四分之一分辨率。
- 反射相机只渲染关键 Layer,比如地形、建筑、大物体。
- 关闭反射相机的阴影、后处理、复杂特效。
- 水面很远或面积很小时降低更新频率。
- 粗糙水面用 Cubemap 或假反射,不强行上平面反射。
- 多块水面尽量共享一张反射 RT,避免多个反射相机同时工作。
一句面试话术
平面反射贵在“多渲染一次场景 + 多一张 RenderTexture”。画质很好,但 CPU、GPU、显存和带宽都会增加,所以优化重点是降 RT 分辨率、裁剪 Layer、关掉反射相机里的阴影后处理,并降低更新频率。
CubeMap 可以做什么?
标准答案
CubeMap 也叫立方体贴图,本质是 6 张正方形贴图组成的环境贴图,分别表示 +X、-X、+Y、-Y、+Z、-Z 六个方向。它不是用普通二维 UV 采样,而是用一个 float3 方向向量采样。
CubeMap 常用来做:
- 天空盒 Skybox:把整个环境包在相机外面。
- 环境反射:金属、水面、玻璃用反射方向采样 CubeMap。
- Reflection Probe:Unity 反射探针会生成 CubeMap。
- PBR IBL:用环境贴图做间接光照和高光反射。
- 点光源阴影:点光源向六个方向投射阴影,常用 Cube Shadow Map。
底层原理
反射时,通常先根据视线方向和法线算出反射方向,再拿这个方向去采样 CubeMap:
c
samplerCUBE _EnvCube; // 声明一张 CubeMap 环境贴图。
float3 worldNormal = normalize(i.worldNormal); // 得到当前像素的世界空间法线。
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // 得到从像素指向相机的观察方向。
float3 reflectDir = reflect(-viewDir, worldNormal); // 根据观察方向和法线计算反射方向。
float3 envColor = texCUBE(_EnvCube, reflectDir).rgb; // 用三维反射方向采样 CubeMap。
return float4(envColor, 1.0); // 输出环境反射颜色。GPU 会根据 reflectDir 判断应该采样六个面里的哪一面,再算出该面内部的二维坐标。所以 CubeMap 很适合表示“从一个点向四周看”的环境。
Unity 工程实践
在 Unity 里,CubeMap 常见来源有:
Skybox/Cubemap材质。Reflection Probe烘焙或实时生成。- 美术提供的 HDRI 环境贴图。
- 运行时用相机捕获六个方向生成动态 CubeMap。
优点是采样便宜、方向连续、适合远处环境和全局反射。缺点是它通常不适合精确反射近处动态物体,因为 CubeMap 假设环境来自某个固定采样点;如果需要更准确的近距离反射,要考虑平面反射、SSR 或 Probe Box Projection。
一句面试话术
CubeMap 就是六面环境贴图,用方向向量采样;它常用于天空盒、Reflection Probe、金属和水面反射、PBR IBL,以及点光源阴影。代价是六面纹理占内存,实时更新会很贵,因为相当于要渲染六个方向。
天空盒如何影响环境光?
标准答案
天空盒会影响环境光,但前提是 Unity 的 Ambient Source 选择了 Skybox。这时天空盒不只是背景图,Unity 会根据天空盒生成环境漫反射光和环境反射数据,让物体的暗部不至于死黑,也让金属、玻璃、水面等材质能反射天空颜色。
底层原理
天空盒影响环境光主要有两条链路:
第一条是漫反射环境光。Unity 会把天空盒的颜色信息处理成低频光照数据,常见理解就是 Ambient Probe / Spherical Harmonics。它不会保留特别清晰的方向细节,而是用来给物体各个方向补柔和的环境光。
第二条是环境反射。PBR 材质会从天空盒或 Reflection Probe 中采样反射贴图。粗糙度低的物体反射更清晰,粗糙度高的物体反射更模糊。所以天空盒颜色变化,会明显影响金属、高光、水面、玻璃这些材质的观感。
Unity 代码示例
c
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Rendering; // 引入渲染相关枚举,例如 AmbientMode 和 DefaultReflectionMode。
public class SkyboxEnvironmentSetter : MonoBehaviour // 定义一个用于设置天空盒和环境光的组件。
{ // 类开始。
[SerializeField] private Material skyboxMaterial; // 在 Inspector 中指定天空盒材质。
[SerializeField] private float ambientIntensity = 1.0f; // 设置环境光强度倍率。
[SerializeField] private DefaultReflectionMode reflectionMode = DefaultReflectionMode.Skybox; // 设置默认反射来源为天空盒。
private void Start() // Start 在对象启用后第一帧执行。
{ // Start 方法开始。
if (skyboxMaterial == null) // 如果没有配置天空盒材质。
{ // if 代码块开始。
return; // 直接返回,避免空引用。
} // if 代码块结束。
RenderSettings.skybox = skyboxMaterial; // 设置当前场景使用的天空盒材质。
RenderSettings.ambientMode = AmbientMode.Skybox; // 设置环境光来源为天空盒。
RenderSettings.ambientIntensity = ambientIntensity; // 设置环境光强度。
RenderSettings.defaultReflectionMode = reflectionMode; // 设置环境反射来源。
DynamicGI.UpdateEnvironment(); // 通知 Unity 更新环境光和反射相关数据。
} // Start 方法结束。
} // 类结束。项目里的坑点
不要把天空盒理解成“自动照亮所有物体”。如果 Ambient Source 不是 Skybox,它可能只是背景。运行时频繁切天空盒并调用 DynamicGI.UpdateEnvironment() 也有成本,不适合每帧做。移动端还要注意反射贴图质量、分辨率和带宽成本。
面试关键句
我会这样答:天空盒作为背景只是视觉效果;当它作为环境光来源时,Unity 会用它生成低频环境漫反射和环境反射数据,最终影响 Lit/PBR 材质的暗部亮度、高光颜色和反射效果。
Light Probe 是什么?
标准答案
Light Probe 是 Unity 里给动态物体使用的烘焙间接光采样点。静态物体可以直接用 Lightmap,但角色、怪物、道具这些会移动的物体不能烘进 Lightmap,所以 Unity 用 Light Probe 在空间里记录一组烘焙光照数据,运行时根据动态物体的位置插值,给它补上环境光。
底层原理
Light Probe 里存的不是普通颜色,而是 SH 球谐光照数据。你可以简单理解成:它用一组系数记录“这个位置周围各个方向来的低频环境光”。
运行时大概流程是:
- 场景烘焙时,每个 Light Probe 采样当前位置的间接光。
- Unity 把这些光照压缩成
Spherical Harmonics。 - 运行时动态物体根据自己的位置,在周围几个 Probe 之间插值。
- 插值后的 SH 数据传给 Shader。
- Shader 根据模型法线方向取环境光,让动态物体融入烘焙场景。
它适合表现柔和的间接光,但不适合表现锐利阴影、镜面反射,这些不是 Light Probe 的职责。
Unity 工程实践
角色、怪物、NPC、可移动箱子,一般会把 Renderer.lightProbeUsage 设置成 Blend Probes:
c
using UnityEngine; // 引入 Unity 基础类型,例如 MonoBehaviour、Renderer、Transform。
using UnityEngine.Rendering; // 引入渲染相关枚举,例如 LightProbeUsage。
public class LightProbeUsageExample : MonoBehaviour // 定义一个演示 Light Probe 设置的组件。
{ // 类开始。
[SerializeField] private Renderer targetRenderer; // 要接收 Light Probe 光照的 Renderer。
[SerializeField] private Transform probeAnchor; // 可选的采样锚点,常放在角色胸口或中心位置。
private void Awake() // Awake 在对象初始化阶段执行。
{ // Awake 函数开始。
if (targetRenderer == null) // 如果没有手动拖 Renderer。
{ // if 代码块开始。
targetRenderer = GetComponentInChildren<Renderer>(); // 自动从子物体里找一个 Renderer。
} // if 代码块结束。
targetRenderer.lightProbeUsage = LightProbeUsage.BlendProbes; // 让 Renderer 使用周围 Light Probe 插值结果。
targetRenderer.probeAnchor = probeAnchor; // 指定采样位置,避免大型角色用错误位置采光。
} // Awake 函数结束。
} // 类结束。常见坑
Light Probe 太稀会导致角色移动时亮度跳变;Probe 放进墙里、地下、封闭物体内部,会采到错误光照;大型动态物体只用一个采样点容易一边亮一边暗,这时可以考虑 Light Probe Proxy Volume。
一句面试话术
Light Probe 是动态物体用的烘焙间接光,底层用 SH 存低频环境光,运行时根据物体位置在探针之间插值,解决动态角色在烘焙场景里发黑或色调不匹配的问题。
Reflection Probe 是什么?
标准答案
Reflection Probe 是 Unity 的反射探针,它会在某个位置捕获周围环境,生成一张 Cubemap,然后让金属、水面、玻璃、光滑地板这类材质用它来做镜面环境反射。
它和 Light Probe 不一样:
Light Probe 主要给动态物体补漫反射间接光;Reflection Probe 主要给材质提供镜面反射环境。
底层原理
Reflection Probe 会从探针位置朝六个方向捕获场景,生成 Cubemap。物体渲染时,根据视线方向和表面法线算出反射方向,再用这个方向采样 Cubemap:
c
samplerCUBE _ReflectionCube; // 声明 Reflection Probe 提供的环境 Cubemap。
float3 worldNormal = normalize(i.worldNormal); // 归一化世界空间法线。
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // 计算从像素指向相机的观察方向。
float3 reflectDir = reflect(-viewDir, worldNormal); // 根据视线和法线计算反射方向。
float mip = roughness * _MaxMipLevel; // 根据粗糙度选择 Cubemap 的 mip 层级。
float3 reflection = texCUBElod(_ReflectionCube, float4(reflectDir, mip)).rgb; // 用反射方向和 mip 采样环境反射。
return float4(reflection, 1.0); // 输出反射颜色。粗糙度越低,采样越清晰的 mip;粗糙度越高,采样越模糊的 mip,所以粗糙表面反射会更散。
Unity 工程实践
Unity 里 Reflection Probe 常见有三种模式:Baked、Custom、Realtime。
Baked 最便宜,适合静态环境;Custom 可以手动指定 Cubemap;Realtime 会运行时重新捕获环境,成本很高,因为本质上要渲染多个方向。移动端一般优先用 Baked 或 Custom。
室内场景还经常开 Box Projection,让反射更贴合房间边界,否则反射会像来自无限远的环境,容易穿帮。
一句面试话术
Reflection Probe 本质是局部环境 Cubemap,给镜面反射用;材质根据反射方向采样它,粗糙度决定采哪个 mip。它比真实实时反射便宜,但不是严格几何正确,Realtime Probe 成本很高。
Probe 更新频率如何影响性能?
标准答案
Probe 的“更新频率”主要影响的是 Realtime Reflection Probe。材质采样 Probe 通常不算贵,真正贵的是 刷新 Probe,因为刷新时要重新捕获环境、渲染 Cubemap、写 RenderTexture、生成 mip。
所以更新频率越高,性能压力越大:
- Every Frame:画面最及时,但成本最高,容易造成帧耗时尖峰。
- 低频更新:比如 0.5 秒或 1 秒更新一次,画面延迟一点,但帧率稳定很多。
- 事件触发更新:只有环境变化、门打开、天气变化时刷新,性价比最高。
- Baked / Custom Probe:基本没有运行时刷新成本,移动端优先考虑。
底层原理
Reflection Probe 刷新不是普通采样,而是重新生成环境 Cubemap。一次刷新可能要渲染六个方向,还可能要生成不同 mip 给粗糙度使用:
c
using UnityEngine; // 引入 Unity 基础类型。
public class ReflectionProbeUpdateLimiter : MonoBehaviour // 定义一个限制反射探针刷新频率的组件。
{ // 类开始。
[SerializeField] private ReflectionProbe probe; // 需要手动更新的 Reflection Probe。
[SerializeField] private float updateInterval = 0.5f; // 更新间隔,数值越大刷新越少。
private float nextUpdateTime; // 下一次允许更新的时间点。
private void Awake() // Awake 在对象初始化时执行。
{ // Awake 函数开始。
probe.refreshMode = ReflectionProbeRefreshMode.ViaScripting; // 改成脚本控制,避免每帧自动刷新。
probe.timeSlicingMode = ReflectionProbeTimeSlicingMode.IndividualFaces; // 分帧渲染六个面,降低单帧尖峰。
} // Awake 函数结束。
private void Update() // 每帧检查是否需要刷新。
{ // Update 函数开始。
if (Time.time < nextUpdateTime) // 如果还没到下一次更新时间。
{ // if 开始。
return; // 直接返回,不刷新 Probe。
} // if 结束。
nextUpdateTime = Time.time + updateInterval; // 记录下一次刷新时间。
probe.RenderProbe(); // 手动触发 Reflection Probe 刷新。
} // Update 函数结束。
} // 类结束。Unity 工程实践
我一般不会让多个 Realtime Probe 每帧更新。更常见的做法是:静态环境用 Baked,变化少的环境用 ViaScripting,真的需要实时反射时再用 Time Slicing 分摊成本。
优化优先级通常是:降低刷新频率、降低 Probe 分辨率、裁剪反射 Layer、关闭不必要阴影和后处理、错开多个 Probe 的更新时间。
一句面试话术
Probe 采样便宜,Probe 刷新贵。更新频率越高,越容易多次重渲染 Cubemap,导致 CPU 剔除和 DrawCall 提交增加,GPU 绘制和 RT 带宽增加;所以项目里要尽量低频、事件触发、时间切片更新。
GPU Skinning 是什么?
标准答案
GPU Skinning 是把骨骼蒙皮的顶点变形从 CPU 放到 GPU 做。CPU 负责更新动画状态和骨骼矩阵,GPU 在顶点着色器里根据每个顶点的 bone index 和 bone weight,把顶点按多个骨骼矩阵加权变换,得到最终动画后的顶点位置。
简单说就是:CPU 算骨骼姿态,GPU 算顶点怎么跟着骨骼动。
底层原理
一个蒙皮顶点通常会受多根骨骼影响。每个顶点会存:
boneIndices:受哪些骨骼影响。boneWeights:每根骨骼影响多少。- 原始顶点位置、法线、切线等。
核心公式是:
c
float4 skinnedPos = float4(0, 0, 0, 0); // 初始化蒙皮后的顶点位置。
float3 skinnedNormal = float3(0, 0, 0); // 初始化蒙皮后的法线方向。
for (int i = 0; i < 4; i++) // 遍历最多 4 根影响当前顶点的骨骼。
{ // for 循环开始。
int boneIndex = input.boneIndices[i]; // 取出第 i 根骨骼的索引。
float weight = input.boneWeights[i]; // 取出第 i 根骨骼的权重。
float4x4 boneMatrix = _BoneMatrices[boneIndex]; // 从骨骼矩阵数组中取出骨骼矩阵。
skinnedPos += mul(boneMatrix, input.position) * weight; // 顶点位置按骨骼矩阵变换后乘权重累加。
skinnedNormal += mul((float3x3)boneMatrix, input.normal) * weight; // 法线也按骨骼矩阵变换后乘权重累加。
} // for 循环结束。
skinnedNormal = normalize(skinnedNormal); // 对累加后的法线重新归一化。CPU Skinning 是 CPU 先把所有顶点算好,再把变形后的网格交给 GPU;GPU Skinning 是 CPU 只上传骨骼矩阵,顶点变形发生在 GPU 顶点阶段。
Unity 工程实践
Unity 里 SkinnedMeshRenderer 常见的角色蒙皮就会涉及这个问题。GPU Skinning 的好处是减少 CPU 顶点变形压力,角色数量多时能缓解主线程开销。
但它不是“免费优化”。代价是 GPU 顶点计算增加,而且阴影、深度、描边等多个 Pass 里可能会重复做 skinning。移动端如果角色很多、骨骼很多、每顶点 4 骨骼权重,还可能变成 GPU 顶点瓶颈。
大量怪物或人群还会进一步做优化:把动画烘焙到纹理里,用动画贴图加 GPU Instancing,减少 Animator 和 SkinnedMeshRenderer 的 CPU 成本。
常见坑
Bounds 不准会导致角色动画被错误剔除;CPU 不能直接拿到 GPU 变形后的顶点位置,所以碰撞、布娃娃、精确命中盒通常不能依赖 GPU 后的顶点;Root Motion、Animator 状态机等逻辑成本也不会因为 GPU Skinning 自动消失。
Morph Target 是什么?
标准答案
Morph Target 是一种顶点形变动画。它会在同一个网格上保存多个目标形态,比如“微笑脸”“眨眼脸”“张嘴脸”,运行时按权重把基础网格的顶点推向目标形态。
在 Unity 里,Morph Target 通常叫 BlendShape。
底层原理
它要求基础网格和目标网格的顶点数量、顶点顺序、拓扑结构一致。每个 Morph Target 本质上存的是顶点差值:
c
delta = targetPosition - basePosition
finalPosition = basePosition + delta * weight多个 Morph Target 可以叠加:
c
finalPosition = basePosition + deltaA * weightA + deltaB * weightB + deltaC * weightC比如角色脸部:
- 基础形态是中性脸。
- 微笑目标形态记录嘴角顶点向上移动的差值。
- 眨眼目标形态记录眼皮顶点闭合的差值。
- 运行时把不同权重混合,就能得到复杂表情。
Unity 工程实践
Unity 里常用 SkinnedMeshRenderer.SetBlendShapeWeight 控制 BlendShape 权重:
c
using UnityEngine; // 引入 Unity 基础类型。
public class FaceBlendShapeExample : MonoBehaviour // 定义一个控制脸部 BlendShape 的组件。
{ // 类开始。
[SerializeField] private SkinnedMeshRenderer faceRenderer; // 角色脸部的 SkinnedMeshRenderer。
[SerializeField] private int smileIndex = 0; // 微笑 BlendShape 的索引。
[SerializeField] private float smileWeight = 70f; // 微笑权重,Unity 通常用 0 到 100。
private void Awake() // Awake 在对象初始化时执行。
{ // Awake 函数开始。
if (faceRenderer == null) // 如果没有在 Inspector 手动赋值。
{ // if 开始。
faceRenderer = GetComponentInChildren<SkinnedMeshRenderer>(); // 自动从子物体中查找 SkinnedMeshRenderer。
} // if 结束。
} // Awake 函数结束。
private void Update() // Update 每帧执行。
{ // Update 函数开始。
faceRenderer.SetBlendShapeWeight(smileIndex, smileWeight); // 设置微笑 BlendShape 的当前权重。
} // Update 函数结束。
} // 类结束。优缺点
优点是表情细腻,很适合做脸部动画、口型、眨眼、肌肉鼓起、衣服褶皱修正、受击变形。骨骼动画适合大幅运动,Morph Target 更适合局部细节。
代价是内存更高,因为每个目标形态都要存顶点差值;同时激活的 BlendShape 越多,顶点计算越多。常见坑是顶点顺序不一致会直接炸形,法线和切线没处理好会导致光照异常。
一句面试话术
Morph Target 本质是同拓扑网格的顶点差值动画,Unity 里叫 BlendShape;它通过权重混合多个目标形态,常用于角色表情、口型和修正形变。
Compute Shader 和 Fragment Shader 区别是什么?
标准答案
Fragment Shader 是图形渲染管线里的片元/像素着色阶段,主要负责“这个像素最终是什么颜色”。Compute Shader 是独立于传统渲染管线的通用 GPU 并行计算内核,主要负责“开很多线程去处理 Buffer 或 Texture 数据”。
一句话区分:
Fragment Shader 是 管线给你像素,你算颜色;Compute Shader 是 你自己发线程,读写 Buffer/Texture 做通用计算。
底层原理
Fragment Shader 依赖图形管线:
- 顶点着色器处理顶点。
- 光栅化把三角形变成 fragment。
- Fragment Shader 计算颜色。
- 结果进入 RenderTarget,再经过深度测试、模板测试、混合等流程。
Compute Shader 不依赖三角形和光栅化,而是通过 Dispatch 启动线程组:
c
using UnityEngine; // 引入 Unity 基础类型。
public class ComputeExample : MonoBehaviour // 定义一个运行 Compute Shader 的组件。
{ // 类开始。
[SerializeField] private ComputeShader computeShader; // 在 Inspector 里绑定 Compute Shader。
[SerializeField] private RenderTexture targetTexture; // Compute Shader 要写入的目标纹理。
private int kernel; // 保存 Compute Shader kernel 的索引。
private void Start() // Start 在第一帧 Update 前执行。
{ // Start 函数开始。
kernel = computeShader.FindKernel("CSMain"); // 找到名为 CSMain 的计算入口。
targetTexture.enableRandomWrite = true; // 允许 Compute Shader 随机写入这张纹理。
targetTexture.Create(); // 创建底层 RenderTexture 资源。
computeShader.SetTexture(kernel, "Result", targetTexture); // 把目标纹理传给 Compute Shader。
computeShader.Dispatch(kernel, 16, 16, 1); // 启动线程组,让 GPU 并行执行计算。
} // Start 函数结束。
} // 类结束。对应 .compute 里常见写法:
c
#pragma kernel CSMain // 声明计算入口函数。
RWTexture2D<float4> Result; // 声明可随机写入的输出纹理。
[numthreads(8, 8, 1)] // 声明每个线程组里有 8 * 8 * 1 个线程。
void CSMain(uint3 id : SV_DispatchThreadID) // 每个线程都会执行这个函数,id 是全局线程坐标。
{ // 函数开始。
float2 uv = id.xy / 128.0; // 把线程坐标简单转换成 0 到 1 的 UV。
Result[id.xy] = float4(uv.x, uv.y, 0.0, 1.0); // 写入一像素颜色到目标纹理。
} // 函数结束。Unity 工程实践
Fragment Shader 适合材质表面、光照、阴影采样、透明混合、全屏后处理。它天然有插值后的 UV、法线、颜色,也天然接在深度测试、模板测试、Blend 等图形管线后面。
Compute Shader 适合粒子模拟、布料、水波、GPU Culling、骨骼动画烘焙、大量实例数据处理、前缀和、模糊、体素、地形生成等任务。它更自由,但也要自己处理线程越界、Buffer 生命周期、读写同步、移动端兼容和显存带宽。
常见坑
Compute Shader 不会自动把结果画到屏幕上,通常要写到 Buffer 或 RenderTexture,再交给后续 Draw 或 Blit 使用。它也不是一定更快:如果任务规模小、读写很随机、带宽压力大,或者移动端支持不好,可能反而更慢。
Instancing 需要哪些条件?
标准答案
Instancing 要成立,核心条件是:同 Mesh、同 Material、Shader 支持 Instancing、差异数据走 per-instance 数据。
具体说:
- 多个物体要用同一个
Mesh,最好连 submesh 也一致。 - 多个物体要用同一个
Material,Shader、Pass、关键字、Render Queue、Blend/ZWrite 等状态要一致。 - 材质上要勾选
Enable GPU Instancing。 - Shader 要支持 Instancing,比如有
#pragma multi_compile_instancing和 instancing property。 - 每个实例不同的颜色、缩放、矩阵等,应该通过
MaterialPropertyBlock或实例数据传,不要复制一堆材质。 - 硬件和图形 API 要支持 GPU Instancing。
底层原理
Instancing 不是把物体真的合成一个 Mesh,而是 CPU 发一次或少量 Draw,GPU 根据 instance id 去实例数据里取不同的矩阵、颜色等:
c
using UnityEngine; // 引入 Unity 基础类型。
public class InstancingColorExample : MonoBehaviour // 定义一个给实例设置颜色的组件。
{ // 类开始。
[SerializeField] private Renderer targetRenderer; // 要设置实例属性的 Renderer。
private MaterialPropertyBlock block; // 保存 MaterialPropertyBlock,避免每次 new。
private void Awake() // Awake 在对象初始化时执行。
{ // Awake 函数开始。
block = new MaterialPropertyBlock(); // 创建属性块,用来设置实例级属性。
targetRenderer.GetPropertyBlock(block); // 读取 Renderer 当前属性块,避免覆盖已有数据。
block.SetColor("_InstanceColor", Color.red); // 设置实例颜色,不创建新材质。
targetRenderer.SetPropertyBlock(block); // 把实例属性写回 Renderer。
} // Awake 函数结束。
} // 类结束。Shader 侧也要支持实例属性:
c
#pragma multi_compile_instancing // 编译 instancing 相关变体。
UNITY_INSTANCING_BUFFER_START(Props) // 开始声明实例属性缓冲。
UNITY_DEFINE_INSTANCED_PROP(float4, _InstanceColor) // 声明每个实例不同的颜色。
UNITY_INSTANCING_BUFFER_END(Props) // 结束实例属性缓冲。
float4 color = UNITY_ACCESS_INSTANCED_PROP(Props, _InstanceColor); // 在 Shader 中读取当前实例的颜色。Unity 工程实践
草、树、石头、子弹、掉落物、重复场景装饰都很适合 Instancing。它主要减少 CPU 侧 DrawCall 提交成本,但 GPU 还是要画所有顶点和像素,所以物体数量巨大时仍然要配合视锥剔除、LOD、遮挡剔除。
容易打散 Instancing 的情况:用了 renderer.material 生成材质实例、Shader keyword 不同、Lightmap index 不同、阴影 Pass 状态不同、开了不合适的静态合批、或者每个物体用了不同材质。检查时可以用 Frame Debugger 看是否显示 instanced draw。
一句面试话术
Instancing 条件就是:同 Mesh、同 Material、Shader 和材质都支持实例化,实例之间的差异走 per-instance 数据,而不是创建新材质;它优化的是 CPU 提交 DrawCall,不是让 GPU 免费渲染。
DrawMeshInstancedIndirect 是什么?
标准答案
DrawMeshInstancedIndirect 是 Unity 的间接实例化绘制:CPU 只发起一次 Draw,真正要画多少个实例、从哪里开始画,由 GPU 里的 argsBuffer 决定。
它常用于 GPU Driven Rendering:比如草海、森林、弹幕、掉落物、远景小物件。Compute Shader 可以先在 GPU 上做视锥剔除、LOD、遮挡筛选,然后把“可见实例数量”写进 argsBuffer,最后 DrawMeshInstancedIndirect 只画可见实例。
注意:Unity 当前文档里这个 API 已标记为 obsolete,建议新项目看 Graphics.RenderMeshIndirect,但面试讲底层思想仍然非常重要。
底层原理
argsBuffer 通常有 5 个整数:
c
[0] index count per instance
[1] instance count
[2] start index location
[3] base vertex location
[4] start instance location其中最关键的是 [1] instance count:它决定这次到底画多少个实例。
简化代码
c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.Rendering; // 引入渲染相关类型。
public class IndirectDrawExample : MonoBehaviour // 定义一个间接实例化绘制示例组件。
{ // 类开始。
[SerializeField] private Mesh mesh; // 要绘制的 Mesh。
[SerializeField] private Material material; // 支持 instancing 的材质。
[SerializeField] private int instanceCount = 1000; // 最大实例数量。
private GraphicsBuffer instanceBuffer; // 存每个实例数据的 GPU Buffer。
private GraphicsBuffer argsBuffer; // 存间接绘制参数的 GPU Buffer。
private readonly uint[] args = new uint[5]; // CPU 侧临时参数数组,对应 indirect args 的五个整数。
private void Start() // Start 在第一帧前初始化资源。
{ // Start 开始。
instanceBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, instanceCount, 64); // 创建实例数据 Buffer,这里假设每个实例存一个 4x4 矩阵。
argsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, 5, sizeof(uint)); // 创建间接绘制参数 Buffer。
args[0] = mesh.GetIndexCount(0); // 设置每个实例需要绘制的索引数量。
args[1] = (uint)instanceCount; // 设置实例数量,实际项目里可由 Compute Shader 写入可见数量。
args[2] = mesh.GetIndexStart(0); // 设置起始索引位置。
args[3] = mesh.GetBaseVertex(0); // 设置基础顶点偏移。
args[4] = 0; // 设置起始实例 ID。
argsBuffer.SetData(args); // 把间接参数上传到 GPU Buffer。
material.SetBuffer("_InstanceData", instanceBuffer); // 把实例数据 Buffer 绑定给材质。
} // Start 结束。
private void Update() // 每帧提交一次间接绘制。
{ // Update 开始。
Bounds bounds = new Bounds(Vector3.zero, Vector3.one * 1000f); // 设置整体包围盒,Unity 用它判断整批是否可见。
Graphics.DrawMeshInstancedIndirect(mesh, 0, material, bounds, argsBuffer); // 发起间接实例化绘制。
} // Update 结束。
private void OnDestroy() // 对象销毁时释放 GPU 资源。
{ // OnDestroy 开始。
instanceBuffer?.Release(); // 释放实例数据 Buffer,避免显存泄漏。
argsBuffer?.Release(); // 释放间接参数 Buffer,避免显存泄漏。
} // OnDestroy 结束。
} // 类结束。面试关键词
普通 DrawMeshInstanced 是 CPU 告诉 GPU 画多少实例;DrawMeshInstancedIndirect 是 GPU Buffer 告诉 GPU 画多少实例。它的价值在于 GPU 剔除后不用把结果读回 CPU,避免 CPU-GPU 同步。
常见坑:argsBuffer 写错会不画或数量异常;Bounds 太小会整批被 Unity 剔除;Buffer 忘记释放会显存泄漏;透明物体排序、阴影 Pass、平台兼容都要额外考虑。
参考:Unity 官方文档 Graphics.DrawMeshInstancedIndirect、GPU Instancing。
移动端 Shader 精度 half、float 怎么选?
标准答案
移动端 Shader 精度选择的核心是:能用 half 就优先用 half,但位置、深度、长距离坐标、复杂数学计算用 float。
half 通常代表 16-bit 浮点精度,范围和精度比 float 小,但在移动端可能减少寄存器压力、带宽和功耗。float 通常是 32-bit,精度更高,更稳定,但成本也更高。
怎么选
half 适合:
颜色、法线方向、切线方向、粗糙度、金属度、遮罩值、短向量、局部空间的小范围计算。
float 适合:
世界坐标、视图空间坐标、裁剪空间坐标、深度值、大范围 UV、时间累加、sin / pow / exp 这类精度敏感计算。
底层原理
GPU 计算时,低精度变量可能占用更少寄存器和带宽。片元 Shader 每帧会跑非常多次,所以移动端如果大量使用 half,有机会降低功耗和发热。
但坑在于:half 不一定在所有平台都真正以 16-bit 执行。有些平台会提升成 float。Unity 文档也提到,half 精度不够时可能出现色带、几何抖动等问题,所以最终一定要真机验证。
Unity 工程实践
我一般会这样写:
c
half4 baseColor = _BaseColor; // 颜色对精度要求通常不高,用 half 就够
half3 normalWS = normalize(input.normalWS); // 法线方向通常可以用 half,节省片元计算成本
half roughness = _Roughness; // 粗糙度是 0 到 1 的材质参数,适合用 half
float3 positionWS = input.positionWS; // 世界坐标范围可能很大,用 float 更安全
float depth = input.positionCS.z / input.positionCS.w; // 深度比较容易受精度影响,用 float 更稳
half3 finalColor = baseColor.rgb * saturate(dot(normalWS, lightDir)); // 最终颜色混合可以回到 half面试补一句
我不会简单说“移动端全用 half”。更准确的说法是:按数据范围和误差容忍度选精度,先用 half 优化颜色和方向类数据,对坐标、深度和复杂数学保留 float,最后用真机和 GPU 工具验证收益。
参考:Unity 16-bit precision in shaders、Unity Shader Graph Precision Modes