Cesium夜空渲染实战:月亮、银河与城市灯光一站式实现
做了几年Cesium项目我一直觉得白天的地球好做难的是夜晚。直到去年接了一个“城市夜游”可视化需求甲方开口就是要有满月、要有银河、天上星星得闪、地面城市灯光得亮。当时我第一反应是——Cesium默认场景把时间拨到晚上天上确实有个月亮但那个月亮灰扑扑的星星稀稀拉拉更别谈银河了。这篇文章就把我后来折腾出来的“夜晚天空系统”完整拆开讲包括月亮怎么做、银河怎么进场景、月光下的地表氛围怎么调、以及一套可以直接抄走的代码组合。适合正在做数字孪生、智慧城市夜景、或者单纯想把Cesium场景做得更耐看的开发者参考。1. 默认夜空为什么“没内味儿”先摸清Cesium给了什么很多朋友一上来就急着换贴图、加粒子结果越改越乱。我的建议是先花十分钟搞清楚Cesium默认的夜晚到底由什么构成再决定动哪里这样后面每一步都知道自己在改什么。1.1 Cesium默认的夜晚三件套Cesium场景里和夜晚直接相关的有三个东西scene.sun、scene.moon、scene.skyBox。默认情况下太阳和月亮是作为真实天体参与计算的位置由天文算法实时算出不是你放一个贴图在那就完事。月亮是一个带纹理的椭球体viewer.scene.moon.show默认是开启的它挂在天空中的位置由当前时钟时间决定。天空盒则是另一套机制scene.skyBox默认加载的是Cesium自带的一套静态星空贴图六个面各一张里面是随机分布的星点。这个星空不参与时间变化也不会有银河这种大面积结构。// 默认月亮相关属性你可以这么检查 console.log(viewer.scene.moon.show); // true console.log(viewer.scene.moon.textureUrl); // 内置纹理 console.log(viewer.scene.moon.onlySunLighting); // true只有被太阳照亮的部分可见1.2 四个让人难受的短板第一月亮太小且不发光。默认月亮是一个几何球体表面纹理确实有月海和高地但它是个“冷光源下的灰球”。你离近了看还凑合离远了就是一个灰白色圆斑完全没有夜空中那种清冷发光感。第二星空静态且没有银河。Cesium默认天空盒里的星星是均匀撒的看着像星空但缺少银河那种横贯天际的亮带结构视觉上总觉得空旷。第三没有月光氛围。globe.enableLighting true开启后夜半球直接黑透既没有月光洒在地表的淡蓝色也没有城市灯光透出来的细节。第四色调太生硬。默认夜晚场景偏冷灰缺少深蓝到靛紫的渐变层次或者说缺少一种“空气感”。1.3 动手前先列需求清单我每次做这类效果前会先给甲方列一个清单也建议你这么做因为“夜晚效果”这四个字太模糊了。月亮要有细节纹理最好能体现月相有发光感或光晕银河横贯天空的亮带不能是噪点糊成一片星星有层次亮星和暗星区分明显数量合适不刺眼地表城市区域有灯光透出水面或地表有微弱反光整体色调深蓝/靛紫氛围远处有雾感后面的所有技术选型都是围绕这个清单展开的。别一上来就堆功能先明确你要哪几种元素再决定每一项用什么方案实现。2. 月亮实现的两条路线内置Moon改造与Billboard光晕月亮是夜空的视觉中心必须先定方案。我试过三条路最后在实际项目中采用了“内置Moon 光晕Billboard”的组合下面把每条路的优劣说清楚。2.1 方案A改造内置Moon物尽其用Cesium的scene.moon虽然效果一般但它有一个极大的优势位置是天文算法算出来的而且自带光照。这意味着你只要换一张高分辨率月球纹理把onlySunLighting保持开启月球的阴晴圆缺就会自动按真实方向变化。// 替换高分辨率纹理建议用1024或2048的月球表面图 viewer.scene.moon.textureUrl /assets/textures/moon_2048.jpg; viewer.scene.moon.onlySunLighting true;这个方案的最大好处是代码量少、月相真实。农历初一的新月你看不到月亮十五的满月亮得像个银盘方向也基本和真实天空吻合。但它的问题同样明显第一月球表面材质毕竟是岩石反照率不会自己发光在Bloom后处理下也没有辉光第二新月或蛾眉月时月亮本身太暗作为夜景主角撑不起画面。2.2 方案BBillboard月亮 光晕叠加既然内置Moon不发光那就用广告牌绕开几何体渲染在场景中放一个Billboard贴上一张发光的月亮图再叠一层光晕贴图。Billboard的好处是可以加disableDepthTestDistance让月亮永远不会被地球或者其他模型遮挡配合eyeOffset可以把它推到视觉远处。const moonHalo viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(0, 0, 8e6), billboard: { image: /assets/textures/moon_glow.png, width: 512, height: 512, disableDepthTestDistance: Number.POSITIVE_INFINITY, eyeOffset: new Cesium.Cartesian3(0, 0, 0), color: Cesium.Color.fromCssColorString(rgba(210, 225, 255, 0.95)) } });这种方案效果出彩月亮可以做到发光、带晕、细节可控。但代价是你需要自己去算月亮的位置然后每帧更新这个Billboard的位置方向。更麻烦的是月相得自己算或者从内置Moon的位置信息里“借”。2.3 我的选型内置Moon定位置Billboard补光晕我在实际项目里把两个方案结合了保留内置Moon让它负责真实的月相和位置同时叠加一个半透明的光晕Billboard位置跟随内置Moon的方向走。这样月亮本体是有纹理、有月相的球体外面又罩了一层柔和的辉光既真实又有氛围。function updateMoonHalo(viewer, halo) { const moonPos new Cesium.Cartesian3(); Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthFixed( viewer.clock.currentTime, moonPos ); // 归一化后推到远距离避免透视变形 const dir Cesium.Cartesian3.normalize(moonPos, new Cesium.Cartesian3()); const farPos Cesium.Cartesian3.multiplyByScalar(dir, 8e6, new Cesium.Cartesian3()); halo.position farPos.clone(); } viewer.clock.onTick.addEventListener(() updateMoonHalo(viewer, moonHalo));注意Simon1994PlanetaryPositions这个工具类可以直接调用Cesium内部计算太阳和月亮位置用的就是它返回的是地球固定系中的笛卡尔坐标。这里我取归一化方向再乘一个8e6也就是8000公里比地球半径略高一点视觉上月亮永远在远处不会有近大远小的奇怪透视。如果你希望月亮永远呈现满月形态也可以直接禁掉Moon自带的太阳光照只保留Billboard的光晕加大月亮贴图视觉效果会更“商业化”。我的建议是面向公众展示、想上大屏的项目用满月方案最稳做仿真类、讲究真实天文的项目保留月相变化更有说服力。3. 银河与星空天空盒贴图制作与无缝拼接银河和星空是最容易被人忽略、但对整体氛围影响最大的部分。我的结论是银河一定要做进天空盒不要用粒子系统一颗颗撒原因下面展开。3.1 为什么银河必须进天空盒刚接到这个需求时我第一个念头是用粒子系统做银河撒个几千个点再加点旋臂。试完之后果断放弃原因是draw call和性能完全扛不住。几千个粒子在移动端直接掉帧而且粒子是动态的你还要处理它们的旋转、扩散、透明度变化做得再好也不像银河那种大规模弥漫结构。天空盒方案则完全不同Cesium渲染天空盒一次draw call就搞定了六张贴图银河、星云、暗星、亮星全部烘焙在贴图里性能几乎为零开销。缺点是你的银河是静止的但夜晚天空本来就是缓慢变化的静止几十秒根本没人看得出来。3.2 三种获取星空贴图的路子我试过三种获取天空盒贴图的方法各有适用场景。第一种直接用Space Engine或者Universe Sandbox这类天文模拟软件截图。优点是真实性极高银河结构、星云颜色都对缺点是你要在软件里找到合适的视角然后把六张图手动拼接处理接缝很费时间。第二种用Blender的星空插件程序化生成。Blender里有很多现成的星空球插件可以设置星数、银河强度、随机种子然后渲染出六个面。这种方法胜在参数化可控甲方说“银河太暗”你重新渲一版就行。第三种也是最推荐项目落地的方式找一张高质量的全景银河贴图比如Stellarium导出的天球图或者NASA相关公开素材在Photoshop里切成六面。切的时候注意Cesium天空盒的方向约定正X、负X、正Y、负Y、正Z、负Z和你相机面对的方向要能对得上。3.3 CesiumSkyBox挂载与六面方向不管是哪种方式最终挂载代码都一样viewer.scene.skyBox new Cesium.SkyBox({ sources: { positiveX: /assets/textures/skybox/px.jpg, negativeX: /assets/textures/skybox/nx.jpg, positiveY: /assets/textures/skybox/py.jpg, negativeY: /assets/textures/skybox/ny.jpg, positiveZ: /assets/textures/skybox/pz.jpg, negativeZ: /assets/textures/skybox/nz.jpg } });一个非常容易踩的坑是星空贴图里如果已经包含了月亮那你的场景里会出现两个月亮一个在天空盒里一个是Cesium的内置Moon。我第一版就犯了这毛病调试了半天没发现最后在地球上抬头一看天上挂着两个月球其中一个还不会动。解决办法是在P图阶段把月亮从星空贴图里抹掉只保留星星和银河。3.4 无缝拼接检查清单从全景切六面或者从模拟软件导出六面接缝是绕不开的问题。我每次处理完贴图后会做一轮检查把六张贴图用Cesium加载时间固定在一个晚上相机慢慢旋转一圈在接缝处重点看银河亮带是否连贯单张贴图内部注意亮星边缘是否有白边有就需要羽化银河的主体亮带最好在负Y轴附近也就是地平线到天顶的过渡区域这样视觉舒适度最高贴图格式用JPG压缩时注意不要压出明显色块关键天空过渡区域可以用PNG或高质量JPG尺寸方面如果项目跑在PC端单张1024x1024足够了2048会更好但加载时间和显存开销会上升。移动端建议降到512星空小目标下很难看出差别帧率反而实打实省出来了。4. 时间联动让月亮按真实天文位置升落贴图解决了“看起来像不像”的问题但一个真正的夜空系统“位置上对不对”同样重要。Cesium内置了天文算法没必要自己写轨道计算直接用就行。4.1 获取月球方向的核心APICesium提供Simon1994PlanetaryPositions这个类内部封装了低精度的月球和太阳位置计算精度足够可视化项目使用const moonPos new Cesium.Cartesian3(); Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthFixed( viewer.clock.currentTime, moonPos );返回的是地球固定坐标系中月球中心的位置单位是米。因为是地球固定系地心到月球的方向就是你抬头看月亮的方向。拿到这个方向以后无论是驱动刚才的光晕Billboard还是自己写一个“跟随月亮”的相机逻辑都顺理成章。4.2 用Clock驱动整个场景既然月亮位置由时间决定那整个场景就必须有一个可靠的时钟体系。我习惯这样设置const start Cesium.JulianDate.fromDate(new Date(2025-06-14T19:00:0008:00)); const stop Cesium.JulianDate.addHours(start, 12, new Cesium.JulianDate()); viewer.clock.startTime start.clone(); viewer.clock.stopTime stop.clone(); viewer.clock.currentTime start.clone(); viewer.clock.clockRange Cesium.ClockRange.LOOP_STOP; viewer.clock.multiplier 300; // 每秒模拟5分钟 viewer.clock.shouldAnimate true;这里有两个细节容易忽略。第一multiplier是模拟速度300倍意味着现实时间1秒走5分钟整个夜晚12小时大约两分半钟走完展示节奏比较合适。第二如果项目需要固定在某个时刻展示比如甲方就要拍“晚上十点整的效果”那建议把shouldAnimate设为false把时间锁死避免演示时月亮突然跑掉。4.3 动态更新Billboard与视角追踪时钟跑起来以后监听onTick事件让月亮光晕跟着时间走const moonHalo viewer.entities.add({ ... }); // 前面已创建 viewer.clock.onTick.addEventListener(function () { const moonPos new Cesium.Cartesian3(); Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthFixed( viewer.clock.currentTime, moonPos ); const dir Cesium.Cartesian3.normalize(moonPos, new Cesium.Cartesian3()); const farPos Cesium.Cartesian3.multiplyByScalar(dir, 8e6, new Cesium.Cartesian3()); moonHalo.position farPos.clone(); });这里要注意position的赋值要clone一个出来不要直接引用同一个对象否则多个entity共享一个Cartesian3引用会互相干扰。另外如果你的项目相机要做一个“跟随月亮升起”的演示可以在onTick里做viewer.camera.lookAt把视线锁定在月亮方向配合缓动让镜头缓缓上抬演示效果会非常加分。5. 夜色氛围地表灯光、雾效与后处理调色月亮和星空搞定了但如果你把镜头拉低看向地面发现地表黑咕隆咚一片整个场景的“夜色感”就塌了一半。这一步要解决的是地面不能死黑要有城市灯光的暖色点缀远处要有雾的层次感整个画面色调还得统一。5.1 让城市灯光亮起来夜间灯光影像图层想让地表在夜晚亮起来最省力的办法是叠加夜间灯光影像图层。NASA GIBS有一个Black Marble夜间灯光数据集可以作为WMTS服务加载如果你有内网环境也可以自己准备一份夜间灯光切片做成XYZ服务。const nightLights viewer.imageryLayers.addImageryProvider( new Cesium.WebMapTileServiceImageryProvider({ url: https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/NASA_BLM_Gray_Land_Shaded_Relief_Bathymetry/default/GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg, layer: NASA_BLM_Gray_Land_Shaded_Relief_Bathymetry, style: default, format: image/jpeg, tileMatrixSetID: GoogleMapsCompatible, maximumLevel: 8 }) );这里最关键的一个隐蔽点夜间灯光影像一定要配合viewer.scene.globe.enableLighting true使用。Cesium的影像图层在白天和夜晚用的是同一套判断逻辑日半球和夜半球的分界会直接决定影像的可见性。你开启了光照计算以后夜半球不会像以前那样一片死黑因为黑色大理石影像里城市灯光的像素本身是亮的会被保留下来。如果你手头没有NASA这个服务还有一个土办法把城市区域的建筑模型单独打一个暖色点光源。模型少的时候效率可观模型多了还是图层方案更稳。5.2 雾和大气让远处更有层次纯黑的夜空中地球边缘和天空的衔接会非常生硬这时候雾就是最好的调和剂。viewer.scene.fog.enabled true; viewer.scene.fog.density 0.0003; viewer.scene.fog.minimumBrightness 0.02;density越大雾越浓0.0003是我在距离地面几百公里的视角下试出来的一个比较舒服的值。如果你主要用于城市级近景可以把density再调小一点到0.0002否则近地面会像蒙了一层纱。minimumBrightness决定了雾最低亮度太大会让夜晚泛白太小则等于没开这个参数建议在0.01到0.03之间微调。5.3 后处理三件套Bloom、ColorGrade与泛光控制夜晚氛围的终极杀气是后处理。我常用的组合是Bloom加ColorGrade。viewer.scene.highDynamicRange true; const bloom viewer.scene.postProcessStages.bloom; bloom.enabled true; bloom.contrast 120; // 对比度越大亮部越亮 bloom.brightness -10; // 亮度偏移负值压暗整体 bloom.glowOnly false; const colorGrade viewer.scene.postProcessStages.colorGrade; colorGrade.enabled true; colorGrade.brightness 0.05; colorGrade.contrast 0.15; colorGrade.saturation 0.1; colorGrade.gamma 0.9;Bloom会把高亮区域的光晕往外“渗”月亮边缘、城市灯光都会被带出一层柔光。但这里有个非常容易翻车的地方Bloom是对整帧画面做处理的如果你星空贴图里星星太多太亮Bloom会把所有星星都糊成大光斑整个天空白花花一片非常难看。解决办法有两个要么把星空贴图整体调暗要么把Bloom的阈值调高让它只对月亮和城市灯光这种极亮区域生效。实测下来我更喜欢前者——直接把贴图调暗反正还有ColorGrade可以拉亮度。Cesium的Bloom参数和Unity、UE的Bloom不太一样它调的是对比度和亮度的偏移量不是强度百分比所以你改参数时跨度要大一些10以下的数值变化几乎看不见。6. 性能优化与避坑记录最后这部分是项目能不能顺利交付的关键。我把自己在这个项目里踩过的坑和优化手段整理成了清单强烈建议你直接收藏。6.1 贴图和draw call的控制星空贴图全部烘焙进SkyBox之后场景里除了SkyBox一次draw call其他都是零成本。真正费draw call的是那些动态闪烁的星星粒子。如果甲方硬性要求“星星要闪烁”我的方案是加一个小规模BillboardCollection控制在200个以内粒子越多越像噪点视觉上并不好看。要闪烁用shader在纹理阶段做正弦波动即可不要让每颗星都是独立entity去改透明度。const stars viewer.scene.primitives.add(new Cesium.BillboardCollection()); for (let i 0; i 200; i) { // 随机分布在半球方向离地球固定距离 const pos randomPointOnCelestialSphere(6e6); stars.add({ position: pos, image: /assets/textures/star.png, width: 8, height: 8, disableDepthTestDistance: Number.POSITIVE_INFINITY }); }如果你追求性能极致这一步可以省掉直接把闪烁星星也烘焙进天空盒用多张不同亮度的天空盒贴图做快速切换模拟闪烁效果。代价是内存占用变高移动端不推荐。6.2 后处理与移动端取舍Bloom是非常吃性能的在移动端尤其明显。我在Pad上实测过开启Bloom后帧率下降10帧以上。建议移动端只保留ColorGrade关闭Bloom。如果确实要有辉光效果可以用月亮光晕贴图自带的透明渐变来假扮Bloom视觉差距没那么大。PC端跑起来也要注意如果项目分辨率是4KBloom的耗时几乎翻倍要不要开就取决于你的显卡预算。6.3 五个高频坑位第一个坑是SkyBox六面方向错乱。Cesium中六张贴图的正负轴方向有明确约定P图或者加载后如果发现星空镜像、星星方向不对优先检查positiveX和negativeX是否反了这个错误肉眼很难发现但飞机在天上飞一圈就露馅了。第二个坑是Billboard月亮被地球遮挡。disableDepthTestDistance一定要设成Number.POSITIVE_INFINITY否则相机转到地球另一侧时月亮直接消失。第三个坑是开启enableLighting以后地表黑到什么都看不见。这不是你调错参数而是夜间灯光图层还没加。先加图层再调后处理顺序不能反。第四个坑是时间倍数设置过大。multiplier设成几千甚至上万时月亮位置会跳变看起来像月亮在抽搐。我建议模拟速度控制在600以内也就是一秒走10分钟视觉过渡才平滑。第五个坑是后处理调色覆盖全局。ColorGrade的brightness和gamma是对整帧画面生效的你调亮了暗部亮部也会受影响。摄影里那句话挪过来完全适用先做减法再做加法。把星空贴图压暗、城市灯光提亮再用ColorGrade微妙调整而不是靠后处理硬拉。6.4 一版可直接落地的initNightScene最后把上面的核心逻辑整合成一个函数方便直接抄进项目function initNightScene(viewer, options {}) { // 1. 天空盒 viewer.scene.skyBox new Cesium.SkyBox({ sources: { positiveX: options.skyPx || /assets/textures/skybox/px.jpg, negativeX: options.skyNx || /assets/textures/skybox/nx.jpg, positiveY: options.skyPy || /assets/textures/skybox/py.jpg, negativeY: options.skyNy || /assets/textures/skybox/ny.jpg, positiveZ: options.skyPz || /assets/textures/skybox/pz.jpg, negativeZ: options.skyNz || /assets/textures/skybox/nz.jpg } }); // 2. 月亮高分辨率纹理 光晕叠加 viewer.scene.moon.textureUrl options.moonTexture || /assets/textures/moon_2048.jpg; viewer.scene.moon.onlySunLighting true; const halo viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(0, 0, 8e6), billboard: { image: options.moonHalo || /assets/textures/moon_halo.png, width: 512, height: 512, disableDepthTestDistance: Number.POSITIVE_INFINITY, color: Cesium.Color.fromCssColorString(rgba(210, 225, 255, 0.95)) } }); viewer.clock.onTick.addEventListener(() { const moonPos new Cesium.Cartesian3(); Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthFixed( viewer.clock.currentTime, moonPos ); const dir Cesium.Cartesian3.normalize(moonPos, new Cesium.Cartesian3()); halo.position Cesium.Cartesian3.multiplyByScalar(dir, 8e6, new Cesium.Cartesian3()).clone(); }); // 3. 时钟体系 const start options.start || Cesium.JulianDate.fromDate(new Date()); const stop Cesium.JulianDate.addHours(start, 12, new Cesium.JulianDate()); viewer.clock.startTime start.clone(); viewer.clock.stopTime stop.clone(); viewer.clock.currentTime start.clone(); viewer.clock.clockRange Cesium.ClockRange.LOOP_STOP; viewer.clock.multiplier options.multiplier || 300; viewer.clock.shouldAnimate true; // 4. 地表照明与夜景灯光 viewer.scene.globe.enableLighting true; if (options.nightLightsUrl) { viewer.imageryLayers.addImageryProvider( new Cesium.WebMapTileServiceImageryProvider({ url: options.nightLightsUrl, layer: NASA_BLM_Gray_Land_Shaded_Relief_Bathymetry, style: default, format: image/jpeg, tileMatrixSetID: GoogleMapsCompatible, maximumLevel: 8 }) ); } // 5. 雾 viewer.scene.fog.enabled true; viewer.scene.fog.density options.fogDensity || 0.0003; viewer.scene.fog.minimumBrightness options.fogMinBrightness || 0.02; // 6. 后处理 viewer.scene.highDynamicRange true; viewer.scene.postProcessStages.bloom.enabled options.bloom ! false; if (options.bloom ! false) { viewer.scene.postProcessStages.bloom.contrast 120; viewer.scene.postProcessStages.bloom.brightness -10; } viewer.scene.postProcessStages.colorGrade.enabled true; viewer.scene.postProcessStages.colorGrade.brightness 0.05; viewer.scene.postProcessStages.colorGrade.contrast 0.15; viewer.scene.postProcessStages.colorGrade.saturation 0.1; viewer.scene.postProcessStages.colorGrade.gamma 0.9; return { halo }; }这个函数把所有效果收拢成一套配置项目里换场景、换时间、换贴图都只需要调一个options对象不用再到处找散落的代码。最后再分享一个实用小技巧。如果你想让月亮在云层后面若隐若现可以给场景加一个半透明云层纹理放在月亮和相机之间云层的边缘用大半径羽化的贴图配合月亮光晕的透明度效果非常像冷夜里月亮从薄云后透出来的感觉。这个方案我是在交付前最后一天临时加的甲方看完直接眼前一亮。夜晚效果这种东西往往就是这最后一层细节把项目从“能用”变成“好看”。