本文属于系列「游戏中的 Shader:十个常用效果的实现与边界」第 9 / 10 篇

场景雾效为何随远裁剪面跳变:世界距离与高度衰减

远处山体已经逐渐融入雾色,但相机把远裁剪面从 100 米改到 400 米后,镜头和山体都没有移动,整片山谷却突然变清晰。同一世界坐标出现两种雾量,说明变化的不是空气,而是雾公式误用了投影后的深度。

距离与高度联合、仅距离以及两种远裁剪面的 WebGL2 实际结果

上一篇水面扭曲把效果限制在一个表面的 UV 采样内,并由宿主约束强度和有效域。场景雾沿用“宿主提交有限参数,Shader 处理当前片元”的边界,但输入不再是 [0,1] UV:顶点阶段必须交付世界位置,片元阶段才能让相机距离和世界高度拥有稳定单位。

裁剪深度不是米,也不保持线性

gl_FragCoord.z 是投影、透视除法和视口映射后的深度值。它适合深度测试,却不等于相机到片元的世界距离;透视投影还会把大量精度压在近裁剪面附近。若再把这个值乘远裁剪距离近似米数,改变 near、far 或投影方式都会改写雾量。

本章顶点入口把世界位置作为 varying 传给片元阶段。实际引擎中,它通常来自 model * position;探针使用固定山谷剖面,让同一世界坐标能在两套远裁剪参数下重复采样。完整片元入口如下:

// .tmp/game-shader-webgl-lab/chapters/09-height-fog/fog.frag
#version 300 es
precision highp float;

in vec3 vWorldPosition;
in vec2 vUv;
uniform vec3 uCameraWorldPosition;
uniform vec3 uFogColor;
uniform float uDensity;
uniform float uFogBottom;
uniform float uFogTop;
uniform float uFarClip;
uniform bool uUseClipDepth;
uniform bool uDistanceOnly;
out vec4 outColor;

void main() {
  float terrainHeight = 4.0
    + sin(vWorldPosition.z * 0.055) * 8.0
    + sin(vWorldPosition.z * 0.019 + 0.8) * 5.0;
  bool terrain = vWorldPosition.y <= terrainHeight;
  vec3 baseColor = terrain
    ? mix(
        vec3(0.08, 0.18, 0.12),
        vec3(0.34, 0.28, 0.16),
        clamp((vWorldPosition.y + 12.0) / 35.0, 0.0, 1.0)
      )
    : mix(
        vec3(0.055, 0.12, 0.20),
        vec3(0.22, 0.38, 0.48),
        vUv.y
      );
  float worldDistance = length(
    vWorldPosition - uCameraWorldPosition
  );
  float distanceTerm = uUseClipDepth
    ? clamp(vWorldPosition.z / uFarClip, 0.0, 1.0) * 188.0
    : worldDistance;
  float heightRange = max(
    uFogTop - uFogBottom,
    0.0001
  );
  float heightTerm = uDistanceOnly ? 1.0 : clamp(
    (uFogTop - vWorldPosition.y) / heightRange,
    0.0,
    1.0
  );
  float opticalDepth = max(
    0.0,
    uDensity * distanceTerm * heightTerm
  );
  float fog = 1.0 - exp(-opticalDepth);
  outColor = vec4(mix(baseColor, uFogColor, fog), 1.0);
}

uUseClipDepth 只保留给失败回归。正式路径的 worldDistance 只由相机与片元世界位置决定,因此远裁剪面不参与结果。固定样本在 far 为 100 米和 400 米时都回读 [102,126,127,255],四通道差值为 0。错误路径分别得到 [108,134,139,255][86,102,94,255],差值升到 99。

裁剪深度、密度钳制和无效输入的固定失败对照

错误样本用 vWorldPosition.z / uFarClip 模拟常见误用,尚且没有完整复现透视深度的非线性;真实 gl_FragCoord.z 对 near/far 更敏感。因此这组数据证明的是“投影参数不应拥有空气距离”,不是为裁剪深度建立可用的校正公式。若只有深度纹理而没有世界位置,可以用当前帧的逆视图投影矩阵重建位置,但矩阵、深度范围和反向 Z 约定必须来自同一个 Pass。

世界位置也不是可以无限增大的无条件输入。大地图若直接把数十万米坐标插值到片元阶段,32 位浮点的有效精度会逐渐不足,相邻片元可能在远离原点后得到相同或抖动的低位。浮动原点或相机相对渲染下,可以提交 positionRelativeToCamera,距离直接取其长度,高度则用稳定的区域原点补回世界 Y;两部分必须绑定同一个原点版本。只对相机做重定位而让雾底、雾顶继续使用旧世界原点,会把空间精度问题变成整层雾上下跳动。

顶点 varying 还有透视插值语义。普通世界位置随三角形做透视正确插值,适合恢复表面片元的位置;全屏后处理从深度重建时则不应先在线性世界距离上做屏幕插值,而应逐像素读取深度并按当前投影反算。两种入口最终都要交付同一含义的相机相对或世界位置,材质雾和后处理雾才能共享参数,而不是共享一个名称相同、量纲不同的 depth

高地和山谷不能只按距离变成同一种灰

仅使用距离时,同一距离的高峰与谷底得到相同雾量,地表层次会被压成均匀渐变。本章在距离光学厚度上乘一个高度因子:雾底以下为 1,雾顶以上为 0,中间线性过渡。固定画面的 RGB 亮度和从联合路径的 69,836,777 变为仅距离路径的 91,977,746;它只是同一 RGBA8 缓冲的行为指纹,不是光度或性能测量。

这个高度项有明确边界。它只读取当前片元高度,相当于用端点密度近似整段视线;相机在山谷中仰视高峰、或视线穿过多层高度变化时,真实光学厚度应沿射线积分。需要局部雾区、洞穴边界、体积阴影或云层时,应改用解析积分、分层采样或体积纹理,不能继续叠加端点经验系数并称为体积雾。

雾参数仍归宿主所有。密度进入 Draw 前限制到 [0,0.08],负数和非有限值退化为零;雾顶必须至少比雾底高 0.001 m,防止除零。该最小范围只保证数值稳定,不保证画面平滑:若美术把 34 米过渡压到 1 毫米,结果会接近硬切层,参数审核仍应拒绝这种配置。

雾色也不能脱离颜色管线单独决定。本章的基础色和雾色都在线性中间值中混合,RGBA8 只承担最终固定回读。若把已经过 sRGB 编码的场景颜色直接与线性雾色插值,中间灰阶会偏暗;若 HDR 场景先做色调映射再加雾,高亮物体的衰减又会与空气层不一致。实际渲染器应在曝光与色调映射之前完成同一光照域内的雾混合,再把结果交给后处理。本文没有验证物理散射颜色,只固定了两端使用同一表示的合同。

// .tmp/game-shader-webgl-lab/chapters/09-height-fog/app.js
export function normalizeFogState(input) {
  const density = Number.isFinite(input.density)
    ? Math.min(Math.max(input.density, 0), 0.08)
    : 0;
  const bottom = Number.isFinite(input.bottom)
    ? input.bottom
    : -12;
  const requestedTop = Number.isFinite(input.top)
    ? input.top
    : bottom + 0.001;
  const top = Math.max(requestedTop, bottom + 0.001);
  return { density, bottom, top };
}

宿主归一化、世界空间雾计算与裁剪深度失败的数据关系

固定像素同时约束空间与退化输入

真实 Chrome 编译并链接 GLSL ES 3.00,对结果和边界两组画面执行 8 次 Draw,再由 gl.readPixels 读取固定区域:

$ python3 $WORK_DIR/.tmp/daily-labs/2026-08-12-1930/run_lab.py
exit_code=0
pass=10 fail=0 skip=0 duration=22.457s
correct_far_clip_sample_delta=0
clip_depth_sample_delta=99
combined_luminance=69836777
distance_only_luminance=91977746
clamped_density=0.08 collapsed_height_top=12.001

Shader 编译与链接日志为空。探针还确认输入 0.40 被钳制为 0.08,负数与非有限密度退化为 0,相同雾底和雾顶被修正为最小正范围。这里没有 GPU 计时,也没有比较顶点 varying、深度重建与体积采样的代价;只能确认世界距离、参数边界和固定像素行为。

透明表面是这条结论的另一处边界。若雾在每个透明 Draw 内计算,透明层必须使用与不透明场景相同的世界距离和雾参数,并在透明混合前得到自己的雾化颜色;若只在不透明深度上做一次全屏雾,深度缓冲看不到后续透明片元,护盾、水面或粒子便可能像贴在雾层前。第七篇已经证明透明颜色、混合与排序是一个完整提交,本篇没有重新验证透明雾,只明确它不能靠一次不透明深度后处理自动覆盖。

渲染顺序因此需要提前选定:不透明材质可以逐材质加雾,也可以先生成场景颜色与深度后统一处理;透明队列要么自行使用同一雾函数,要么进入能够表达多层深度的专门方案。两条路径可以共存,但雾密度、高度范围、相机原点和颜色表示必须由帧级状态统一提交。否则同一对象从不透明 LOD 切到透明淡出 LOD 时,会在相同位置获得两种雾量。

这个切片解决了大场景雾最危险的状态耦合:投影远裁剪面不再改变同一世界坐标的雾量,山谷与高地也能在明确高度范围内获得不同衰减。它适用于地表场景的距离雾与端点高度近似,不覆盖透明物排序、体积阴影、多重散射或局部雾区。下一篇会把已经完成材质与雾混合的场景颜色交给全屏低血量 Pass,并把 UI 留在后处理之后合成。