切线空间:unity中shader切线空间,切线矩阵,TBN矩阵 ,法线贴图深度剖析
unity中shader切线空间
看了网上各种解释,各种推理。直接脑袋大。感觉复杂的高大上。当深入了解后,才发是各种扯淡。
一切从模型法向量开始
在shader中,大部分的光照计算都是与法向量有关。通过法向量和其他向量能计算出模型在光线照射下的明暗变化。
所以我们从模型法线开始
现在模型上看一下法线的样子,法线实际上是一个向量,基于切线空间的。shader是无法显示向量的,但我们可以转换成颜色:
在unity里新建一个shader。命名为Light(可以根据自己喜好来),代码如下
Shader "Custom/Light"
{
SubShader
{
Pass
{
CGPROGRAM
//声明顶点着色器入口
#pragma vertex vert
//声明片元着色器入口
#pragma fragment frag
// 包含 UnityObjectToWorldNormal helper 函数的 include 文件
#include "UnityCG.cginc"
struct v2f {
// 我们将输出世界空间法线作为常规 ("texcoord") 插值器之一
half3 worldNormal : TEXCOORD0;
float4 pos : SV_POSITION;
};
// 顶点着色器:将对象空间法线也作为输入
v2f vert (float4 vertex : POSITION, float3 normal : NORMAL)
{
v2f o;
o.pos = UnityObjectToClipPos(vertex);
// UnityCG.cginc 文件包含将法线从对象变换到
// 世界空间的函数,请使用该函数
o.worldNormal = UnityObjectToWorldNormal(normal);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 c = 0;
// 法线是具有 xyz 分量的 3D 矢量;处于 -1..1
// 范围。要将其显示为颜色,请将此范围设置为 0..1
// 并放入红色、绿色、蓝色分量
c.rgb = i.worldNormal*0.5+0.5;
return c;
}
ENDCG
}
}
}
然后新建一个材质球Light并使用这个shader,在场景中创建一个胶囊体并将材质球赋给它,直接拖到上面就行
显示效果
凹凸图
实际模型法线是通过凹凸图计算出来的,因为凹凸图每个像素可以代表一个模型的某点的包括顶点的法向量,使模型更细腻。
先附上凹凸图
代码如下
Shader "Custom/Light"
{
Properties
{
// 材质上的法线贴图纹理,
// 默认为虚拟的 "平面表面" 法线贴图
_BumpMap("Normal Map", 2D) = "bump" {}
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f
{
float3 worldPos : TEXCOORD0;
// 通过变换。将基于模型空间的切线空间坐标轴变换到世界空间的坐标轴
half3 wTangent : TEXCOORD1;
half3 wBitangent: TEXCOORD2;
half3 wNormal: TEXCOORD3;
// 法线贴图的纹理坐标
float4 uv : TEXCOORD4;
float4 pos : SV_POSITION;
};
// 来自着色器属性的法线贴图纹理
sampler2D _BumpMap;
float4 _BumpMap_ST;
// 顶点着色器现在还需要每顶点切线矢量。
// 在 Unity 中,切线为 4D 矢量,其中使用 .w 分量
// 指示双切线矢量的方向。
// 我们还需要纹理坐标。
v2f vert(float4 vertex : POSITION, float3 normal : NORMAL, float4 tangent : TANGENT, float2 uv : TEXCOORD0)
{
v2f o;
o.pos = UnityObjectToClipPos(vertex);
o.worldPos = mul(unity_ObjectToWorld, vertex).xyz;
half3 wNormal = UnityObjectToWorldNormal(normal);
half3 wTangent = UnityObjectToWorldDir(tangent.xyz);
// 从法线和切线的交叉积计算双切线
half tangentSign = tangent.w * unity_WorldTransformParams.w;
half3 wBitangent = cross(wNormal, wTangent) * tangentSign;
//基于模型空间的切线空间坐标系坐标轴(单位向量)在世界坐标系的表示
o.wTangent= wTangent ;
o.wBitangent= wBitangent ;
o.wNormal= wNormal ;
o.uv.xy = TRANSFORM_TEX( uv,_BumpMap);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 c = 0;
// 对法线贴图进行采样,并根据 Unity 编码进行解码
half3 tnormal = UnpackNormal(tex2D(_BumpMap, i.uv));
// 将切线空间法线xyz(切向空间个个坐标轴分量(是个标量))分别乘上世界空间下切线空间转换的世界新坐标系下的坐标轴
half3 worldNormal=normalize(i.wTangent*tnormal.x+i.wBitangent*tnormal.y+i.wNormal*tnormal.z);
c.rgb = worldNormal * 0.5 + 0.5;
return c;
}
ENDCG
}
}
}
将凹凸图给材质球
效果如下:
与上图不使用法线贴图相比。模型颜色大基调是不变,模型上方都是绿色,中间由橘黄到粉色再到蓝色转变。只不过法线纹理在这基础上添加了更多细节变化
shader的第一座大山
切线空间
现在引入切线空间的定义
- 切线空间的定义
切线空间是一个局部坐标系统,在模型的每个顶点上定义,模型的顶点为切线空间坐标系的原点。它由以下三个基向量组成:
切线向量 (Tangent):沿着纹理坐标的 U 方向(即纹理横向)的方向。它定义了模型表面在纹理方向上的伸展。
双切线向量(又叫副切线向量) (Bitangent or Binormal):沿着纹理坐标的 V 方向(即纹理纵向)的方向。它与切线向量一起定义了表面上的局部坐标系。
法线向量 (Normal):垂直于表面的方向。
这三个向量一起构成了切线空间坐标轴,并且坐标轴都相互垂直
这些向量形成了一个右手坐标系,切线、法线和副切线向量的关系可以通过一个 3x3 矩阵表示。
说明:(非常重要,后面文章都是围绕它)
1.切线空间的法向量是模型顶点的法向量,原点为模型的顶点,因为法向量和顶点(切线空间坐标原点)是基于模型坐标系,
2.所以,切线空间也是基于模型空间定义的三个坐标轴。
3.所以,三个坐标轴变换到世界是相当于模型空间的三个向量变换到世界坐标的向量,然后就会构成一个和切线空间坐标系完全一样的新空间坐标系,只不过一个是基于模型空间的,一个是基于世界空间的新坐标系。
4.知道模型顶点变换到世界上的点的方法,就能知道三个坐标轴变换到世界坐标轴的方法。而法线和切线做模型顶点信息数据里里已经有了。
5.非常重要的一个概念: 在模型中,切线空间的法线,切线已经有了。然后通过正交得到双切线。我们要做的只是把这切线空间坐标系(模型空间里的单位向量法线、切线、双切线)从模型空间切换到世界空间
6.知道切线空间中法向量在坐标轴上的分量xyz(分量是一个标量),同时xyz分量也是切线坐标系转换到世界的心坐标系的分量。因为两个坐标系都一样。
7.然后将分向量相加,就得到世界坐标系的法向量
直接上代码:
Shader "Custom/Light"
{
Properties
{
// 材质上的法线贴图纹理,
// 默认为虚拟的 "平面表面" 法线贴图
_BumpMap("Normal Map", 2D) = "bump" {}
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f
{
float3 worldPos : TEXCOORD0;
// 通过变换。将基于模型空间的切线空间坐标轴变换到世界空间的坐标轴
half3 wTangent : TEXCOORD1;
half3 wBitangent: TEXCOORD2;
half3 wNormal: TEXCOORD3;
// 法线贴图的纹理坐标
float4 uv : TEXCOORD4;
float4 pos : SV_POSITION;
};
// 来自着色器属性的法线贴图纹理
sampler2D _BumpMap;
float4 _BumpMap_ST;
// 顶点着色器现在还需要每顶点切线矢量。
// 在 Unity 中,切线为 4D 矢量,其中使用 .w 分量
// 指示双切线矢量的方向。
// 我们还需要纹理坐标。
v2f vert(float4 vertex : POSITION, float3 normal : NORMAL, float4 tangent : TANGENT, float2 uv : TEXCOORD0)
{
v2f o;
o.pos = UnityObjectToClipPos(vertex);
o.worldPos = mul(unity_ObjectToWorld, vertex).xyz;
half3 wNormal = UnityObjectToWorldNormal(normal);
half3 wTangent = UnityObjectToWorldDir(tangent.xyz);
// 从法线和切线的交叉积计算双切线
half tangentSign = tangent.w * unity_WorldTransformParams.w;
half3 wBitangent = cross(wNormal, wTangent) * tangentSign;
//基于模型空间的切线空间坐标系坐标轴(单位向量)在世界坐标系的表示,然后分向量相加得到法向量
o.wTangent= wTangent ;
o.wBitangent= wBitangent ;
o.wNormal= wNormal ;
o.uv.xy = TRANSFORM_TEX( uv,_BumpMap);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 c = 0;
// 对法线贴图进行采样,并根据 Unity 编码进行解码
half3 tnormal = UnpackNormal(tex2D(_BumpMap, i.uv));
// 将切线空间法线xyz(切向空间个个坐标轴分量(是个标量))分别乘上世界空间下切线空间转换的世界新坐标系下的坐标轴
half3 worldNormal=normalize(i.wTangent*tnormal.x+i.wBitangent*tnormal.y+i.wNormal*tnormal.z);
c.rgb = worldNormal * 0.5 + 0.5;
return c;
}
ENDCG
}
}
}
切线空间工具
前面已经阐述l非常重要的一个概念: 在模型中,切线空间的法线,切线已经有了。然后通过正交得到双切线。我们要做的只是把这切线空间坐标系(模型空间里的单位向量法线、切线、双切线)从模型空间切换到世界空间
为了解释切线空间,先写一个工具脚本TangentSpaceDraw显示切线空间。
新建一个脚本TangentSpaceDraw,写入下面代码,将脚本挂载到模型上。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.Serialization;
public class TangentSpaceDraw : MonoBehaviour
{
public bool isShowLine = true;
private Mesh mesh;
[Range(0.001f, 0.1f)] public float lineLenght = 0.05f;
public Color lineNormalPlane = new Color(0.1f, 0.5f, 0.0f, 0.5f);
public Color lineNormalColor = Color.green;
public Color lineTangentColor = Color.red;
public Color lineBTangentColor = Color.blue;
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
private void OnDrawGizmos()
{
if (!isShowLine)
{
return;
}
if (!mesh)
{
mesh = GetComponent<MeshFilter>().mesh;
}
for (int i = 0; i < mesh.vertices.Length; i++)
{
Vector3 normal = transform.TransformDirection(mesh.normals[i]);
Vector4 tangent = transform.TransformDirection(mesh.tangents[i]);
Vector3 pos = transform.TransformPoint(mesh.vertices[i]);
Handles.color = lineNormalPlane;
Handles.DrawSolidDisc(pos,
transform.TransformDirection(mesh.normals[i]), lineLenght * 0.6f); //画个圆,假设这是目标对象
Handles.color = lineNormalColor;
//绘制切线空间法线坐标轴
Handles.ArrowHandleCap(
0,
transform.TransformPoint(mesh.vertices[i]),
transform.rotation * Quaternion.LookRotation(normal),
lineLenght,
EventType.Repaint
);
Handles.color = lineTangentColor;
//绘制切线空间 切线坐标轴
Handles.ArrowHandleCap(
0,
pos,
transform.rotation * Quaternion.LookRotation(tangent),
lineLenght,
EventType.Repaint
);
Handles.color = lineBTangentColor;
Vector3 btangent = Vector3.Cross(normal, tangent).normalized;
btangent *= Mathf.Sign(tangent.w);
//绘制切线空间 副切线坐标轴
Handles.ArrowHandleCap(
0,
pos,
transform.rotation * Quaternion.LookRotation(btangent),
lineLenght,
EventType.Repaint
);
}
}
}
通过这个脚本将会绘制切线空间如下
这是你会看到模型顶点的所有切线空间坐标系,放大后
核心代码
首先获取模型顶点的法线
Vector3 normal = transform.TransformDirection(mesh.normals[i]);
获取模型顶点的切线
Vector4 tangent = transform.TransformDirection(mesh.tangents[i]);
模型顶点坐标就是切线空间的坐标原点
Vector3 pos = transform.TransformPoint(mesh.vertices[i]);
模型顶点数据里没有副切线,但是三个切线因为是坐标轴相互垂直。 通过法线和切线坐标轴叉乘获取副切线
Vector3 btangent = Vector3.Cross(normal, tangent).normalized;
计算副切线的方向
btangent *= Mathf.Sign(tangent.w);
通过脚本就会发现每个顶点上都有对应的切线空间
切线空间到世界空间的转换矩阵推导
数学上的一致性
列主序与行主序
列主序(Column-major order):
在列主序的表示中,矩阵的每一列代表一个基向量的分量。这是许多图形学库(包括OpenGL和DirectX)以及数学计算中的标准表示方式。在这种表示方式中,矩阵的列向量通常对应于向量的分量。
行主序(Row-major order):
在行主序的表示中,矩阵的每一行代表一个基向量的分量。这种表示方式在一些其他计算环境和数学软件中常见。
在图形学中,特别是涉及到变换矩阵时,列主序是最常见的方式。许多图形API和数学库默认使用列主序,这使得切线空间矩阵 TT 的列向量表示更为自然和一致。
unity中也是用列主序
先回到高中数学
在三维空间中,一个向量 v 通常表示为 ( ( v x , v y , v z ) ) ((v_x, v_y, v_z)) ((vx,vy,vz)),其中 ( v x ) 、 ( v y ) (v_x)、(v_y) (vx)、(vy)和 ( v z ) (v_z) (vz) 是分量,它们是标量。分量分别表示向量在 (x)、(y) 和 (z) 轴方向上的“伸展”程度,但这些分量本身不是向量,而是数值。
为了形成向量,我们需要将这些标量分量与基向量结合。基向量在三维空间中的标准基向量是:
- i = (1, 0, 0),沿 (x) 轴方向
- j = (0, 1, 0),沿 (y) 轴方向
- k = (0, 0, 1),沿 (z) 轴方向
因此,向量 v 可以写作:
v = v x i + v y j + v z k \mathbf{v} = v_x \mathbf{i} + v_y \mathbf{j} + v_z \mathbf{k} v=vxi+vyj+vzk
这里, ( v x ) 、 ( v y ) 、 ( v z ) (v_x)、(v_y)、(v_z) (vx)、(vy)、(vz) 是分量(标量),而 i 、 j 和 k \mathbf{i}、\mathbf{j} 和\mathbf{k} i、j和k是基向量。只有将标量分量与基向量结合在一起,才能得到一个完整的向量。
** 总结一下:
- 分量(标量): ( v x ) 、 ( v y ) 、 ( v z ) (v_x)、(v_y)、(v_z) (vx)、(vy)、(vz) 是标量,表示向量在各坐标轴方向上的“大小”。
- 基向量:i、j、k 是单位向量,指向各坐标轴方向。
- 向量:由分量与基向量组合得到,即
v = v x i + v y j + v z k \mathbf{v} = v_x \mathbf{i} + v_y \mathbf{j} + v_z \mathbf{k} v=vxi+vyj+vzk
第一步:切线空间定义
在切线空间中,法线向量的表示为:
Normal
tangent
=
[
x
y
z
]
\text{Normal}_{\text{tangent}} = \begin{bmatrix} x \\ y \\ z \end{bmatrix}
Normaltangent=
xyz
说明:
- 切线空间的法线向量实际上是模型空间中的法线向量,但其坐标系统基于模型的顶点。
- 切线空间的原点是模型顶点,因此切线空间的坐标轴(即切线、双切线和法线)也是基于模型空间的坐标轴。
- 具体来说,切线空间的三个基向量(切线、双切线和法线)都是相对于模型坐标系定义的。
为了将切线空间中的法线向量转换到世界空间,我们需要知道模型空间中的基向量(即切线、双切线和法线)在世界空间中的表示。可以通过将模型顶点的坐标从模型空间变换到世界空间的变换方法来推断出切线空间基向量在世界空间中的表示。即,切线空间的三个基向量(切线、双切线和法线)在世界空间中的表示将由以下步骤确定:
- 切线向量 T \mathbf{T} T 在模型空间中定义,为 T model \mathbf{T}_{\text{model}} Tmodel。
- 双切线向量 B \mathbf{B} B 在模型空间中定义,为 B model \mathbf{B}_{\text{model}} Bmodel。
- 法线向量 N \mathbf{N} N 在模型空间中定义,为 N model \mathbf{N}_{\text{model}} Nmodel。
这些向量在世界空间中的表示分别为 T world \mathbf{T}_{\text{world}} Tworld、 B world \mathbf{B}_{\text{world}} Bworld 和 N world \mathbf{N}_{\text{world}} Nworld。通过将模型空间的向量变换到世界空间的方法,我们可以获得切线空间中基向量的世界空间表示。
第二步:世界空间定义
为了将切线空间的法线向量转换为世界空间的法线向量,我们需要切线、双切线和法线在世界空间中的表示:
- 切线在世界空间中的表示: T world = [ T x T y T z ] \mathbf{T}_{\text{world}} = \begin{bmatrix} T_x \\ T_y \\ T_z \end{bmatrix} Tworld= TxTyTz
- 双切线在世界空间中的表示: B world = [ B x B y B z ] \mathbf{B}_{\text{world}} = \begin{bmatrix} B_x \\ B_y \\ B_z \end{bmatrix} Bworld= BxByBz
- 法线在世界空间中的表示: N world = [ N x N y N z ] \mathbf{N}_{\text{world}} = \begin{bmatrix} N_x \\ N_y \\ N_z \end{bmatrix} Nworld= NxNyNz
第三步:展开法线转换公式
切线空间的法线向量 Normal tangent \text{Normal}_{\text{tangent}} Normaltangent 可以表示为世界空间的切线、双切线、法线的分向量(世界空间的切线、双切线、法线在第1步以做说明,而且shader由内置函数,在上面代码也能找到)
half3 wNormal = UnityObjectToWorldNormal(normal);
half3 wTangent = UnityObjectToWorldDir(tangent.xyz);
// 从法线和切线的交叉积计算双切线
half tangentSign = tangent.w * unity_WorldTransformParams.w;
half3 wBitangent = cross(wNormal, wTangent) * tangentSign;
回到数学知识:向量的分解,一个向量都可以分解到坐标轴上的三个分向量,并且一个向量都能表示这个向量在坐标轴的分向量分步骤相加。但要明白的是xyz是坐标轴上三个分向量的长度,是一个标量。
前面说明解释:
前面已说明原理,为了方便,再次说明一下:
1.切线空间的法向量是模型顶点的法向量,原点为模型的顶点,因为法向量和顶点(切线空间坐标原点)是基于模型坐标系,
2.所以,切线空间也是基于模型空间定义的三个坐标轴。
3.所以,三个坐标轴变换到世界是相当于模型空间的三个向量变换到世界坐标的向量,然后就会构成一个和切线空间坐标系完全一样的新空间坐标系,只不过一个是基于模型空间的,一个是基于世界空间的新坐标系。
4.知道模型顶点变换到世界上的点的方法,就能知道三个坐标轴变换到世界坐标轴的方法。而法线和切线做模型顶点信息数据里里已经有了。
5.非常重要的一个概念: 在模型中,切线空间的法线,切线已经有了。然后通过正交得到双切线。我们要做的只是把这切线空间坐标系(模型空间里的单位向量法线、切线、双切线)从模型空间切换到世界空间
6.知道切线空间中法向量在坐标轴上的分量xyz(分量是一个标量),同时xyz分量也是切线坐标系转换到世界的心坐标系的分量。因为两个坐标系都一样。
7.然后将分向量相加,就得到世界坐标系的法向量
因此:
Normal
world
=
T
tangent
⋅
x
+
B
tangent
⋅
y
+
N
tangent
⋅
z
=
T
world
⋅
x
+
B
world
⋅
y
+
N
world
⋅
z
\text{Normal}_{\text{world}} = \mathbf{T}_{\text{tangent}} \cdot x + \mathbf{B}_{\text{tangent}} \cdot y + \mathbf{N}_{\text{tangent}} \cdot z= \mathbf{T}_{\text{world}} \cdot x + \mathbf{B}_{\text{world}} \cdot y + \mathbf{N}_{\text{world}} \cdot z
Normalworld=Ttangent⋅x+Btangent⋅y+Ntangent⋅z=Tworld⋅x+Bworld⋅y+Nworld⋅z
Normal
world
=
T
world
⋅
x
+
B
world
⋅
y
+
N
world
⋅
z
\text{Normal}_{\text{world}} = \mathbf{T}_{\text{world}} \cdot x + \mathbf{B}_{\text{world}} \cdot y + \mathbf{N}_{\text{world}} \cdot z
Normalworld=Tworld⋅x+Bworld⋅y+Nworld⋅z
展开得到:
Normal
world
=
[
T
x
⋅
x
+
B
x
⋅
y
+
N
x
⋅
z
T
y
⋅
x
+
B
y
⋅
y
+
N
y
⋅
z
T
z
⋅
x
+
B
z
⋅
y
+
N
z
⋅
z
]
\text{Normal}_{\text{world}} = \begin{bmatrix} T_x \cdot x + B_x \cdot y + N_x \cdot z \\ T_y \cdot x + B_y \cdot y + N_y \cdot z \\ T_z \cdot x + B_z \cdot y + N_z \cdot z \end{bmatrix}
Normalworld=
Tx⋅x+Bx⋅y+Nx⋅zTy⋅x+By⋅y+Ny⋅zTz⋅x+Bz⋅y+Nz⋅z
第四步:整理为矩阵乘法形式
将矩阵竖着看,你会发现正好切向,双切线,法线的分量与
Normal
tangent
\text{Normal}_{\text{tangent}}
Normaltangent的xyz分别相乘。将上述展开式转化为矩阵乘法的形式,得到:
Normal
world
=
[
T
x
B
x
N
x
T
y
B
y
N
y
T
z
B
z
N
z
]
⋅
[
x
y
z
]
\text{Normal}_{\text{world}} = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix} \cdot \begin{bmatrix} x \\ y \\ z \end{bmatrix}
Normalworld=
TxTyTzBxByBzNxNyNz
⋅
xyz
Normal
world
=
[
T
x
B
x
N
x
T
y
B
y
N
y
T
z
B
z
N
z
]
⋅
Normal
tangent
\text{Normal}_{\text{world}} = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix} \cdot \text{Normal}_{\text{tangent}}
Normalworld=
TxTyTzBxByBzNxNyNz
⋅Normaltangent
其中矩阵
T
\mathbf{T}
T 是:
T
=
[
T
x
B
x
N
x
T
y
B
y
N
y
T
z
B
z
N
z
]
\mathbf{T} = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix}
T=
TxTyTzBxByBzNxNyNz
总结
通过这些步骤,我们得到了矩阵
T
\mathbf{T}
T,它将切线空间中的法线向量转换为世界空间中的法线向量。这个矩阵的每一列向量分别是切线、双切线和法线在世界空间中的表示。矩阵
T
\mathbf{T}
T 实现了从模型空间中的切线空间到世界空间的转换。
得到变换矩阵就可以在shader里实现了
Shader "Custom/Light"
{
Properties
{
// 材质上的法线贴图纹理,
// 默认为虚拟的 "平面表面" 法线贴图
_BumpMap("Normal Map", 2D) = "bump" {}
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f
{
float3 worldPos : TEXCOORD0;
// 这三个矢量将保持一个 3x3 旋转矩阵,
// 此矩阵进行从切线到世界空间的转换
half3 tspace0 : TEXCOORD1; // tangent.x, bitangent.x, normal.x
half3 tspace1 : TEXCOORD2; // tangent.y, bitangent.y, normal.y
half3 tspace2 : TEXCOORD3; // tangent.z, bitangent.z, normal.z
// 法线贴图的纹理坐标
float2 uv : TEXCOORD4;
float4 pos : SV_POSITION;
};
// 来自着色器属性的法线贴图纹理
sampler2D _BumpMap;
float4 _BumpMap_ST;
// 顶点着色器现在还需要每顶点切线矢量。
// 在 Unity 中,切线为 4D 矢量,其中使用 .w 分量
// 指示双切线矢量的方向。
// 我们还需要纹理坐标。
v2f vert(float4 vertex : POSITION, float3 normal : NORMAL, float4 tangent : TANGENT, float2 uv : TEXCOORD0)
{
v2f o;
o.pos = UnityObjectToClipPos(vertex);
o.worldPos = mul(unity_ObjectToWorld, vertex).xyz;
half3 wNormal = UnityObjectToWorldNormal(normal);
half3 wTangent = UnityObjectToWorldDir(tangent.xyz);
// 从法线和切线的交叉积计算双切线
half tangentSign = tangent.w * unity_WorldTransformParams.w;
half3 wBitangent = cross(wNormal, wTangent) * tangentSign;
// 输出切线空间矩阵
o.tspace0 = half3(wTangent.x, wBitangent.x, wNormal.x);
o.tspace1 = half3(wTangent.y, wBitangent.y, wNormal.y);
o.tspace2 = half3(wTangent.z, wBitangent.z, wNormal.z);
o.uv = TRANSFORM_TEX(uv, _BumpMap);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 c = 0;
// 对法线贴图进行采样,并根据 Unity 编码进行解码
half3 tnormal = UnpackNormal(tex2D(_BumpMap, i.uv));
// 将法线从切线变换到世界空间
half3 worldNormal;
worldNormal.x = dot(i.tspace0, tnormal);
worldNormal.y = dot(i.tspace1, tnormal);
worldNormal.z = dot(i.tspace2, tnormal);
c.rgb = worldNormal * 0.5 + 0.5;
return c;
}
ENDCG
}
}
}
half3 worldNormal;
worldNormal.x = dot(i.tspace0, tnormal);
worldNormal.y = dot(i.tspace1, tnormal);
worldNormal.z = dot(i.tspace2, tnormal);
这段代码实际就是
Normal
world
=
T
⋅
N
tangent
\text{Normal}_{\text{world}} = \mathbf{T}\cdot \mathbf{N_\text{tangent}}
Normalworld=T⋅Ntangent
展开式。
当然你也可以构建T矩阵
half3x3 T = half3x3(i.tspace0, i.tspace1, i.tspace2);
half3 worldNormal;
worldNormal = mul(T, tnormal);
效果是一样的
再反过来看
在切线空间里法向量(从法线贴图读出的原色转换而来)
N
tangent
=
[
x
y
z
]
N_\text{tangent}=\begin{bmatrix} x\\y\\z \end{bmatrix}
Ntangent=
xyz
矩阵
T
\mathbf{T}
T定义了切线空间基向量在世界空间中的方向:
T
=
[
T
B
N
]
\mathbf{T} = \begin{bmatrix} \text{T}&\text{B} & \text{N} \end{bmatrix}
T=[TBN]
由上面向量拆分的世界坐标系的分向量:
T
=
[
T.x
B.x
N.x
T.y
B.y
N.y
T.z
B.z
N.z
]
\mathbf{T} = \begin{bmatrix} \text{T.x} & \text{B.x} & \text{N.x} \\ \text{T.y} & \text{B.y} & \text{N.y} \\ \text{T.z} & \text{B.z} & \text{N.z} \end{bmatrix}
T=
T.xT.yT.zB.xB.yB.zN.xN.yN.z
Normal
world
=
T
⋅
N
tangent
\text{Normal}_{\text{world}} = \mathbf{T}\cdot \mathbf{N_\text{tangent}}
Normalworld=T⋅Ntangent
Normal
world
=
T
=
[
T.x
B.x
N.x
T.y
B.y
N.y
T.z
B.z
N.z
]
⋅
[
x
y
z
]
\text{Normal}_{\text{world}} = \mathbf{T} = \begin{bmatrix} \text{T.x} & \text{B.x} & \text{N.x} \\ \text{T.y} & \text{B.y} & \text{N.y} \\ \text{T.z} & \text{B.z} & \text{N.z} \end{bmatrix}\cdot\begin{bmatrix} x \\ y\\ z\end{bmatrix}
Normalworld=T=
T.xT.yT.zB.xB.yB.zN.xN.yN.z
⋅
xyz
展开得:
Normal
world
=
[
T
x
⋅
x
+
B
x
⋅
y
+
N
x
⋅
z
T
y
⋅
x
+
B
y
⋅
y
+
N
y
⋅
z
T
z
⋅
x
+
B
z
⋅
y
+
N
z
⋅
z
]
\text{Normal}_{\text{world}} = \begin{bmatrix} T_x \cdot x + B_x \cdot y + N_x \cdot z \\ T_y \cdot x + B_y \cdot y + N_y \cdot z \\ T_z \cdot x + B_z \cdot y + N_z \cdot z \end{bmatrix}
Normalworld=
Tx⋅x+Bx⋅y+Nx⋅zTy⋅x+By⋅y+Ny⋅zTz⋅x+Bz⋅y+Nz⋅z
整理分别得:
第一个正好是法线向量在坐标轴(切线空间到世界空间坐标轴)的分向量分步相加得到的法向量
Normal
world
=
[
T
⋅
x
+
B
⋅
y
+
N
⋅
z
]
\text{Normal}_{\text{world}} = \begin{bmatrix} T \cdot x +B \cdot y+N \cdot z \end{bmatrix}
Normalworld=[T⋅x+B⋅y+N⋅z]
第二个是切线空间法线在世界坐标轴的分量
worldNormal.x
=
T.x
⋅
x
+
B.x
⋅
y
+
N.x
⋅
z
worldNormal.y
=
T.y
⋅
x
+
B.y
⋅
y
+
N.x
⋅
z
worldNormal.z
=
T.x
⋅
x
+
B.x
⋅
y
+
N.x
⋅
z
\text{worldNormal.x} = \text{T.x} \cdot \text{x} + \text{B.x} \cdot \text{y} + \text{N.x} \cdot \text{z}\\ \text{worldNormal.y} = \text{T.y} \cdot \text{x} + \text{B.y} \cdot \text{y} + \text{N.x} \cdot \text{z}\\ \text{worldNormal.z} = \text{T.x} \cdot \text{x} + \text{B.x} \cdot \text{y} + \text{N.x} \cdot \text{z}
worldNormal.x=T.x⋅x+B.x⋅y+N.x⋅zworldNormal.y=T.y⋅x+B.y⋅y+N.x⋅zworldNormal.z=T.x⋅x+B.x⋅y+N.x⋅z
每一行的几何意义
第一行计算:
worldNormal.x
=
T.x
⋅
x
+
B.x
⋅
y
+
N.x
⋅
z
\text{worldNormal.x} = \text{T.x} \cdot \text{x} + \text{B.x} \cdot \text{y} + \text{N.x} \cdot \text{z}
worldNormal.x=T.x⋅x+B.x⋅y+N.x⋅z
这里
worldNormal.x
\text{worldNormal.x}
worldNormal.x是切线空间法线
tnormal
\text{tnormal}
tnormal 在世界坐标系 X 轴方向上的长度分量。
T.x
⋅
x
\text{T.x} \cdot \text{x}
T.x⋅x:表示切线方向(在世界 X 轴上的分量)与切线空间法线在切线方向上的分量的乘积。
B.x
⋅
y
\text{B.x} \cdot \text{y}
B.x⋅y:表示双切线方向(在世界 X 轴上的分量)与切线空间法线在双切线方向上的分量的乘积。
N.x
⋅
z
\text{N.x} \cdot \text{z}
N.x⋅z:表示法线方向(在世界 X 轴上的分量)与切线空间法线在法线方向上的分量的乘积。
同理,第二行和第三行分别计算了在世界 Y 轴和 Z 轴方向上的分量。
到这里如果你完全理解矩阵的原理那么后面的shader就一路平川了
通过上面的知识,shader切线空间转换到世界空间坐标还有一种更好的写法。就是上面推理开始的步骤,将切线空间坐标轴转换到世界空间系构成一个世界空间内的新空间坐标系。这个坐标系和切向空间坐标系是一样的。只不过一个是相对于模型,一个相对于世界的。就是上面我么提到的
代码如下
Shader "Custom/Light"
{
Properties
{
// 材质上的法线贴图纹理,
// 默认为虚拟的 "平面表面" 法线贴图
_BumpMap("Normal Map", 2D) = "bump" {}
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f
{
float3 worldPos : TEXCOORD0;
// 通过变换。将基于模型空间的切线空间坐标轴变换到世界空间的坐标轴
half3 wTangent : TEXCOORD1;
half3 wBitangent: TEXCOORD2;
half3 wNormal: TEXCOORD3;
// 法线贴图的纹理坐标
float4 uv : TEXCOORD4;
float4 pos : SV_POSITION;
};
// 来自着色器属性的法线贴图纹理
sampler2D _BumpMap;
float4 _BumpMap_ST;
// 顶点着色器现在还需要每顶点切线矢量。
// 在 Unity 中,切线为 4D 矢量,其中使用 .w 分量
// 指示双切线矢量的方向。
// 我们还需要纹理坐标。
v2f vert(float4 vertex : POSITION, float3 normal : NORMAL, float4 tangent : TANGENT, float2 uv : TEXCOORD0)
{
v2f o;
o.pos = UnityObjectToClipPos(vertex);
o.worldPos = mul(unity_ObjectToWorld, vertex).xyz;
half3 wNormal = UnityObjectToWorldNormal(normal);
half3 wTangent = UnityObjectToWorldDir(tangent.xyz);
// 从法线和切线的交叉积计算双切线
half tangentSign = tangent.w * unity_WorldTransformParams.w;
half3 wBitangent = cross(wNormal, wTangent) * tangentSign;
//基于模型空间的切线空间坐标系坐标轴(单位向量)在世界坐标系的表示
o.wTangent= wTangent ;
o.wBitangent= wBitangent ;
o.wNormal= wNormal ;
o.uv.xy = TRANSFORM_TEX( uv,_BumpMap);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 c = 0;
// 对法线贴图进行采样,并根据 Unity 编码进行解码
half3 tnormal = UnpackNormal(tex2D(_BumpMap, i.uv));
// 将切线空间法线xyz(切向空间个个坐标轴分量(是个标量))分别乘上世界空间下切线空间转换的世界新坐标系下的坐标轴
half3 worldNormal=normalize(i.wTangent*tnormal.x+i.wBitangent*tnormal.y+i.wNormal*tnormal.z);
c.rgb = worldNormal * 0.5 + 0.5;
return c;
}
ENDCG
}
}
}
代码段解释
// 将法线从切线空间转换到世界空间
half3 worldNormal = normalize(i.wTangent * tnormal.x + i.wBitangent * tnormal.y + i.wNormal * tnormal.z);
解释步骤
法线贴图采样:
half3 tnormal = UnpackNormal(tex2D(_BumpMap, i.uv));
这行代码从法线贴图中采样出法线 tnormal,它是切线空间中的法线向量。UnpackNormal 函数用于解码法线贴图中的颜色信息到切线空间的法线向量。
法线向量在切线空间的分量:
tnormal.x:法线在切线空间的 x 分量。
tnormal.y:法线在切线空间的 y 分量。
tnormal.z:法线在切线空间的 z 分量。
切线空间基向量:
i.wTangent:切线在世界空间的表示。
i.wBitangent:双切线在世界空间的表示。
i.wNormal:法线在世界空间的表示。
法线转换:
c
复制代码
half3 worldNormal = normalize(i.wTangent * tnormal.x + i.wBitangent * tnormal.y + i.wNormal * tnormal.z);
加权求和:将切线空间中的法线 tnormal 的每个分量分别乘以对应的世界空间基向量(切线、双切线和法线),然后将这些结果相加。这个过程实际上是在进行从切线空间到世界空间的变换。
标准化:通过 normalize 函数将结果向量 worldNormal 标准化,确保其长度为 1。这样做是为了保持法线的单位长度,以便正确地表示表面的法线方向。
几何意义
切线空间法线到世界空间法线的转换:通过将切线空间中的法线向量的分量与世界空间中的切线空间基向量(切线、双切线、法线)结合,可以将法线从切线空间转换到世界空间。这是通过对切线空间中法线分量的加权求和实现的。
直观的几何操作:这种方式简洁而直观地表达了如何将切线空间中的法线向量转化为世界空间中的法线向量,同时保持了法线的方向性和单位长度。
上面这两个shader结果是一样的,只不过一个是使用坐标空间转换,一个是矩阵转换。第二个是第一个基础上推理出来的。