PA5 Bumpiness

Shader 与渲染算法 - 表面与光照 - 凹凸

1 原教旨主义:Bump Mapping

Bump Mapping COOL COOL COOL

Bump Mapping 本质上是一种法线扰动技术,它通过定义切线方向(tangent direction)和副法线方向(binormal),进而生成新的法线方向来模拟细微的凹凸效果。由此避免实际改变几何体的形状,增加模型的顶点数。

注:"binormal" 叫做 “副法线” 或 “副切线” 都行,“副切线” 更符合直觉一些。但是为了保持中英逻辑一致,本文中译作 “副法线”。

1.1 有趣的想法 Fascinating Idea

  1. properties: 添加 _HeightMap 纹理(高程图,无需 scale or offset);

  2. fragment shader: 在 shader 开始调用自定义的 normal 处理函数,生成凹凸表面;

  3. 制作自定义的 normal 处理函数:

    1. 对于 height map 纹理上的每一个 fragment,通过在 U 和 V 方向上分别取两个采样点,计算变化率(du 和 dv),生成 tangent vectors(切线向量)。比如 u1 - u2 对应 U 坐标方向切线;

    2. 将两个方向的 tangent vectors 进行 cross product(叉积)转化为垂直于表面的 normal 向量。

Drawing

1.2 CODING

BumpMapping.shader
Shader "Code/PA5_Bumpiness/BumpMapping" {
	Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _HeightMap ("Heights", 2D) = "gray" {}
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smoothness ("Smoothness", Range(0, 1)) = 0.1
	}

	SubShader {
		Pass {
			Tags {
				"LightMode" = "ForwardBase"
			}
			
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile _ VERTEXLIGHT_ON
			#pragma vertex Vert
			#pragma fragment Frag
			#define FORWARD_BASE_PASS
			#include "BumpMapping_Light.cginc"
			ENDCG
		}

		Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}
			Blend One One
			ZWrite Off
			
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile_fwdadd
			#pragma vertex Vert
			#pragma fragment Frag
			#include "BumpMapping_Light.cginc"
			ENDCG
		}
	}
}
变量 _HeightMap_TexelSize 里保存的数值是什么?

变量 _HeightMap_TexelSize 是一个 float4 类型的变量,它用于在着色器中获取与纹理像素相关的尺寸信息,具体来说,前两个分量分别为纹理在 U、V 方向上每个像素的长度,后两个分量为纹理的宽度和高度。

举个例子,比如纹理为 256 × 128 pixel,那么变量里保存的就是 (0.00390625, 0.0078125, 256, 128)。

其中 0.00390625=12560.00390625 = \frac{1}{256}0.0078125=11280.0078125 = \frac{1}{128}

因为 δ\delta (delta)是相邻两个采样点(1 pixel)的距离,因此 _HeightMap_TexelSize.x_HeightMap_TexelSize.y 分别就是 U 和 V 方向上的 δ\delta 值。

如何理解计算变化率 du 和 dv 所使用的 Central Difference 算法?

du 和 dv 的含义分别是 delta u 和 delta v,即两个方向的变化率。在代码中该变化率的使用的是 Central Difference (中心差分)算法。

与初代版本使用的 Forward Difference(前向差分)不同,Central Difference 考虑了前后半个像素的采样点高度来取得变化率,这样既考虑了前向变化,也考虑了后向变化。

其公式表达为: f(u)=limδ0f(u+δ2)f(uδ2)δf'(u) = \lim_{\delta \to 0} \frac{f(u+\frac{\delta}2) - f(u - \frac{\delta}2)}{\delta}

其中 δ\delta 为两个采样点之间的距离,在这里是一个像素大小。

如何计算变化率?

变化率是通过这两个采样点的高度差除以两者之间的距离来得到的。

如上图,斜率为 f(1)f(0)10=f(1)f(0)\frac{ f(1)-f(0)}{1-0} = f(1)-f(0)

红色的线和矩阵将该变化率描述为了一个切线向量,在其基础上包含了方向信息。

可以理解为,变化率 = 高度差 / 距离,切线向量 = [距离,高度差]。

如何理解语句 i.normal = float3(u1 - u2, 1, v1 - v2)

这条语句是以下语句的展开:

float3 tu = float3(1, u2 - u1, 0);
float3 tv = float3(0, v2 - v1, 1);
i.normal = cross(tv, tu); // 这个 cross 的顺序讲解在下个 Expand 里 

tutv 分别表示了三维坐标系下,UV 两个坐标方向上的切线向量,通过计算它们的叉积,可以取得最终法线向量。

因为叉积的几何意义是,返回一个与两输入向量都垂直的向量

通过线性代数 cross product 相关知识可知,因为 [0fv1]×[1fu0]=[fu1fv]\begin{bmatrix} 0 \\ f_v' \\ 1\end{bmatrix} ×\begin{bmatrix}1\\f_u'\\0 \end{bmatrix} = \begin{bmatrix}-f_u' \\ 1 \\ -f_v' \end{bmatrix},所以可以直接写得该式。

此外对于上述 tu 和 tv 的计算疑惑,请看 VCR:

以 tu 方向为例,可以看到其在 Unity X 轴上被投影为单位长度 1, 其在 Unity Z 轴上毫无建树故而为 0,而主要变化在 Y 轴上的采样点。tv 方向同理。

为什么在叉积计算 i.normal = cross(tv, tu) 时,要写作 tv 在前的顺序?

否则叉积得到的法线方向就是反的。

如上图,将 3Blue1Brown 的 cross product 正负图翻转九十度,就知道怎么才能得出为正值的法线了。

2 维新派:Normal Mapping

设置出一个温润型 Bumpiness

Bump mapping 产生了大量的运算(纹理采样 & 差分运算)。因为 normal 往往是相同的,所以希望仅运算一次,并且将运算结果存储在材质中。

2.1 粗糙的想法 Rough Idea

  1. 使用 3D 软件创建纹理,将法线信息编码为 RGB 值,导入 Unity(需将 Texture Type 设置为 “Normal Map”,并勾选 Create from Grayscale,设置 “Bumpiness” 值);

  2. 在 Unity 中解码,即将 normal map 中保存的 RGB 信息还原为法线向量坐标。

2.2 CODING

Bumpiness_Normal-Mapping_Main.shader
Properties {
	_Tint ("Tint", Color) = (1, 1, 1, 1)
	_MainTex ("Albedo", 2D) = "white" {}
	[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
	_BumpScale ("Bump Scale", Float) = 1
	[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
	_Smoothness ("Smoothness", Range(0, 1)) = 0.1
}
如何理解 UnpackScaleNormal 函数?

UnpackScaleNormal 是一个 Unity 中定义的便捷工具,一键式实现了对 normal map 的正确采样。

其中包含了对 normal map 所使用的 DXT5nm 格式的解码、XYZ 各分量的计算,以及 bump 的 scale 计算。

i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1;
i.normal.xy *= _BumpScale;
i.normal.z = sqrt(1 - saturate(dot(i.normal.xy, i.normal.xy)));
  1. 这里的坐标均在 normal map 所使用的坐标系下计算,y 分量到 z 分量的转换在之后进行;

  2. i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1 语句中,实现了对 normal map 的采样,其中 (× 2 - 1) 是因为来自 texture 文件颜色的向量空间 (0, 1) 需要向法线的向量空间进行转换 (-1, 1); Unity 将所有的 normal map 编码为 DXT5nm 格式,它会丢弃 z 分量,将 x 分量存储在 4 号位(RGBA: A),将 y 分量存储在 2 号位(RGBA: G)。因此在上述解码中,i.normal.xy 在 normal map 中取值,需要分别需要对应到 4 号位 w 和 2 号位 y;

  3. i.normal.xy *= _BumpScale 语句是在执行 bump 的凹凸程度调整;

  4. i.normal.z = sqrt(1 - saturate(dot(i.normal.xy, i.normal.xy))) 是根据 x 和 y 分量计算 z 值。 因为法线是单位向量,其长度为 1,因此有 N=N2=Nx2+Ny2+Nz2=1\Vert N \Vert = \Vert N \Vert^2 = N_x^2 + N_y^2 +N_z^2 = 1,于是 Nz=1Nx2Ny2N_z=\sqrt {1-N_x^2 - N_y^2}

如何理解 i.normal = i.normal.xzy 语句?

这一步实际上是调整法线向量的分量顺序,以确保它们正确映射到 Unity 的坐标系中。

因为 Unity 的 normal map 计算采用了将 z 分量存储在 y 通道中的约定,即纹理表面 u-v 坐标系对应了 x-y 平面,而 z 分量作为 “凹凸感” 的分量。所以需要在这里进行调换。

Drawing

3 配置升级:Bump 叠 Bump

无需对原有逻辑进行大幅修改,仅需动动手指即可对 Bump 超级加倍 🤌。

3.1 粗糙的想法 Rough Idea

  1. 升级相关配置(主要是修改 uv 取值逻辑):

    1. 添加 detail texture 相关 properties 及相关控制变量;

    2. Interpolators structure:uv 分量从 float2 类型变为 float4 类型;

    3. vertex shader:对 uv 的前后两组分量分别实现纹理顶点转换;

    4. fragment shader:albedo 变量为漫反射中的材质 KdK_d 项(值来自 uv rgb 和 tint)。将主纹理的的 uv 采样修改为 uv 的 xy 分量,并与细节纹理叠加;

  2. InitializeFragmentNormal 函数里添加细节纹理的法线解码逻辑(使用 UnpackScaleNormal 函数),并将 main normal 与 detail normal 的结果叠加(使用 Whiteout blending 方法)。

3.2 CODING

BumpDetail.shader
Properties {
	_Tint ("Tint", Color) = (1, 1, 1, 1)
	_MainTex ("Albedo", 2D) = "white" {}
	[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
	_BumpScale ("Bump Scale", Float) = 1
	[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
	_Smoothness ("Smoothness", Range(0, 1)) = 0.1
	_DetailTex ("Detail Texture", 2D) = "gray" {}
	[NoScaleOffset] _DetailNormalMap ("Detail Normals", 2D) = "bump" {}
	_DetailBumpScale ("Detail Bump Scale", Float) = 1
}
为什么法线混合不是将两个法线简单相加?

直接相加两个法线向量会导致不精确的结果,尤其是当其中一个法线接近平坦时,另一个法线的影响会被大幅削弱。

// 将两法线向量简单相加的算法,同除以 z 分量取得正确的偏导数结果
void InitializeFragmentNormal(inout Interpolators i) {
    float3 mainNormal =
        UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
    float3 detailNormal =
        UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
    i.normal =
        float3(mainNormal.xy / mainNormal.z, detailNormal.xy / detailNormal.z, 1);
    i.normal = i.normal.xzy;
    i.normal = normalize(i.normal);
}  

这里的计算逻辑是一个反归一化过程。

在 UnpackScaleNormal 函数中,z 向量是经由 x2+y2+z2=1\sqrt{x^2+y^2+z^2} = 1 公式得出,因此向量已经是单位向量,归一化后,x 和 y 分量的大小是经过缩放的,无法直接反映法线的实际变化率。所以,这里需要将 x 和 y 分量还原,通过除以 z 分量来得到未归一化前的偏导数(平行于表面的变化率)。

如何理解法线混合所使用的 BlendNormals 函数?

BlendNormals 函数使用了 whiteout blending 的法线混合算法。

Whiteout Blending 的可以更好地保留细节法线的影响,尤其是在一个法线接近平坦时,另一个法线的细节不应被削弱。它通过对法线的 x 和 y 分量进行线性相加,同时通过相乘 z 分量的方式来达到这个目的。

// BlendNormals looks like:
half3 BlendNormals (half3 n1, half3 n2) {
    return normalize(half3(n1.xy + n2.xy, n1.z * n2.z));
}

假设有一个非常平坦的 main normal vector n1=(0.01,0.01,1)n1 = (0.01, 0.01, 1),以及一个陡峭的 detail normal vector n2=(0.7,0.7,0.5)n2 = (0.7, 0.7, 0.5),whiteout blending 算法将其计算为 (x,y,z)=(0.01+0.7,0.01+0.7,10.5)=(0.71,0.71,0.5)(x,y,z)=(0.01+0.7,0.01+0.7,1∗0.5)=(0.71,0.71,0.5),然后进行归一化操作。

上述的操作 z 分量相乘,使得其结果值更小。而 z 分量越小,表面越陡峭。

因为法线向量是单位向量,其长度必然为 1,那么考虑当 z 分量为 1 时,说明法线垂直于表面(类似于之前的法线结果,向上方向数值为 1 ),那表面就是平坦的;反之如果 x 和 y 数值更大,z 值更小,那么就是 x 和 y 方向占主导,结果是表面看起来更加陡峭。

4 处理非平面:从平面到更多 Mesh

将 bump mapping 从平面扩展到其他复杂的 3D 模型时,需要一个局部坐标系统来计算表面法线。对于之前简单的平面来说,法线、切线和副法线方向是相对固定的,平面环境天然提供了 bump mapping 所需的 X-Y-Z 本地向量空间。

但对于复杂的网格模型,表面的方向会不断变化,因此就需要一种方法在每个顶点或像素附近定义一个一致的坐标系。

这个自定义的各像素本地空间,就是 Tangent Space,切线空间。

这一节将首先创建切线空间并将其可视化,然后将切线空间导入 shader,使得可以用于 bump mapping。

4.1 破解之道:切线空间 Tangent Space

如何定义切线空间?

首先容易得到法线向量 NN,此时只需要一个额外的向量,就可以通过这两个向量,叉积得到第三个向量。

可以得到的第二个向量是切线向量 TT,它是 mesh 的顶点数据的一部分,它由物体表面法线定义,与 U 坐标方向匹配(point to the right)。

第三个叉积出来的向量 BB,称为 bitangent 或 binormal(副法线),与 V 坐标方向匹配(pointing forward)。

因为 B=N×TB=N×T,叉积结果指向背面(backwards),需要乘以 -1。这个修正因子作为 TT 的第四个分量存储。

由此,切线空间被称为 TBN 空间。

为什么 -1 会被存储在 tangent vector 里?

创建 3D 模型时,经常会使用对称的方法来创建,即只对其中一半进行操作,另一半镜像。这种情况下,normal vector 和 tangent vector 都会被镜像,而 binormal 是被计算出来的,并没有经过镜像。

如果此时对 BB(binormal)进行镜像,那么 TT(tangent normal) 就没有被处理,会出现错误的空间结果。而选择对 TT 进行镜像,被计算出的 BB 作为后置的计算步骤,自然也是正确的。

Drawing
TBN space VS Unity space

4.2 粗糙的想法 Rough Idea

使用 mikktspace 算法。根据 “是否在 vertex shader 阶段插值计算第三个向量 binormal vector”,给出条件编译的两种方式。

  1. 组件

    1. struct VertexData:获取顶点 tangent vector(切线向量)信息;

    2. struct Interpolators:使用条件编译添加插值器(选择顶点阶段插值,则为 binormal 也添加一个插值器);

  2. vertex shader:

    1. 该阶段不插值:分别处理 i.tangent 的 xyz 与 w 分量;

    2. 该阶段插值:处理 i.tangent 的 xyz 分量,调用自定义 CreateBinormal 函数生成第三个向量 binormal vector;

    3. ⚠️:i.tangent 的 xyz 分量需转换为世界空间;w 分量作为标识位不参与转换,单独用作 CreateBinormal 函数的参数;

  3. fragment shader:调用 InitializeFragmentNormal 函数,生成 Bump 后的新 normal。

4.3 CODING

TangentSpace_Visualizer.cs
// 可视化切线空间。normal-green, targent-red, binormal-blue.
using UnityEngine;

public class TangentSpace_Visualizer : MonoBehaviour {
	// 调整可视化 lines 的长度和位置偏移(不会紧贴在表面)
	public float offset = 0.01f;
	public float scale = 0.1f;

	void OnDrawGizmos () {
		MeshFilter filter = GetComponent<MeshFilter>();
		if (filter) {
			Mesh mesh = filter.sharedMesh;
			if (mesh) {
				ShowTangentSpace(mesh);
			}
		}
	}

	void ShowTangentSpace (Mesh mesh) {
		Vector3[] vertices = mesh.vertices;
		Vector3[] normals = mesh.normals;
		Vector4[] tangents = mesh.tangents;
		for (int i = 0; i < vertices.Length; i++) {
			ShowTangentSpace(
				transform.TransformPoint(vertices[i]),
				transform.TransformDirection(normals[i]),
				transform.TransformDirection(tangents[i]),
				tangents[i].w
			);
		}
	}

	void ShowTangentSpace (
		Vector3 vertex, Vector3 normal, Vector3 tangent, float binormalSign
	) {
		vertex += normal * offset;
		Gizmos.color = Color.green;
		Gizmos.DrawLine(vertex, vertex + normal * scale);
		Gizmos.color = Color.red;
		Gizmos.DrawLine(vertex, vertex + tangent * scale);
		Vector3 binormal = Vector3.Cross(normal, tangent) * binormalSign;
		Gizmos.color = Color.blue;
		Gizmos.DrawLine(vertex, vertex + binormal * scale);
	}
}
如何理解 "Mikktspace 算法"?

Mikktspace 算法是切线空间生成的标准。包含了对 tangent 四个分量的处理和 shader 处理流程,即 顶点阶段使用归一化向量进行切线空间生成后插值。计算公式为 corss(normal.xyz, tangent.xyz) * tangent.w

如上所述,tangent 前三个分量定义了切线的方向,而其第四个分量则是一个标志位,用来处理双法线(binormal)的问题。

如果 tangent.w == 1,表示模型的切线空间没有经过镜像,双法线不需要反转,否则当 tangent.w == -1,则双法线需要反转。

也因此可以理解,在对 binormal 的计算中,需要分别传入 normal vector、tangent.xyztangent.w

如何理解 BINORMAL_PER_FRAGMENT 条件编译?

切线空间可以在 vertex shader 中计算为一个 TBN 空间再插值给 fragment shader;也可以在 fragment shader 阶段进行计算。如果选择在 vertex shader 中计算,就需要添加一个 binormal 插值器,并且在后续的计算中调整 binormal 计算的位置。

Unity 使用了顶点中插值 binormal 的方式。

如何理解 CreateBinormal 中的 unity_WorldTransformParams.w 参数?

使用 unity_WorldTransformParams.w 修正方向,处理非均匀缩放或镜像变换带来的问题。

unity_WorldTransformParams.w 是 Unity 内部提供的一个修正因子,用于保证在所有情况下,切线空间的双切线方向都与实际模型变换保持一致。

切线空间由三个基向量组成:Tangent(切线)、Binormal(双切线)和 Normal(法线),它们应该互相垂直且形成一个右手系或左手系。生成双切线时,通常用以下方法:

Binormal = cross(Normal, Tangent) * Sign

这里的 Sign 是由 binormalSignunity_WorldTransformParams.w 共同决定的,确保计算结果的方向性正确。

函数 UnityObjectToWorldDir 和函数 unity_ObjectToWorld 有什么不同?
  1. unityObjectToWorldDir:是一个简化函数,它只应用了旋转和缩放部分,不会处理平移。专门用于处理方向向量的变换,适用于法线、切线等数据

  2. unity_ObjectToWorld:是完整的变换矩阵,它包含对象空间到世界空间的完整变换,通常用于处理顶点位置变换,将点从对象空间转换到世界空间。


APPENDIX

Anki

Reference

Last updated