城市白膜项目做多了会有一种感受:建模阶段最怕数据不对,渲染阶段最怕“灰成一片”。上个月给一个新区做楼宇效果展示,客户交付的就是一批S3M格式白色模型,没有贴图,没有场景光源方案,拉进Cesium之后整个视口像一张没上色的白纸。领导反馈很直接:“这跟没做完有什么区别。”项目本身不复杂,难点在三个动作上:把S3M白膜稳定加载进Cesium,用分层设色把高度信息变成渐变颜色,再补一个能产生远近衰减的点光源。三步做完,白膜立刻从“灰饼”变成了有体积感的夜间重点楼宇效果。这篇就把完整流程和调试过程记录一下,给正准备接S3M白膜、或者正被“白膜效果单一”卡住的人做个参考。
整个方案我前后在两个数据源上重新验证过,部分坑位属于特定数据才触发,文章里会单独标注。下面直接进入正文。
1. S3M白膜加载:先解决“模型能不能进场景”的问题
1.1 白膜与S3M格式的基本认知
白膜这个词来自建筑设计审查,指的是只有外轮廓几何、没有真实纹理的建筑简模。它保留了楼层高度、建筑边界、体量关系,但因为缺贴图,常规渲染下显得特别单调。S3M类的瓦片格式为了支撑海量城市级数据,内部做成了多层LOD树结构,加载时先显示低精度块,相机靠近再补高精度,这一点和Cesium原生3D Tiles的调度逻辑一致。
把S3M接进Cesium的麻烦点不在于数据读取,而在于Cesium原生不认识S3M。所以第一步不是在代码里写图层,而是先决定加载路线。路线选错了,后面做分层设色和点光源时会多走很多弯路。
1.2 路线A:通过扩展直接加载S3M图层
我在第一个项目里用的是最省事的方式:引入支持S3M的扩展库,然后按图层方式直接挂进场景。核心代码大概长这样:
const viewer = new Cesium.Viewer('cesiumContainer', { animation: false, timeline: false, baseLayerPicker: false }); // 具体构造类名以你使用的S3M扩展版本为准 const s3mLayer = new S3MTilesLayer({ url: 'https://your-host/s3m/whitemodel', name: '白膜图层', maximumScreenSpaceError: 16 }); viewer.scene.layers && viewer.scene.layers.add(s3mLayer); viewer.flyTo(s3mLayer);这种方式的好处是改动小,图层自带的LOD和分组信息都能保留,加载大范围白膜时调度表现不错。但有个很关键的限制:S3M扩展的图层有自己的材质渲染管线,后面第三种方案里要用的Cesium原生CustomShader,在实际测试中并不保证能作用到这个图层上。
提示:如果你只是把白膜“加载出来看个大概”,路线A完全够用。但如果你后续要做复杂着色、自定义光照,请认真考虑路线B。
1.3 路线B:转成3D Tiles后用原生接口加载
第二个项目我学乖了,拿到S3M之后先用转换脚本把整批数据转成3D Tiles瓦片,再走Cesium标准流程加载。转换这一步不是写一个命令就万事大吉,LOD层级、纹理质量、坐标基准都会影响最终效果。我常用的转换命令大概长这样:
# 伪命令示例,实际参数以你使用的转换工具为准 s3m-convert \ --input ./data/whitemodel.s3m \ --output ./public/tiles/whitemodel \ --lod-level 4 \ --coordinate-system "EPSG:4490" \ --texture-quality 85转换完成后,前端就是一段非常标准的Cesium代码:
const tileset = await Cesium.Cesium3DTileset.fromUrl( '/tiles/whitemodel/tileset.json', { maximumScreenSpaceError: 16 } ); viewer.scene.primitives.add(tileset); await viewer.zoomTo(tileset);两条路线的区分其实很清晰,我整理成了一个简单对照表:
| 对比项 | 路线A:S3M扩展直接加载 | 路线B:转3D Tiles加载 |
|---|---|---|
| 接入速度 | 快,代码量少 | 需要额外转换步骤 |
| Cesium原生样式支持 | 有限,受图层管线限制 | 完整支持Cesium3DTileStyle和CustomShader |
| 后续定制能力 | 相对受限 | 灵活 |
| 适合场景 | 快速验证、数据不可二次处理的场景 | 正式可视化项目、需要自定义渲染效果 |
如果你想做的只是“加载出来看个大概”,路线A够了;如果你要做分层设色、点光源、自定义PBR这类深度渲染,直接选B,省得后面跟材质管线搏斗。
1.4 加载前必须处理的高程与坐标问题
不管走哪条路线,白膜最容易出现的问题都是“漂浮在空中”或“陷进地下”。这通常不是代码写错,而是S3M数据内部的高程基准和Cesium场景地形基准不一致,常见于某些数据生产工具把建筑底面高程设成了0,而不是真实海拔。
我的处理顺序是:先不动数据,用前端偏移试出方向和量级。代码是这样:
// 先试一个向上2.5米的偏移,看模型是否逼近地面 const offsetMatrix = Cesium.Matrix4.fromTranslation( new Cesium.Cartesian3(0, 0, 2.5) ); tileset.modelMatrix = offsetMatrix;正面偏移不行就换成负数;确定方向后把偏移值固化到转换参数里重新出瓦片。不要一上来就去改源数据,前端做偏移验证成本低得多,改一次重新转瓦片动辄十几分钟,排错效率完全不一样。
2. 分层设色渐变:把“高度”翻译成颜色
2.1 分层设色的原理:地图学概念迁移到三维白膜
“分层设色”最早是用在地形图上的,核心思想是按海拔区间填不同颜色,蓝色表示低洼、绿色表示平原、褐色表示山地,让不懂等高线的人也能一眼看出地势高低。这个思路迁移到建筑白膜上完全成立:白膜保留了每个顶点的真实高程,我们把高度做归一化,然后映射到一条色带上。
假设场景里建筑高度范围是hMin到hMax,某个顶点的高度是h,先算归一化系数:
t = (h - hMin) / (hMax - hMin)t从0到1,再根据色带上的若干控制点做插值,就能得到连续渐变的颜色。真正的工程问题不是这个公式本身,而是“按要素染色”还是“按像素染色”的取舍。
2.2 要素级方案:用Cesium3DTileStyle做区间映射
如果你的白膜瓦片里每个建筑feature带有高度属性,最快的方式是直接写样式条件,Cesium3DTileStyle会按条件逐要素渲染:
tileset.style = new Cesium.Cesium3DTileStyle({ color: { conditions: [ ['${height} < 20', 'rgba(70, 130, 230, 0.85)'], ['${height} < 40', 'rgba(50, 160, 180, 0.85)'], ['${height} < 60', 'rgba(90, 180, 120, 0.85)'], ['${height} < 80', 'rgba(220, 200, 80, 0.85)'], ['${height} >= 80', 'rgba(230, 100, 60, 0.85)'] ] } });这个方案我只推荐在数据带height属性时使用。实际碰到的白膜数据很多都是纯几何瓦片,feature属性表里根本没有高度字段,那这段样式怎么写都不会生效。判断方法很简单:先在控制台遍历一下当前瓦片的属性名,确认存在再写。
另一个要接受的是:要素级染色的粒度是一栋楼一栋楼来的,楼内不会有渐变,适合做“高度分区统计”展示,不适合做平滑的地势感渐变。
2.3 像素级方案:CustomShader实现连续渐变
要做真正平滑的分层设色渐变,我的做法是用Cesium的CustomShader在片元着色器里按坐标高度算颜色。它的好处是每个像素都会根据自己所在的高程动态插值,色带完全连续,没有要素边界。
核心着色器代码我拆成这样:
vec3 getHeightColor(float h, float minH, float maxH) { float t = clamp((h - minH) / (maxH - minH), 0.0, 1.0); vec3 lowColor = vec3(0.15, 0.30, 0.80); // 低层:深蓝 vec3 midColor = vec3(0.10, 0.65, 0.55); // 中层:青绿 vec3 highColor = vec3(0.90, 0.40, 0.20); // 高层:砖红 if (t < 0.5) { return mix(lowColor, midColor, t * 2.0); } return mix(midColor, highColor, (t - 0.5) * 2.0); } void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { // 由视图坐标转回世界坐标,取真实高度 vec4 positionWC = czm_inverseView * vec4(fsInput.attributes.positionEC, 1.0); material.diffuse = getHeightColor(positionWC.z, 0.0, 80.0); }这里有一个细节容易踩坑:直接对t做线性映射时,如果数据里存在一根特别高的构筑物(比如楼顶水塔),会把整条色带拉偏,大部分建筑都便宜挤在低层区间。我的习惯是先对高度做一次分位数统计,取5%到95%分位点作为minH/maxH,超出范围直接截断,这样视觉上更均衡。
2.4 色带与透明度的配合
色带选择对最终观感影响非常大。给白膜用的时候,我试过几套组合,总结如下:
| 色带类型 | 低层色 | 中层色 | 高层色 | 适用情况 |
|---|---|---|---|---|
| 冷色主导 | 深蓝 | 青绿 | 黄 | 科技感、夜景展示 |
| 暖色主导 | 黄绿 | 橙 | 暗红 | 日照分析、高度预警 |
| 高对比 | 蓝紫 | 白 | 橙红 | 强调高度反差,但看久了累 |
透明度方面,个人建议保持在0.85以上。低于0.5时,模型后面的建筑会透出来,深度排序错误变得非常明显,大片区域会出现闪烁网格。
3. 点光源落地:让白膜从平面图变成建筑雕塑
3.1 为什么默认平行光达不到效果
Cesium默认场景光照是平行光,用来模拟太阳,方向固定、没有距离衰减。全球地形用起来没有问题,但城市级白膜展示需要的是“焦点感”:某个片区亮、边缘暗,楼体朝向光源的面亮、背光的面积累阴影。
平行光做不到这个效果,因为亮度不会随着离光源的远近而变化。真正需要的是点光源:光源位置在场景里是一个具体坐标,离光源越远亮度越低,楼与楼之间才能拉开层次。
3.2 场景级API的边界:别把宝押在内置光源对象上
写到“设置点光源”,很多人第一反应是去翻Cesium场景光源API,把scene.light替换成点光源对象。实际折腾下来你会发现,这个位置在多数稳定版本里主要支持方向光,点光源类的公开API在不同发行版里都不太一致。我不建议在这个接口上赌版本,否则上生产环境换一个Cesium版本,光照效果马上变得不可控。
如果你想快速验证可以这样写:
viewer.scene.light = new Cesium.PointLight({ color: new Cesium.Color(1.0, 0.95, 0.85, 1.0), intensity: 2.0 });但我的实际结论是:更稳妥、效果也更可控的做法,是直接在自定义Shader里实现一个点光源光照模型。
3.3 自定义Shader点光源:与分层设色合体的完整实现
思路是:基础色仍然用2.3节的分层设色结果,然后在片元着色器里自己计算“光源方向、距离衰减、漫反射、高光”,把光照结果叠加到自己算出的基础色上。这样,分层设色负责“颜色讲高度”,点光源负责“光照讲体积”,两件事互不干扰。
着色器里的核心代码:
uniform vec3 u_lightPosition; // 点光源世界坐标 uniform vec3 u_lightColor; // 光源颜色 uniform float u_lightIntensity; // 光源强度 vec3 getHeightColor(float h, float minH, float maxH) { float t = clamp((h - minH) / (maxH - minH), 0.0, 1.0); vec3 lowColor = vec3(0.15, 0.30, 0.80); vec3 midColor = vec3(0.10, 0.65, 0.55); vec3 highColor = vec3(0.90, 0.40, 0.20); if (t < 0.5) { return mix(lowColor, midColor, t * 2.0); } return mix(midColor, highColor, (t - 0.5) * 2.0); } void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { vec4 positionWC = czm_inverseView * vec4(fsInput.attributes.positionEC, 1.0); float baseHeight = positionWC.z; // 1. 分层设色作为基础色 vec3 baseColor = getHeightColor(baseHeight, 0.0, 80.0); // 2. 点光源方向与距离衰减 vec3 L = u_lightPosition - positionWC.xyz; float dist = length(L); L = normalize(L); // 衰减系数:常数项 + 一次项 + 二次项,控制近亮远暗的速度 float attenuation = 1.0 / (1.0 + 0.09 * dist + 0.032 * dist * dist); // 3. 漫反射与高光 vec3 N = normalize(fsInput.attributes.normalEC); N = normalize(czm_inverseView * vec4(N, 0.0)).xyz; float diff = max(dot(N, L), 0.0); vec3 V = normalize(-fsInput.attributes.positionEC); vec3 H = normalize(L + V); float spec = pow(max(dot(N, H), 0.0), 48.0); // 4. 环境光保持一点底亮,避免背面全黑 vec3 finalColor = baseColor * (0.12 + diff * u_lightIntensity * attenuation); finalColor += spec * u_lightColor * u_lightIntensity * attenuation * 0.5; material.diffuse = finalColor; }对应的前端挂载逻辑:
const customShader = new Cesium.CustomShader({ uniforms: { u_lightPosition: { type: Cesium.UniformType.VEC3, value: lightPosition }, u_lightColor: { type: Cesium.UniformType.VEC3, value: new Cesium.Cartesian3(1.0, 0.95, 0.85) }, u_lightIntensity: { type: Cesium.UniformType.FLOAT, value: 2.0 } }, fragmentShaderText: shaderText }); tileset.customShader = customShader;注意fsInput.attributes.normalEC在一些不带法线的数据里可能拿不到有效值。遇到这种情况,要么在转换阶段给模型补算法线,要么在顶点着色器里根据面片相邻顶点求近似法线。
3.4 光源位置交互:让点光源活起来
点光源真正的价值在于“可以动”。我通常会在场景里加一个很小的球体作为光源标记,然后把光源移动事件跟鼠标联动。每次光源位置变化时,不用重新创建Shader,只需要更新uniform的值:
const lightUniform = customShader.uniforms.u_lightPosition; lightUniform.value = currentLightPosition;光源标记可以是一个非常简单的点实体:
const lightMarker = viewer.entities.add({ position: currentLightPosition, point: { pixelSize: 12, color: Cesium.Color.WHITE, outlineColor: Cesium.Color.DARKGRAY, outlineWidth: 2 } });这样在联调的时候,你能直接看到光源在哪里、光照扫过哪些建筑区域,而不是对着数字调参数。
4. 联调踩坑记录:颜色跳变、过曝、帧率滑坡的排查过程
4.1 再现:色带边缘生硬、出现“条纹状颜色跳变”
第一个项目联调时,建筑楼面出现了非常规则的横向色带,每一层都清清楚楚,看起来像楼梯间,而不是我想要的那种缓慢渐变。
检查过程是这样的:先在着色器里单独输出positionWC.z,发现高度计算起来没问题,问题出在数据本身——那批白膜内部有很多裙楼、女儿墙、机房,高度分布有大量极值。直接线性映射时,大部分建筑主体被压到很窄的t区间里,视觉上就变成了“一层一种颜色”。
处理方式就是2.3节提到的分位数裁剪,把5%到95%分位点作为映射上下限,然后用着色器uniform传两个高度阈值进来。改完之后,渐变明显平滑了。
4.2 遇见:加了点光源之后整面楼体过曝发白
点光源强度一开始我给了6.0,结果不是“照亮”,而是“晒伤”。靠近光源的墙面全部变白,纹理和分层设色的颜色全部丢失。
原因很简单: diffuse 乘了强度再乘衰减,结果远超1,被后端归一化之后削顶,变成一片白色。排查时我用的是笨方法,从1.0开始逐步减半,最后发现u_lightIntensity在2.0附近比较合理。后来又试着把高光的pow指数从32调到48,缩小了高光斑块的面积,过曝感明显下降。
经验是:点光源强度宁小勿大,夜景演示场景里1.5到3.0之间最常用。
4.3 回归:加完自定义Shader,帧率从60掉到20
这是让我最头疼的一个问题。仔细观察后发现,瓶颈居然不完全在Shader上,而是半透明排序:当白膜透明度低于0.7时,Cesium对大量半透明片段的排序开销会指数上升,视角一转动就掉帧。
定位方法是在Cesium调试面板里打开scene.debugShowFramesPerSecond,同时把瓦片调度误差参数从16调到24,把加载精度稍微放宽一些。优化了一下负载,帧率恢复到45以上,白膜这种城市级模型场景,其实用不到特别高的屏幕空间误差精度。
4.4 黑屏:路线A下自定义Shader不生效怎么办
如果你走的是S3M扩展加载路线,自己在前端写了半天CustomShader,结果模型直接黑屏或毫无变化,大概率不是代码写错,而是S3M图层内部有专属材质管线,Cesium的CustomShader根本没参与渲染。
遇到这个情况,我就一个建议:把数据转成3D Tiles,走原生管线。不要浪费时间在一个不开放的管道里去塞自定义着色器,数据转换二十分钟,调试省十小时。
5. 整体效果与可复用参数表
5.1 一个最小可运行的整合示例
把加载、分层设色、点光源三个点串起来,就是一个最小可跑的流程:
// 1. 加载3D Tiles白膜 const tileset = await Cesium.Cesium3DTileset.fromUrl('/tiles/whitemodel/tileset.json'); viewer.scene.primitives.add(tileset); await viewer.zoomTo(tileset); // 2. 分层设色 + 点光源着色器 const lightPosition = Cesium.Cartesian3.fromDegrees(116.391, 39.907, 150.0); const customShader = new Cesium.CustomShader({ uniforms: { u_lightPosition: { type: Cesium.UniformType.VEC3, value: lightPosition }, u_lightColor: { type: Cesium.UniformType.VEC3, value: new Cesium.Cartesian3(1.0, 0.95, 0.85) }, u_lightIntensity: { type: Cesium.UniformType.FLOAT, value: 2.0 } }, fragmentShaderText: shaderText }); tileset.customShader = customShader;这个流程适合在白天动手,环境光值保持在0.1左右,我们只关心渲染逻辑是否正确。
5.2 参数速查表
很多效果问题最后都归结为参数调优。我把这几次项目里沉淀的参数做成一张表,方便下次直接对照:
| 参数 | 推荐范围 | 作用 | 备注 |
|---|---|---|---|
| 分层设色高度下限 | 数据5%分位点 | 映射最低颜色 | 不要用绝对0 |
| 分层设色高度上限 | 数据95%分位点 | 映射最高颜色 | 超出则截断 |
| 白膜透明度 | 0.85 - 1.0 | 保证深度排序 | 低于0.5容易闪烁 |
| 点光源强度 | 1.5 - 3.0 | 控制整体亮度 | 过高会过曝 |
| 点光源距离衰减一次项 | 0.05 - 0.10 | 中距离亮度递减 | 数值越小光越远 |
| 点光源距离衰减二次项 | 0.02 - 0.05 | 远距离快速衰减 | 数值越大光衰减越快 |
| 高光指数 | 32 - 64 | 控制高光集中程度 | 越大光斑越小 |
| maximumScreenSpaceError | 16 - 24 | 控制瓦片精度 | 城市模型不必过高 |
5.3 我的调试顺序和小技巧
顺序很重要。我的习惯是先把分层设色调到视觉上舒服,再加点光源;先把点光源强度和位置定好,再把透明度调低。两个一起改的话,一旦效果不对,你会分不清是颜色映射的问题还是光照的问题,调试效率非常低。
另外分享一个小技巧:调试点光源的时候,光源位置标记可以用一个半透明的大球体而不是小球,这样你能更直观地感受到“光源在这个半径内影响有多大”。等光照调好了,把大球体隐藏只留小球,视觉上干净很多。
这套流程走完,白膜就是从灰白到“按高度上色、按光照塑形”的完整可视化效果。后面如果再遇到别的格式白膜,转换路径和着色思路基本可以复用。