案例 28.3距离雾与高度雾进阶
目标:写一个屏幕空间的雾:随距离变浓的距离雾、贴着地面堆积的高度雾,以及迎着太阳方向变亮变暖的“内散射”色调。
从透射率说起
光在均匀介质中穿过距离 d,被吸收和散射掉的比例由比尔-朗伯定律(Beer–Lambert Law)描述。设介质的消光系数为 σ(单位:1/米),透射率是
τ 叫光学厚度(Optical Depth)。雾的合成公式就是“场景颜色乘透射率,再加上雾色乘 (1 − 透射率)”:
这个框架的好处是:不同来源的雾可以把光学厚度相加,因为 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。光学厚度可以积出解析式:
(1 − e−k)/k 这一项在视线水平(vy → 0,k → 0)时是 0/0 的形式,极限是 1;直接算会除零或者严重丢精度。代码里在 |k| 很小时改用泰勒展开 1 − k/2,这是写解析积分时必须处理的数值细节。
// 高度雾的光学厚度:密度 σ(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; }
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),与谁在画无关。

#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”,说的就是这件事。本案例出自《URP 渲染实战》第 28 章「屏幕空间技术」。在章节中阅读上下文 →