GPU Gems 1 · Chapter 2. Rendering Water Caustics
从水面法线与反向折射出发,用多遍渲染和着色器生成实时水焦散。
学习目标
- 能解释水焦散为何由曲面折射造成,并区分前向光子追踪、反向追踪与本章的美学近似
- 能从波面高度函数的两个偏导构造法线,并用线面相交得到海底采样点
- 能按三遍渲染流程实现海底底图、焦散叠加与水面反射,并说明屏幕空间与纹理空间的取舍
- 能调整水深、波面坡度与焦散图分辨率,预测光斑会如何改变并用实验验证
为什么水底会出现焦散
先想象一块起伏的玻璃压在手电筒前面:光线经过平面玻璃只会整体偏移,经过弯曲表面却会被分散到不同方向;一些邻近光线恰好落到同一片地面,就形成亮斑。水焦散(caustics)就是这种由曲面反射或折射造成的光线集中,亮斑会随波面移动。
原书的目标不是建立昂贵的海洋光学仿真,而是用一个实时可行的、视觉上可信的模型。先预测:如果把水面坡度调大,你觉得海底亮斑会更均匀,还是会更集中?答案会在后面的法线、折射方向和实验里逐步出现。
1. 从完整光线输运退到可计算近似
严格计算焦散需要从太阳发射大量光子,逐次追踪它们与水面的交互,最后统计每个海底小区域收到的能量。前向光子追踪直观却浪费严重:多数光子碰不到水面,碰到水面的光子中又只有少数真正贡献给某个焦散亮斑。
↡从光源发出光子并沿传播路径累计贡献的光线追踪方法正是从太阳向下发射;↡从被照亮的采样点反向寻找可能入射光线的估计方法则从海底点出发向上猜测候选光线。后者可以用蒙特卡洛采样估计积分,但仍要丢弃大量没有打到光源的假设光线。
本章选择反向思路,却进一步采用美学近似:假定中午、太阳在正上方,海底采样点先垂直投影到水面;再由波面法线反向产生一条候选折射光线。由于太阳盘距离极远,候选方向越接近垂直,越可以把它看作获得了更多太阳能量。
水的折射率设为约 1.33。这不是把整套光学公式藏起来,而是让实现集中在“波面如何改变候选方向”这一视觉主因上。真正的物理准确度与实时预算之间,正是本章需要读者能说清楚的工程边界。
2. 用波面法线找到候选光线
设水面高度是 z = f(x, y, t),在某个海底顶点 p₀ 上方的水面点记为 p。水面沿两个水平轴的变化率分别是 fₓ 与 fᵧ。↡曲面高度沿某个坐标轴的局部变化率把“波面往哪边上升”变成可计算的斜率,于是未归一化的表面方向可写为:
其中 ↡垂直于曲面切平面的单位方向,决定光线如何被反射或折射 N 是波面改变光线方向的关键。若原来的波函数由多层余弦或正弦叠加而成,就可以按链式法则逐层计算 fₓ 与 fᵧ;GPU 不需要先把整张高度图读回 CPU。
斯涅尔定律在这里扮演什么角色
↡描述光线穿过两种介质时入射角与折射角关系的定律写成 η₁ sin θ₁ = η₂ sin θ₂,其中 η 是介质折射率。原书的 OpenGL 版本可以沿法线和折射率构造反向折射线;着色器版本则发现,用“沿波面法线到海底的截距”做环境映射,视觉上已非常接近完整折射计算,而且更便宜。
这个取舍很值得记住:不是因为折射定律不重要,而是因为远处太阳盘近似、垂直入射假设和水底距离共同决定了最终亮度;把可见差异很小的步骤合并,才能把预算留给每像素细节。
3. 线面相交把方向变成纹理坐标
法线给出方向,却还没有给出海底上哪个纹素应该变亮。把候选光线写成:
把海底平面写成 n · p = D,则交点距离为:
这里 ↡把一条射线与平面联立,得到射线在平面上的交点的计算把方向 v 转成 p_hit;再将 p_hit.xy 归一化,就可以访问太阳光图。n · v 接近零时,光线几乎平行于海底,应该降低贡献或走保护分支,不能直接除零。
Caustics Lab
先预测,再改变一个光线条件
观察:坡度增加会让亮斑更集中;水越深,传输衰减越明显。
实验操作顺序:先预测坡度滑杆从 0.24 增大到 0.45 后,亮斑是否变得更尖;再只改变一个滑杆,观察亮度网格和候选光线的变化。最后点“重置实验”,确认你看到的是参数变化,而不是视图残留。
4. 三遍渲染:把焦散叠加到海底
原书的基础 OpenGL 实现采用多遍绘制。第一遍以普通纹理四边形绘制海底;第二遍使用与波网格粒度相近的细网格,为每个顶点计算焦散值并做加法叠加;第三遍绘制水面波形,并用平面环境映射得到天空反射。焦散值可以从 0(没有集中光线)到 1(高度集中)理解。
在第二遍中,每个海底顶点执行以下链条:
- 从海底点向上发出垂直射线,和波面相交。
- 用相交点的法线与水的折射率构造反向折射方向。
- 让折射方向与海底平面求交,得到
p_hit。 - 用
p_hit计算太阳光图坐标,并把焦散值写入细网格顶点颜色。
这种 ↡把多个渲染结果按像素颜色相加的混合方式只负责把光照贡献叠到海底底色上,不负责重新计算海底材质。水面反射仍是独立的第三遍,因此调焦散强度时不会意外改变水面几何。
5. 从顶点计算迁移到着色器
把同一波函数复制到 HLSL 或 Cg 等高层着色语言后,可以将焦散从逐顶点提升为逐像素计算。这样视觉质量不再强依赖细网格密度:每个像素都可以按自身位置计算波面梯度,再得到线面交点。
一个压缩后的实现骨架如下。代码只保留因果链,真实项目还应加入参数缓冲、边界保护、颜色空间和深度衰减:
float2 gradWave(float x, float y, float time);
float3 linePlaneHit(float3 p, float3 v, float3 planeN, float planeD) {
float denominator = dot(planeN, v);
float distance = (planeD - dot(planeN, p)) / denominator;
return p + v * distance;
}
float4 shadeFloor(VertexOut input, sampler2D lightMap,
sampler2D groundMap, float time) {
float2 slope = gradWave(input.position.x, input.position.y, time);
float3 candidate = normalize(float3(slope.x, slope.y, 1.0));
float3 hit = linePlaneHit(input.position.xyz, candidate,
float3(0, 0, 1), -0.8);
float3 caustic = tex2D(lightMap, hit.xy * 0.8).rgb;
return float4(tex2D(groundMap, input.uv).rgb + caustic, 1.0);
}这里的 ↡在计算过程中用上一个采样结果决定下一个纹理坐标的读取是性能与画质的交汇点:hit.xy 不是静态 UV,而是由当前波面、法线和深度共同决定的坐标。
6. 屏幕空间还是纹理空间
屏幕空间只为当前可见像素计算焦散,因此小窗口或局部水底很划算;但可见面积变大时,每帧工作量也随之膨胀。纹理空间把焦散写入固定分辨率的 render target,成本更稳定,还能利用自动 mipmap 生成与各向异性过滤降低混叠;代价是不可见区域也会被计算,并且分辨率选得过低时会显出双线或十字纹理。
工程上可以按三个问题选方案:焦散覆盖多少屏幕?需要多少高频细节?过滤和带宽预算能否支撑目标分辨率?不要把“逐像素”直接等同于“永远更快”,也不要把固定 render target 当作无需调参的万能解。
本章回顾:近似如何形成可控效果
- 焦散是曲面反射或折射后光线在局部集中形成的亮斑。
- 反向采样从海底点找候选光线,比追踪所有光子更适合实时近似。
- 波面偏导得到法线;线面相交把折射方向落到海底纹理坐标。
- 三遍渲染把海底、焦散叠加和水面反射分开,便于调试与扩展。
- 屏幕空间与纹理空间分别交换可见性收益、固定成本和过滤能力。
练习
问题 1 改 Demo 代码:把实验中的候选光线从“按波面坡度计算”改成固定竖直方向。你预计亮斑会失去什么变化?请指出应改动的向量,并用重置前后的截图验证。
问题 2 推导题:若海底平面为 z = -0.8,采样点 p₀=(0.2,0.3,0),候选方向 v=(0.1,0.2,-1),用线面相交公式求 t 并写出交点。
问题 3 场景选型题:一个小型水池只占屏幕的四分之一,另一个海底镜头几乎铺满屏幕且希望焦散图跨帧稳定。分别选择屏幕空间或纹理空间,并说明过滤策略。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 水焦散
- 曲面折射或反射后,许多光线集中到局部区域形成的动态亮斑。
- 前向光子追踪
- 从光源发射光子并跟随它们传播,统计它们最终照亮哪里的办法。
- 反向光线追踪
- 从被照亮的点反过来寻找可能来自光源的光线,减少无关光线的办法。
- 蒙特卡洛采样
- 用许多随机候选样本近似一个难以直接计算的光照积分。
- 偏导数
- 固定其他坐标后,只看曲面沿一个方向变化得有多快。
- 法线
- 垂直于表面的方向;它决定光线遇到表面后会朝哪边转。
- 斯涅尔定律
- 描述光线进入另一种介质后,入射角和折射角如何关联的公式。
- 线面相交
- 计算一条光线在什么位置穿过平面,把方向变成落点。
- 加法混合
- 把新算出的光照颜色加到已有底色上的像素混合方式。
- 依赖纹理读取
- 用计算出的交点或其他结果临时生成纹理坐标,再读取纹理。