WebGPU数字地球引擎:大气与光照实现详解

WebGPU数字地球引擎:大气与光照实现详解 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 : vec4f32, cameraPosition : vec4f32, cameraTarget : vec4f32, viewProjection : mat4x4f32, };对应 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和projectionMatrixWebGPU 下也能这么做但我建议基础阶段直接传一个合成好的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) vec4f32 { let normal normalize(in.worldNormal.xyz); let sunDir normalize(frameUniform.sunDirection.xyz); // 漫反射项 let ndl max(dot(normal, sunDir), 0.0); // 加一点环境光避免夜晚纯黑 let ambient vec3f32(0.03, 0.05, 0.08); let albedo vec3f32(0.4, 0.6, 0.3); // 临时地表色 let color albedo * (ambient vec3f32(0.95, 0.92, 0.88) * ndl); return vec4f32(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 布局不要使用 vec3WGSL 里vec3f32在 uniform 地址空间的对齐规则容易让人阴沟翻船。不同驱动上你可能在自己电脑看着正常别人电脑上某个字段错位导致光照方向完全不对。我处理这类问题的惯例是只要进 uniform buffer一律用vec4f32代替vec3f32。比如太阳方向用vec4f32只取.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 更小早期多做验证后面做复杂功能时受益会更明显。第一版优先保证一件事任何视角、任何时间下光照和大气颜色都自洽。只要这个基础牢固后边的质感提升就只是加细节的问题。