GameTech/URP 渲染实战

案例 19.3折射与吸收进阶

目标:水底的画面被水面的起伏弯折;光在水里按颜色被吸收,深处偏蓝绿;同时解决折射最常见的瑕疵——水面上方的物体“漏”进水里。

不再依赖 Alpha 混合

案例 19.2 的水靠 Alpha 混合透出水底:GPU 把水的颜色和已有画面按比例混合。要做折射,我们需要读取“旁边一点”的水底画面,Alpha 混合做不到。所以从这一步开始换一种思路:自己把水下画面算出来,输出 alpha = 1。水下画面从 _CameraOpaqueTexture 读取(第 18 章案例 18.8 讲过它的拷贝时机),采样位置按法线偏移。

折射偏移量用法线的切线空间 xy 分量:平静水面法线是 (0,0,1),没有偏移;起伏处 xy 越大偏移越大。这一步需要法线贴图,所以配图里提前打开了 _NORMALS(下一个案例讲它),暂时只用一层。

两个让折射更可信的细节

第一,越浅扰动越小。岸边几厘米深的水不应该把岸上的沙子扭得很厉害,否则岸线会抖成锯齿。偏移量乘 saturate(水深 × 2),半米以内逐渐减弱。

第二,远处的偏移要缩小。偏移是在屏幕空间加的,同样 0.05 的屏幕偏移,对近处的水只是几厘米,对远处的水可能是好几米。除以水面的观察深度做一个近似的透视补偿,远近的扭曲幅度看起来就一致了。

前景泄漏

屏幕空间折射有一个固有缺陷:偏移后的 UV 可能落在水面上方的物体上。图 19-5 里,水面像素 A 的折射 UV 偏到了 B,而 B 处的不透明纹理里是一个悬在水面上方的箱子——于是箱子的橙色出现在了水里。修正方法是在偏移后的位置再采样一次深度:如果那里的场景深度比水面本身还近,说明采到的是前景,就放弃偏移,退回原来的 UV。

水面上方的箱子 相机 A:水面像素 偏移后的 UV 指向 B B B 处场景深度 < A 处水面深度 → B 在水面前方 → 是前景,不能用 → 退回 A 的 UV
图 19-5前景泄漏:折射偏移把采样点挪到了水面之上的物体上。比较偏移点的场景深度与水面深度即可识别。
Assets/Cookbook/Ch19/WaterCore.hlsl(节选:refraction)
// 折射:按法线扰动屏幕 UV;如果扰动后取到的是“水面之上的东西”(深度比水面还近),说明发生了前景泄漏,退回原 UV
float2 RefractUV(float2 screenUV, half3 normalTS, float surfaceEye, float verticalDepth)
{
    // 越浅扰动越小:岸边几厘米深的水不该把岸上的东西扭得很厉害
    float2 offset = normalTS.xy * _RefractionStrength * saturate(verticalDepth * 2.0);
    // 远处的水在屏幕上更小,同样的偏移量会显得更夸张,除以深度做个近似的透视补偿
    offset /= max(1.0, surfaceEye * 0.25);
    float2 uv = screenUV + offset;
    if (_RefractLeakFix > 0.5)
    {
        float eye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
        if (eye < surfaceEye) uv = screenUV;
    }
    return uv;
}
// 折射:按法线扰动屏幕 UV;如果扰动后取到的是“水面之上的东西”(深度比水面还近),说明发生了前景泄漏,退回原 UV
vec2 RefractUV(vec2 screenUV, vec3 normalTS, float surfaceEye, float verticalDepth)
{
    // 越浅扰动越小:岸边几厘米深的水不该把岸上的东西扭得很厉害
    vec2 offset = normalTS.xy * _RefractionStrength * saturate(verticalDepth * 2.0);
    // 远处的水在屏幕上更小,同样的偏移量会显得更夸张,除以深度做个近似的透视补偿
    offset /= max(1.0, surfaceEye * 0.25);
    vec2 uv = screenUV + offset;
    if (_RefractLeakFix > 0.5)
    {
        float eye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
        if (eye < surfaceEye) uv = screenUV;
    }
    return uv;
}

这个修正不是完美的:被拒绝的像素退回原 UV,失去了扭曲,在箱子的轮廓附近会出现一小圈“不扭曲”的区域。但比起前景颜色漏进水里,这点瑕疵几乎察觉不到。

Beer-Lambert 吸收

光在均匀介质中传播距离 d 后,剩下的比例(透射率)是

T=e−σd

σ 是吸收系数,单位是“每米”。水对红光的吸收系数远大于蓝绿光,所以 σ 按 RGB 分别给(本例默认 (0.9, 0.32, 0.22),这是为了风格化效果调出来的数值,不是实测数据)。被吸收掉的那部分光并不是变成黑色——水中的微粒会把环境光散射回来,所以用“散射色”(水体被照亮的颜色)补上:

C=C水底·T+C散射·(1−T)
Assets/Cookbook/Ch19/WaterCore.hlsl(节选:absorption)
// Beer-Lambert:光在水里每走 1 米,各通道按 exp(-σ) 衰减。红光衰减最快,所以深水偏蓝绿。
// 被吸收掉的部分用“散射色”补上(水体本身被照亮的颜色)
half3 UnderwaterColor(half3 sceneColor, float pathLength, half3 scatterColor)
{
    half3 transmittance = exp(-_Absorption.rgb * pathLength);
    return sceneColor * transmittance + scatterColor * (1.0h - transmittance);
}
// Beer-Lambert:光在水里每走 1 米,各通道按 exp(-σ) 衰减。红光衰减最快,所以深水偏蓝绿。
// 被吸收掉的部分用“散射色”补上(水体本身被照亮的颜色)
vec3 UnderwaterColor(vec3 sceneColor, float pathLength, vec3 scatterColor)
{
    vec3 transmittance = exp(-_Absorption.rgb * pathLength);
    return sceneColor * transmittance + scatterColor * (1.0 - transmittance);
}

这里的 d 用的是视线路径长度,而不是竖直水深:从远处斜看浅水,视线在水里走得长,水的颜色会更浓——和真实世界一致。由于三个通道衰减速度不同,浅水几乎无色透明、稍深一点泛青绿、再深变成蓝色,这个颜色渐变是 Beer-Lambert 自动给出的,不需要手调渐变色。

图 19-6左:折射强度 0(只有吸收);中:折射强度 0.2、不做泄漏修正——箱子右上方的水面里出现了橙色的“倒影”,这是箱子颜色漏了进来;右:开启修正后泄漏消失,水底的折射保持不变。

折射分支里还有一行:color = lerp(SampleSceneColor(screenUV), color, saturate(wd.vertical / _EdgeFade))。水深趋近 0 时,最终颜色过渡到“没有水”的原始画面,代替了案例 19.2 用 alpha 做的岸线柔化。