Unity Shader 中一个用于支持 GPU Instancing 的宏定义,
它是给每个顶点“打上实例编号”的工具,用于在 一个 draw call 中渲染多个对象实例 时区分每个象。
🎯 给每个顶点输入结构中,添加一个“实例 ID”,以便你在 Shader 中识别它属于哪个实例
它等价于:uint instanceID : SV_InstanceID;
o.vertex = UnityObjectToClipPos(v.vertex);
📌 将模型空间的顶点坐标转换为裁剪空间,供 GPU 渲染使用。
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
📌 将模型法线 v.normal 转换成 世界空间法线向量 worldNormal,以便后续和光照方向对比。
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
📌 获取当前方向光的方向(假设是 Directional Light),已在世界空间中 → 不需要额外转换。
✅ Unity 封装好的“黑箱方法”:
使用 | 作用 | 说明 |
---|---|---|
UnityObjectToClipPos(v.vertex) | 顶点空间 → 裁剪空间 | 顶点位置变换(包好了 MVP) |
UnityObjectToWorldNormal(v.normal) | 模型法线 → 世界法线 | 自动处理了缩放/变换后的法线方向 |
_WorldSpaceLightPos0 | 光源方向(世界空间) | Unity 自动传入主光源信息 |
saturate(x) | 等价于 clamp(x, 0, 1) | 内建 HLSL 函数 |
_LightColor0 | 当前主光源的颜色 | Unity 自动提供(来自光源) |
✅ 小贴士:怎么判断是不是 Unity 封装的?
只要满足下面任一条件,一般就是:
-
变量名以
_World
,_Light
,_Camera
开头的系统变量 -
函数名是
UnityObjectTo...
开头 -
不在你的
Properties{}
中声明,却能用 -
不需要你手动传值,就自动生效(比如光源位置、颜色)
Unity 内置 Shader 变量对照表(常用封装变量和它们的用途),可以帮助你快速识别哪些变量是 Unity 自动提供的:
🔑 变量名 | 类型 | 说明 | 用途 |
---|---|---|---|
_WorldSpaceLightPos0 | float4 | 主方向光的位置或方向(世界空间) | 用于计算 dot(N, L) |
_LightColor0 | float4 | 主光源颜色(RGB) | 用于漫反射和高光乘色 |
_WorldSpaceCameraPos | float3 | 摄像机位置(世界空间) | 用于视线方向计算 |
_Time | float4 | x = t, y = t*2, z = t*3, w = t*4 | 时间动画控制、UV 扫描 |
_SinTime , _CosTime | float4 | 正余弦时间序列 | 做周期运动 |
_Object2World / unity_ObjectToWorld | float4x4 | 模型到世界变换矩阵 | 顶点变换 |
_World2Object / unity_WorldToObject | float4x4 | 世界到模型矩阵 | 法线变换 |
UNITY_MATRIX_MVP | float4x4 | 模型 → 裁剪空间 | 位置投影 |
UNITY_MATRIX_IT_MV | float4x4 | 模型视图逆转置矩阵 | 法线变换(旧方式) |
unity_MatrixVP | float4x4 | 相机视图投影矩阵 | 屏幕空间转换 |
_ProjectionParams | float4 | x: 1/near, y: far/near, z: 正交?, w: 1 | 深度纹理、Z 空间判断 |
_ScreenParams | float4 | x: 宽度, y: 高度, z: 1/宽, w: 1/高 | 屏幕坐标计算 |
unity_CameraProjection | float4x4 | 投影矩阵 | 从世界 → 屏幕 |
unity_CameraInvProjection | float4x4 | 投影矩阵逆 | 屏幕 → 世界 |
unity_StereoEyeIndex | int | VR 当前渲染眼 | 双眼分离绘制(VR 支持) |
为什么叫 saturate
?它和“饱和”有什么关系?
📚 英文中的 "saturate" 本意就是:
“使达到极限”、“使充满”
在颜色 / 图像 / 物理中指:达到最大值或最小值边界,不再继续变化
-
颜色值的有效范围通常是 [0, 1]
-
如果一个亮度/颜色计算结果超出这个范围,例如
1.3
,会导致“过曝”或“出界” -
为了让结果不溢出,我们限制它在 0~1 之间
-
这个“限制到上下限”的过程,就叫:
saturation(饱和裁切)
为什么不直接用 clamp(x, 0, 1)
?
saturate(x)
是 硬件优化后的专用指令:
-
它比
clamp()
更快 -
编译时能更好映射到 GPU 的 ALU 指令
-
所以在光照/颜色计算中极其常用
✅ 你定义或传入的值:
使用 | 作用 | 来源 |
---|---|---|
_Diffuse | 表面材质颜色 | 是你通过 Properties 定义或材质球中设置 |
v.normal / v.vertex | 顶点法线 / 顶点坐标 | Unity 自动传入每个 Mesh 顶点 |
o.color / i.color | 插值中间值 | 你自己在 v2f 中定义并赋值传递 |
ambient = UNITY_LIGHTMODEL_AMBIENT.xyz
-
✅ 是 Unity 提供的 全局环境光(你在 Lighting 面板设置的环境颜色)
-
📌 它代表了四面八方、非方向性的背景亮度
-
❌ 不依赖光源方向、不依赖法线、不产生阴影
✅ 为什么是 加(+)而不是乘或者混合?
因为它们代表不同物理来源的 “独立能量通道”:
成分 | 物理含义 | 数学操作 | 原因 |
---|---|---|---|
ambient | 全局环境亮度(间接光) | 加法 | 无方向、常量补光 |
diffuse | 主光源照亮表面产生的亮度 | 加法 | 方向光直接作用于表面 |
specular | 镜面高光 | 加法 | 视角相关的高能聚光反射 |
半 Lambert 是什么?
“半 Lambert”这个说法通常指 在 Lambert 模型的基础上做出调整,使得背对光源(即 N⋅L<0N \cdot L < 0N⋅L<0)的区域依然有一定亮度,而不是直接为 0。
为何使用“半 Lambert”?
原始 Lambert | 半 Lambert 改进 |
---|---|
光照在背面为 0,暗部偏死黑 | 背面也有一定亮度,更柔和自然 |
符合物理真实 | 更偏艺术需求、美术可控性 |
可能导致暗部太突兀 | 暗部可以有一定“补光”或“柔光”效果 |
尤其在卡通渲染(Toon Shading)或 stylized 渲染中,“半 Lambert”常被用于增强轮廓、弱化物理限制。
Phong 高光模型(Phong Specular Model)
为何叫 “Phong”,不是 “Blinn-Phong”?
-
Phong 高光模型使用的是:
reflect()
得到反射方向,和视角方向做点乘 →dot(R, V)
-
Blinn-Phong 模型使用的是:
半角向量H = normalize(L + V)
和法线N
的点乘 →dot(N, H)
所以你图中写的是 Phong 不是 Blinn-Phong。
Blinn-Phong 不是只有 specular,它是一整套经典光照模型,包括 ambient、diffuse、specular 三个部分,适用于标准光照。
如果你想进一步做得更真实(如 PBR),那就会引入:
-
环境贴图(Ambient Cubemap / IBL)
-
金属度 / 粗糙度
-
BRDF 函数替代 Blinn-Phong
但在非PBR、传统光照中,Blinn-Phong 处理 ambient 是默认流程中的一环。
fixed3 reflectDir = normalize(reflect(-worldLight, worldNormal));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - UnityObjectToWorldDir(v.vertex));
fixed3 specular =
_LightColor0.rgb * _Specular.rgb * pow(max(0, dot(reflectDir, viewDir)), _Gloss);
在 Unity 的 Shader 中,**只有通过 TEXCOORD
(或其他语义标记)传出的数据,才会在顶点和片元之间被 GPU 插值,并传给 Fragment Shader。
如果你不在结构里明确写出带语义的变量,那些值就不会自动传过去。
-
在底层 GPU 中,插值变量必须占据特定的寄存器或插值槽(interpolator slots)。
-
TEXCOORD0
,TEXCOORD1
, ...,COLOR
,NORMAL
等都是语义标记(Semantics),告诉 GPU 你希望这段值参与插值并传递到下一个阶段。 -
Unity 中虽然语义是固定名字,但在最终 HLSL 会映射到 hardware slot。
UnityWorldSpaceLightDir(float4 worldPos)
接收一个世界空间中的顶点位置 worldPos
,返回从该点指向光源的方向向量。需要注意的是,该函数返回的方向向量未归一化,因此在使用时通常需要对结果进行归一化处理。
worldPos
是将模型空间中的顶点位置转换到世界空间,lightDir
是从该点指向光源的方向向量。
UnityWorldSpaceViewDir(float3 worldPos)
接收一个世界空间中的位置 worldPos
,返回从该点指向摄像机的方向向量。需要注意的是,该函数返回的方向向量未归一化,因此在使用时通常需要对结果进行归一化处理。
To access different vertex data, you need to declare the vertex structure yourself, or add input parameters to the vertex shader. Vertex data is identified by Cg/HLSL semantics, and must be from the following list:
半角计算方式,省去了
直接使用(light入射向量(向外)+Viewdir)出来的向量与法线点乘
模拟出射向量点乘viewDir
viewDir是不会自动normalize向量的,需要自己来,但lightDir一般都是单位向量,,在顶点上
法线贴图是改变normal的值
Height Map
-
Grayscale image:
-
White = high, black = low
-
-
Used for:
-
Generating normal maps dynamically
-
Parallax mapping
-
Displacement mapping
-
-
Doesn't store direction, only scalar height.
Model-Space Normal Map
-
RGB channels = XYZ directions in model space
-
Bright, colorful look with sharp edges between faces
-
Used in:
-
Static objects
-
Precise lighting (baked normals)
-
-
❌ Not suitable for animated or deforming models (because normals don't follow skinning)
Tangent-Space Normal Map
-
Most common normal map type
-
RGB encodes the normal direction relative to the surface’s tangent/bitangent/normal basis
-
Appears purple-ish because the “base normal” (0, 0, 1) = (0.5, 0.5, 1) in color
-
✅ Suitable for:
-
Skinned meshes
-
Dynamic characters
-
Most modern game engines (Unity, Unreal)
-
the main difference is the coordinate space.
But this difference leads to very different use cases, flexibility, and performance implications.
Model-Space Normals
-
Normals are stored in absolute coordinates (world-independent).
-
RGB in the normal map directly encodes directions in model space:
-
X → right
-
Y → up
-
Z → forward
-
-
The shader interprets these normals as fixed directions.
-
🔒 Tied to the model’s geometry and orientation.
Tangent-Space Normals
-
Normals are stored relative to the surface (i.e., local to each triangle).
-
They rely on a local Tangent-Bitangent-Normal (TBN) basis.
-
This lets the normal direction “follow” deformation, animation, or UV operations.
-
🔁 Reusable across many models with similar UV layout.
In Tangent Space:
We define:
-
Tangent (T) → along the U axis of the texture
-
Bitangent (B) → along the V axis of the texture
-
Normal (N) → points outward from the surface
❗ Question:
Is
TBN
a right-handed or left-handed basis?
That depends on:
-
How your engine defines bitangent (Unity, OpenGL, etc.)
-
Whether you compute B = cross(N, T) or use the handedness sign (
w
component of tangent)
tangent.w 是什么?
在图形编程中,尤其是在使用法线贴图进行光照计算时,切线空间(Tangent Space)的构建至关重要。切线空间由三个正交的向量组成:
-
Tangent(切线):通常与纹理的 U 方向对齐。
-
Bitangent(副切线或称为 Binormal):通常与纹理的 V 方向对齐。
-
Normal(法线):垂直于表面。
为了确保这三个向量形成一个一致的右手坐标系,通常会将副切线计算为:
其中,sign 是一个标志位,用于指示当前三角形的切线空间是右手系还是左手系。这个标志位通常存储在切线向量的第四个分量中,即 tangent.w。
-
如果 tangent.w = +1,表示当前三角形的切线空间是右手系。
-
如果 tangent.w = -1,表示当前三角形的切线空间是左手系,需要在计算副切线时进行方向修正。
-
叉乘的计算公式在不同引擎中是一致的,但由于引擎可能采用不同的坐标系(右手或左手),导致相同的叉乘操作在不同引擎中可能产生方向相反的结果。
-
tangent.w 是一个标志位,用于指示当前三角形的切线空间是右手系还是左手系。在构建 TBN 矩阵时,根据 tangent.w 的值调整副切线的方向,确保切线空间的一致性。
#define TANGENT_SPACE_ROTATION \
float3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w; \
float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
构建的是一个三维正交矩阵 rotation
,它由:
-
X轴:切线(Tangent)
-
Y轴:副切线(Binormal/Bitangent)
-
Z轴:法线(Normal)
这就是所谓的 TBN 矩阵,它定义了一个“局部表面坐标系”。
这个矩阵 rotation
可以实现:
应用 | 含义 |
---|---|
TBN * vec_in_tangent_space | 把切线空间的向量转换为模型空间(或世界空间) |
transpose(TBN) * vec_in_model_space | 把模型空间中的向量转换为切线空间 |
Unity 自动处理 = 自动生成 Mesh.tangents
数据
-
Unity 会根据:
-
顶点法线
normals
-
UV 坐标
uv
-
-
为每个顶点计算:
-
tangent.xyz
(表示切线方向) -
tangent.w
(表示副切线方向修正,±1)
-
👉 最终形成的切线数据是:
Vector4 tangent = new Vector4(x, y, z, w);
模型导入时(如 FBX、OBJ)
在你导入模型(.fbx/.obj)时,Unity 会自动:
-
检查是否带有 Tangent 属性(模型软件导出)
-
如果没有带,Unity 就自己 根据 UV 和法线生成切线 + w
模型 → Inspector → Model → Tangents → Calculate / Import
如果你用 C# 自己创建网格(Mesh
),Unity 不会自动生成切线!
这时你必须自己调用:mesh.RecalculateTangents();
⚠️ 这会在 CPU 上重新计算切线(包括 w 分量),计算逻辑与导入时相同。
在 Unity 里用 cross(normal, tangent)
或 cross(tangent, normal)
的顺序是有影响的,因为它决定了副切线(bitangent)的方向。
但 ✔️ 如果你正确使用 tangent.w
去修正结果方向,最终确实可以消除这个“顺序影响”,得到正确的右手坐标系。
✅ tangent.w
就是来解决这个“方向模糊性”的
binormal = cross(normal, tangent.xyz) * tangent.w;
“不管你叉乘结果是哪个方向,我用 w = ±1
来修正它,让它始终变成我想要的方向。”
-
cross(normal, tangent)
+ 正确使用w
✅ ✅ -
cross(tangent, normal)
+ 正确使用-w
✅ ✅
最终都可以得到一致的 binormal,构建正确的右手系 TBN。
你可以这样判断 TBN 是否为右手系(debug 时):
float3x3 TBN = float3x3(tangent, binormal, normal);
float determinant = determinant(TBN); // 如果 ≈1 就是右手系,≈-1 就是左手系
变量 | 来源 | 作用 |
---|---|---|
v.texcoord | 顶点数据中传入的 TEXCOORD0 | 模型原始 UV 坐标 |
_MainTex_ST | Unity 自动为 _MainTex 生成的缩放+偏移(4分量) | 控制颜色贴图的 tiling & offset |
_BumpMap_ST | Unity 自动为 _BumpMap 生成的 ST 参数 | 控制法线贴图的 tiling & offset |
把视角和光照转换到tangent空间
当你使用 法线贴图(normal map) 时,确实必须将:
🚩 光照方向(lightDir)
🚩 视角方向(viewDir)
从世界空间(或模型空间)转换到切线空间(Tangent Space),才能与法线贴图中的“切线空间法线”进行一致的 dot 运算(例如 Lambert、Phong、Blinn-Phong 等光照模型)
法线贴图中的法线是贴图空间(Tangent Space)下的值:
-
红色通道(R)= x 轴 → 沿 Tangent 方向
-
绿色通道(G)= y 轴 → 沿 Bitangent 方向
-
蓝色通道(B)= z 轴 → 沿 Normal 方向
但你的光照方向 L
、视角方向 V
通常是在 世界空间 或 模型空间。
所以必须
L_tangent = mul(L_world, TBN);
V_tangent = mul(V_world, TBN);
切线空间向量 | 含义 | 映射结果(世界空间) |
---|---|---|
(1, 0, 0) | 纯粹沿切线方向的向量 | T |
(0, 1, 0) | 纯粹沿副切线方向(bitangent) | B = cross(N, T) * w |
(0, 0, 1) | 纯粹沿法线方向 | N |
这张图正在从世界空间推导切线空间变换矩阵(TBN 的逆矩阵),通过:
-
把世界空间下的 Tangent / Bitangent / Normal 三个向量
-
乘一个变换矩阵(你标的“变化矩阵”)
-
得到标准基
(1,0,0) / (0,1,0) / (0,0,1)
💡 正在寻找:“哪个矩阵能把世界方向投影到切线空间基向量上”
精炼一下这张图表达的数学含义:
你的“变化矩阵”是什么?
它就是你想要求解的:
从 世界向量 → tangent space 的变换矩阵
在 Unity 中我们通常写成:float3x3 TBN = float3x3(T, B, N); // 这个是 tangent → world
float3x3 TBN_inv = transpose(TBN); // world → tangent
前提是世界bitangent已知,但世界bitangent是需要通过世界normal和世界tangent算出的
用于构建 TBN 的世界向量
在 Shader 或图形引擎中,我们构建 TBN 通常只依赖:
-
顶点的 world normal
-
顶点的 world tangent(+
.w
)
float3 bitangent = cross(normal, tangent) * tangentW;
✅ 这个 bitangent 是可以实时由 N 和 T 推出的,不需要模型提供。
所以只依靠世界normal和世界tangent这两个输入的值,就可以算出bitangent的方向,
通过了叉乘,,但这个叉乘的方向是左手还是右手坐标系的,,这个取决于世界tangent.w,,
而这个世界tangent.w也是属于输入的appdata v中的v.tangent的一部分,是在一开始就已知的
背后为什么需要这个 w
?
因为:
-
在模型构建或 UV 展开时,有些面是镜像展开或使用了负缩放
-
这会导致模型某些面上的 T、N 所构成的平面,在数学上可能构成 左手系
-
为了确保 Shader 中 TBN 始终是右手系,Unity 在导入时会比较真实的 bitangent 和
cross(N, T)
得到的方向是否一致 -
如果不一致,就设置
tangent.w = -1
,否则是+1
所以最终你只需要乘一下,就自动统一方向正确性。
“只要在每个顶点处先通过标准向量(基底)构造出一个局部的变化矩阵,后续所有需要从世界空间变换到切线空间的向量,就都可以直接乘这个矩阵,不需要再次重建。”
✔️ “我们只需通过世界空间下的 tangent 和 normal 构造出每个顶点的转换矩阵(TBN⁻¹),就可以将任意世界空间方向向量变换到 tangent 空间进行统一计算。”
“a 左乘 b” 的意思是:
a 在左边,b 在右边,写作 a × b
或 mul(a, b)
说法 | 实际含义 | 写法 |
---|---|---|
向量左乘矩阵 | 向量在左,矩阵在右 | v × M = mul(v, M) |
矩阵右乘向量(等价) | 同上,若视向量是行向量 | Mᵀ × vᵀ |
“a 左乘 b” | a × b,a 在左 | mul(a, b) |
“a 右乘 b” | b × a,a 在右 | mul(b, a) |
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
float3 worldBitangent = cross(worldNormal, worldTangent) * v.tangent.w;
float3x3 TBN = float3x3(worldTangent, worldBitangent, worldNormal);
float3x3 TBN = float3x3(T, B, N); // TBN: tangent → world
float3x3 TBN_inv = transpose(TBN); // 世界 → 切线
float3 lightDir_world = normalize(...); // 世界空间方向
float3 lightDir_tangent = mul(lightDir_world, TBN_inv); // ← 你说的“左乘 TBN 矩阵”