GameTech/URP 渲染实战

案例 19.8焦散:程序化 Voronoi 投射到水下高阶

目标:水底出现游动的亮纹网;只出现在水下、只出现在朝上的面上、深处逐渐消失、柱子的阴影里没有;亮纹边缘带一点彩虹色的色散。

焦散的图案

真实焦散是起伏的水面把光线会聚到水底形成的亮线网。精确计算需要追踪大量光线,实时渲染里通常用一张循环的焦散贴图或程序化图案来“画”出来。Voronoi(Worley)噪声的 F2 − F1(到第二近特征点的距离减去到最近特征点的距离)在细胞边界处为 0、细胞内部较大,取反后就是一张亮线网,形态和焦散非常接近。让每个特征点绕自己的中心转圈,亮线网就“活”了起来;两层不同尺度的网叠加,交叉处更亮,形成焦散特有的亮节点。

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:caustics)
// 程序化焦散:Voronoi 的 F2-F1 在细胞边界处接近 0,取反后就是一张亮线网,很像焦散的光网
float2 VoronoiF1F2(float2 p, float t)
{
    float2 cell = floor(p), f = frac(p);
    float F1 = 8.0, F2 = 8.0;
    [unroll] for (int j = -1; j <= 1; j++)
    [unroll] for (int i = -1; i <= 1; i++)
    {
        float2 g = float2(i, j);
        float2 o = Hash22(cell + g);
        o = 0.5 + 0.45 * sin(t + 6.2831 * o);      // 特征点绕各自中心转圈,图案就“活”了
        float2 r = g + o - f;
        float d = dot(r, r);
        if (d < F1) { F2 = F1; F1 = d; }
        else if (d < F2) { F2 = d; }
    }
    return sqrt(float2(F1, F2));
}

half CausticPattern(float2 p, float t)
{
    float2 v1 = VoronoiF1F2(p, t);
    float2 v2 = VoronoiF1F2(p * 1.43 + 7.1, t * 1.21);
    half c1 = 1.0h - smoothstep(0.0h, 0.18h, v1.y - v1.x);
    half c2 = 1.0h - smoothstep(0.0h, 0.18h, v2.y - v2.x);
    // 两层叠加:交叉处更亮,形成焦散特有的“亮节点”
    return c1 * c1 + c2 * c2 + 2.0h * c1 * c2;
}

// 把焦散投到水下表面:沿主光方向把水底点“投影回”水面,在那里查图案,图案就会随太阳方向偏移
half3 Caustics(float3 sceneWS, float verticalDepth, half upFacing, Light light, float t)
{
    float3 L = light.direction;
    float2 p = sceneWS.xz + L.xz / max(L.y, 0.2) * verticalDepth;
    p *= _CausticScale;
    // RGB 三个通道错开一点点采样,模拟不同波长折射角不同产生的色散
    float2 split = float2(_CausticSplit, _CausticSplit * 0.5);
    half3 c = half3(CausticPattern(p + split, t), CausticPattern(p, t), CausticPattern(p - split, t));
    // 刚入水时焦散还没聚焦,太深又被吸收:用两个衰减把它限制在“中间深度”
    half fade = saturate(verticalDepth * 4.0) * exp(-verticalDepth / _CausticDepthFade);
    // 只有朝上的面才接到焦散(upFacing 由调用方用屏幕导数求出,见 SceneUpFacing)
    fade *= upFacing;
    // 阴影要在水底那一点上查:柱子的影子里不该有焦散
    half shadow = MainLightRealtimeShadow(TransformWorldToShadowCoord(sceneWS));
    return c * fade * _CausticIntensity * light.color * shadow;
}
// 程序化焦散:Voronoi 的 F2-F1 在细胞边界处接近 0,取反后就是一张亮线网,很像焦散的光网
vec2 VoronoiF1F2(vec2 p, float t)
{
    vec2 cell = floor(p), f = fract(p);
    float F1 = 8.0, F2 = 8.0;
    for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++)
    {
        vec2 g = vec2(i, j);
        vec2 o = Hash22(cell + g);
        o = 0.5 + 0.45 * sin(t + 6.2831 * o);      // 特征点绕各自中心转圈,图案就“活”了
        vec2 r = g + o - f;
        float d = dot(r, r);
        if (d < F1) { F2 = F1; F1 = d; }
        else if (d < F2) { F2 = d; }
    }
    return sqrt(vec2(F1, F2));
}

float CausticPattern(vec2 p, float t)
{
    vec2 v1 = VoronoiF1F2(p, t);
    vec2 v2 = VoronoiF1F2(p * 1.43 + 7.1, t * 1.21);
    float c1 = 1.0 - smoothstep(0.0, 0.18, v1.y - v1.x);
    float c2 = 1.0 - smoothstep(0.0, 0.18, v2.y - v2.x);
    // 两层叠加:交叉处更亮,形成焦散特有的“亮节点”
    return c1 * c1 + c2 * c2 + 2.0 * c1 * c2;
}

// 把焦散投到水下表面:沿主光方向把水底点“投影回”水面,在那里查图案,图案就会随太阳方向偏移
vec3 Caustics(vec3 sceneWS, float verticalDepth, float upFacing, Light light, float t)
{
    vec3 L = light.direction;
    vec2 p = sceneWS.xz + L.xz / max(L.y, 0.2) * verticalDepth;
    p *= _CausticScale;
    // RGB 三个通道错开一点点采样,模拟不同波长折射角不同产生的色散
    vec2 split = vec2(_CausticSplit, _CausticSplit * 0.5);
    vec3 c = vec3(CausticPattern(p + split, t), CausticPattern(p, t), CausticPattern(p - split, t));
    // 刚入水时焦散还没聚焦,太深又被吸收:用两个衰减把它限制在“中间深度”
    float fade = saturate(verticalDepth * 4.0) * exp(-verticalDepth / _CausticDepthFade);
    // 只有朝上的面才接到焦散(upFacing 由调用方用屏幕导数求出,见 SceneUpFacing)
    fade *= upFacing;
    // 阴影要在水底那一点上查:柱子的影子里不该有焦散
    float shadow = MainLightRealtimeShadow(TransformWorldToShadowCoord(sceneWS));
    return c * fade * _CausticIntensity * light.color * shadow;
}

投射到水下:在哪里查图案

焦散画在水底,而不是水面上。我们在水面 Shader 里已经重建了水底的世界坐标 sceneWS(案例 19.2),所以只要用它来查焦散图案,再把结果加到折射得到的水下颜色上——焦散自然只出现在“透过水面看到的地方”,岸上的沙滩不会有。

更进一步:光是从太阳方向斜射下来的。水底一点 P 接收到的光,是从水面上 P 沿光线方向往回追溯到的那一点进来的。所以查图案的位置应该是 P 沿光方向投影回水面的位置:

pxz=Pxz+LxzLy·depth

这样太阳斜照时,越深的地方图案偏移越多;石头侧面与顶面的图案也会错开,立体感更好。

水面 y=0 水底 光方向 L P(sceneWS) 在这里查焦散图案 depth L.xz / L.y × depth
图 19-11把水底点沿光方向投影回水面,在投影点查焦散图案。

三个衰减

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:upfacing)
// 用重建坐标的屏幕导数求水下表面的法线:ddx/ddy 是相邻像素的世界坐标差,叉积就是面法线。
// 屏幕 y 方向在不同平台可能相反,叉积符号会翻转,所以取绝对值。要用“未折射”的坐标:
// 折射后的 UV 每个像素扰动不同,求出的导数全是噪声
half SceneUpFacing(float3 sceneWS)
{
    float3 n = normalize(cross(ddy(sceneWS), ddx(sceneWS)));
    return saturate(abs(n.y) * 1.5 - 0.3);
}
#ifdef FRAGMENT   // dFdx/dFdy 只能在片元着色器里用
// 用重建坐标的屏幕导数求水下表面的法线:dFdx/dFdy 是相邻像素的世界坐标差,叉积就是面法线。
// 屏幕 y 方向在不同平台可能相反,叉积符号会翻转,所以取绝对值。要用“未折射”的坐标:
// 折射后的 UV 每个像素扰动不同,求出的导数全是噪声
float SceneUpFacing(vec3 sceneWS)
{
    vec3 n = normalize(cross(dFdy(sceneWS), dFdx(sceneWS)));
    return saturate(abs(n.y) * 1.5 - 0.3);
}
#endif

最后,R、G、B 三个通道在略微错开的位置查图案,亮线边缘出现彩色镶边,模拟不同波长折射角不同造成的色散——这和第 18 章全息色差是同一个技巧。

图 19-12左:调试视图,直接用水底坐标查到的 Voronoi 焦散图案(未做任何衰减,柱子侧面也被画上了条纹);中:没有焦散;右:加入焦散——亮纹只出现在水下朝上的表面,暗礁顶部最强,柱子的阴影里没有焦散。
深入程序化 Voronoi 每个像素要遍历 3×3 个格子,两层、三个通道一共 54 次哈希与距离计算,在移动端偏贵。常见的替代方案是预先生成一张可循环的焦散贴图(甚至可以用本案例的函数在编辑器里烘焙出来),运行时采样两次、错开流动。另一种架构是把焦散做成贴花或灯光 Cookie 投射到水底物体上,好处是不依赖水面像素,从水下看也有焦散。