网上做Cesium白天场景的教程一抓一大把,但真正要做一个“夜晚月亮银河夜空效果”的时候,很多人会卡在同一个地方:背景颜色改了、星空图也贴了,结果地球表面还亮得跟大白天似的,月亮也怎么看怎么假。我最早做智慧城市大屏的时候,甲方提了一嘴“加个夜晚模式”,我以为就是换个深色背景,前前后后折腾了两天,才把天空盒、太阳、大气、地表纹理、动态光照这堆东西真正理顺。
这篇东西就是把我那次踩坑之后整理出来的完整思路写出来,核心是“Cesium夜晚月亮银河夜空效果”到底应该怎么拆解、怎么实现、哪些地方容易翻车。适合正在做三维可视化、数字孪生、展厅大屏,或者单纯想把Cesium场景做出更有氛围感的朋友参考。我会把可复现的代码、参数配置和实际试过有效的技巧都写明白。
1. 项目需求与整体实现思路
1.1 先搞清楚“夜景”到底要改哪些东西
Cesium默认给我们的是一套标准的“白天模式”:天空盒是蓝天白云、太阳在场景里起到主光源作用、大气散射让远处泛白。如果你只是把viewer的背景色改成深色,或者扔一张星空图上去,很快会发现整个画面特别割裂——天空是黑了,地表却还是白天的亮度,光照方向也不对,月亮更像是一个贴上去的贴纸。
所以要实现夜晚月亮银河夜空效果,必须从几个层面一起动:
- 关闭或替换默认天空盒和太阳,让背景变成真正的夜空;
- 调整大气和雾效,去掉白天那种蓝白色散射;
- 让地球表面进入低照度状态,可以用夜间纹理或降低地表亮度;
- 添加月亮、银河、星星这些视觉元素;
- 重新设置方向光来模拟月光,保证地表、建筑物有合理明暗关系;
- 如果要做得更细腻,还要考虑后处理泛光、动态特效联动。
一句话总结:这不是“加一个月亮”那么简单,而是要把Cesium渲染管线的多个环节统一改成夜晚模式。我后来做项目的习惯是先把这几个开关都列出来,做成一个可以一键切换白天夜晚的函数,而不是每次到处找配置。
1.2 技术选型:月亮、银河、星星分别用什么方案
在写代码之前,先把技术方案定下来,否则做到一半会发现效果到不了预期。
月亮这块我首推“Billboard + 光晕”组合,而不是直接用scene.moon。Cesium自带的Moon其实是一个粗糙的椭球体,灰度纹理、表面细节少,在近距离看非常出戏。我用Billboard贴一张带透明通道的月亮PNG,再叠加一层半透明的圆形光晕,视觉上要柔和很多,而且位置、大小、透明度全部可控。
银河是整场效果里最吃方案的部分。常见两条路:
- 静态银河:做一张包含银河的天空盒CubeMap,六张贴图换掉Cesium默认skyBox。优点是性能好、真实感强、实现最简单,缺点是银河不会流动,看久了有点“照片感”。
- 动态银河:用Cesium粒子系统或自定义Primitive,把大量粒子分布成银河光带的形态。优点是动起来很震撼,星星闪烁、旋转都能做,但粒子数量控制不好会拖垮帧率。
星星就简单了,粒子系统或者直接放在天空盒贴图里都行。如果天空盒已经带了密集的星空,就不需要额外加粒子星星,否则会显得重复。
我在实际项目里一般用“静态天空盒打底 + 少量粒子点缀”的组合,性能和效果平衡得最好。下面章节会详细展开。
2. 夜晚环境搭建:把“白天场景”彻底切到夜晚
2.1 初始化Viewer并关闭默认太阳和大气
第一步是创建Viewer实例,这一步里建议直接把天空盒换掉。Cesium默认的skyBox是程序生成的天空渐变,不适合夜晚。下面是我常用的配置:
const viewer = new Cesium.Viewer('cesiumContainer', { baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, animation: false, timeline: false, fullscreenButton: false, skyBox: new Cesium.SkyBox({ sources: { positiveX: 'skybox/px.jpg', negativeX: 'skybox/nx.jpg', positiveY: 'skybox/py.jpg', negativeY: 'skybox/ny.jpg', positiveZ: 'skybox/pz.jpg', negativeZ: 'skybox/nz.jpg' } }) });创建之后还要处理这几个关键开关:
const scene = viewer.scene; scene.sun.show = false; // 关掉太阳,否则夜空里有个刺眼的亮点 scene.moon.show = true; // 先留着默认月亮,后面会用billboard替代 scene.skyAtmosphere.show = true; // 大气散射可以保留一点,但要注意别太亮 scene.fog.enabled = true; scene.fog.density = 0.002; scene.backgroundColor = Cesium.Color.fromCssColorString('#050510');这里有个很容易踩的坑:背景色如果设成纯黑,银河和月亮会显得很突兀,缺少大气感。我试下来用接近深蓝黑的颜色(比如#050510、#030812)效果更自然。同时skyAtmosphere不要完全关掉,留一点点散射可以让远处地表边缘产生微弱的蓝灰色过渡,真实感提升明显。
2.2 让地球表面进入“夜晚模式”
背景切完了,接下来是最容易被忽略的一步:地表。Cesium默认地表用的是影像服务的白天纹理,亮度非常高,即使把太阳关了,地表依旧泛白。
有几种做法:
- 方法一:切换影像源为夜间灯光纹理。NASA的Black Marble这类数据能直接表现城市灯光,效果最真实。但需要确保数据源可用,而且栅格瓦片加载慢,最好预缓存。
- 方法二:把globe.baseColor调暗,同时调节影像图层亮度。
- 方法三:关闭enableLighting并把地表材质统一换成深色,适合不需要真实地形的场景。
我推荐的做法是方法二的变体:保留原始影像图层,但用一个半透明的黑色图层压暗地表,或者直接设置图层亮度参数。示例:
const imageryLayers = viewer.imageryLayers; imageryLayers.get(0).brightness = 0.4; imageryLayers.get(0).contrast = 1.1;如果还是觉得太亮,可以再加一层纯色暗化图层。这个思路其实和学习PS调色很像:不要指望一个参数解决所有问题,分层调节更容易得到自然效果。
还有一个关键点:如果你想保留城市灯光那种“万家灯火”的感觉,建议使用带自发光效果的夜间纹理。如果只是做氛围,普通影像压暗就够了。真要做智慧城市类的数字孪生,最好把建筑模型本身的材质也换成夜晚自发光版本,跟地表配合起来才统一。
3. 月亮与银河效果的核心实现
3.1 月亮:Billboard + 光晕,位置、大小、亮度全可控
Cesium默认的scene.moon在晚上看起来其实还行,但它的位置是Cesium内部基于天文算法算出来的,不太方便手动调节。如果你想让月亮固定在天边某个角度,或者想要更大、更圆、带一点暖黄色的月晕,就得自己上Entity。
我的做法是这样的:
const moonTexture = 'assets/moon.png'; // 带透明通道的月亮贴图 const moonEntity = viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(120, 30, 400000), // 大致放在高空远处 billboard: { image: moonTexture, width: 128, height: 128, scale: 1.0, translucencyByDistance: new Cesium.NearFarScalar(1e6, 1.0, 1e8, 0.8), disableDepthTestDistance: Number.POSITIVE_INFINITY } });再叠加一层光晕:
viewer.entities.add({ position: moonEntity.position.getValue(Cesium.JulianDate.now()), billboard: { image: 'assets/moon_glow.png', width: 320, height: 320, color: Cesium.Color.fromCssColorString('#fff5d5').withAlpha(0.4), disableDepthTestDistance: Number.POSITIVE_INFINITY } });这里有两个关键细节。一是disableDepthTestDistance要设成无限大,否则月亮飞远了会被地球挡住,或者被地形遮挡。二是光晕贴图一定要用径向渐变的PNG,透明边缘要柔和,不然接缝和锯齿会非常明显。
如果想让月亮跟随相机方向,可以用CallbackProperty动态计算位置。比如让月亮始终出现在相机方向偏左30度的位置,这个看起来更贴近真实视角。我后来在一个景区可视化项目里就是让月亮固定在东南方向,这样相机绕场的时候月亮不会穿帮。
3.2 银河:静态天空盒还是粒子系统?
银河是整个夜空效果里最出氛围的部分。我两条路都做过,分别说一下实测感受。
方案A:静态天空盒CubeMap
如果你已经有了一张全景星空图(equirectangular格式),可以转成六张CubeMap贴图,然后直接替换skyBox。这个方案在视觉上最“干净”,因为Cesium渲染天空盒是底层绘制,不存在粒子排序、遮挡问题,性能消耗几乎为零。
转换工具可以用常见的全景图转立方体贴图工具。注意在转换前,把银河区域放在贴图的正上方偏斜位置,因为相机视角通常朝前上方看,银河斜跨天际的构图更自然。
方案B:粒子系统动态银河
粒子系统的优势是动态感强。银河不是一块静止的画布,而是无数星星的集合。如果你希望星星有明暗变化、甚至缓慢旋转,粒子是更优解。
下面是一个基于Cesium.ParticleSystem的星星粒子示例:
const starTexture = 'assets/star.png'; const starParticles = viewer.scene.primitives.add(new Cesium.ParticleSystem({ image: starTexture, imageSize: new Cesium.Cartesian2(8, 8), startScale: 0.5, endScale: 1.0, startColor: Cesium.Color.WHITE.withAlpha(0.9), endColor: Cesium.Color.WHITE.withAlpha(0.0), particleLife: 20, emissionRate: 60, emitter: new Cesium.SphereEmitter(5000), modelMatrix: Cesium.Matrix4.fromTranslation(Cesium.Cartesian3.fromDegrees(100, 20, 500000)), lifeTime: Number.POSITIVE_INFINITY }));不过严格说,这种球形发射器做“银河”是不够的,因为银河是一条光带,不是一团星云。要做出带状分布,我试过两个办法:一是用多个粒子发射器沿弧形路径排布,二是在粒子初始化时传入自定义位置数组。Cesium原生ParticleSystem没有直接支持自定义位置数组,所以我当时是用Three.js共享WebGL上下文来做的银河粒子,效果最理想——把9000个粒子按“银河平面”的伏安曲线分布在三维空间里,再用着色器让它们闪烁。
但Three.js共享上下文这种方式复杂度高,不是所有项目都愿意引入。如果你想控制在纯Cesium范围内,我的建议是天空盒打底、粒子点缀的方式:静态天空盒提供银河的大结构,粒子系统负责星星闪烁的细节。这样既避免了粒子数量爆炸,又保留了一点动态感。
两种方案的对比我做成了下面这张表:
| 方案 | 真实感 | 动态效果 | 性能开销 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 静态天空盒 | 高 | 无 | 极低 | 低 | 展厅大屏、性能敏感场景 |
| 纯粒子系统 | 中 | 高 | 高 | 高 | 强调交互和沉浸感的项目 |
| 天空盒 + 粒子点缀 | 高 | 中 | 低 | 中 | 我推荐的大多数项目 |
4. 动态光照与周边特效联动
4.1 用时间轴让“月光”动起来
夜晚场景不能只是静态贴图,光的方向和强度最好能跟着视角、时间变化。Cesium本身有Clock机制,可以通过JulianDate控制时间,再配合DirectionalLight模拟月光。
下面是一个简单的动态光照示例:
const moonLight = new Cesium.DirectionalLight({ direction: new Cesium.Cartesian3(-0.5, -0.3, -0.8), color: Cesium.Color.fromCssColorString('#a0b8ff').withAlpha(0.6) }); viewer.scene.light = moonLight; viewer.scene.globe.enableLighting = true;这样设置之后,地表明暗会跟着方向光走,地形起伏会有阴影变化,看起来更像真实月光。
如果你想要光线方向随时间缓慢变化,可以用CallbackProperty绑定viewer.clock:
viewer.scene.light = new Cesium.DirectionalLight({ direction: new Cesium.CallbackProperty(() => { const angle = viewer.clock.currentTime.secondsOfDay / 1000; return new Cesium.Cartesian3(Math.sin(angle), Math.cos(angle), -0.5).normalize(); }, false), color: Cesium.Color.fromCssColorString('#a0b8ff').withAlpha(0.6) });注意:scene.light属性在部分Cesium版本里表现为DirectionalLight,如果你用的是比较老或比较新的版本,记得先打印一下scene.light看结构。另外,enableLighting开启后,夜间纹理图层不要继续设置过高的亮度,否则月光方向对地表的影响会被纹理亮度盖过去。
我就是在这块卡过一会儿。明明月亮贴好了,光也打了,但画面整体还是发灰。最后发现是imagerLayer的brightness太高,月光方向的明暗对比完全体现不出来。调低地表亮度之后,地形的轮廓和阴影一下就出来了。
4.2 结合雨效、动态水面和雾效,把氛围拉满
热词里能看到很多人搜“cesium gpu局部雨效果”“动态水面”之类的,其实这些和夜景是绝配。雨夜的场景最能衬托月亮和灯光的质感。
局部雨效果用Cesium粒子系统就能做。雨滴本质上是细长的粒子,用一条白色半透明的短线段纹理,从空中向地面下落,配合深色背景非常像样:
const rainParticles = viewer.scene.primitives.add(new Cesium.ParticleSystem({ image: 'assets/raindrop.png', imageSize: new Cesium.Cartesian2(2, 24), speed: 20, emissionRate: 800, startColor: Cesium.Color.WHITE.withAlpha(0.4), endColor: Cesium.Color.WHITE.withAlpha(0.0), lifetime: 10, emitter: new Cesium.RectangleEmitter(...) }));粒子数量记得控制,800到1000已经够密了,再加就掉帧。
动态水面如果场景里有河流、海面,可以自定义一个法线贴图材质,配合月光方向去反射高光。我试过把太阳光关闭之后,水面默认镜面反射会有点过暗,需要在材质里加一个自发光项。最简单的做法是给水面材质叠加一个偏蓝灰色的diffuseMap,再增强alpha,让它反射天空盒里的银河。
雨景、雾、银河、月光这四样叠起来,画面会非常饱满。但你得知道性能开销是乘法级别的:粒子雨、粒子星、后处理泛光、雾效,任何一个模块写得不克制都可能让帧率掉到20以下。所以平常我会加一个“环境强度”控制面板,开发时把每一项的强度做成可调参数,给甲方看效果时开满,自己调试时关到最低。
5. 性能优化与多视角效果对比
5.1 性能配置:requestRenderMode和粒子数量控制
Cesium场景里粒子系统和后处理是最吃性能的部分。如果你做的不是实时交互项目而是大屏演示,可以考虑开启requestRenderMode,让场景不常驻渲染,减少GPU占用:
const viewer = new Cesium.Viewer('cesiumContainer', { contextOptions: { webgl: { powerPreference: 'high-performance' } }, requestRenderMode: true, maximumRenderTimeChange: 100 });注意requestRenderMode开启后,如果还需要粒子动画平滑播放,要确保粒子系统被持续标记为需要渲染,或者适当调小maximumRenderTimeChange。否则粒子会“一卡一卡”地动,那种体验很糟糕。
粒子数量的经验值:静态星空粒子几千颗没问题;动态银河粒子建议控制在8000以内;雨滴粒子由于本身是细长形,800左右就够。粒子贴图尺寸不要太大,64x64甚至32x32就足够,大了反而会因为fillrate把GPU拖垮。纹理格式尽量用PNG压缩过的,避免直接上无损大图。
5.2 多视图对比:白天和夜晚一键切换的两种实现
做演示项目经常要拿白天和夜晚做对比,让客户直观看到夜晚模式的氛围。我试过两种做法。
第一种最简单:同一Viewer内切换时间。
// 切到夜晚 viewer.scene.sun.show = false; viewer.scene.skyAtmosphere.hueShift = -0.2; viewer.clock.shouldAnimate = false; // 切回白天 viewer.scene.sun.show = true; viewer.scene.skyAtmosphere.hueShift = 0; viewer.clock.shouldAnimate = true;这种方案实现成本低,但切换过程不够直观,两边同时对比做不到。
第二种适合做分屏对比:使用两个Viewer或两个canvas,同步camera和时钟。Cesium官方有Splitter相关的示例,本质是让两个场景共享相机参数。我实际用的是双Viewer方式,比较麻烦的点是两个Viewer都要正确处理entity和primitives,内存占用翻倍。所以双Viewer方案一般只做两个固定视角的对比,不适合长时间运行。
如果只是要“切白天夜晚切得好看”,可以用时间线动画过渡,而不是硬切。把sun.show、背景色、地表亮度、光的方向都做成随时间线性变化的参数,利用CallbackProperty在一分钟内从黄昏过渡到深夜,这个效果在展厅里非常抓眼球。
6. 常见问题与排查技巧实录
6.1 夜晚背景灰白,银河不显示
这个问题90%是skyAtmosphere和backgroundColor打架导致的。Cesium的大气散射在靠近地平线的地方会产生一层灰白色的haze,如果背景色不够暗,会被它盖住。我通常这样做:
viewer.scene.skyAtmosphere.brightnessShift = 0.2; viewer.scene.skyAtmosphere.hueShift = -0.1; viewer.scene.backgroundColor = Cesium.Color.fromCssColorString('#050510');如果改了还是发灰,检查一下是否自己设置了后处理bloom且bloom阈值太高,导致暗部被整体提亮。把bloom的contrast调低一点,或者把阈值调到0.5以上。
6.2 月亮被地表遮挡或位置不对
月亮billboard看不到,先确认disableDepthTestDistance是否设成了无限大。很多新人只设了一个很大的数,比如1e8,但当地表模型特别大或者相机离得远时,这个值还是不够稳。直接用Number.POSITIVE_INFINITY最省心。
位置不对的根源是Cartesian3.fromDegrees的height参数没给够。如果height设成几万米,可能被全球地形的高山挡住;如果设成几百万米又觉得月亮太小。我建议直接用角秒级别的高度,比如400000以上,同时保证相机朝向能看到它。
6.3 地表过亮或全黑,怎么调都别扭
这两种情况其实是一对矛盾。
地表过亮:多半是影像图层brightness没降下来,或者globe.baseColor是白色。解决思路是给最底层影像层一个很低的opacity,再叠加一层纯黑图层压暗。
地表全黑:多半是sun.show关了以后,scene.light没有被正确设置,场景里没有方向光了。如果没设置scene.light,Cesium会把太阳光作为默认方向光;太阳一关,地表就全黑。这时候必须手动设置scene.light为月光方向,同时加一点环境光的感觉,可以给DirectionalLight的color一个较小的alpha,而不是纯白。地表稍微保留一点蓝色反光,比死黑更耐看。
6.4 粒子多就卡,帧率惨不忍睹
粒子卡顿的排查顺序我一般是:
- 先检查粒子贴图SizeMultiplier是不是设太大了,粒子边缘模糊和大尺寸贴图是fillrate杀手;
- 再看emissionRate是否爆表,雨效开2000、星星开5000这种配置在任何显卡上都会卡;
- 然后看相机范围,粒子数量和相机视野内粒子密度成正比,如果视野里有大量粒子集中出现,考虑减少粒子生命周期;
- 最后检查是否开了多重后处理叠加,bloom + 雨效 + 动态水面 + 阴影一起上,性能翻倍。
6.5 不同版本Cesium的API差异
我一开始用的是1.87版本,后来项目升级到1.103,发现SkyBox构造参数和scene.light的用法都有一点细微变化。Cesium在1.100以后对WebGL2支持更好,但部分旧写法会报deprecation warning。建议项目锁定版本,不要随意升级。如果要用热词里搜到的“cesium中文文档”,优先看对应版本的文档,旧版本博客里的代码大概率需要微调。
最后再分享一个经验
我今年做过一个滨海文旅的展示项目,核心场景就是夜晚的海面、月亮和银河。当时为了表现银河的流动感,我在Three.js共享上下文里用着色器做了20000个粒子,效果确实震撼,但一部署到展厅的普通电脑上就掉到15帧。后来我把粒子分了两层:靠近相机的3000个用动态粒子,远处的银河直接画进天空盒纹理里,帧率稳定在50以上,视觉差异几乎看不出来。
这个经验后来变成我做Cesium特效的一个默认原则:越高频的视觉元素,越不要用实时计算去堆;能贴进纹理的贴纹理,能合并的粒子节点尽量合并。
另一个小技巧:银河纹理的接缝问题。如果你用六张CubeMap拼天空盒,记得在转换前把贴图的左右边缘处理成无缝。我自己一开始偷懒没处理,结果相机转起来每过90度就能看到一条明显的竖线。后来在PS里用偏移滤镜修了接缝再转,问题彻底消失。
如果你也想做类似效果,建议先搭一个小demo,只放天空盒和月亮,确认光照方向没问题,再逐步加粒子、雨效、水面。别一上来就全部叠满,不然出了bug你都不知道是哪一层的问题。