写 WebGPU 版 Cesium 高性能数字地球引擎时,前面的阶段可以靠模型加载、相机控制和图层管理撑起来,但一旦把相机从地面拉到太空,再从太空落回地面,视觉是否成立就完全取决于大气和光照的处理。Atmosphere 系列这一篇要解决的不是“天空盒好不好看”,而是三个问题:天空颜色从哪来、太阳光如何驱动整个场景、以及 WebGPU 管线里怎么高效地渲染这层大气。本文会从大气散射的物理模型入手,逐步实现一个基于计算着色器预计算 LUT、再由天空 Pass 采样输出的最小渲染模块,并把太阳方向、地表材质和天空颜色统一到同一个光照上下文里。
1. 为什么大气和光照要单独设计,而不是贴一张天空盒
很多数字地球项目刚开始都会把天空做成一个巨大的球体贴图。这个方案在地面视角、相机固定不动时效果尚可,但一旦相机穿越大气层,从太空视角回到地表,静态天空盒会出现明显的接缝、比例失真和亮度跳变。原因在于真实地球的视觉结构不是一个圆球加一张贴图,而是相机与地表之间隔着一段不断变化的光学介质。
1.1 数字地球的“地”与“空”需要统一的光照上下文
如果单独处理地表和天空,最容易出现的现象是:地表模型被方向光照射,影子方向朝东,天空背景中的太阳却在西边。这个错误在传统三维场景里很常见,因为开发者会把天空当作背景,而不是当作受光照驱动的场景成员。
真实的大气系统里,地面看到的光一部分来自太阳直射,另一部分来自天空散射光。地表材质会同时受这两部分光影响。因此,要做高逼真数字地球,至少要把以下几条链路对齐:
- 太阳方向向量必须同时传给地表着色器和大气着色器。
- 大气散射结果应该作为地表漫反射环境光的一部分,而不是独立图层。
- 相机高度从地表到太空变化时,地表与天空之间不能出现硬切边。
WebGPU 重写 Cesium 风格的渲染内核时,计算着色器、存储纹理和统一 BindGroup 布局让这件事变得更容易组织。大气散射的中间结果可以写入纹理缓存,供多个 Pass 共用,而不是像 WebGL1/2 时代那样靠大量 Uniform 和纹理采样硬编码进着色器。
1.2 Cesium 经典大气实现与 WebGPU 重写的定位差异
Cesium 的经典渲染链路里,大气层被当作地球外侧一个更大的壳层,像素着色器需要对该像素对应的视线路径做多次采样。视线从相机出发,穿过大气到达空间或地表,每个采样点需要计算太阳直射光的衰减、散射方向和透过率。为了保证视觉质量,这类计算往往不能做得太粗,代价是着色器复杂度高、分支多、GPU 压力大。
WebGPU 版本可以换一种组织方式:把“整条视线积分”从片元着色器里抽出来,放入计算着色器预先写入 LUT 纹理。这样天空 Pass 只需要按相机视线方向查询 LUT,而不是每个像素重复做几十次步进。这个思路本质是把开销从逐像素实时计算,变成一次性的、按需更新的预计算。
这里的重点不是“哪一种 API 更强”,而是渲染架构的调整。WebGPU 的 compute shader、storage texture、render bundle 和 timestamp query 为这种拆分提供了更规范的支持。
1.3 本文要验证的三个里程碑
为了让后续修改有明确检查点,建议先用三个结果判断大气模块是否正常:
- 地面视角能看到蓝天渐变,太阳方向变化时,天空高亮区域跟随太阳移动。
- 相机从地面拉高到太空,大气边缘是一条渐变的亮带,边缘内部没有明显接缝。
- 地表 PBR 材质、阴影贴图和大气散射使用同一个太阳方向,光影方向一致。
这三个结果也是本系列后续接入阴影、体积云和夜间灯光前的基础。
2. 先把大气物理模型讲清楚:蓝天、黄昏和天边亮带从哪里来
想理解大气着色器代码,必须知道大气颜色不是某个面片的“固有颜色”,而是太阳光穿过大气层、被粒子散射后进入相机的结果。渲染“天空”本质是在计算一条视线路径上的散射光积分。
2.1 粒子散射:视线终点不重要,路径上的介质才重要
当太阳光进入大气层,会与空气中的分子、气溶胶、水滴发生相互作用。一部分光保持原方向继续传播,另一部分偏离原方向射向新的方向。如果这个新方向恰好对着相机,相机就看到了“散射光”。
我们看到的蓝天,就是太阳光在视线路径上被小分子散射后进入眼睛的光。离太阳方向越远的位置,去往相机的散射光会减少,因此天空从太阳附近到反太阳方向会形成亮暗渐变。
这类描述意味着渲染时必须做两件事:
- 计算视线路径上每个采样点接收到的太阳光强度。
- 计算该点的散射光沿视线方向到达相机的透过率。
如果视线穿过大气层的路径很长,例如看向地平线,入射光被严重衰减,同时路径上更多粒子参与散射,画面就会出现低角度亮带和偏白效果。
2.2 Rayleigh 散射与 Mie 散射的分工
大气渲染主要使用两种散射模型。
Rayleigh 散射针对远小于光波长的粒子,例如空气分子。它的散射强度与波长的四次方成反比,所以蓝紫光散射远强于红光。这就是白天天空呈蓝色的直接原因。Rayleigh 散射在高度上按指数衰减,通常用标高 8km 描述,即每上升 8km,分子密度大约降为原来的 1/e。
Mie 散射针对尺寸接近光波长的粒子,例如尘埃、雾滴。它的散射强度对波长不敏感,而且前向散射很强,太阳周围的白亮光晕主要由它造成。Mie 散射粒子更集中在近地面,通常标高用小一些的 1.2km 左右。
用数学表达时,散射系数随高度指数衰减:
- Rayleigh 散射系数:βR(h) = βR(0) · exp(-h / HR)
- Mie 散射系数:βM(h) = βM(0) · exp(-h / HM)
路径上每一点到光的入射方向还需要计算相位函数。Rayleigh 相位函数会产生与视角相关的对称分布,Mie 相位函数则通过一个不对称因子 g 控制前向峰值。
2.3 单次散射近似在实时渲染中的取舍
严格来说,大气散射存在多次散射。一个光子可能被粒子散射多次,最终才进入相机。单次散射只考虑“太阳直射光到达采样点,再由该点散射向相机”这一条路径。
单次散射的优点是计算量可控,且能覆盖蓝天、地平线亮带、日落变红这些最重要视觉特征。它的主要问题在地平线附近:当视线路径很长,单次散射会低估从侧面补充进来的光,导致低角度天空偏暗。生产渲染器中可以通过参数补偿或叠加一个低强度的各向同性散射项,让地平线更饱满。
实时渲染里,先跑通单次散射,再考虑多次散射的增量,是更稳妥的路线。
2.4 常用大气参数表与单位说明
大气参数没有“唯一正确值”,不同引擎会根据自己的色彩空间、曝光和美术风格做调整。下面列出的是一组可用于验证算法正确性的初始值,落地时需要配合自己的色调映射重新标定。
| 参数 | 符号 | 常用初始值 | 说明 |
|---|---|---|---|
| 地球半径 | RE | 6371 km | WGS84 简化成球体时使用 |
| 大气层顶半径 | RA | RE + 60 km | 超过该高度不再考虑大气散射 |
| Rayleigh 标高 | HR | 8.0 km | 每升高 8km,分子密度降为 1/e |
| Mie 标高 | HM | 1.2 km | 气溶胶主要集中在近地面 |
| Rayleigh 散射系数 | βR(0) | 约 [5.802e-6, 13.558e-6, 33.1e-6] | 单位是 1/m,蓝通道最大 |
| Mie 散射系数 | βM(0) | 约 3.996e-6 | 与波长相关性弱 |
| Mie 不对称因子 | g | 0.76 ~ 0.9 | 值越大,前向散射越集中 |
| 采样步数 | N | 16 ~ 64 | 步数越多越平滑,开销越高 |
注意:这些单位的数量级相差很大,代码里建议统一使用“米”作为长度单位,避免在 scale height 和 radius 混用千米时出现系数错误。Cesium 生态里有大量基于米制椭球的代码,混用单位是早期最常见的问题。
3. 用 WebGPU 计算管线搭建大气 LUT 与天空 Pass
理解模型之后,下一步是把它放进 WebGPU 管线。目标不是写一个能直接上生产的完整大气系统,而是给出可运行的核心结构,方便继续在数字地球引擎中扩展。
3.1 环境约束:WebGPU 只能在安全上下文里运行
WebGPU 不是所有浏览器都能直接使用,需要在安全上下文中运行,也就是 localhost 或 HTTPS 页面。开发时可以先用本地静态服务器调试,上线则需要 HTTPS。
动手前先做一次特性检测:
async function checkWebGPU(): Promise<boolean> { if (!navigator.gpu) { return false; } const adapter = await navigator.gpu.requestAdapter(); if (adapter === null) { return false; } return true; }拿到 Adapter 后,再请求 Device:
const device = await adapter.requestDevice({ requiredFeatures: [], });如果需要 GPU 时间统计,可以在 requiredFeatures 中加入timestamp-query。需要注意不同浏览器版本对特性名单的支持有差异,代码里最好做一次能力检查,不要假设所有设备都支持。
3.2 模块划分与渲染流程
建议把大气渲染拆成三个文件:
- AtmosphereRenderer:负责创建 pipeline、组织 bind group、在每帧更新 Uniform。
- AtmosphereLUTPass:计算 LUT 的计算 shader,写入散射结果。
- SkyPass:全屏 Pass,采样 LUT 得到最终天空颜色。
渲染流程可以采用“先地表、后天空”的顺序:
- 更新相机 Uniform 和太阳光照参数。
- 用 Compute Pass 计算或更新大气散射 LUT。
- 渲染不透明地表模型并写入深度。
- 渲染天空全屏 Pass,深度测试设为
less-equal,不写深度。
这种顺序能保证地表遮挡天空,天空又不会覆盖已经写入的地表颜色。
3.3 核心 Uniform 设计
在 WebGPU 里,C++ 风格的结构体需要满足 16 字节对齐,设计 WGSL 结构时要把 vec3 补齐成 vec4,避免绑定错误。
struct AtmosphereParams { sunDirection: vec4f, // xyz 为太阳方向 cameraPosition: vec4f, // xyz 为相机位置,w 可存相机高度 earthRadius: f32, atmosphereRadius: f32, rayleighScaleHeight: f32, mieScaleHeight: f32, rayleighBeta: vec4f, // 只使用 xyz,w 留作曝光相关 mieBeta: vec4f, mieG: f32, exposure: f32, lutWidth: f32, lutHeight: f32, frameIndex: u32, // 多帧累积或隔帧更新时使用 };CPU 侧写入时,每渲染一帧都要更新 sunDirection 和 cameraPosition。如果相机不动、太阳不变,可以考虑隔几帧再重新计算 LUT,但不建议把 LUT 当成永久静态纹理,因为数字地球的太阳位置是随时间和相机位置变化的。
3.4 LUT 计算 Pass:散射积分的主体循环
下面是一段说明核心思路的 WGSL 分片,重点在密度函数和步进循环。
@group(0) @binding(0) var<uniform> params: AtmosphereParams; @group(0) @binding(1) var lutTexture: texture_storage_2d<rgba16float, write>; fn densityRayleigh(height: f32) -> f32 { return exp(-height / params.rayleighScaleHeight); } fn densityMie(height: f32) -> f32 { return exp(-height / params.mieScaleHeight); } fn raySphereIntersect(origin: vec3f, dir: vec3f, radius: f32) -> f32 { // 返回视线进入球体的距离,无交点返回 -1 let oc = origin; let b = dot(dir, oc); let c = dot(oc, oc) - radius * radius; let h = b * b - c; if (h < 0.0) { return -1.0; } let t = -b - sqrt(h); return select(-1.0, t, t > 0.0); } @compute @workgroup_size(8, 8, 1) fn main(@builtin(global_invocation_id) gid: vec3u) { let size = textureDimensions(lutTexture); if (gid.x >= size.x || gid.y >= size.y) { return; } // 使用归一化坐标映射视线方向 let u = (f32(gid.x) + 0.5) / f32(size.x); let v = (f32(gid.y) + 0.5) / f32(size.y); // viewDir 需要根据相机高度和视线天顶角计算 // 这里只保留积分主体,具体 LUT 映射在扩展中补齐 let cameraHeight = params.cameraPosition.w; let earthRadius = params.earthRadius; let rayleighDensity0 = densityRayleigh(max(cameraHeight - earthRadius, 0.0)); let mieDensity0 = densityMie(max(cameraHeight - earthRadius, 0.0)); var rayleighDepth = 0.0; var mieDepth = 0.0; let steps = 32; let stepLength = (params.atmosphereRadius - earthRadius) / f32(steps); for (var i = 0; i < steps; i = i + 1) { let h = max(cameraHeight - earthRadius + stepLength * f32(i), 0.0); rayleighDepth += densityRayleigh(h) * stepLength; mieDepth += densityMie(h) * stepLength; } let transmittance = exp(-vec2(rayleighDepth, mieDepth)); // 结果写入 LUT textureStore(lutTexture, gid.xy, vec4f(transmittance.x, transmittance.y, 0.0, 1.0)); }这里的关键点是,LUT 纹理使用rgba16float,也就是半浮点纹理。写入大气散射值时,如果使用rgba8unorm,会丢失暗部细节,导致日落过渡出现色阶断层。散射计算涉及高动态范围,半浮点是最低要求。
3.5 天空全屏 Pass:单次采样代替逐像素积分
LUT 准备好后,天空 Pass 可以只做一次纹理采样,把像素颜色算出来。全屏 Pass 的 vertex shader 直接输出一个三角形或两个三角形覆盖屏幕。
struct VsOut { @builtin(position) position: vec4f, @location(0) uv: vec2f, }; @vertex fn vs_main(@builtin(vertex_index) index: u32) -> VsOut { let positions = array<vec2f, 3>( vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0), ); let uv = array<vec2f, 3>( vec2f(0.0, 0.0), vec2f(2.0, 0.0), vec2f(0.0, 2.0), ); var out: VsOut; out.position = vec4f(positions[index], 0.0, 1.0); out.uv = uv[index]; return out; }fragment shader 中根据 uv 重建视线方向,再去查询 LUT。真正的数字地球中,视线方向需要从深度缓冲重建,避免把被地形遮挡的地方也画成天空。
@group(0) @binding(0) var lutSampler: sampler; @group(0) @binding(1) var lutTexture: texture_2d<f32>; @group(0) @binding(2) var depthTexture: texture_depth_2d; @fragment fn fs_main(in: VsOut) -> @location(0) vec4f { // 从 uv 和深度重建世界空间视线方向 // 根据视线与大气层的交点决定是否采样天空 // 这里先使用 uv 构造简化视线,实际项目需要传入反向投影参数 let color = textureSample(lutTexture, lutSampler, in.uv).rgb; // 色调映射一般在后处理阶段完成 return vec4f(color, 1.0); }fragment shader 写在展示核心结构,实际项目还需要把 uv 转换成世界方向,并把 LUT 采样结果和地表颜色正确合成。
3.6 地表与大气合成:先深度后颜色
数字地球的典型场景是:一部分像素属于地表,另一部分属于天空。如果天空 Pass 在全屏最后执行,完全没有深度测试,天空就会覆盖山体。正确做法是在渲染所有不透明地表后再画天空,并把渲染管线的 depthCompare 设置为less-equal:
const skyPipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module: skyShader, entryPoint: 'vs_main', }, fragment: { module: skyShader, entryPoint: 'fs_main', targets: [{ format: presentationFormat }], }, primitive: { topology: 'triangle-list', }, depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: 'less-equal', }, });注意:天空 Pass 不要写深度。如果天空也写深度,后续叠加雨雪粒子或高空对象时,深度关系会错乱。天空只负责在“地表未覆盖的像素”里显示颜色。
4. 光照:太阳方向如何驱动整个场景
大气模块运行正常后,光照部分的核心问题浮现:太阳到底是怎么进入渲染器的?
4.1 用太阳方向向量而不是“时间刻度”驱动渲染
数字地球引擎需要根据日期、时间和相机地理位置计算太阳位置。Cesium 里有完整的天文算法,可以返回太阳方向。WebGPU 版引擎无论底层计算来自哪里,最终传给 GPU 的应该是一组向量,而不是让 GPU 自己处理时间字符串。
方向向量对 GPU 友好,也方便阴影系统和大气系统共用:
export interface SceneLighting { sunDirection: { x: number; y: number; z: number }; sunIrradiance: { r: number; g: number; b: number }; environmentIntensity: number; }CPU 每帧把太阳方向写入 Uniform Buffer,再同时用于:
- 大气 LUT 计算中的太阳入射方向。
- 地表 PBR 材质的直射光方向。
- 阴影贴图的 Light Space 矩阵。
如果太阳方向每帧更新,而 LUT 不更新,就会出现场景内模型影子在动,天空高光点不动,视觉上非常违和。因此,LUT 的更新频率至少要覆盖太阳方向和相机方向的显著变化。
4.2 将太阳直射光和天空环境光分开
真实光照由两部分组成,渲染时不能混成一个变量。
| 光源类型 | 作用对象 | 示例 |
|---|---|---|
| 太阳直射光 | 地表直接受光,产生硬阴影 | glTF 材质中的 directional light |
| 天空环境光 | 照亮阴影区域,提供漫反射环境光 | 大气散射产生的天空颜色 |
| 地表反射的间接光 | 复杂光照中的反弹 | 需要额外反射探针或光照贴图 |
大气散射不仅影响天空背景,还应该作为环境光的来源。一个简单做法是把大气散射方向光与阳光方向、天空颜色绑进同一个 lighting uniform,地表着色器可以同时计算:
let sunDiffuse = max(dot(normal, sunDirection), 0.0) * sunIrradiance; let skyDiffuse = textureSample(envLUT, envSampler, normal).rgb; let finalColor = albedo * (sunDiffuse + skyDiffuse);这里的 envLUT 可以是从大气 LUT 生成的漫反射环境图。WebGPU 中可以使用 compute shader 将天空 LUT 进一步卷积成不同 mip 层,供 PBR 材质采样,这也是从“贴图天空”升级为“光照天空”的关键一步。
4.3 动态光照:日出、日落和光线渐变的实现思路
动态光照不是简单地改变太阳方向。真实日出日落中,太阳高度角低时,阳光穿过的大气路径变长,蓝光被散射,阳光呈现橙红色。这个现象本身已经由大气散射模型产生,不需要额外加滤镜。
只要保证以下逻辑成立:
- 太阳方向低,LUT 中散射到视线方向的红光占比增加。
- 太阳掉到地平线以下,直射光强度应当降为 0,切换到月光或夜间环境光。
- 地表 PBR 材质不能继续保持全亮。
实现时可以在 CPU 侧判断太阳高度角,低于阈值后让 sunIrradiance 减弱,并开启夜间城市灯光。白天到夜晚的过渡需要做平滑插值,阈值建议用区间而不是单点,避免出现亮度跳变。
5. 运行验证:从太空到地面,用三种视角检查渲染结果
代码写完要先验证,否则无法判断大气参数和 Pass 组织是否正确。
5.1 地面视角:蓝天渐变与太阳高光
相机放在地面,高度大约 1.5km 以内,抬头看天空:
- 天顶方向应呈现蓝色或蓝紫色渐变。
- 看向太阳时,周围亮度明显提升,出现白色光晕。
- 反太阳方向天空更暗,蓝色更深。
如果天顶颜色明显偏灰或偏白,可能是 Rayleigh 散射系数过低,或曝光参数过高。如果太阳周围没有亮带,检查 Mie 散射系数和 g 值。
5.2 地平线视角:低角度亮带与落日颜色
把相机视线压低到接近地平线:
- 地平线附近应出现灰白或偏橙的亮带。
- 如果太阳接近地平线,整体天空应偏橙红色。
- 亮带不应出现硬切边或锯齿。
当地平线出现暗色带时,常见原因是采样步数太少,或 LUT 分辨率不足以表达低角度的剧烈变化。可以先把步数提高到 64,观察是否改善。
5.3 太空视角:大气边缘过渡带
相机移动到卫星高度:
- 地球边缘应有一条从亮到透明缓慢过渡的大气层。
- 大气带不应像一个半透明的圆环贴在球体外侧,而是随视线路径自然变亮。
- 从太空看向太阳一侧,大气边缘会更亮。
如果大气带边缘过于锐利,说明大气层顶半径与实际渲染的壳层半径不一致,或者 LUT 在靠近大气边界时插值不连续。
5.4 使用 GPU 时间戳做定量验证
视觉验证之外,还需要量化性能。WebGPU 的 timestamp query 可以记录 compute pass 和 render pass 耗时。
const device = await adapter.requestDevice({ requiredFeatures: ['timestamp-query'], });启用后,可以在 CommandEncoder 中插入时间戳:
passEncoder.writeTimestamp(querySet, 0); // compute 或 render 逻辑 passEncoder.writeTimestamp(querySet, 1); passEncoder.end();读回数据后需要把两个时间戳差值除以时间戳频率,得到毫秒。示例调试结构:
{ "atmosphereComputeMs": 0.32, "skyPassMs": 0.12, "earthPassMs": 1.86, "totalFrameMs": 8.5 }注意:timestamp-query 不是所有设备都支持。移动端会存在不支持的情况,调试代码要降级为纯帧耗时统计。
6. 大气和光照常见问题:现象、原因与排查步骤
把大气模块接入现有 WebGPU 渲染器时,错误通常不是“算法不物理”,而是管线组织不匹配。下面整理几个高频问题。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 天空全黑,地表正常 | LUT 计算没有执行,或 storage texture 未绑定 | 检查 compute pipeline 与 bind group 布局 | 从简单输出纯色验证 compute pass |
| 太阳方向变化后画面不更新 | CPU 未更新 uniform,或 LUT 更新频率过低 | 检查 Uniform Buffer 写入日志 | 在 sunDirection 变化时强制执行一次 compute |
| 大气层边缘出现硬边 | LUT 分辨率太低或采样步数太少 | 提高 LUT 到 512x256,步数到 64 | 尽可能在 LUT 中保存连续过渡 |
| 色彩断层明显 | 输出格式不支持 HDR | 检查纹理格式是否为 rgba16float | 使用半浮点纹理,最后再接色调映射 |
| 天空把地表覆盖 | 天空 Pass 写入了深度或未开启深度测试 | 检查 depthCompare 与 depthWriteEnabled |