Unity Shader:向量在世界空间与切线空间中变换的相关数学原理与应用

1,贴图中的法线

法线贴图(或凹凸贴图)是一种特殊的材质,将法线向量转为rgb颜色分量后存到法线贴图中。渲染时,法线贴图进入纹理缓存,颜色向量在顶点或片段着色器中取出再转换回法线。这个变换是线性的,向量的每个分量都是一个[-1,1]与[0,1]的集的相互映射。

vector–>rgb打包转换函数规则:

Color.rgb = Normal.xyz / 2.0 + 0.5;

例如一个0,0,1的向量会变为0.5,0.5,1,0,0,-1的向量会变为0.5,0.5,0。
Unity将rgb转回法线解包的函数包成了一个方法,里面应该就是上面的逆运算,减0.5后再乘以2:

float3 normal = UnpackNormal(rgba);

法线贴图按颜色渲染一般是蓝色调的,根据转换公式可看出解包转换前的法线向量z值都很大,测试一下:
在这里插入图片描述
(图1:用几何着色器将法线贴图中的解包前的法线进行图元转换可视化, 渲染出来后法线基本朝向都是plane物体的z轴方向)

这些法线是用来计算光照的,对于图1中的物体来说,接收光线的法线以z轴为朝向很显然是不对的,该平面(以及一般物体)的正面/受光面朝向的是y轴,于是这时需要将这些法线角度根据渲染物坐标轴进行调整:
在这里插入图片描述
(图2:解包变换后的角度正确的法线)

法线朝向由图1到图2的变换过程既是本文要研究的主题:向量在世界空间与切线空间(或局部表面坐标空间)中的变换。

2,构建切线空间与矩阵并将向量移入该切线空间

假设在世界空间中有一个球体,还有一个光源,光源的方向与点p的法线方向是正对着的。
这里写图片描述
(图3)

已知向量p.normal与lightDirection,通过正向光照公式NdotL我们就可以计算该点的光照强度了。任何一个向量计算都需要在同一个3D空间中,p.normal与lightDirection一般是在世界空间中进行计算。以p为原点,围绕着p.normal,我们还可以构造一个3维空间,叫切线空间或局部表面坐标空间,在此空间中,p的法线normal作为z轴,p的切线tangent作为x轴,p的副法线binormal作为y轴,将p.normal,lightDirection向量变换到此同一空间后依然可以进行正确的光照计算。

切线空间中,z轴normal是最先可以确定的,binormal由normal和tangent叉乘而得,所以tangent的方向计算比较关键。光照计算涉及的是一片连续的像素,正确的光照效果需要tangent的方向一致性,这里tangent的方向会受到顶点uv值的影响,由之前文章已知(Unity Shader:Unity网格(1)—顶点,三角形朝向,法线,uv,以及双面渲染三角形)一般物体的uv值都是连续有序的,渲染管线会利用这点将tangent的方向变为与顶点的uv.v值(或当前像素插值后的v值)一致,所以副法线binormal的方向实际上与u值也是一致的。

确定了坐标系后,在该切线空间的角度观察light direction:
这里写图片描述
(图4)

接下来尝试计算Light Direction在该切线空间中的值,首先将light direction起始点移到点P:
这里写图片描述
(图5)

接下来将light direction变为一个3x1的矩阵,并乘以一个由Tangent,Binormal,Normal组成的3x3矩阵,所得结果既是light direction在切线空间中的值。

[ T a n g e n t x T a n g e n t y T a n g e n t z B i n o r m a l x B i n o r m a l y B i n o r m a l z N o r m a l x N o r m a l y N o r m a l z ] ∗ [ L i g h t D i r e c t i o n . x L i g h t D i r e c t i o n . y L i g h t D i r e c t i o n . z ] = [ L i g h t D i r e c t i o n . x T L i g h t D i r e c t i o n . y T L i g h t D i r e c t i o n . z T ] \begin{bmatrix} Tangent_{x} & Tangent_{y} & Tangent_{z} \\ Binormal_{x} & Binormal_{y} & Binormal_{z} \\ Normal_{x} & Normal_{y} & Normal_{z} \end{bmatrix} * \begin{bmatrix} LightDirection.x \\ LightDirection.y \\ LightDirection.z \end{bmatrix} =\begin{bmatrix} LightDirection.x_T \\ LightDirection.y_T \\ LightDirection.z_T \end{bmatrix} TangentxBinormalxNormalxTangentyBinormalyNormalyTangentzBinormalzNormalzLightDirection.xLightDirection.yLightDirection.z=LightDirection.xTLightDirection.yTLightDirection.zT

那么为什么乘以此矩阵既能做到坐标系变换呢?先将此矩阵乘法中的三次行列相乘展开后发现等式形式为xx+yy+zz,可变为三个点乘:

T a n g e n t x ∗ L i g h t D i r e c t i o n . x + T a n g e n t y ∗ L i g h t D i r e c t i o n . y + T a n g e n t z ∗ L i g h t D i r e c t i o n . z Tangent_x*LightDirection.x+Tangent_y*LightDirection.y+Tangent_z*LightDirection.z TangentxLightDirection.x+TangentyLightDirection.y+TangentzLightDirection.z
= d o t ( T a n g e n t , L i g h t D i r e c t i o n ) =dot(Tangent,LightDirection) =dot(Tangent,LightDirection)

B i n o r m a l x ∗ L i g h t D i r e c t i o n . x + B i n o r m a l y ∗ L i g h t D i r e c t i o n . y + B i n o r m a l z ∗ L i g h t D i r e c t i o n . z Binormal_x*LightDirection.x+Binormal_y*LightDirection.y+Binormal_z*LightDirection.z BinormalxLightDirection.x+BinormalyLightDirection.y+BinormalzLightDirection.z
= d o t ( B i n o r m a l , L i g h t D i r e c t i o n ) =dot(Binormal,LightDirection) =dot(Binormal,LightDirection)

N o r m a l x ∗ L i g h t D i r e c t i o n . x + N o r m a l y ∗ L i g h t D i r e c t i o n . y + N o r m a l z ∗ L i g h t D i r e c t i o n . z Normal_x*LightDirection.x+Normal_y*LightDirection.y+Normal_z*LightDirection.z NormalxLightDirection.x+NormalyLightDirection.y+NormalzLightDirection.z
= d o t ( N o r m a l , L i g h t D i r e c t i o n ) =dot(Normal,LightDirection) =dot(Normal,LightDirection)

点乘的一个重要性质是投影projection。上面的三个点乘可在3D空间中具象化:
这里写图片描述
(图6:dot(Normal,LightDirection)得出的是Pn,lightDirection在Normal轴上的投影)
这里写图片描述
(图7:dot(Tangent,LightDirection)得出的是Pt,lightDirection在Tangent轴上的投影)
这里写图片描述
(图8:dot(Binormal,LightDirection)得出的是Pb,lightDirection在Binormal轴上的投影)

Pn,Pt,Pb既是三次点乘后的结果,lightDirection分别在Normal,Tangent,Binormal轴上的投影,也既是lightDirection进行坐标系转换后的xyz值。


(分割线内内容为用三角函数Cosine分析点乘的投影)

以Binormal为例,解释一下Pb是如何通过点乘计算出来的。
在Unity中,网格顶点的Normal,Tangent的长度均为1,因此叉乘的Binormal长度也为1。然后将点乘进行展开:

dot(Binormal,LightDirection)=|Binormal|*|LightDirection|*cos θ \theta θ=|LightDirection|*cos θ \theta θ

然后将Pb与LightDirection组成的直角三角形转移到二维平面几何中分析。

这里写图片描述
(图8:Pb长度为1的直角三角形)

直角三角形中的cos θ \theta θ等于两邻边之比:

c o s θ = ∣ P b ∣ ∣ L i g h t D i r e c t i o n ∣ cos\theta=\frac{|Pb|}{|LightDirection|} cosθ=LightDirectionPb

所以 c o s θ ∗ ∣ L i g h t D i r e c t i o n ∣ = ∣ P b ∣ cos\theta*|LightDirection|=|Pb| cosθLightDirection=Pb,也既是点乘dot(Binormal,LightDirection)的结果。

那么Pb的意义是什么呢?已知Binormal是切线空间坐标系中的y轴,那么Pb既是LightDirection.y,LightDirection在此坐标系中的y值,依次类推,将三个投影值组合起来,(Pt,Pb,Pn)既是向量LightDirection变换到此切线空间后的(x,y,z)值。
这里写图片描述
(图9:投影长度可转换为xyz值)

这种投影变换xyz对于各种角度都是有效的,例如图7中的角度 θ \theta θ如果大于了90度,由于cosine的性质,tangent的投影值将是一个负数。而图6中的 θ \theta θ接近于180度,normal的投影值接近于负1。因此lightDirection转换后的xyz在三维空间中的方向是正确的。以上的数学原理解释也适用于其他所有的OpenGL空间变换以及各种矩阵在3D空间中的应用。


3,在Unity中将向量移入切线空间的应用

切线空间转换的一个重要应用是凹凸贴图,正确的凹凸贴图需要将法向量贴图中的法线从切线空间转移到世界空间,或将光线从世界空间转移到切线空间。

在这里插入图片描述
(图10:同样的球体。左边未对光线向量进行切线空间转换,右边进行了切线空间转换)

以下代码会将光线向量移入到切线空间并进行光照计算。
示例Shader代码:

Shader "Unlit/C8E5v_TangentSpaceBump"
{
	Properties
	{
		_BumpMap ("BumpMap", 2D) = "white" {}
		_LightPos("Light Position",vector)=(0,0,0)
	}
	SubShader
	{
		Tags { "RenderType"="Opaque" }
		LOD 100

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			// make fog work
			#pragma multi_compile_fog
			#include "UnityCG.cginc"

			struct appdata
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
				float4 tangent : TANGENT;
				float3 normal:NORMAL;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				UNITY_FOG_COORDS(1)
				float4 vertex : SV_POSITION;
				float3 lightDir: float;
			};

			sampler2D _BumpMap;
			float4 _BumpMap_ST;
			float4 _LightPos;

			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _BumpMap);
				UNITY_TRANSFER_FOG(o,o.vertex);
                _LightPos=mul(unity_ObjectToWorld,_LightPos);
                v.vertex=mul(unity_ObjectToWorld,v.vertex);

				o.lightDir=_LightPos-v.vertex.xyz;
                //矩阵构建
                v.normal=UnityObjectToWorldNormal(v.normal);
                half3 wTangent = UnityObjectToWorldDir(v.tangent.xyz);
                half tangentSign = v.tangent.w * unity_WorldTransformParams.w;
                float3 binormal=cross(v.normal,wTangent)* tangentSign;
				float3x3 world2tangent=float3x3(wTangent,binormal,v.normal);
                //光照入射向量进入切线空间
				o.lightDir=mul(world2tangent,o.lightDir);     
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				// sample the texture
				float3 normalTex = UnpackNormal(tex2D(_BumpMap, i.uv)).xyz;
				float3 normal=normalize(normalTex);
				// apply fog
				i.lightDir=normalize(i.lightDir);
				fixed4 col=dot(normal,i.lightDir);
				UNITY_APPLY_FOG(i.fogCoord, col);
				return col;
			}
			ENDCG
		}
	}
}

4,利用转置矩阵将向量从切线空间中移出

由基本的代数运算规则知道,如果a*b=c,那么b=c/a。但是在矩阵运算的领域中是没有除法的概念的,除以一个矩阵的概念要用乘以这个矩阵的负数(逆矩阵)实现,既是A*B=C,那么 B = A − 1 ∗ C B=A^{-1}*C B=A1C。这里与基本代数运算法则依然相通,但要注意矩阵的乘法是没有交换律的,既是要注意左右位置。

并不是所有的矩阵都有逆矩阵(它的负一次方),首先它要是n*n的方阵,第二它的行列式判定不能等于0。本文中的矩阵都假定它有逆矩阵。而一个矩阵的逆矩阵的计算,2*2的矩阵比较简单,3*3及更大的方阵会涉及到虽有规律但很繁琐的计算步骤,好在本文中的坐标系矩阵是正交矩阵,可以利用正交矩阵的逆矩阵的一个重要性质。

一个矩阵与它的逆矩阵,他们倆相乘一定等于一个单位矩阵:
M ∗ M − 1 = I = [ 1 0 0 0 1 0 0 0 1 ] M*M^{-1}=I=\begin{bmatrix} 1 & 0& 0 \\ 0 & 1& 0 \\ 0 & 0& 1 \end{bmatrix} MM1=I=100010001

再观察矩阵 M M M和它的转置矩阵 M T M^T MT(行变成列):

M : [ T a n g e n t x T a n g e n t x T a n g e n t x B i n o r m a l y B i n o r m a l y B i n o r m a l y N o r m a l z N o r m a l z N o r m a l z ] M: \begin{bmatrix} Tangent_{x} & Tangent_{x} & Tangent_{x} \\ Binormal_{y} & Binormal_{y} & Binormal_{y} \\ Normal_{z} & Normal_{z} & Normal_{z} \end{bmatrix} MTangentxBinormalyNormalzTangentxBinormalyNormalzTangentxBinormalyNormalz

M T : [ T a n g e n t x B i n o r m a l x N o r m a l x T a n g e n t y B i n o r m a l y N o r m a l y T a n g e n t z B i n o r m a l z N o r m a l z ] M^T: \begin{bmatrix} Tangent_{x} & Binormal_{x} & Normal_{x} \\ Tangent_{y} & Binormal_{y} & Normal_{y} \\ Tangent_{z} & Binormal_{z} & Normal_{z} \end{bmatrix} MT:TangentxTangentyTangentzBinormalxBinormalyBinormalzNormalxNormalyNormalz

矩阵M与它的转置矩阵有一个特点, M ∗ M T M*M^T MMT也一定也会等于一个单位矩阵:
M ∗ M T = I = [ 1 0 0 0 1 0 0 0 1 ] M*M^{T}=I=\begin{bmatrix} 1 & 0& 0 \\ 0 & 1& 0 \\ 0 & 0& 1 \end{bmatrix} MMT=I=100010001

(可以想象一下,在Unity空间中,一个坐标系矩阵在零点实际上就是上面的单位矩阵,转置后还是单位矩阵,两个单位矩阵相乘依然还是单位矩阵。)
M ∗ M T = I M*M^T=I MMT=I时,M叫做正交矩阵,正交矩阵的一个性质既是它的转置矩阵等于它的逆矩阵:
M T = M − 1 M^T=M^{-1} MT=M1

通过正交矩阵的性质,我们就可以省去逆矩阵的判定与运算步骤,很简单的将原矩阵手动转置一下就可以得到逆矩阵了。

已知向量v,正交可逆矩阵M,转换后的向量v’:
M ∗ v = v ′ M*v=v' Mv=v
v = M T ∗ v ′ v=M^{T}*v' v=MTv

也就是说向量v在被正交矩阵M变换后,可用M的转置矩阵变换回原来的向量。

那么将切线空间中的向量变换回世界空间既是:

[ T a n g e n t x B i n o r m a l x N o r m a l x T a n g e n t y B i n o r m a l y N o r m a l y T a n g e n t z B i n o r m a l z N o r m a l z ] ∗ [ X T Y T Z T ] = [ X W Y W Z W ] \begin{bmatrix} Tangent_{x} & Binormal_{x} & Normal_{x} \\ Tangent_{y} & Binormal_{y} & Normal_{y} \\ Tangent_{z} & Binormal_{z} & Normal_{z} \end{bmatrix} * \begin{bmatrix} X_T \\ Y_T \\ Z_T \end{bmatrix} = \begin{bmatrix} X_W \\ Y_W \\ Z_W \end{bmatrix} TangentxTangentyTangentzBinormalxBinormalyBinormalzNormalxNormalyNormalzXTYTZT=XWYWZW

5,在Unity中将向量移出切线空间的应用

示例代码1:

Unity官网示例Shader,注意tspace的使用:

Shader "Unlit/SkyReflection Per Pixel"
{
    Properties {
        // normal map texture on the material,
        // default to dummy "flat surface" normalmap
        _BumpMap("Normal Map", 2D) = "bump" {}
    }
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct v2f {
                float3 worldPos : TEXCOORD0;
                // these three vectors will hold a 3x3 rotation matrix
                // that transforms from tangent to world space
                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
                // texture coordinate for the normal map
                float2 uv : TEXCOORD4;
                float4 pos : SV_POSITION;
            };

            // vertex shader now also needs a per-vertex tangent vector.
            // in Unity tangents are 4D vectors, with the .w component used to
            // indicate direction of the bitangent vector.
            // we also need the texture coordinate.
            v2f vert (float4 vertex : POSITION, float3 normal : NORMAL, float4 tangent : TANGENT, float2 uv : TEXCOORD0)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(vertex);
                o.worldPos = mul(_Object2World, vertex).xyz;
                half3 wNormal = UnityObjectToWorldNormal(normal);
                half3 wTangent = UnityObjectToWorldDir(tangent.xyz);
                // compute bitangent from cross product of normal and tangent
                half tangentSign = tangent.w * unity_WorldTransformParams.w;
                half3 wBitangent = cross(wNormal, wTangent) * tangentSign;
                // output the tangent space matrix
                //转置矩阵!
                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 = uv;
                return o;
            }

            // normal map texture from shader properties
            sampler2D _BumpMap;
        
            fixed4 frag (v2f i) : SV_Target
            {
                // sample the normal map, and decode from the Unity encoding
                half3 tnormal = UnpackNormal(tex2D(_BumpMap, i.uv));
                // transform normal from tangent to world space
                half3 worldNormal;
                //使用点乘代替乘以矩阵
                worldNormal.x = dot(i.tspace0, tnormal);
                worldNormal.y = dot(i.tspace1, tnormal);
                worldNormal.z = dot(i.tspace2, tnormal);

                // rest the same as in previous shader
                half3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
                half3 worldRefl = reflect(-worldViewDir, worldNormal);
                half4 skyData = UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, worldRefl);
                half3 skyColor = DecodeHDR (skyData, unity_SpecCube0_HDR);
                fixed4 c = 0;
                c.rgb = skyColor;
                return c;
            }
            ENDCG
        }
    }
}

示例代码2:
与图10中的C8E5v_TangentSpaceBump一样效果的代码,这次没有将光线向量移入切线空间,而是将凹凸贴图中的法线从切线空间移出到了世界空间:

Shader "Unlit/C8E5v_WorldSpaceBump"
{
    Properties
    {
        _BumpMap ("BumpMap", 2D) = "white" {}
        _LightPos("Light Position",vector)=(0,0,0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float4 tangent : TANGENT;
                float3 normal:NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
                float3 lightDir: float;
                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
            };

            sampler2D _BumpMap;
            float4 _BumpMap_ST;
            float4 _LightPos;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _BumpMap);
                UNITY_TRANSFER_FOG(o,o.vertex);
                _LightPos=mul(unity_ObjectToWorld,_LightPos);
                v.vertex=mul(unity_ObjectToWorld,v.vertex);
                o.lightDir=_LightPos-v.vertex.xyz;
                //矩阵构建
                v.normal=UnityObjectToWorldNormal(v.normal);
                half3 wTangent = UnityObjectToWorldDir(v.tangent.xyz);
                half tangentSign = v.tangent.w * unity_WorldTransformParams.w;
                float3 binormal=cross(v.normal,wTangent)* tangentSign;

                o.tSpace0=float3(wTangent.x,binormal.x,v.normal.x);
                o.tSpace1=float3(wTangent.y,binormal.y,v.normal.y);
                o.tSpace2=float3(wTangent.z,binormal.z,v.normal.z);

                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                float3 normalTex = UnpackNormal(tex2D(_BumpMap, i.uv)).xyz;
                float3 worldNormal;
                //使用点乘代替乘以矩阵
                worldNormal.x = dot(i.tSpace0, normalTex);
                worldNormal.y = dot(i.tSpace1, normalTex);
                worldNormal.z = dot(i.tSpace2, normalTex);

                worldNormal=normalize(worldNormal);
                i.lightDir=normalize(i.lightDir);
                fixed4 col=dot(worldNormal,i.lightDir);

                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

在这里插入图片描述
(图11:同样的球体。左边C8E5v_WorldSpaceBump,右边C8E5v_TangentSpaceBump)

————————————————————————————————————————————————
Reference:
1,Cg Tutorial-Nvidia
2,http://www.jmargolin.com/uvmath/uvmath.htm --Jed Margolin
3,https://docs.unity3d.com/Manual/SL-VertexFragmentShaderExamples.html --Unity
4,https://betterexplained.com/articles/vector-calculus-understanding-the-dot-product/ --BetterExplained
5,Opengl编程指南8.2 凹凸贴图映射 --Khronos

更新日志:
2017-6-23:更改了标题和一些错误。
2017-6-24:增加了reference。
2017-8-22:更改了标题。
2020-2-8:改进

  • 14
    点赞
  • 45
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值