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) (v