在顶点位置发生了偏移后,原始法线将失效,若不重计算法线,光照效果会停留在位移前的阶段。
对偏移函数求导
适用场景
偏移逻辑由明确数学公式(如正弦波、Gerstner 波)定义。
计算步骤: 定义函数:
$$ P'(x,z) = (x', y', z') $$求偏导以构建切线:
$$ \begin{aligned} T_x &= \frac{\partial P'}{\partial x} = \left(\frac{\partial x'}{\partial x}, \frac{\partial y'}{\partial x}, \frac{\partial z'}{\partial x}\right) \\ T_z &= \frac{\partial P'}{\partial z} = \left(\frac{\partial x'}{\partial z}, \frac{\partial y'}{\partial z}, \frac{\partial z'}{\partial z}\right) \end{aligned} $$叉乘:
$$ N = \text{normalize}(T_x \times T_z) $$可根据向量叉乘公式:
$$ \begin{aligned} N_x &= (T_z)_y (T_x)_z - (T_z)_z (T_x)_y \\ N_y &= (T_z)_z (T_x)_x - (T_z)_x (T_x)_z \\ N_z &= (T_z)_x (T_x)_y - (T_z)_y (T_x)_x \end{aligned} $$代码示例: 最终公式尽可能简化,例如下面的例子中 $y=f(x,z)$ 且 $x, z$ 均不变化,简化后仅计算两个偏导即可。
// 计算偏移(即偏移函数)(这里是简单的sin函数偏移)
v.vertex.y += _WaveInt * sin(v.vertex.x * _WaveFreq + _Time.y * _WaveSpeed) + _WaveInt * sin(v.vertex.z * _WaveFreq + _Time.y * _WaveSpeed);
// 计算偏导
float d_dx = _WaveInt * _WaveFreq * cos(v.vertex.x * _WaveFreq + _Time.y * _WaveSpeed);
float d_dz = _WaveInt * _WaveFreq * cos(v.vertex.z * _WaveFreq + _Time.y * _WaveSpeed);
// 计算法线
v.normal = normalize(float3(-d_dx, 1, -d_dz));更复杂的例子请查看:
有限差分法
适用场景
偏移由噪声函数或高度图(Texture)驱动,无法直接求导。
详细步骤:
- 采样当前点:计算当前顶点位置 $P$。
- 采样邻近点:在 X 和 Z 方向步进一个微小值 $\epsilon$,计算 $P_x(x+\epsilon, z)$ 和 $P_z(x, z+\epsilon)$。
- 计算差分向量:通过减法得到近似切线。
- 叉乘:$N = \text{normalize}((P_z - P) \times (P_x - P))$。
这里用的是前向差分法,此外还有更重的中心差分法,参考:
代码示例:
// 步进值
float epsilon = 0.01;
// 设 getOffset 为采样函数
// 1. 获取当前点高度
float h = getOffset(v.vertex.xz);
// 2. 获取偏移点高度
float hx = getOffset(v.vertex.xz + float2(epsilon, 0));
float hz = getOffset(v.vertex.xz + float2(0, epsilon));
// 3. 构建偏移后的三维点
float3 P = float3(v.vertex.x, h, v.vertex.z);
float3 Px = float3(v.vertex.x + epsilon, hx, v.vertex.z);
float3 Pz = float3(v.vertex.x, hz, v.vertex.z + epsilon);
// 4. 计算法线
v.normal = normalize(cross(Pz - P, Px - P));ddx & ddy
适用场景
在片元着色器中进行法线修改(如凹凸映射),追求极简实现。
详细步骤:
- 传递位置:在顶点着色器中计算偏移后的世界坐标,并传给片元着色器。
- 求导:利用
ddx和ddy指令获取相邻像素间的坐标差。 - 叉乘:利用叉乘直接获得面法线。
代码示例:
// 在 Fragment Shader 中
void surf (Input IN, inout SurfaceOutputStandard o) {
float3 worldPos = IN.worldPos;
// 这里的TBN并非跟UV平齐的TBN,而是相对Pixels分块的(屏幕)
// T = ddx(worldPos)
// B = ddy(worldPos)
// N = cross(B, T)
float3 normal = normalize(cross(ddy(worldPos), ddx(worldPos)));
o.Normal = normal;
}更多信息:
4. 几何着色器 (Geometry Shader)
适用场景
需要根据变形后的几何体生成绝对精确的面法线。
详细步骤:
- 输入图元:GS 接收一个三角形的三个顶点 $V_0, V_1, V_2$。
- 计算边向量:$E_1 = V_1 - V_0$,$E_2 = V_2 - V_0$。
- 叉乘归一化:$N = \text{normalize}(E_1 \times E_2)$。
- 流输出:将该法线分配给所有三个顶点。
[maxvertexcount(3)]
void gs_main(triangle v2g IN[3], inout TriangleStream<g2f> triStream) {
// 1. 计算面法线
float3 v1 = IN[1].worldPos - IN[0].worldPos;
float3 v2 = IN[2].worldPos - IN[0].worldPos;
float3 faceNormal = normalize(cross(v1, v2));
// 2. 将法线赋给三个顶点并输出
for (int i = 0; i < 3; i++) {
g2f o;
o.pos = IN[i].pos;
o.normal = faceNormal;
triStream.Append(o);
}
}方案对比
| 方案 | 性能 | 精度 | 平滑度 | 局限性 |
|---|---|---|---|---|
| 偏移函数法 | 极高 | 极高 | 极高 | 必须有解析公式 |
| 有限差分法 | 较低 | 中高 | 高 | 多次采样,开销随精度增加 |
| 屏幕空间导数 | 高 | 中 | 低 | 仅 FS 可用,有锯齿感 |
| 几何着色器 | 低 | 高 | 零 | 硬边效果,移动端兼容性差 |