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 矩阵”