GameTech/URP 渲染实战

案例 28.3距离雾与高度雾进阶

目标:写一个屏幕空间的雾:随距离变浓的距离雾、贴着地面堆积的高度雾,以及迎着太阳方向变亮变暖的“内散射”色调。

从透射率说起

光在均匀介质中穿过距离 d,被吸收和散射掉的比例由比尔-朗伯定律(Beer–Lambert Law)描述。设介质的消光系数为 σ(单位:1/米),透射率是

T=e−τ,τ=∫0dσ(t)dt

τ 叫光学厚度(Optical Depth)。雾的合成公式就是“场景颜色乘透射率,再加上雾色乘 (1 − 透射率)”:

Cfinal=Cscene·T+Cfog·(1−T)

这个框架的好处是:不同来源的雾可以把光学厚度相加,因为 e−(τ₁+τ₂) = e−τ₁·e−τ₂,物理上就是光依次穿过两种介质。如果各自算出雾量再做两次 lerp,结果虽然也能看,但叠加顺序会影响结果,浓度也会不自然地叠加。

距离雾

密度处处相同(σ 为常数),τ = σ · max(0, d − dstart),就得到经典的指数雾。URP 自带的雾(Lighting 窗口里的 Fog,Shader 里的 ComputeFogFactor/MixFog)也是这一类,只不过它在每个物体的 Shader 里逐像素计算(URP 14 的 ShaderVariablesFunctions.hlsl 里 _FOG_FRAGMENT 被强制为 1),而我们这里在屏幕空间统一计算。两者的取舍是:

屏幕空间雾
不依赖每个 Shader 写对雾的代码;能做高度雾、体积感、太阳散射等复杂模型;一次全屏计算。但透明物体处理麻烦,天空也要单独考虑。
逐物体雾(URP 内置)
每个 Shader 都要 multi_compile_fog 并调用 MixFog,漏写的物体就“不进雾”;但透明物体天然正确,移动端也最便宜。

高度雾的解析积分

真实的雾、霾在低处浓、高处淡,常用指数分布描述:σ(y) = a · e−b(y − h₀),a 是基准高度 h₀ 处的密度,b 决定随高度衰减的快慢。视线从相机 c 出发沿单位方向 v 走过长度 L,沿途高度是 cy + t vy。光学厚度可以积出解析式:

τ=∫0Lae−b(cy+tvy−h0)dt=ae−b(cy−h0)⏟相机处密度·L·1−e−kk,k=bvyL

(1 − e−k)/k 这一项在视线水平(vy → 0,k → 0)时是 0/0 的形式,极限是 1;直接算会除零或者严重丢精度。代码里在 |k| 很小时改用泰勒展开 1 − k/2,这是写解析积分时必须处理的数值细节。

h₀(基准高度) 相机 向下看:穿过浓雾,τ 大 向上看:越走越稀,τ 小 σ(y)=a·e^(−b(y−h₀))
图 28-6高度雾:密度随高度指数衰减,同样长度的视线,向下看比向上看的光学厚度大得多
Assets/Cookbook/Ch28/HeightFog.shader(节选:heightfog)
// 高度雾的光学厚度:密度 σ(y) = a·exp(-b·(y - h0)),沿射线 c + t·v (0≤t≤L) 积分
float HeightFogOpticalDepth(float3 camPos, float3 viewDir, float rayLength)
{
    float a = _HeightDensity, b = _HeightFalloff;
    float densityAtCam = a * exp(-b * (camPos.y - _FogBaseHeight));
    float k = b * viewDir.y * rayLength;          // 射线跨过的“高度衰减量”
    // (1 - e^{-k}) / k 在 k→0 时趋于 1;用泰勒展开避免水平视线除零
    float factor = abs(k) > 1e-3 ? (1 - exp(-k)) / k : 1 - 0.5 * k;
    return densityAtCam * rayLength * factor;
}
// 高度雾的光学厚度:密度 σ(y) = a·exp(-b·(y - h0)),沿射线 c + t·v (0≤t≤L) 积分
float HeightFogOpticalDepth(vec3 camPos, vec3 viewDir, float rayLength)
{
    float a = _HeightDensity, b = _HeightFalloff;
    float densityAtCam = a * exp(-b * (camPos.y - _FogBaseHeight));
    float k = b * viewDir.y * rayLength;          // 射线跨过的“高度衰减量”
    // (1 - e^{-k}) / k 在 k→0 时趋于 1;用泰勒展开避免水平视线除零
    float factor = abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k;
    return densityAtCam * rayLength * factor;
}
Assets/Cookbook/Ch28/HeightFog.shader(节选:frag)
half4 Frag(VaryingsRay input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv);
    float rawDepth = SampleSceneDepth(uv);

    float3 viewDir = normalize(input.farRayWS);
    float rayLength = IsSkyDepth(rawDepth) ? _SkyDistance
                    : length(ReconstructPositionWSFromRay(input.farRayWS, rawDepth) - _WorldSpaceCameraPos);

    float tauDistance = _DistanceDensity * max(0, rayLength - _FogStart);
    float tauHeight = HeightFogOpticalDepth(_WorldSpaceCameraPos, viewDir, rayLength);
    float transmittance = exp(-(tauDistance + tauHeight));

    // 朝太阳看时雾更亮更暖(前向散射的廉价近似)
    Light mainLight = GetMainLight();
    float sunAmount = pow(saturate(dot(viewDir, mainLight.direction)), _SunScatterPower);
    half3 fogColor = lerp(_FogColor.rgb, _SunScatterColor.rgb * mainLight.color, sunAmount);

    color.rgb = color.rgb * transmittance + fogColor * (1 - transmittance);
    return color;
}
vec4 Frag()
{
    vec2 uv = texcoord;
    vec4 color = SamplePointClamp(_BlitTexture, uv);
    float rawDepth = SampleSceneDepth(uv);

    vec3 viewDir = normalize(farRayWS);
    float rayLength = IsSkyDepth(rawDepth) ? _SkyDistance
                    : length(ReconstructPositionWSFromRay(farRayWS, rawDepth) - _WorldSpaceCameraPos);

    float tauDistance = _DistanceDensity * max(0.0, rayLength - _FogStart);
    float tauHeight = HeightFogOpticalDepth(_WorldSpaceCameraPos, viewDir, rayLength);
    float transmittance = exp(-(tauDistance + tauHeight));

    // 朝太阳看时雾更亮更暖(前向散射的廉价近似)
    Light mainLight = GetMainLight();
    float sunAmount = pow(saturate(dot(viewDir, mainLight.direction)), _SunScatterPower);
    vec3 fogColor = mix(_FogColor.rgb, _SunScatterColor.rgb * mainLight.color, sunAmount);

    color.rgb = color.rgb * transmittance + fogColor * (1.0 - transmittance);
    return color;
}

天空像素没有深度,这里给它一个固定的“等效距离” _SkyDistance。这样水平方向的天空被雾吞没、头顶的天空依然清澈(因为向上看时高度雾的光学厚度很小),地平线自然地融进雾里。

雾色的“太阳内散射”是一个廉价近似:视线方向与太阳方向越接近,雾色越偏向太阳色。物理上这对应介质的前向散射(见 28.4 节的相位函数),这里用 pow(saturate(dot), n) 来控制光晕范围。注意 GetMainLight() 在全屏 Pass 里一样能用:主光的方向和颜色是 URP 每台相机设置一次的全局变量(_MainLightPosition、_MainLightColor),与谁在画无关。

图 28-7左:只有距离雾,远处柱子均匀地褪成雾色;中:只有高度雾,地面和低处的方块被吞没,远处高塔的上半截露出雾面;右:两者光学厚度相加,并按太阳方向混入暖色的内散射
坑:Shadows.hlsl 的隐藏依赖这个 Shader 里有一行看似多余的 #include "…/CommonMaterial.hlsl"。去掉它,编译会报 undeclared identifier 'LerpWhiteTo',错误位置在 URP 的 Shadows.hlsl。原因是 RealtimeLights.hlsl 包含了 Shadows.hlsl,而后者用到了 core 里 CommonMaterial.hlsl 的 LerpWhiteTo,却没有自己 include 它;普通物体 Shader 走 Lighting.hlsl 时恰好被别的文件带上了,全屏 Shader 只 include 了一部分就会暴露出来。URP 自己的 ScreenSpaceShadows.shader 开头有一句注释 “Keep compiler quiet about Shadows.hlsl”,说的就是这件事。