WebGPU 版 Cesium 的高性能数字地球引擎做到第三个模块,我开始处理 Atmosphere:基础大气和光照。先给结论:这个模块不是给星球边缘多一圈蓝色描边,而是决定整颗地球在观察者眼里“是不是真实的一颗球”。实测里最明显的分水岭是——同一个球体,关掉大气时它像游戏里的贴图球,打开大气后从太空视角能看到大气环从亮侧过渡到暗侧,从地面视角能看到地平线附近和天顶的颜色明显不同。这篇内容偏实现,主要适合正在自研 WebGL/WebGPU 地球引擎的人,或者想把 Cesium 的 WebGL 渲染思路迁移到 WebGPU 的人。我会按“光照与大气分别解决什么、GPU 每帧要传什么、散射怎么做、WebGPU 有哪些坑、怎么验收”来拆,尽量让你看完能复现出第一版效果。
1. 先拆清楚:大气和光照在数字地球里到底解决什么问题
很多人第一次看到 Atmosphere 这个英文词,会以为它等于 Cesium 里的雾气、天空颜色或者大气层贴图。实际上它是一整套视觉计算,包含两个相互纠缠的部分:光照模型和大气散射。两者不能分开做,因为大气散射的颜色本来就是由太阳方向决定的。
1.1 数字地球不是普通三维场景
普通的游戏场景里,太阳通常只是一个方向光,大部分材质用 Lambert、Blinn-Phong 就能骗过眼睛。数字地球不同,观察者面对的是一个半径约 6371 公里的球体,相机可能在太空,也可能贴在地表。相机离地面 10 米和离地面 1000 公里时,同样一个太阳方向,地面亮度和天空颜色变化极大。如果你只做一个固定颜色渐变,就很难还原“从太空看到地球边缘泛蓝”和“从地面看到地平线泛白”这两种同时存在的现象。
Cesium 在不同模块里把这些逻辑分开处理了。日常查 Cesium 中文文档时,最容易搜到的一个光照入口是viewer.scene.globe.enableLighting,打开它之后,地球表面会因为太阳方向产生明暗变化。而太空视角看到的蓝色大气边缘,则由viewer.scene.skyAtmosphere这类模块负责。自研引擎时,很多人直接去搜可视域分析、动态光照、模型节点、雷达效果、箭头线材质,却忽略了这些后续功能全部构建在基础渲染链路之上。基础大气和光照就是这条链路里最先要打通的一环。
1.2 大气模块不是“一个图层”
常见错误理解是把大气当成一个半透明球壳,再叠加一层蓝色透明材质。这个方案在固定相机角度里看着还行,一旦相机进入大气层以内,或者从夜晚一侧飞过,球壳方案就会出现明显穿帮:边缘颜色不自然、地球本身没有受光、夜晚侧完全死黑。
正确理解是把大气当作一组与光线方向、观察方向相关的物理近似计算。屏幕上的每个像素颜色,除了地表本身反光之外,还要加上光线穿过大气时发生的散射和衰减。WebGPU 的实现思路也是这样:它不是给一张贴图加滤镜,而是在着色器里计算“从相机到当前像素的射线,在穿过大气层路径上累积了多少散射光”。
1.3 和 Cesium 架构的对应关系
我写这版引擎时,先对着 Cesium 的渲染分层做过一次映射,大致是这样:
| 视觉目标 | Cesium 里的对应 | 自研 WebGPU 引擎里的分工 |
|---|---|---|
| 太空背景、星空 | viewer.scene.skyBox | 背景 Pass |
| 地球外侧大气边缘 | viewer.scene.skyAtmosphere | 大气 Pass,作用于球外与天空方向 |
| 地表明暗受光 | globe.enableLighting | 地表 Shader 里的平行光统一计算 |
| 近距离地雾 | Cesium 的 Fog 模块 | 后续再做,不放进第一阶段 |
映射完之后你会发现,WebGPU 里并不需要严格复刻 Cesium 的类名,只要把同样的数据源准备好就行:每帧太阳方向、相机位置、地球椭球参数、视口变换。这些数据进入 GPU Buffer 后,剩下的难题全在 Shader 里。
2. WebGPU 侧先把每帧的数据传递理顺
WebGL 时代很多人习惯在每帧里直接改uniform值,代码写起来很随意。WebGPU 的资源管理思想完全不一样,它是描述式、缓冲式和绑定式的。改几个全局变量这种操作,在 WebGPU 的着色器里不好使。你需要把光照和大气需要用到的参数统一放到 GPU Buffer 里,然后在绘制每个模型之前指定 bind group。
2.1 最小数据集合
我建议第一阶段只准备一个FrameUniformBuffer,不要拆成几十个小 buffer。虽然从“减少 upload 数据”的角度看,拆开更干净,但第一版功能还没稳定,绑定越少越容易排查。
开始时我把结构设计成下面这样:
struct FrameUniform { // 注意这里不用 vec3,统一缓冲布局容易踩对齐坑 sunDirection : vec4<f32>, cameraPosition : vec4<f32>, cameraTarget : vec4<f32>, viewProjection : mat4x4<f32>, };对应 TypeScript 侧需要创建缓冲区并标记好用途:
const device = await navigator.gpu.requestDevice(); const context = canvas.getContext('webgpu'); const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format: canvasFormat, alphaMode: 'opaque', }); const frameUniformBuffer = device.createBuffer({ size: 256, // UNIFORM 表示要用于 uniform // COPY_DST 表示允许用队列写入 usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, });很多第一次接触 WebGPU 的人会遇到一个问题:创建 buffer 后忘了加GPUBufferUsage.COPY_DST,然后调用device.queue.writeBuffer时会直接报错。遇到这类报错不要急着怀疑 Shader,先看 buffer usage。
2.2 太阳方向怎么来
太阳方向是光照计算里最核心的一个向量。最省事的做法是先写死一个方向,例如(1, 0.3, 0.5)归一化后当作早晨或下午光线。这种做法适合验证引擎链路,但不适合继续往下做昼夜变化。
Cesium 里太阳方向是按当前时间和位置推算的,中间会涉及儒略日、太阳赤纬、恒星时等概念。我们自研时不必一开始就实现完整天文算法,可以做两步走。
第一步,先把太阳方向作为引擎里一个可由外部传入的变量,不写死在 Shader 里。有了这个变量,后续无论是固定时间、动态计时还是接入真实天文算法,都只改 CPU 侧代码,不动 Shader。
第二步,等基本效果稳定后再实现简化版:把用户传入的 UTC 时间换算成太阳赤纬和时角,最终得到地固系(ECEF)下的方向向量。如果你只是做静态演示,甚至完全不需要这步,直接把sunDirection设成一个常量向量就能继续。
关键点是:太阳方向必须和地球几何体处在同一个坐标系。如果你的地球顶点都是 ECEF 坐标,那么太阳方向也必须是 ECEF 下的向量;如果你把地球先转到本地东北天坐标系,那太阳方向也要跟着转。这个坑比 Shader 本身更容易引发“光照错位”。
2.3 为什么要用 mat4x4 而不是单独传 view 和 projection
很多 WebGL 教程喜欢分开传viewMatrix和projectionMatrix,WebGPU 下也能这么做,但我建议基础阶段直接传一个合成好的viewProjection。理由是大气和地表 Shader 需要的世界坐标、法线方向主要来自顶点数据,不需要在 FS 里重复做视图变换。你只要在 VS 里用同一个viewProjection处理顶点,能减少一半的 uniform 数据量,排查时也只需要盯一个矩阵。
后面进到地面相机近景,需要做晴天阴影、法线贴图时,再单独传 view、normalMatrix 不迟。第一版最好用最少的变量解决最多的问题。
3. 地表基础光照:先让地球有昼夜,再谈好看
地表光照的起点不需要复杂,先做方向光漫反射就能看出地球的立体感。很多引擎调来调去,最后问题反而是法线方向或亮度范围不对。所以这部分的核心不是公式,而是“先跑通最典型的明暗测试”。
3.1 圆球或椭球上的法线计算
如果是球体,法线方向可以直接用 normalize(worldPos.xyz)。但如果是 WGS84 椭球,就不能这么粗暴。椭球表面某一点的法线,实际上要从位置除以半轴平方后再归一化得到。
这一步我在 Shader 里写成这样:
// radiiSquared 需要从 CPU 侧传入:vec3(rx^2, ry^2, rz^2) let ellipsoidNormal = normalize(worldPos.xyz / radiiSquared);注意,这个公式只适合不带地形高度的基准椭球顶点。如果你的地球已经叠加了高度图或真实地形,顶点位置已经偏移出椭球面,继续用这个公式算法线就会在某些山区出现规则的面状错误。第一版如果只做一个光秃秃的椭球,可以先这么算;一旦接入了地形高度,就要改成从顶点相邻位置差或高度图梯度计算法线。
3.2 方向光漫反射与最小 Shader
得到法线后,最基础的地表光照就是这个样子:
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> { let normal = normalize(in.worldNormal.xyz); let sunDir = normalize(frameUniform.sunDirection.xyz); // 漫反射项 let ndl = max(dot(normal, sunDir), 0.0); // 加一点环境光,避免夜晚纯黑 let ambient = vec3<f32>(0.03, 0.05, 0.08); let albedo = vec3<f32>(0.4, 0.6, 0.3); // 临时地表色 let color = albedo * (ambient + vec3<f32>(0.95, 0.92, 0.88) * ndl); return vec4<f32>(color, 1.0); }为什么环境光要单独加低值?因为数字地球引擎面向的不只是漂亮场景,你后面还要在夜晚侧叠加城市灯光、火光、气象数据图层。如果夜晚侧完全没有亮度,叠加上去后根本看不出效果;环境光给一个小基底值,可以让夜光图层有承接关系。
我这里给的 albedo 是临时色,实际引擎中应该来自地表影像纹理或矢量数据。把地表色抽出来而不是写死,是保证后续能接入影像瓦片的关键。如果你把 albedo 直接和光照项乘在一起,后续换纹理时会非常痛苦。
3.3 亮度范围和 HDR 的先见之明
在非 HDR 管线里,输出颜色超过 1 会被直接截断。太阳直射的高光附近很容易出现一片纯白,这种现象在普通 WebGL 场景里不明显,因为很多项目根本没有高亮物体。但大气散射恰恰会生成大面积超过 1 的亮度,比如太阳周围的银白色区域。
所以在第一版就建议把swapChain的格式和后处理格式分开思考。最稳妥的做法是先用一个离屏半浮点纹理承接场景颜色,比如rgba16float,然后再采样到 canvas 上做 tonemapping。也就是说不要让每个物体的 Shader 直接把颜色写进 canvas,而是先渲染到中间纹理,再统一做色彩映射。
如果你现在不想做完整 HDR 流程,最低限度也应在材质 Shader 里加一个“亮度缩放系数”,让散射最亮区域不要直接撞到 1.0 截断。后面加了 tonemapping 后再去掉这个临时系数。
4. 大气散射:从“能看出来”的单散射开始
地表光照跑通之后,下一步就是整篇的核心:大气散射。很多做 Cesium 类引擎的人会搜到大量关于 Rayleigh 散射和 Mie 散射的论文,容易被公式劝退。我的建议是第一版不要追求论文级效果,先做一个屏幕空间射线采样或者简化解析积分,目标是把“太空看地球边缘泛蓝、靠近太阳方向更暖”这两个特征做出来。
4.1 先理解两个散射的含义
Rayleigh 散射来自大气分子对阳光的散射,强度与波长的 4 次方成反比,所以蓝光散射得最厉害。这就是白天天空呈蓝色、大气边缘偏蓝的原因。
Mie 散射来自大气中较大的粒子,比如气溶胶、小水滴,它没有明显的波长依赖,但散射能量集中在前向,也就是在太阳周围形成一层白色或偏黄的光晕。日落时太阳附近的天空看起来偏红,本质是一条较长路径上的蓝光被散射掉,剩下来的红黄光到达观察者。
在地球引擎里,你只需要把这两个散射写成两个函数,一个是按波长系数的衰减函数,一个是随视角变化的相位函数。瑞利相位函数大致是3 / (16 * PI) * (1 + cos^2θ),米相函数里面有参数 θ 控制太阳光晕集中程度。
一个很粗糙但能跑的 WGSL 片段大致是这样:
fn rayleighPhase(cosTheta: f32) -> f32 { let k = 3.0 / (16.0 * PI); return k * (1.0 + cosTheta * cosTheta); } fn miePhase(cosTheta: f32) -> f32 { let g = 0.76; let g2 = g * g; let denom = pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5); return 3.0 / (8.0 * PI) * (1.0 - g2) * (1.0 + cosTheta * cosTheta) / denom; }这两个函数本身很简单,但你还需要知道光线从太阳到每个采样点、再从采样点到相机这条路径上的“透过率”。没有透过率,散射光会显得过分均匀,地球的影子、夜侧边缘的微妙过渡都出不来。
4.2 参数先走一组可视觉验证的参照
第一版大气参数不需要逐字复制某篇论文。我建议在 Shader 顶部放一组常量,把它当作默认预设,之后通过实时调参观察效果。下面这组值是我在 WebGPU 实现里做第一版视觉效果时常用的参照,注意它不等于 Cesium 源码里的最终数值,落地前要根据你的半径单位、颜色空间重新标定:
| 参数 | 作用 | 调整方向 |
|---|---|---|
innerRadius约 6371000 | 地球椭球平均半径 | 正常不需要改 |
outerRadius约 6371000 + 60000 | 大气层外边界 | 越大天空整体越亮,越小地平线越锐利 |
| Rayleigh scale height 约 7994 | 控制瑞利密度随高度下降速度 | 越小低空越蓝,越大整体越亮 |
| Mie scale height 约 1200 | 控制米散射密度随高度下降速度 | 越小近地面雾感越强 |
| Mie g 约 0.76 | 控制太阳周围光晕聚集度 | 越大太阳周围亮斑越集中 |
这几个参数有一个特点:它们之间是相互影响的。很多人调 sky 颜色失败,不是公式错了,而是把outerRadius设得太大,又同时把 Rayleigh scale height 调得很高,结果整个天空变成过曝的浅白色。
第一版调参时,最好每次只改一个量。并且一定要同时观察两个视角:太空视角和地面视角。很多参数在太空视角看起来没问题,切到地面视角就穿帮;反过来也一样。
4.3 屏幕空间里怎么组织射线
我第一版实现的做法是:
- 先画一个全屏三角形或四边形作为天空 Pass。
- 在 VS 里还原出屏幕像素对应的世界射线方向。
- 在 FS 里从相机位置沿射线与大气外球求交。
- 在外球内把射线分段采样,累积散射亮度和透过率。
- 如果射线与地表椭球相交,说明这个像素最终属于地球表面,就不应该直接输出大气颜色,而是把采样得到的大气干扰量交给后面的地表 Pass 处理。
这里有个容易犯错的地方:射线重建不要从相机矩阵的逆矩阵里想当然,要同时考虑视角宽高比和视场角。如果射线方向算错,太阳位置会和几何体完全错位,看起来像是光照和地球分开在转。
对于与地表相交的像素,第一阶段实现里可以先简单地根据散射累积量给地表颜色加一点雾感。此时地表雾的值不需要精确,它只负责让“地面和地平线之间”有一个自然的颜色衔接。
这部分的代码如果完整写出来会很长,但我建议你在第一版尽量不用采样 LUT,而是直接按 16 到 32 步采样。虽然性能很差,但它能让你最快看到散射效果并理解参数,比一开始就上 LUT 更容易排查。
5. WebGPU 实现里的关键坑和性能边界
我在把 WebGL 思路改成 WebGPU 时,遇到最多的问题不是散射公式,而是渲染状态、缓冲布局、管线格式这类非常具体的东西。下面列几个我踩过的坑,都是实际报错和画面表现层面的问题。
5.1 uniform 布局:不要使用 vec3
WGSL 里vec3<f32>在 uniform 地址空间的对齐规则容易让人阴沟翻船。不同驱动上,你可能在自己电脑看着正常,别人电脑上某个字段错位,导致光照方向完全不对。
我处理这类问题的惯例是:只要进 uniform buffer,一律用vec4<f32>代替vec3<f32>。比如太阳方向用vec4<f32>,只取.xyz。看起来浪费 4 个字节,但能避免大量无法解释的视觉 bug。
5.2 绑定组和管线布局必须完全一致
WebGPU 要求 pipeline 创建时指定 pipeline layout,而 draw call 时的 bind group layout 也要和它匹配。常见的报错是:
Bind group layout does not match pipeline layout这种问题多数不是因为你写错了 bind group 结构,而是因为在 pipeline creation 和 bind group creation 时分别用了两套 layout 对象。解决办法是让 bind group layout 对象和 pipeline layout 用同一引用,或者确保两个 layout 的 entry 完全一致。
另外,shader 里的绑定位置也必须和 layout 中的 binding 对应。@group(0) @binding(0)是最常用的,代码里把它当模板之后,后面新增资源时一定要从 1、2、3 递增,并且要在所有代码里同步修改。这个听起来很基础,但在模块多了以后,错误往往出在某个旧 shader 还绑定着旧的 index。
5.3 深度缓冲区和大气的遮挡关系
大气 Pass 如果直接画全屏三角形且不开深度测试,它会覆盖整个场景,包括地球。如果开了深度测试,但大气 Pass 的深度写入逻辑和地表不一致,又可能出现大气把整个地球遮住或者大气完全看不到的问题。
一个稳妥的做法是分成两层:
- 球外大气:只对“射线没有碰到地球”的像素输出散射颜色。
- 地表大气混合:地表像素继续走地表 Pass,只采样已有的大气透射和雾量。
分段采样时还要注意数值精度。观察点在地面时,cameraPos 距离地球中心约 6371000 米,而两个采样点之间的距离可能只有几百米甚至几十米。在 float32 精度下,直接用“外球交点加步长”累加位置,会在远距离处出现抖动。第一版可以先把计算切到相机局部坐标系,也就是不要每次都从地球中心加一个大数,而是让 ray origin 附近的位置相对比较小。
5.4 分辨率与性能的边界
直接做 per-pixel 采样的代价,在 1080p 屏幕上已经不小。如果按 16 步采样,再乘上地球和大气两个球体求交的计算量,普通独显能跑,但核显和手机端就会明显卡顿。
我的建议是:
- 先在 1080p 下验证视觉正确性。
- 如果能跑通,再降为半分辨率渲染大气纹理。
- 如果继续优化,可以把地球半径、大气密度参数放到一个低分辨率 LUT 中,用 WebGPU compute shader 预先算好,像素着色器里只采样 LUT。
低配机器能跑通第一版,不代表它能马上跑批量分析或高分辨率场景。批量任务、多层模型、可视域分析那类功能是后面的内容,它们都会进一步吃 GPU 资源。到那个阶段,再根据实际瓶颈决定优化方向。
5.5 不要一开始就上后处理串联
WebGPU 的 render pass 比 WebGL 更“显式”,你要自己决定多个 pass 之间换不换颜色纹理、清不清深度、是否保持上一个 pass 的结果。很多时候新手会做出一张黑屏,不是因为 Shader 写错,而是 render pass 的颜色附件没配对。
大气和光照第一版,我个人建议先用一个“朴素流程”:清屏、画地表、画大气。不要在第一步就叠加 HDR、bloom、噪点等后处理。后处理通常是为让画面更华丽,但基础大气效果没调好前,后处理只会掩盖问题。等你能看到清晰的日落和淡蓝大气边缘,再加 bloom 不迟。
6. 怎样验证第一版效果:验收清单和排查链路
基础大气和光照这个模块,很难用“能跑”来验收。因为不报错不等于画面正确。我建议按下面的清单一项一项过。
6.1 视觉验收对照表
| 测试场景 | 正常应该看到的现象 | 如果不对,优先查什么 |
|---|---|---|
| 太空视角看地球 | 地球边缘有淡蓝大气环,亮侧比暗侧更明显 | 射线是否与地表相交、太阳方向是否在 ECEF 下 |
| 拉近到地面 | 地平线附近偏白蓝,天顶方向更深 | 大气层厚度、scale height 参数 |
| 朝向太阳 | 太阳周围有一圈暖色或白色光晕 | Mie 参数、g 值、采样步数是否过少 |
| 背向太阳 | 球体夜侧偏暗但不完全死黑 | 环境光基底值 |
| 转动相机 | 亮侧和暗侧过渡应该平滑跟随 | 观察射线方向和 viewProjection 是否一致 |
| 修改时间或太阳方向 | 明暗和大气颜色应同时改变 | uniform buffer 是否每帧更新,writeBuffer 是否正确执行 |
6.2 出问题时的排查顺序
如果画面看起来很怪,不要第一个就去调散射系数。先按顺序查:
- 先确认太阳方向归一化后是否正确传入。加一行调试代码,把输出颜色直接设成
sunDirection * 0.5 + 0.5,如果颜色不是预期朝向,说明是 uniform 或坐标变换问题。 - 再看射线方向。把大气 Pass 的亮度设成射线方向和太阳方向的夹角,你会看到屏幕上应该出现从暗到亮的规则渐变。如果这个渐变是花的或乱跳,那射线重建代码有问题,别急着碰散射系数。
- 随后做法线调试。把地表 Shader 正常项去掉,直接输出法线颜色,观察球体上的颜色渐变是否平滑。法线调试通过后,再接光照项。
- 再检查深度缓冲。大气是否绘制到地表内部、地表是否被遮挡,这通常和 Render Pass 的深度清除、深度比较方式相关。
- 最后才调大气参数。Rayleigh 和 Mie 的参数一般只要在一个量级范围内,视觉效果就八九不离十,真正影响大的是坐标和采样逻辑。
6.3 这个阶段不要过度追求什么
第一版 Atmosphere 不需要实现多散射,也不需要 LUT 预计算。这两个东西是用来提升真实感和性能的,但如果你连最基础的晴天地球都没调对,直接演进到 LUT 只会增加排查成本。
也不要在这一阶段去管云层、体积云、地表阴影、可视域分析后处理。那些模块都各自依赖一套更复杂的数据结构,但现在引擎的基础链路已经能稳定输出“受光的地球+正确的大气边缘”时,后续接任何一个模块都会顺很多。Cesium 真正强大的地方不是某个大气公式多高级,而是它能把这些视觉模块和地形、影像、模型数据稳定地串在一条渲染流水线上。WebGPU 的优势在于,底层状态的失控概率比 WebGL 更小,早期多做验证,后面做复杂功能时受益会更明显。
第一版优先保证一件事:任何视角、任何时间下,光照和大气颜色都自洽。只要这个基础牢固,后边的质感提升就只是加细节的问题。