在顶点位置发生了偏移后,原始法线将失效,若不重计算法线,光照效果会停留在位移前的阶段。

对偏移函数求导

计算步骤: 定义函数:

$$ 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$ 均不变化,简化后仅计算两个偏导即可。

HLSL
// 计算偏移(即偏移函数)(这里是简单的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));
点击展开查看更多

更复杂的例子请查看:


有限差分法

详细步骤:

  1. 采样当前点:计算当前顶点位置 $P$。
  2. 采样邻近点:在 X 和 Z 方向步进一个微小值 $\epsilon$,计算 $P_x(x+\epsilon, z)$ 和 $P_z(x, z+\epsilon)$。
  3. 计算差分向量:通过减法得到近似切线。
  4. 叉乘:$N = \text{normalize}((P_z - P) \times (P_x - P))$。

这里用的是前向差分法,此外还有更重的中心差分法,参考:

代码示例:

HLSL
// 步进值
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

详细步骤:

  1. 传递位置:在顶点着色器中计算偏移后的世界坐标,并传给片元着色器。
  2. 求导:利用 ddxddy 指令获取相邻像素间的坐标差。
  3. 叉乘:利用叉乘直接获得面法线。

代码示例:

HLSL
// 在 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)

详细步骤:

  1. 输入图元:GS 接收一个三角形的三个顶点 $V_0, V_1, V_2$。
  2. 计算边向量:$E_1 = V_1 - V_0$,$E_2 = V_2 - V_0$。
  3. 叉乘归一化:$N = \text{normalize}(E_1 \times E_2)$。
  4. 流输出:将该法线分配给所有三个顶点。
HLSL
[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 可用,有锯齿感
几何着色器 硬边效果,移动端兼容性差

版权声明

作者: Cheyne Xie

链接: https://chaim.eu.org/posts/3d253bb1/

许可证: CC BY-NC-SA 4.0

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. Please attribute the source, use non-commercially, and maintain the same license.

开始搜索

输入关键词搜索文章内容

↑↓
ESC
⌘K 快捷键