案例 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 沿光方向投影回水面的位置:
这样太阳斜照时,越深的地方图案偏移越多;石头侧面与顶面的图案也会错开,立体感更好。
三个衰减
- 深度衰减:刚入水的地方光还没被会聚,焦散很弱;太深的地方光被吸收殆尽。用
saturate(把它限制在“中间深度”。depth × 4) × exp(−depth / fade) - 朝向衰减:焦散只照在朝上的面上。我们不知道水底的法线(没有法线图),但可以用重建坐标的屏幕导数求:
ddx(sceneWS)、ddy(sceneWS)是相邻像素的世界坐标差,它们的叉积就是那个表面的法线。注意要用未经折射偏移的坐标求导:折射后每个像素的偏移量都不同,导数会变成噪声。 - 阴影:在水底点 P 上重新查一次主光阴影(
MainLightRealtimeShadow(),柱子影子里就没有焦散了。注意这是在水底点查阴影,不是在水面点。TransformWorldToShadowCoord( P))
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 章全息色差是同一个技巧。

深入程序化 Voronoi 每个像素要遍历 3×3 个格子,两层、三个通道一共 54 次哈希与距离计算,在移动端偏贵。常见的替代方案是预先生成一张可循环的焦散贴图(甚至可以用本案例的函数在编辑器里烘焙出来),运行时采样两次、错开流动。另一种架构是把焦散做成贴花或灯光 Cookie 投射到水底物体上,好处是不依赖水面像素,从水下看也有焦散。