因醉鞭名马幌 发表于 2021-2-5 10:40

Unity常用矩阵运算的推导补遗——切线空间

在上一篇文章中,我写了一些关于Unity中各个坐标空间及其转换矩阵是如何得到的,说实在的,我是那种“记忆需要依靠外部装置存储”类、如同《攻壳机动队》的电子脑一样的人,每次遇到问题了再去对着笔记慢慢翻找才是我的风格:
在文章中我漏了一个本该提到的内容,那就是切线空间。
一般在法线贴图中,颜色都是偏蓝的,按照颜色空间和坐标空间的对应来看,z值一般都很大,大部分情况下,都是接近垂直于物体表面的。
如果是不需要法线贴图的情况下还好说,用NORMAL语义可以直接从顶点获得模型空间下的法线,如果要使用法线贴图?那就需要切线空间了。
常识告诉我们,构建一个空间笛卡尔直角坐标系需要三个互相垂直的基向量,而常识又告诉我们,两个向量的叉乘结果垂直于这两个向量构成的平面,故我们可以用两个向量完成切线空间的构建。
一个就是法线(Normal),一个是切线(Tangent),其叉乘结果为副切线(Bitangent)或者副法线(Binormal),这两种叫法我都看到过。以Unity官方的Shader例子举例:
v2f vert (float4 vertex : POSITION, float3 normal : NORMAL, float4 tangent : TANGENT, float2 uv : TEXCOORD0)
{
    v2f o;
    //...
    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);
    //...
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    // 从法线贴图里采样并解码法线
    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);
    //...
}众所周知,两个向量的叉乘不支持交换律,会因为顺序的不同而具备有两个方向的结果,而很多时候模型可能被缩放过,导致它是镜像的,为了避免该问题,使用这个Vector4向量unity_WorldTransformParams的w值来保证,如果模型被镜像偶数次,则w=1,反之则为-1。
这个矩阵一般被简称TBN矩阵,用于将切线空间向其他坐标空间转换,它需要使用到其他坐标空间下的法线和切线进行计算。
而Unity又在UnityCG.cginc里提供了这样一个宏:
// Declares 3x3 matrix 'rotation', filled with tangent space basis
#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 )这里居然直接用模型坐标空间的法线和切线在做叉乘?莫慌,看看其他地方都用它干了啥,哦,视差贴图啊?那没事了……
冯乐乐已经谈到过这件事情:
这涉及到非标准缩放的问题——
这是因为Unity 5之前,如果我们对一个模型A进行了非统一缩放,Unity内部会重新在内存中创建一个新的模型B,模型B的大小和缩放后的A是一样的,但是它的缩放系数是统一缩放。换句话说,在Unity 5以前,实际上我们在Shader中根本不需要考虑模型的非统一缩放问题,因为在Shader阶段非统一缩放根本就不存在了。但从Unity 5以后,我们就需要考虑非统一缩放的问题了。说了这么多介绍类的东西,还没说正题:那就是为什么左乘TBN矩阵可以把向量转换出去?
我们先把TBN矩阵的运算写一下:

https://www.zhihu.com/equation?tex=%5Cbegin%7Bbmatrix%7D+t_x+%26+b_x%26+n_x++%5C%5C+t_y+%26+b_y+%26+n_y+%5C%5C+t_z+%26+b_z+%26+n_z+%5Cend%7Bbmatrix%7D%5Cbegin%7Bbmatrix%7D+v_x+%5C%5C+v_y++%5C%5C+v_z++%5Cend%7Bbmatrix%7D%3D+%5Cbegin%7Bbmatrix%7D+t_x+v_x+%2B+b_xv_y%2Bn_xv_z%5C%5C+t_yv_x%2Bb_yv_y%2Bn_yv_z++%5C%5C+t_zv_x%2B+b_zv_y%2Bn_zv_z++%5Cend%7Bbmatrix%7D%5C%5C%5C
这个形式相当于与形如 https://www.zhihu.com/equation?tex=%5Cbold+x+%3D%28t_x%2C+b_x%2C+n_x%29 的向量做了点积,而就是世界空间的基向量在切线空间的表示,因为 https://www.zhihu.com/equation?tex=t_x 是 https://www.zhihu.com/equation?tex=%5Cbold+t 在世界空间下对轴基向量的投影:https://www.zhihu.com/equation?tex=t_x%3Ddot%28%5Cbold+t%2C+%281%2C0%2C0%29%29%3D%5Cleft%7C+%5Cbold+t+%5Cright%7Ccos%5Ctheta%5C%5C 反过来,也可以认为是轴基向量在切线空间基向量 https://www.zhihu.com/equation?tex=t 轴上的投影,同理 https://www.zhihu.com/equation?tex=b_x 和 https://www.zhihu.com/equation?tex=n_x+ 也是如此。

对做点积,就相当于把自己投影了过去,三个分量就是在世界坐标三个基向量上的投影长度,组合起来的新的向量,就是在世界坐标空间下的表示。
谈到这个就不得不说我在头发渲染中犯下的问题了:
此处做一个勘误。
当时我信誓旦旦地这么写:
一个是副切线的计算问题,不能直接用世界空间下的法线和切线cross,而是应当在模型空间cross好再转换:o.tangent = mul(unity_ObjectToWorld, v.tangent);
o.normal = mul(unity_ObjectToWorld, v.normal);
o.bitangent = mul(unity_ObjectToWorld, cross(v.normal, v.tangent));
//错误写法:o.bitangent = cross(o.normal, o.tangent);其实是错误的,因为本文提到过,叉乘结果是有两个方向的,我这边叉乘的顺序恰好得到的是相反的副切线,而当时数学不过关,大脑萎缩,一看副切线结果不同,想当然以为是没转换空间,正确的写法应是:
o.normal = UnityObjectToWorldNormal(v.normal);
o.tangent = UnityObjectToWorldDir(v.tangent.xyz);
o.bitangent = cross(v.normal, v.tangent) * v.tangent.w * unity_WorldTransformParams.w;其次是使用副切线而不是切线的问题,这个是在Unity里材质预览窗看到的效果,在Preview窗口,切成Plane并输出tangent可以看到是红色的,是水平而非垂直方向的,所以高光偏移时要传入垂直方向上的副切线。
利用叉乘,同样还能从高度图中反推法线图:
fixed4 frag (v2f i) : SV_Target
{
        float2 uvx0 = i.uv - float2(_MainTex_TexelSize.x, 0) * 0.5;
        float2 uvx1 = i.uv + float2(_MainTex_TexelSize.x, 0) * 0.5;
        float2 uvy0 = i.uv - float2(0, _MainTex_TexelSize.y) * 0.5;
        float2 uvy1 = i.uv + float2(0, _MainTex_TexelSize.y) * 0.5;
        float3 dx = float3(_MainTex_TexelSize.x,
                0,
                (1 - tex2D(_MainTex, uvx1).r) - (1 - tex2D(_MainTex, uvx0).r));
        float3 dy = float3(0,
                _MainTex_TexelSize.y,
                (1 - tex2D(_MainTex, uvy1).r) - (1 - tex2D(_MainTex, uvy0).r));
        float3 n = cross(dx, dy);
        n.z *= 10;//这里可以控制法线收束于z轴的程度
        float3 normal = normalize(n);
        normal = normal * 0.5 + 0.5;       
    return float4(normal, 1);
}我们分别在UV的x和y方向上单独讨论,以x方向为例,我们可以得知在该点处的梯度向量形如 https://www.zhihu.com/equation?tex=%5Cbold+k+%3D+%28dx%2C+0%2Cf%28x%2B0.5+%2A+dx%29+-+f%28x-0.5%2Adx%29%29+%5C%5C
此处,高度上的插值反映在z轴上,因为切线空间又可以被认为是局部物体表面空间,xy平面被认为是uv,而z轴穿插入物体表面。
使用x和y方向单独计算出来的梯度向量,就能够得到法线方向了。但有一点需要注意的是,计算出来的法线需要手动调整收束程度以适配项目使用。比如想要更平缓的法线图,就需要收束法线到z轴上更多一点。
页: [1]
查看完整版本: Unity常用矩阵运算的推导补遗——切线空间