前置计算:通过ddx、ddy计算 T、B
数学推导
1. 曲面参数化定义
设三维几何表面由纹理坐标 $(u, v)$ 参数化,表面上的任意点 $P$ 可表示为:
$$ P(u, v) = \begin{bmatrix} x(u,v) \\ y(u,v) \\ z(u,v) \end{bmatrix} $$其中,$P \in \mathbb{R}^3$ 为世界空间坐标,$(u, v) \in \mathbb{R}^2$ 为纹理坐标。我们的目标是恢复该点处的切线(Tangent)$T$ 与副切线(Bitangent)$B$ ,定义为:
$$ T = \frac{\partial P}{\partial u}, \quad B = \frac{\partial P}{\partial v} $$2. 屏幕空间导数与链式法则
在光栅化过程中,GPU 可计算几何属性相对于屏幕空间坐标 $(x_s, y_s)$ 的偏导数。记屏幕空间导数为 $\frac{\partial}{\partial x_s}$ 和 $\frac{\partial}{\partial y_s}$ 。根据链式法则,位置 $P$ 对屏幕坐标的导数可展开为:
$$ \frac{\partial P}{\partial x_s} = \frac{\partial P}{\partial u} \frac{\partial u}{\partial x_s} + \frac{\partial P}{\partial v} \frac{\partial v}{\partial x_s} $$$$ \frac{\partial P}{\partial y_s} = \frac{\partial P}{\partial u} \frac{\partial u}{\partial y_s} + \frac{\partial P}{\partial v} \frac{\partial v}{\partial y_s} $$
引入简写符号,令屏幕空间位置导数为
$$ P_x = \frac{\partial P}{\partial x_s}, P_y = \frac{\partial P}{\partial y_s} $$UV 导数为
$$ \begin{aligned} u_x &= \frac{\partial u}{\partial x_s}, & v_x &= \frac{\partial v}{\partial x_s} \\ u_y &= \frac{\partial u}{\partial y_s}, & v_y &= \frac{\partial v}{\partial y_s} \end{aligned} $$代入 $T$ 和 $B$ 的定义,上述关系式可重写为:
$$ P_x = u_x T + v_x B $$$$ P_y = u_y T + v_y B $$
3. 线性方程组构建
将上述关系视为关于未知向量 $T$ 和 $B$ 的线性方程组。写成矩阵形式如下:
$$ \begin{bmatrix} P_x \\ P_y \end{bmatrix} = \begin{bmatrix} u_x & v_x \\ u_y & v_y \end{bmatrix} \begin{bmatrix} T \\ B \end{bmatrix} $$其中,系数矩阵由 UV 的屏幕空间导数构成,已知量 $P_x, P_y$ 由几何位置的屏幕空间导数给出。
4. 切向量解析解
为求解 $T$ 和 $B$ ,计算系数矩阵的行列式 $\Delta$ :
$$ \Delta = u_x v_y - u_y v_x $$假设 $\Delta \neq 0$ (即 UV 映射非退化),利用克莱姆法则或直接求逆,可得 $T$ 和 $B$ 的解析解:
$$ T = \frac{1}{\Delta} (v_y P_x - v_x P_y) $$$$ B = \frac{1}{\Delta} (u_x P_y - u_y P_x) $$
在实际着色器实现中,由于后续通常会对切线空间基底进行归一化和重新正交化,常数因子 $\frac{1}{\Delta}$ 常被省略,仅保留方向信息:
$$ T \propto v_y P_x - v_x P_y $$$$ B \propto u_x P_y - u_y P_x $$
示例代码
float3 dpdx = ddx(worldPos);
float3 dpdy = ddy(worldPos);
float2 duvdx = ddx(uv);
float2 duvdy = ddy(uv);
// 忽略 Δ
// float determinant = duvdx.x * duvdy.y - duvdx.y * duvdy.x;
// float3 tangent = (duvdy.y * dpdx - duvdx.y * dpdy) / determinant;
float3 tangent = (duvdy.y * dpdx - duvdx.y * dpdy);
// float3 bitangent = (duvdx.x * dpdy - duvdy.x * dpdx) / determinant;
float3 bitangent = (duvdx.x * dpdy - duvdy.x * dpdx);
tangent = normalize(tangent);
bitangent = normalize(bitangent);法一:通过法线重建
核心区别:使用插值后的顶点法线,再用 Gram–Schmidt 修正 T/B,使 TBN 与平滑着色法线一致。 适用场景:普通网格 + normal mapping 的实时渲染,基本上非特殊情况用这个就行。
提示
真实几何法线并不重要,重要的是:TBN 要和 vertex normal 的 shading basis 一致,以保证更好的视觉效果。
数学推导
已知由屏幕空间导数求得的初始切线为 $T_{\text{raw}}$ ,且当前片元接收到由顶点插值而来的几何法线为 $N$ 。由于 $T_{\text{raw}}$ 是纯粹根据当前像素的几何微分计算出的,它通常不会严格垂直于插值后的平滑 $N$ 。 具体方法是使用施密特正交化方法,将 $T_{\text{raw}}$ 投影到 $N$ 所在的法平面上,剔除掉它在 $N$ 方向上的分量。
$T_{\text{raw}}$ 在 $N$ 方向上的投影长度为:
$$ T_{\text{raw}} \cdot N $$对应的投影向量为:
$$ (T_{\text{raw}} \cdot N) N $$从原始切线中减去该投影分量,得到与 $N$ 严格正交的切线向量:
$$ T_{\text{raw}} - (T_{\text{raw}} \cdot N) N $$归一化得到切线 T:
$$ T = \text{normalize}(T_{\text{raw}} - (T_{\text{raw}} \cdot N) N) $$计算副切线 B:
$$ B = N \times T $$最终构建的 TBN 矩阵:
$$ \mathbf{M}_{TBN} = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix} $$代码示例
// 前置计算只需要计算 T 即可
float3 T = normalize(T_raw - N * dot(N, T_raw));
// 通过 Normal 计算 B
float3 B = cross(N, T);
// 最终 TBN 矩阵
float3x3 TBN = float3x3(T, B, N);法二:完全通过导数重建
核心区别:从位置导数直接计算,完全由当前像素的几何微分决定,完全数学正确,但会三角化。
适用场景:程序化或没有顶点法线的数据,如屏幕空间表面、raymarch、FFT 水面、位移曲面等。
数学推导
已知由屏幕空间导数求得的初始切线为 $T_{\text{raw}}$ 与初始副切线 $B_{\text{raw}}$ 。由于它们是直接从 $P$ 对 $u, v$ 的偏导数解析解中获得的,它们严格定义了当前像素处的几何切平面。
直接推导出当前像素的几何法线:
$$ N = \text{normalize}(T_{\text{raw}} \times B_{\text{raw}}) $$重新计算副切线,确保副切线与两者皆垂直:
$$ B = \text{normalize}(N \times T_{\text{raw}}) $$为了保证切线空间基底的单位正交性,需先对向量进行重新归一化与正交化处理:
$$ T = \text{normalize}(T_{\text{raw}}) $$最终构建的 TBN 矩阵:
$$ \mathbf{M}_{TBN} = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix} $$代码示例
// 1. 计算几何法线 (基于原始导数向量)
float3 N = normalize(cross(T_raw, B_raw));
// 2. 重新计算副切线以确保正交 (使用原始 T_raw 即可)
float3 B = normalize(cross(N, T_raw));
// 3. 归一化切线
float3 T = normalize(T_raw);
// 4. 最终构建 TBN
float3x3 TBN = float3x3(T, B, N);