☰
Cesium白膜分层设色与点光源:实现建筑高度渐变夜景场景
2026/10/24 1:09:38 网站建设 项目流程

最近在做一个片区级的三维可视化演示项目,核心场景是用Cesium加载一片S3M格式的建筑白膜,最终效果要能一眼看出哪栋楼高、哪栋楼矮、城市空间峰值在哪。我选用的实现路径是:白膜先离线转成3D Tiles,在Cesium里用分层设色的方式给建筑按高度上色,实现从蓝到红的渐变过渡,再在场景中央设一个点光源,把白天模式切换成略带夜景感的氛围。这套流程从数据准备到前端渲染,前后踩了不少坑,今天把完整思路和参考代码整理出来,希望能给遇到同类需求的同学省点时间。

这套玩法非常适合智慧园区、城市建筑高度普查、规划限高比对、天际线分析这类场景。只要你的数据是S3M白膜或者类似的体块模型,都可以按这个思路改造。下面按我实际操作的顺序来讲,重点放在方案选型、颜色映射、点光源调参和那些文档里不会写的坑。

1. 整体思路与方案选型:白膜为什么需要分层设色

1.1 白膜的分层设色到底解决了什么问题

白膜就是不带贴图的建筑体块模型,通常用白色或者浅灰色显示。这种数据特别适合做空间分析,因为它足够"干净"——没有纹理干扰视线,所有信息都集中在几何形状和高度上。但问题也出在这里:一片白膜加载到Cesium之后,远远望去就是一个白色积木堆,楼高楼矮只能靠目测,完全没有信息层次。

分层设色(Hypsometric tinting)最早是地图制图里用来表现地形高程的方法,把连续的DEM高度切分成若干区间,每个区间填一个颜色,形成从低到高的色彩阶梯。把这套逻辑搬到建筑白膜上,就是把垂直方向上的建筑高度映射到颜色梯度,让大脑能立刻从颜色读出高度信息。

1.2 S3M数据在Cesium里的加载路线怎么选

原生Cesium并不认识S3M格式,直接用传统的primitive或者entity加载都会报错。要把S3M塞进Cesium,常规有两类路线:

路线做法优势劣势
A用带S3M加载能力的Cesium增强框架直接加载数据不用转换,加载快,属性保留完整引入额外的SDK依赖,调试栈较深,与自行封装的技术栈常有冲突
B先用本地转换工具把S3M离线转为3D Tiles,再用原生Cesium加载环境干净,加载逻辑可控,后续样式、裁剪都走标准3D Tiles流程多一道转换工序,纹理细节会有些损耗

我自己做的是路线B。原因很直接:白膜本来就没有纹理,转换损失几乎可以忽略;而且项目里已经有了基于Cesium二次封装的渲染框架,如果再塞进一套独立的S3M加载SDK,打包体积和版本冲突都会很头疼。转成3D Tiles之后,分层设色直接用Cesium3DTileStyle表达式来做,性能也比遍历每一个几何体去上色要好得多。

1.3 渐变效果的层次设计,不只是选颜色

分层设色最核心的设计工作,是确定颜色区间怎么划分。这里的"渐变"不是随便找一个colormap从头拉到尾,而是要先搞清楚你的数据长什么样,再决定分段策略。

我建议先统计一下白膜建筑的高度分布:最大值、最小值、25%分位、50%分位、75%分位。如果数据本身带"楼层数"字段,那更简单,直接按楼层语义来分层。比如常见的分层方案是:

高度区间层数参考颜色参考业务含义
0-12米1-4层蓝色低矮附属建筑
12-24米5-8层青色底层商业/多层住宅
24-50米9-16层绿到黄普通高层
50-90米17-30层橙色中高层公寓/写字楼
90米以上30层以上红色超高层地标

这样分段的好处是,每一个颜色区间都有业务含义,后面做图例、讲解、分析报告都很自然。但要注意,这种离散分段的缺点是有明显的"等高线感"——如果你希望过渡更柔和,可以把区间增加到十几二十段,取同一色带上连续采样的颜色值,视觉上就会平滑很多。

2. 核心代码实现:按高度给白膜上渐变颜色

2.1 先确认3D Tiles里有没有高度字段

用Cesium加载白膜之后,第一件事不是着急写样式,而是先检查这张3D Tiles里到底带了哪些属性。S3M转换工具不同,字段保留情况差别很大。有的会保留height、floor,有的只留一个objectid和几个不明所以的字段。检查方法很简单:

const tileset = viewer.scene.primitives.add( new Cesium.Cesium3DTileset({ url: "http://localhost:8080/data/tileset.json" }) ); tileset.readyPromise.then(function() { // 打印tileset里的属性列表 console.log(tileset.properties); // 随机查看一个feature携带的属性 const content = tileset.root; if (content.content && content.content.featuresLength > 0) { const feature = content.content.getFeature(0); const propertyNames = feature.getPropertyIds(); console.log(propertyNames); } });

这一步信息量很大。实测中我遇到最典型的坑是:转换工具把S3M里的height字段改名成了Height,还有的单位是厘米而不是米,读出来数值有几位数,直接就影响到后面的分层区间。所以,写死颜色条件之前,务必先看几个feature真实输出。

2.2 用Cesium3DTileStyle写分层设色表达式

拿到字段名之后,核心染色代码其实很短。假设白膜的height字段就是米制高度,直接写一组conditions条件表达式:

tileset.style = new Cesium.Cesium3DTileStyle({ color: { conditions: [ ["${height} < 12", "color('#2563eb')"], ["${height} >= 12 && ${height} < 24", "color('#06b6d4')"], ["${height} >= 24 && ${height} < 50", "color('#22c55e')"], ["${height} >= 50 && ${height} < 80", "color('#eab308')"], ["${height} >= 80", "color('#ef4444')"] ] } });

这里有几个必须注意的细节:

  • conditions数组是按顺序匹配的,所以条件顺序就是你想要的分层顺序,一定要从小到大写,否则低层先被高位区间命中。
  • 属性名如果是字符串型,Cesium在比较时会尽量转成数字,但如果你发现样式完全不变色,就用Number(${height})包一层再比较,这个操作在表达式里是支持的。
  • color()函数支持常见的CSS颜色名、#hex、rgb()等写法。如果以后要配CSS变量或者外部配置的色板,建议统一用#hex,后面调色替换也方便。

Cesium3DTileStyle最大的优势是它在GPU上按feature做条件判断,几十万个建筑体块也只是迭代特征,不涉及遍历三角面片,性能扛得住。

2.3 没有高度属性时的兜底方案

如果转换出来的数据连height字段都没有,还有一种常用兜底方案:用每个tile的boundingSphere去估算建筑顶部高度。原理是建筑白膜的boundingSphere中心点加上半径,就能粗略得到模型最高点。

tileset.readyPromise.then(function() { let maxHeight = 0; const tiles = tileset.root; // 递归遍历瓦片,取包含建筑的最高点 function traverseTile(tile) { if (tile.content && tile.content.boundingSphere) { const bs = tile.content.boundingSphere; const top = bs.center.z + bs.radius; if (top > maxHeight) maxHeight = top; } if (tile.children) { tile.children.forEach(traverseTile); } } traverseTile(tiles); console.log("建筑最大高度约:", maxHeight); });

但这种做法有几个问题:遍历瓦片的时机要等到整棵瓦片树加载完,否则结果不完整;另外boundingSphere包含的是整个瓦片包围体,比真实建筑顶部高出不少。所以它只能用来估算全局高度范围,用来确定分层区间,不能用来精确给每一栋楼染色。

如果项目允许动数据,最干净的方案其实是在转换S3M之前,就在源数据里补一列height,或者从楼层数×层高推导出来。这步用GIS软件或者写个批量脚本就能完成,后面所有事情都会轻松很多。

2.4 让过渡更自然的几个细节

每次做分层设色,我都要提醒自己:分层不等于越细越好。分得太粗,颜色断层明显,像幼儿园填色本;分得太细,相邻颜色差别拉不开,等于白分。这个度怎么把握?我的经验是,先按建筑高度的分布情况做一次频率统计,找到数据密集区,在密集区多分几段,稀疏区少分几段。

比如某片区域大量建筑集中在18米到36米之间,那就可以在12-24、24-36、36-50之间各安排一个色阶,而不是机械地每10米一段。这样既保证了过渡自然,又突出了"大多数建筑其实集中在一个高度段"这个信息。

另外一个让渐变更平滑的小技巧,是使用同一色带的连续采样色,而不是从不同色相里硬拼。比如我常用blue -> cyan -> green -> yellow -> red这条全谱,但会从中间多取几个插值色,让颜色变化是连续的矢量方向,而不是几个孤立的色块。配合半透明的建筑材质,还能让底下的地形隐约透出来,层次感会好很多。

3. 点光源配置:从默认平行光到局部夜景氛围

3.1 先说清楚Cesium的光源机制

Cesium 1.98之前的版本,场景光照基本就是写死的太阳光,用户能控制的只有globe.enableLighting开关。1.98版本以后,scene.light开放成了可切换的光源对象,你可以把它换成DirectionalLight或者PointLight。点光源(PointLight)就是模拟一个向四周均匀发光的点,适合做塔吊灯、高杆灯、路灯这类局部光源。

这个能力对我们做夜间可视化非常关键。默认的太阳光方向固定、照度固定,建筑物看起来就是一片亮,没有明暗层次。换成点光源之后,点光源照射方向上的建筑立面会亮一些,背向面会暗下去,整个白膜城市立刻有了体积感。

3.2 最小可用配置,先照出来再说

viewer.scene.globe.enableLighting = true; viewer.scene.highDynamicRange = true; // 把场景主光源换成点光源 viewer.scene.light = new Cesium.PointLight({ position: Cesium.Cartesian3.fromDegrees(120.15, 30.28, 120), color: Cesium.Color.ORANGE, intensity: 300 }); // 控制模型高光强度,稍微压低一点避免过曝 viewer.scene.specularLightIntensity = 0.5;

position决定了光源在世界坐标系下的位置,通常我会把它放在场景建筑群的正上方偏一点。color用暖黄色,和分层设色的冷色系建筑形成对比。intensity初始值不用设太大,300左右就能看到明显效果,之后再根据实际场景微调。

如果你用的Cesium版本比较新(大概1.10x以上),PointLight还支持radius属性,用来控制光线的衰减半径,单位是米。比如radius: 500表示500米范围内有光照效果,超过这个距离光就衰减得几乎看不到了。这个参数特别适合把灯光限制在目标片区,避免光照范围铺满整张地图。老版本如果没这个参数,写了也不会生效,代码做好版本判断就行。

3.3 点光源调参的三个实战心得

第一个心得:点光源效果取决于模型有没有法线数据。这不只在Cesium里,任何渲染引擎都这样。白膜如果转3D Tiles时保留了法线信息,点光源打上去就会有亮面、暗面、过渡面;如果某些转换工具为了省体积把法线丢了,那你换了点光源之后整片建筑还是死白一片,完全看不出来。排查方法很直接:换一台有法线的模型试试,如果有效果,基本可以确定是白膜转换时弄丢了法线。

第二个心得:intensity和距离要配合着调。点光源是物理衰减的,距离越远照度越低。我刚开始做的时候把光源放得很远,intensity开到几千,结果近处曝光、远处没光。后来做法是先把光源放到场景中央,以半径300-500米为基准,再根据明暗用一次二分法确定intensity。注意不要上来就调HDR,HDR会把曝光差距放大,明暗更不好控。

第三个心得:点光源不要做成纯白色。纯白色点光源容易把分层设色的颜色冲淡,建筑看起来就像蒙了一层白雾。用淡黄色或者淡橙色,既能制造"傍晚灯光"的氛围感,又不会盖过建筑主体颜色。如果后续还要接入夜景模式,这个暖色光源还可以配合夜景贴图做整体灯光设计。

3.4 点光源和分层设色的组合思路

点光源加上分层设色之后,整个场景就有了两个信息维度:一是建筑自身的高度色带,二是光照带来的明暗层次。这两个维度是可以叠加的,并不冲突。我实际调出来的效果是:低层蓝色建筑在暖色点光源下会偏冷灰,高层红色建筑在光源照射下会接近橙红,整体色调是在蓝绿和暖黄之间平衡,比较耐看。

操作顺序上,建议先定好分层设色的色带,再调点光源。因为色带是"内容",光源是"氛围"。先把内容做到能准确读取,再去调氛围。不然光源参数和颜色参数挤在一起调,容易越调越乱。

4. 完整实操流程:从S3M白膜到渐变夜景场景

4.1 环境与数据准备

做这个方案我建议的环境配置是:

项目版本/说明
Cesium1.98以上,推荐1.105或更新
数据格式S3M白膜切片
转换工具本地切片转换工具,选支持S3M转3D Tiles的即可
前端框架原生Cesium或自封装框架均可

数据准备阶段,最好先确认白膜数据本身是地心坐标系(WGS84经纬度)还是投影坐标系(如某个地方坐标系)。S3M数据常见的是投影坐标,转换前需要统一到WGS84,否则后面加载时建筑位置可能偏到太平洋里去。这一点很多人容易忽略。

4.2 S3M转3D Tiles的实操注意事项

转换这一步,不同工具界面不一样,但核心参数就那么几个,我这里说通用的注意事项:

  1. 坐标系设置:优先选择EPSG:4326的经纬度输出,或者与Cesium场景底图一致的坐标系。如果底图是3857墨卡托,那3D Tiles也用对应投影加载,两者混用会出现偏移。
  2. LOD层级:白膜是简单体块,LOD不需要做得很深,3到5层足够。层数太多,生成的文件体积变大,加载时反而会频繁切换瓦片,拖慢渲染。
  3. 纹理选项:白膜没有贴图,转换时直接关闭纹理/材质压缩。这一步能大幅减小体积,加载速度提升明显。
  4. 坐标偏移问题:转换完成后先在本地方壳预览一次,如果发现建筑悬浮或者陷入地下,多半是高度基准不一致,需要在转换参数里设置高程偏移。

转换输出的目录里应当有tileset.json和若干.b3dm文件。把这个目录丢到静态资源服务器里,就是一个标准的3D Tiles服务了。

4.3 前端加载与渲染的完整代码

下面是我整理的一份可直接参考的完整页面代码。把Cesium库文件和转换后的3D Tiles路径替换成你自己的,基本就能跑起来:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>白膜分层设色 + 点光源</title> <style> html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } </style> <script src="./Cesium/Cesium.js"></script> <link href="./Cesium/Widgets/widgets.css" rel="stylesheet"> </head> <body> <div id="cesiumContainer"></div> <script> // 1. 创建Viewer const viewer = new Cesium.Viewer('cesiumContainer', { baseLayerPicker: false, timeline: false, animation: false, scene3DOnly: true, infoBox: false, selectionIndicator: false, geocoder: false, // 关闭搜索 homeButton: false, // 关闭home按钮 navigationHelpButton: false }); // 2. 打开真实光照与HDR viewer.scene.globe.enableLighting = true; viewer.scene.highDynamicRange = true; // 3. 加载S3M转换后的3D Tiles白膜 const tileset = viewer.scene.primitives.add( new Cesium.Cesium3DTileset({ url: './data/white_model_3dtiles/tileset.json', maximumScreenSpaceError: 12 }) ); tileset.readyPromise.then(function() { // 3.1 按高度做分层设色 tileset.style = new Cesium.Cesium3DTileStyle({ color: { conditions: [ ['${height} < 12', "color('#2563eb')"], ['${height} >= 12 && ${height} < 24', "color('#06b6d4')"], ['${height} >= 24 && ${height} < 50', "color('#22c55e')"], ['${height} >= 50 && ${height} < 80', "color('#eab308')"], ['${height} >= 80', "color('#ef4444')"] ] } }); // 3.2 相机飞到白膜所在位置 viewer.camera.flyTo({ destination: tileset.boundingSphere, orientation: { heading: 0, pitch: Cesium.Math.toRadians(-35), range: 800 }, duration: 2.0 }); }); // 4. 设置点光源 viewer.scene.light = new Cesium.PointLight({ position: Cesium.Cartesian3.fromDegrees(120.15, 30.28, 120), color: Cesium.Color.ORANGE, intensity: 300, radius: 500 // 高版本Cesium可用,低版本记得删除 }); // 5. 压低高光,避免过曝 viewer.scene.specularLightIntensity = 0.5; // 6. 如果不想让默认的白色模型阴影干扰,可以调低环境光 // viewer.scene.environmentMapIntensity = 0.6; </script> </body> </html>

这份代码里的flyTo用了range参数,它会在tileset.boundingSphere外围找一个合适的观察视点。如果你希望固定朝某个方向看,可以改用destination: Cesium.Cartesian3.fromDegrees(经度, 纬度, 相机高度),再配合orientation来控制相机姿态。

4.4 场景定位与交互补充

多块白膜拼接的场景也很常见。如果项目里有多个Cesium3DTileset实例,建议不要各自flyTo,而是先把它们的boundingSphere合并成一个:

const spheres = tilesetArray.map(t => t.boundingSphere); const combined = Cesium.BoundingSphere.fromBoundingSpheres(spheres); viewer.camera.flyTo({ destination: combined });

这样能让整个片区都装进视野。交互方面,我建议保留viewer默认的鼠标拖拽和滚轮缩放,这个对演示来说是最自然的。如果客户想看某一栋楼的高度,还可以在leftClick事件里做pick,把feature的height字段弹出来。

viewer.screenSpaceEventHandler.setInputAction(function(movement) { const pick = viewer.scene.pick(movement.position); if (Cesium.defined(pick) && pick.primitive === tileset) { const feature = pick.feature; if (feature) { const h = feature.getProperty('height'); if (h) { viewer.selectedEntity = undefined; // 弹一个原生气泡或者调用自己的浮窗组件 console.log('当前建筑高度:', h); } } } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);

这样分层设色就不仅是一个好看的效果,还能变成鼠标点选查询的交互功能。

5. 常见问题排查与避坑实录

5.1 白膜加载阶段的问题

现象原因处理办法
加载不出来,控制台报404tileset.json路径错误或服务器未正确配置静态目录检查请求路径,确认tileset.json和其他b3dm在同一目录
加载出来了但位置飘在海里/完全错位S3M转换时坐标系没统一检查源坐标系,转到EPSG:4326重新转换
建筑陷入地下或悬浮高程基准不一致在转换参数里设置高程偏移,或用tileset.modelMatrix做整体修正
加载很卡LOD层级太深或maximumScreenSpaceError太小白膜LOD控制在3-5层,maximumScreenSpaceError设在10-16之间

5.2 分层设色相关的问题

问题:颜色完全没变。先看tileset.properties和feature.getPropertyIds(),确认字段名对不对。如果字段是Height而你在样式里写${height},Cesium匹配不到,默认就会给一个白色。问题:只有部分建筑变色。说明部分feature没有height属性,这部分数据在S3M原始数据里就是缺失的。需要回到数据源头把缺失字段补齐,或对这部分建筑单独给一个默认颜色兜底。

问题:颜色渐变断得太难看。前面说过,分段太粗会有等高线感。把颜色区间增加到10段以上,每一段的色值从同一个色带里等距取值,断带感就会明显减弱。

5.3 点光源相关的问题

问题:换成PointLight之后场景完全没有变化。大概率是Cesium版本低于1.98。查看一下Cesium.VERSION,如果版本不够,要么升级,要么放弃这个方案改用自定义着色器(成本高很多,不推荐)。

问题:点光源只照亮了地球,建筑不亮。检查白膜3D Tiles里是否保留了法线,或者模型是否被设置了不响应光照的材质。如果你的白膜是从S3M快速转换来的,很多体块会丢失法线,这时需要去转换工具里看有没有"保留法线"之类的选项。问题:画面整体曝光过度。先把intensity降到100以内,再关闭highDynamicRange试试。HDR打开时,点光源亮度会被放大,观感上和关闭时完全不一样,这两者要联动调。

5.4 还有几个容易忽略的小坑

第一个小坑:点光源的position要写在场景范围内。如果写在模型群的另一侧,或者离目标太远,光照可能恰好打在建筑群的背面,正面一片漆黑,还找不到原因。我习惯先把光源放在目标建筑群的质心位置,再往光源朝向方向偏移一小段距离。

第二个小坑:分层设色之后,建筑模型默认的高光反射可能过强。白膜虽然没有贴图,但有些转换工具导出的材质默认带较高的镜面反射,点光源打上去会出现大面积亮斑。这时候把viewer.scene.specularLightIntensity调到0.2到0.5之间,观感会舒服很多。

第三个小坑:图例和标注要及时加上。分层设色的效果做得再好,如果没有一个从蓝到红的色带图例说明"什么颜色代表什么高度",演示时客户依然看不懂。我通常会在页面上放一个自定义的色带图例组件,对应几档高度区间,这个细节对项目交付很重要。

最后说点实在的

这套组合方案我在好几个智慧园区和城市风貌项目里反复用过,最大的体会是:分层设色不是把色带填进去就完了,它真正的价值在于让数据读取变得直觉化;点光源也不是随便加一个灯,它能让整个白膜场景从"工程预览模式"切到"演示叙事模式"。如果你后续要把这个方向继续做深,可以考虑给建筑加一个柱状图叠加显示,或者把高度分档做成可交互的图例控件,让用户在界面上切换不同的高度区间,白膜场景的可玩性还会更高。颜色和灯光这种事,多试几组总能找到手感,但前面的数据和选型步骤,一步都不能省。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询