MEGALOVANIA MEGALOVANIA

Audaces fortuna iuvat:命运眷顾勇敢之人

目录
图形学 渲染篇 其三 法线切线映射
/  

图形学 渲染篇 其三 法线切线映射

这一篇参考 cat-likeCoding 渲染第六章

前置知识

完美的平面

新建一个材质,调整我们的模型,换成一个平滑的 quad,然后调整摄像机。并且将环境光强度调整到 0.

接下来我们考虑,怎么让这个平面变得凹凸不平。

明显顶点法线是不够用的,我们需要一个方案,来修改逐像素的法线来处理

高度贴图

我们引入这样一张贴图

尝试用这张贴图来映射法线的变换。

1[NoScaleOffset]_HeightTex ("HeightMap", 2D) = "white" {}

前面的[NoScaleOffset]表示我们使用和 uv 坐标相同的纹理坐标,无需缩放或者偏移。

对于我们现在平面的法线,我们统一都是(0,1,0)因此,如果我们想要让法线变换,应该要在 xz 方向上做变换.

切向量

想象一下,高度贴图的颜色值代表高度,那么平面就是一个凹凸不平的,高度各有差异的坑坑洼洼的地形。

我们可以先对 u 和 v 分别求偏导来获得切向量

对于 uv 平面,我们首先尝试对 u 求偏导,获得在 u 方向上的偏导数

求偏导之前,我们要知道 δ 是什么,Unity 为我们提供了这样一个接口

1float4 _HeightTex_TexelSize;

它的前两个组件包含纹素大小,即 U 和 V 的分数。其他两个组件包含像素量。例如,对于 256×128 纹理,它将包含 (0.00390625, 0.0078125, 256, 128)

接下来我们来求 uv 平面上对 u 的偏导,来得到 x 方向上的切向量

1float fxy = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, i.uv).rgb;
2float delta = _HeightTex_TexelSize.x;
3float fx_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x+delta,i.uv.y)).rgb;
4float3 tangle_x = float3(1,(fx_delta-fxy)/delta,0);

事实上我们切向量的长度并不关键,因此我们可以改成这样,这样没有除法我们的精度能高一些

1float3 tangle_x = float3(delta,fx_delta-fxy,0);

法向量

然后,我们将这个切向量绕着 z 轴旋转 90 度就可以得到对应的法向量:

可以通过交换矢量的 X 和 Y 分量,并翻转新 X 分量的符号,将 2D 矢量逆时针旋转 90°。所以我们最终得到

1float3 normal = float3(fxy-fx_delta, delta, 0)

我们得到了一坨看起来黑乎乎的东西

思其原因,是因为我们的 Delta 定得实在是太小了,以 256*256 的分辨率距离,我们的 Delta 几乎就在 1/256 这个级别。

因此我们需要让这个切线平滑一些,因为我们只是想要它微微移动一下即可,因此我们直接将整体的偏导数乘以一个缩放倍数,最后归一化的结果是这样

1float3 tangle_x = float3(1,(fx_delta-fxy),0);

这时候我们得到的是这样的

嗯,似乎有一点点形状出来了!

但是明显这个不太完整:我们有 u 和 v 两个方向上的变化分量,但是我们只考虑了 u 方向上的分量,而没有考虑 v 方向上的分量,因此我们需要同样计算 v 方向上的偏导,然后将两个偏导出来的切线叉乘,就得到了比较准确的法向量了!

 1float fx_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x+_HeightTex_TexelSize.x*0.5,i.uv.y)).rgb
 2- SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x-_HeightTex_TexelSize.x*0.5,i.uv.y)).rgb;
 3float3 tangle_x = float3(1,fx_delta,0);
 4
 5float fz_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x,i.uv.y+_HeightTex_TexelSize.y*0.5)).rgb
 6- SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x,i.uv.y-_HeightTex_TexelSize.y*0.5)).rgb;
 7float3 tangle_z = float3(0,fz_delta,1);
 8
 9i.normal = cross(tangle_z,tangle_x);
10i.normal = normalize(i.normal);

然后得到的结果就很不错了,似乎

(说实话,看起来似乎没什么区别)

法线贴图

好的,到了这一步,我们稍微用我们的头脑一想,高度贴图不靠谱。首先在片元着色器里反复采样图片 4 次外加一次叉乘运算就开销不小,我们可能需要一个更牛逼的方案来简单帮我们搞定。

那就是法线贴图,非常有名。

我们把贴图改成 NormalMap,并且 creat from grayscale

法线贴图的采样是非常简单的:颜色的范围是 0-1,法线每个分量的范围是-1,1.

因此我们对采样出来的颜色*2-1 即可

另外注意的是,我们需要交换 z 和 y 的分量

1i.normal = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv).xyz*2.0 - 1.0;
2i.normal = i.normal.xzy;
3i.normal = normalize(i.normal);

额,看起来似乎不太对。。。

DXT5nm

这里直接贴上原文的解释吧

这是因为 Unity 最终以与我们预期不同的方式对法线进行编码。尽管纹理预览显示 RGB 编码,但 Unity 实际上使用的是 DXT5nm。

DXT5nm 格式仅存储法线的 X 和 Y 分量。其 Z 分量将被丢弃。正如您所料,Y 分量存储在 G 通道中。但是,X 分量存储在 A 通道中。不使用 R 和 B 通道。

为什么以这种方式存储 X 和 Y?

使用四通道纹理来仅存储两个通道似乎很浪费。当使用未压缩的纹理时,确实如此。DXT5nm 格式的思路是它应该与 DXT5 纹理压缩一起使用。默认情况下,Unity 会执行此作。

DXT5 通过将 4×4 像素的块分组并使用两种颜色和查找表来压缩像素。用于颜色的位数因通道而异。R 和 B 各获得 5 位,G 获得 6 位,A 获得 8 位。这就是 X 坐标移动到 A 通道的原因之一。另一个原因是 RGB 通道获得一个 Looking Table,而 A 获得自己的查找表。这将使 X 和 Y 分量保持隔离。

压缩是有损的,但对于法线贴图来说是可以接受的。与未压缩的 8 位 RGB 纹理相比,您可以获得 3:1 的压缩比。

Unit 使用 DXT5nm 格式对所有法线贴图进行编码,无论您是否实际压缩它们。但是,当以移动平台为目标时,情况并非如此,因为它们不支持 DXT5。在这些情况下,Unity 将使用常规 RGB 编码。

法线贴图实现

因此,当使用 DXT5nm 时,我们只能检索法线的前两个分量。

1i.normal.xy = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv).wy*2.0 - 1.0;

然后我们利用法线长度为 1 来计算另一个分量,最后再交换 y 和 z 的位置

1i.normal.z = sqrt(1-dot(i.normal.xy,i.normal.xy));
2i.normal = i.normal.xzy;

然后我们会法线很多小黑点:

这是由于精度的限制,导致了在求 i.normal 的长度的时候有可能超过了 1,那么最终的值就会是一个错误的值,因此我们要对之做些限制,使得这个值限制在 0-1 之间

1i.normal.xy = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv).wy*2.0 - 1.0;
2i.normal.z = sqrt(1-saturate(dot(i.normal.xy,i.normal.xy)));
3i.normal = i.normal.xzy;

现在好多了

我们有官方提供给我们的方法

1i.normal.xyz = UnpackNormalAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv));
2i.normal = i.normal.xzy;
3i.normal = normalize(i.normal);

引入 BumpScale

我们需要另一个参数来调节法线贴图的强度

1_BumpScale ("Bump Scale", Range(0,1)) = 1

然后使用,对应的带有 scale 的方法

1i.normal.xyz = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv), _BumpScale);

然后我们可以自由调节 scale 了

DetailMap,第二套 uv

我们将 albedo 颜色给上,现在是这样的

我们尝试为之添加一下 detailmap

关于 detailMap,我们需要第二套 uv,我们可以简单把第二套 uv 的信息存储到原来 uv 的 zw 分量上来

1_DetailTex ("Detail Texture", 2D) = "gray" {}
1struct v2f
2{
3    float4 uv : TEXCOORD0;
4    float4 vertex : SV_POSITION;
5    float3 normal : NORMAL;
6    float3 worldPos : TEXCOORD1;
7};
1o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
2o.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);

然后最后在所有的采样部分,要注意把原来的 i.uv 变成 i.uv.xy,第二套 uv 改成 i.uv.zw

1float3 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv.xy).rgb;
2
3albedo*=SAMPLE_TEXTURE2D(_DetailTex, sampler_DetailTex, i.uv.zw).rgb;

得到如下结果,上图为有细节贴图的,下图为没有的

好吧,几乎看不出区别

DetailNormalMap

同样,我们可以给出第二套细节的 normalMap,我们还是从 grayscale 创建一张 normalMap

代码我略过了声明,只需要生命一个贴图和一个 scale 的参数即可

1float3 detailNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv.xy), _DetailBumpScale);
2i.normal = detailNormal.xzy;
3i.normal = normalize(i.normal);

可以看到一些非常细节的凸起

那么,接下来的问题是,我们如何将两套法线融合到一起呢?

法线融合

我们首先想到的方式是:平均。但是这是一个想想就非常愚蠢的做法:法线平均毫无意义,而且会把原来的法线展平。

我们尝试从数学原理上来推导

我们首先来思考我们的高度贴图是怎么映射成法线的

 1float fx_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x+_HeightTex_TexelSize.x*0.5,i.uv.y)).rgb
 2- SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x-_HeightTex_TexelSize.x*0.5,i.uv.y)).rgb;
 3float3 tangle_x = float3(1,fx_delta,0);
 4
 5float fz_delta = SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x,i.uv.y+_HeightTex_TexelSize.y*0.5)).rgb
 6- SAMPLE_TEXTURE2D(_HeightTex, sampler_HeightTex, float2(i.uv.x,i.uv.y-_HeightTex_TexelSize.y*0.5)).rgb;
 7float3 tangle_z = float3(0,fz_delta,1);
 8
 9i.normal = cross(tangle_z,tangle_x);
10i.normal = normalize(i.normal);

我们将最后的叉乘展开,得到的是

1i.normal = float3(fx_delta1fz_delta)

其中 fx_delta 代表的是在(x,y)这个点 f(x+delta*0.5,y)-f(x-delta*0.5,y)的值。Delta 我们在这里取了_HeightTex_TexelSize.x。fz_delta 同理。

那么我们应用细节法线的时候,我们在做什么?本质上是针对我们原来法线里的这些高度变化(fx_delta,fz_delta)再做一次添加。因此假设我们的 normal1 是原法线贴图的法线,normal2 是我们细节法线贴图的法线,我们应该得到的高度应该是 normal3 这样:x 分量和 z 分量分别相加,y 分量保持不变

1float3 normal1 = float3(fx_delta11fz_delta1)
2float3 normal2 = float3(fx_delta21fz_delta2)
3float3 normal3 = float3(fx_delta1+fx_delta21fz_delta1+fz_delta2)

当然,对于采样出来的结果。我们没有办法保证 y 分量为 1,所以我们需要先缩放向量,来使得其 y 值为 1

因此我们可以得到

1i.normal.xyz = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv.xy), _BumpScale);
2i.normal = i.normal.xzy;
3
4float3 detailNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, i.uv.xy), _DetailBumpScale);
5detailNormal = detailNormal.xzy;
6i.normal.xz += detailNormal.xz;
7i.normal = normalize(i.normal);

得到的结果:上图为 detailbumpness=0,下图为 detailbump ness=1

可以看到后面圈出来的凸起的细节

我们尝试在法线映射出来的 xyz 转成 xzy 之前做这个融合,少些两行代码,也就是原文给出的计算方法(你会发现和我推导出的结果是一致的)

1   float3 mainNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv.xy), _BumpScale);
2
3   float3 detailNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, i.uv.xy), _DetailBumpScale);
4   i.normal = float3(mainNormal.xy / mainNormal.z + detailNormal.xy / detailNormal.z, 1);
5   i.normal = i.normal.xzy;
6i.normal = normalize(i.normal);

whiteout 混合

继续优化,这里直接用原文的一些内容来解释好了

在组合大部分是平面的地图时,它的效果非常好。但是,合并陡峭的斜坡仍会丢失细节。一种稍微替代的方法是 whiteout 混合。首先,将新常态乘以 MzDzMzDz.我们可以这样做,因为无论如何我们之后都会正常化。这给了我们向量

然后去掉 X 和 Y 的缩放,导致

此调整会夸大 X 和 Y 分量,从而在陡峭的斜坡上产生更明显的凹凸。但是,当其中一条法线平坦时,另一条法线不会改变。

1i.normal =float3(mainNormal.xy + detailNormal.xy, mainNormal.z * detailNormal.z);

最后,我们还是用回 unity 里给出的方法

1i.normal = BlendNormal(mainNormal,detailNormal);

其实会发现里面的计算是一样的

切线空间

现在我们的法线贴图看起来很完美,其实是不太对的,比如从这个球体就能看出来

因为我们原来的假设是:uv 方向分别是世界空间的 xz 方向,我们才能得到一些看似正确的结果。那么当加了变换或者是换成球面这种不规则的坐标系以后,我们就没有办法继续用之前的方案来处理了。

我们需要在每一个点上定义我们前面处理的那种三维空间。首先我们知道的是法向量,那么除此以外我们还需要分别代表 u,v 的两个向量。

事实上我们只需要知道一个即可,另一个可以通过和法向量的叉乘得出。我们暂时称这个向量为附加向量。

对于这个附加向量,我们的网格数据其实给出了一个好的方向,那就是切线向量 T。按照惯例我们将之与 u 轴匹配,指向右侧。

第三个向量 B(bnormal)=N x T.但是这会生成一个反向的向量。我们需要将结果乘以-1.

关于为什么,原文的解释是:

1为什么在切线向量中存储 1 
2
3在创建具有双边对称性的 3D 模型(如人和动物)时,一种常见的技术是左右镜像网格。这意味着您只需编辑网格的一侧。而且您只需要原本需要的一半纹理数据。这意味着法线向量和切线向量也会被镜像。但是,不应镜像 binormal!为了支持这一点,镜像切线在其第四个分量中存储 1,而不是 1。所以这个数据实际上是可变的。这就是为什么必须明确提供它的原因。

可视化切线空间

这样说还是太过抽象,我们尝试用 C#的绘制方法来看一看具体切线空间长什么样子。

用一个 DrawGizmo 把所有的方向都画出来:N,T,B

 1public class TangentSpaceVisualizer : MonoBehaviour
 2{
 3    public float offset = 0.01f;
 4    public float scale = 0.1f;
 5
 6    private void OnDrawGizmos()
 7    {
 8        MeshFilter filter = GetComponent<MeshFilter>();
 9        if (filter) {
10            Mesh mesh = filter.sharedMesh;
11            if (mesh) {
12                ShowTangentSpace(mesh);
13            }
14        }
15    }
16  
17  
18    void ShowTangentSpace (Mesh mesh) {
19        Vector3[] vertices = mesh.vertices;
20        Vector3[] normals = mesh.normals;
21        Vector4[] tangents = mesh.tangents;
22        for (int i = 0; i < vertices.Length; i++) {
23            ShowTangentSpace(
24                transform.TransformPoint(vertices[i]),
25                transform.TransformDirection(normals[i]),
26                transform.TransformDirection(tangents[i])
27            );
28        }
29    }
30
31    void ShowTangentSpace (Vector3 vertex, Vector3 normal, Vector3 tangent) {
32        vertex += normal * offset;
33        Gizmos.color = Color.green;
34        Gizmos.DrawLine(vertex, vertex + normal * scale);
35        Gizmos.color = Color.red;
36        Gizmos.DrawLine(vertex, vertex + tangent * scale);
37      
38        Gizmos.color = Color.cyan;
39        Gizmos.DrawLine(vertex, vertex + Vector3.Cross(normal,tangent)  * scale);
40
41    }
42}

叉乘方向

明显能看出来我们的叉乘方向是反了的。正常的 UV 坐标系应该是这样的:

补充一点基础知识:如何判断叉乘方向?这要取决于我们的坐标系是左手坐标系还是右手坐标系

大拇指指向 +x,食指指向 +y,中指指向 +z.然后看哪只手符合就用哪只手。

那么对应判断叉乘方向的,就用对应的左右手法则即可。左手坐标系用左手,右手坐标系用右手。

Unity 是左手坐标系

所以左手法则一下,自然也知道正确方向了

利用 w 分量

因此我们需要用到前面所说的,切线分量中的 w 来取相反方向

代码变成

 1void ShowTangentSpace (Mesh mesh) {
 2    Vector3[] vertices = mesh.vertices;
 3    Vector3[] normals = mesh.normals;
 4    Vector4[] tangents = mesh.tangents;
 5    for (int i = 0; i < vertices.Length; i++) {
 6        ShowTangentSpace(
 7            transform.TransformPoint(vertices[i]),
 8            transform.TransformDirection(normals[i]),
 9            transform.TransformDirection(tangents[i]),
10            tangents[i].w
11        );
12    }
13}
14
15void ShowTangentSpace (Vector3 vertex, Vector3 normal, Vector3 tangent, float w) {
16    vertex += normal * offset;
17    Gizmos.color = Color.green;
18    Gizmos.DrawLine(vertex, vertex + normal * scale);
19    Gizmos.color = Color.red;
20    Gizmos.DrawLine(vertex, vertex + tangent * scale);
21  
22    Gizmos.color = Color.cyan;
23    Gizmos.DrawLine(vertex, vertex + Vector3.Cross(normal,tangent)  * scale);
24
25}

现在看起来正确多了

在着色器中处理切线空间

接下来我们要做的事情就非常简单了

首先从 appdata 中引入切线:

1struct appdata
2{
3    float4 vertex : POSITION;
4    float2 uv : TEXCOORD0;
5    float3 normal : NORMAL;
6    float4 tangent : TANGENT;
7};

v2f 也安排上

1struct v2f
2{
3    float4 uv : TEXCOORD0;
4    float4 vertex : SV_POSITION;
5    float3 normal : TEXCOORD1;
6    float3 worldPos : TEXCOORD2;
7    float4 tangent : TEXCOORD3;
8};

要得到需要用一下转换

 1v2f vert (appdata v)
 2{
 3    v2f o;
 4    o.vertex = TransformObjectToHClip(v.vertex);
 5    o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
 6    o.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
 7  
 8    o.normal = mul(transpose((float3x3)unity_WorldToObject), float4(v.normal,0));
 9
10    o.worldPos = TransformObjectToWorld(v.vertex.xyz);
11  
12    o.tangent = float4(TransformObjectToWorldDir(v.tangent.xyz), v.tangent.w);
13  
14    return o;
15}

关于这个 TransformObjectToWorldDir

其实就是一个针对方向向量变成 3x3 矩阵而已,别的没有什么不同

关于片元:

 1half4 frag (v2f i) : SV_Target
 2{
 3    i.normal = normalize(i.normal);
 4
 5    // sample the texture
 6    float3 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv.xy).rgb;
 7
 8    albedo*=SAMPLE_TEXTURE2D(_DetailTex, sampler_DetailTex, i.uv.zw).rgb;
 9  
10    float3 mainNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv.xy), _BumpScale);
11
12    float3 detailNormal = UnpackNormalmapRGorAG(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, i.uv.xy), _DetailBumpScale);
13 float3 tangentSpaceNormal = BlendNormal(mainNormal, detailNormal);
14 tangentSpaceNormal = tangentSpaceNormal.xzy;

首先将我们之前算出来的法线变成一个单独的变量

计算切线空间的各个轴(其实只是计算一下 binormal 轴)

1float3 binomal = cross(i.normal, i.tangent.xyz)* i.tangent.w; ;

然后将之作为基底,相乘求和。最后再归一化

1i.normal = (i.tangent* tangentSpaceNormal.x+i.normal* tangentSpaceNormal.y+ binomal* tangentSpaceNormal.z);
2i.normal = normalize(i.normal);

因为我们在最后做了一次归一化的,因此我们其实不需要专门把基向量(Normal,tangent,binormal)归一化。

我们来对比一下相同配置下的不同颜色:

错误的版本:

正确的版本:

大功告成!

加上一些光照,调节一下参数,得到了一个水嫩之中带着点恶心的球

20250419170927rec.mp4

处理 transform 变换

直接用原文,解释得很清楚了

在构造 binormal 时,还有一个额外的细节。假设对象的比例设置为 (-1, 1, 1)。这意味着它是镜像的。在这种情况下,我们必须翻转 binormal,以正确镜像切线空间。事实上,当奇数个维度为负数时,我们必须这样做。通过定义**float4** unity_WorldTransformParams 变量来帮助我们解决这个问题。当我们需要翻转二态时,它的第四个分量包含 −1,否则包含 1。

标准

标准很重要。如果为引擎生产的贴图与最终引擎的法线算法不同,那确实非常恐怖。

一般来说,模型是不会导出切线的,需要在导入游戏引擎的时候通过某种算法自行计算模型切线,这时候就需要一种算法。

比较有名的是 mikktspace 算法。导入 Unity 网格时,可以选择使用 mikktspace**来生成切线。**这里就不展开讨论这个切线算法了,似乎是开源的,有兴趣的可以参考一下。

顶点计算的切线空间

既然我们在 fragment 里计算了 binormal,自然我们也能够在顶点着色器里计算 binormal 从而推导出切线空间。

我们需要的东西除了必要的代码以外,我们还需要一个针对 binormal 的插值器。这个插值器显然与其他重心坐标插值的方案不同。

这里就不展开了,我没太理解原文里是怎么通过一个宏来控制 vertex 到 fragment 的插值器的。盲猜是定义一个宏以后单纯提前预处理了一些代码,但是我没有证据

总结

原本以为这一部分的内容是比较浅而且很熟悉的,原打算是跳过的,但是真正看下来却觉得自己很多细节都不甚了解,包括高度图怎么到法线贴图,包括法线的混合。

虽然感觉过两天就忘光了,但是自己真的全部手敲调试一遍,然后真的哪天需要参考对照的时候,再回来复习,也不枉花这么多精力一点点写了。

纸上得来终觉浅,绝知此事要躬行。


标题:图形学 渲染篇 其三 法线切线映射
作者:matengli110
地址:https://www.sunsgo.world/articles/2025/04/19/1745041060457.html