上周帮朋友排一个三维场景的问题,倾斜摄影模型加载得很顺,飞到目标区域一看,整片建筑群平得像一张贴纸:太阳悬在天上,楼体外立面没有明暗过渡,地面也找不到一点影子。他第一反应是"数据做得不好",其实跟他那套倾斜摄影数据一点关系都没有——Cesium 里光照和阴影是两套完全独立的机制,各有一套开关,任何一个没打开,画面都会"看起来很平"。这类问题我在实际项目里遇到过不止五次,几乎每次都卡在同样的几个参数上。
这篇内容就把 Cesium 光照效果和倾斜摄影日照阴影这条链路完整拆一遍:光照从哪来、阴影由谁控制、倾斜摄影为什么默认不出影子、怎么调质量、怎么控性能、出问题按什么顺序排查。适合已经在用 Cesium 做三维可视化、手上有一份 3DTiles 倾斜摄影数据、想把画面从"能看"做到"能交付"的同学,也适合准备相关方向面试、想把光照阴影这块讲清楚的读者。代码按 Web 端 Cesium 的常规写法给,思路对其它三维引擎也通用。
1. 光照和阴影在 Cesium 里是两套独立机制
很多人第一次调这块,会把"开光照"和"开阴影"当成一件事,改一个参数期待画面整体变立体,结果改了半天没反应。我一开始也这么想,后来把渲染管线捋了一遍才明白,这俩走的是不同的路径。
1.1 scene.globe.enableLighting 只管地球表面
viewer.scene.globe.enableLighting这个开关的作用非常明确:让地球表面(地形、影像)根据太阳位置做明暗计算。打开它,地球就有了晨昏线,朝太阳的一面亮,背太阳的一面暗。它默认是false,所以很多人加载完地形觉得"一片死白",就是这个开关没开。
viewer.scene.globe.enableLighting = true;但要注意,它只影响 globe 表面。倾斜摄影是作为Cesium3DTileset挂在scene.primitives上的,它不吃 globe 的这套光照参数。所以你打开enableLighting之后,地形暗下来了,倾斜摄影的楼还是亮的,两者对不上,看起来特别割裂。这就是我朋友遇到的第一层问题。
还有一个容易忽略的点:enableLighting打开之后,地球会沿晨昏线渐暗,远处缩放时还会有一段"光照淡出"的处理,由lightingFadeOutDistance和lightingFadeInDistance控制。默认值很大(千万米级),如果你做的是城市级局部场景,基本碰不到;但如果做全球视图,这两个值会影响远景的地球是不是全黑。
另外scene.globe.dynamicAtmosphereLighting控制大气层是否跟随光照变化,默认true。想让大气也跟太阳走,再补一句viewer.scene.globe.dynamicAtmosphereLightingFromSun = true。
1.2 shadowMap 是全局总闸,ShadowMode 是逐对象的开关
阴影这边是两级控制,必须都打开才有影子。
第一级是全局的viewer.shadowMap.enabled,它是总闸。总闸不开,后面怎么设都没用。总闸的另一种开法是在创建Viewer时传shadows: true,效果一样。
// 写法一:构造时开启 const viewer = new Cesium.Viewer('cesiumContainer', { shadows: true, terrainShadows: Cesium.ShadowMode.RECEIVE_ONLY }); // 写法二:运行时开启 viewer.shadowMap.enabled = true;第二级是每个可渲染对象自己的shadows属性,取值是Cesium.ShadowMode枚举。这里是最关键的坑:Cesium3DTileset.shadows的默认值是ShadowMode.DISABLED。也就是说,哪怕你全局开关打开了,倾斜摄影依然不投影也不接收阴影,因为对象自己被关掉了。
对比一下就很清楚:Globe.shadows的默认值是RECEIVE_ONLY(地形默认接收阴影),Model.shadows默认是DISABLED。所以"地形上有影子、倾斜摄影没影子"这个现象,从默认值就能解释。
1.3 太阳位置由时钟决定,阴影方向跟着太阳走
Cesium 的太阳不是固定的,它由viewer.clock.currentTime决定位置。这意味着你截屏出来的画面,会随当前系统时间变化——白天打开是一个样,晚上打开地球那半边就是黑的。
// 锁定到一个固定时刻,方便出图和对比效果 viewer.clock.currentTime = Cesium.JulianDate.fromIso8601('2024-06-21T04:00:00Z'); viewer.clock.shouldAnimate = false;阴影相机的方向来自scene.light。默认情况下 Cesium 会每帧把太阳方向同步给这个光,所以阴影方向跟太阳一致。一旦你手动给scene.light赋了一个自己的DirectionalLight,它就跟你走,不再自动同步太阳了——这个特性正好可以用来做"假太阳",后面第 4 节会展开。
把这三层关系理清楚,后面所有问题都有了定位坐标:画面平,先看enableLighting;有光没影,先看shadowMap.enabled;地形有影、模型没影,直接看tileset.shadows。
2. 倾斜摄影没影子,九成是这三处没配对
把光照和阴影拆开之后,倾斜摄影不出影子的原因就收敛到很小的范围了。我按实际排查的频率排个序,基本上前两条能解决绝大多数情况。
2.1 tileset.shadows 默认 DISABLED,必须显式赋值
这是最高频的原因,没有之一。加载倾斜摄影的标准写法很多人只写 URL 和maximumScreenSpaceError,剩下全用默认值,阴影自然是不存在的。
const tileset = viewer.scene.primitives.add( await Cesium.Cesium3DTileset.fromUrl('/data/oblique/tileset.json') ); // 关键:显式打开阴影 tileset.shadows = Cesium.ShadowMode.ENABLED;这段代码有个细节要注意:fromUrl是异步的,await回来之后 tileset 才加入场景。shadows属性在 tileset 加入primitives之后随时可以改,不需要等到readyPromiseresolve,但为了逻辑清晰,我一般还是等加载完成后再统一配参数。
还有一点,如果你的倾斜摄影是分区块加载的(比如一个城市切成几十个 tileset.json),每一个 tileset 都要单独设置shadows。这个坑我踩过一次,某项目只给主城区那个 tileset 开了阴影,边缘几个区块全是平的,排查了半天才发现是漏了几个对象。
2.2 ShadowMode 四种取值,别一律用 ENABLED
Cesium.ShadowMode一共四个值,用错了不是没效果,而是效果不符预期或者白白浪费性能。
| 取值 | 含义 | 典型用法 |
|---|---|---|
DISABLED | 不投影也不接收 | 远处小物件、装饰性模型,默认值 |
ENABLED | 既投影又接收 | 主体建筑、需要互相遮挡的模型群 |
CAST_ONLY | 只投影,不接收 | 投到地面/其它模型上的物体,比如高架桥、塔吊 |
RECEIVE_ONLY | 只接收,不投影 | 地面、道路这类"被投影"的承载面 |
实际项目里最省性能的组合是:主体倾斜摄影用ENABLED,地形用RECEIVE_ONLY(默认就是),次要模型用DISABLED。我曾经图省事把所有 tileset 全设成ENABLED,结果帧率掉了将近一半,因为每个对象都要参与两遍渲染——一遍生成阴影贴图,一遍做接收计算。
还有一个反直觉的点:如果一个模型只需要接收阴影、完全不需要投影(比如背景里的一片远景建筑),设成RECEIVE_ONLY比ENABLED省资源,因为RECEIVE_ONLY的对象不会被渲染进阴影贴图。
2.3 模型自身的问题:法线、单面片、透明贴图
如果tileset.shadows确实设成了ENABLED,但阴影还是"长得不对",那问题往往在模型本身。倾斜摄影是实景建模的产物,有几类几何特征会直接影响阴影效果。
第一类是法线异常。倾斜摄影数据在生产过程中会做网格简化和重拓扑,偶尔出现法线翻转的面片。这类面片在光照计算里会显得特别黑或者特别亮,阴影投射时也可能出现撕裂。判断方法是把tileset.shadows临时设成DISABLED,如果关掉阴影后画面依然有明显异常暗块,那基本就是法线问题,需要在建模端修。
第二类是单面片几何。实景建模里常见的围墙、广告牌、幕布这类薄片结构,因为只有一个面、没有厚度,投影时容易出现阴影缺失或者只投出一半。这种情况可以尝试关掉背面剔除:
tileset.backFaceCulling = false;代价是渲染量会增加,只在确实需要的时候开。
第三类是透明贴图。用 alpha 通道做出来的树丛、栏杆,投影时会变成一整块实心方形阴影,因为阴影贴图默认不参与 alpha 测试。这个目前没有特别优雅的通用解法,我的处理方式是把这类小物件单独拆成一个 tileset,设成RECEIVE_ONLY或者直接DISABLED,牺牲它们的投影换取整体观感干净。
3. 阴影质量的调法:从锯齿、漏光到阴影错位
阴影能出来之后,下一步就是"好不好看"。这一步全是参数活,调对了画面立刻上一个档次,调错了就是一堆莫名其妙的瑕疵。
3.1 size 和 maximumDistance 决定清晰度与开销
阴影贴图本质上是一张深度图,分辨率越高,边缘越锐利。
viewer.shadowMap.size = 2048; // 默认 2048,可调 1024 / 4096 viewer.shadowMap.maximumDistance = 5000; // 默认 5000 米,超过这个距离不计算阴影size从 2048 提到 4096,阴影边缘会明显干净,但显存和绘制开销是平方级增长,4096 贴图在移动端基本别想。我一般在桌面端固定 2048,只有在做单体的重点展示(比如一个小区、一栋楼)时才上 4096。
maximumDistance更值得关注。默认 5000 米意味着离摄像机超过 5 公里的对象不参与阴影计算。做城市级大场景时,这个默认值其实挺合理;但如果你的场景只有几百米范围,把maximumDistance调到 1000~2000,会有两个好处:一是阴影贴图的精度全部分配给近处,边缘更锐;二是远处的对象直接跳过阴影计算,省性能。
这里有个经验值:maximumDistance设成场景主体范围的两倍左右比较合适。太小了,边缘建筑会突然"脱影";太大了,贴图精度被摊薄,阴影边缘全是锯齿。
3.2 normalOffset 和阴影漏光、阴影痤疮
阴影贴图有个经典问题叫shadow acne(阴影痤疮),表现是平面上出现一条条明暗相间的条纹,尤其在斜射光下特别明显。成因是深度比较时浮点精度不够,面片自己跟自己比出了深度差。
Cesium 给的解法是normalOffset,默认就是true:
viewer.shadowMap.normalOffset = true; // 默认开启,沿法线方向偏移采样点如果你把倾斜摄影的法线问题修完了还是有条纹,可以检查一下这个值是不是被谁改成了false。反过来,如果偏移量在特定几何上过大,会出现另一个问题:物体和它的阴影"脱开"一点缝,看起来像悬浮。这个缝隙叫 peter-panning,通常出现在薄物体上。Cesium 暴露出来的可调项有限,遇到这种情况我的做法是调整maximumDistance让阴影精度集中在近处,缝隙会明显减小。
3.3 softShadows 和 darkness 是观感开关
这两个参数不改变阴影的"正确性",只改变观感,但对成图效果影响很大。
viewer.shadowMap.softShadows = true; // 阴影边缘软化 viewer.shadowMap.darkness = 0.3; // 阴影浓度,0 全黑,1 无阴影softShadows打开后,阴影边缘从硬边变成渐变,接近真实日照下那种柔和过渡。代价是有一定性能开销,而且边缘会略微"糊"。做建筑漫游、日照分析这类需要真实感的场景,建议开;做需要精准边界的分析类场景(比如阴影范围测算),反而应该关掉,保持硬边便于判读。
darkness的默认值是 0.3,表现是比较浅的灰影。做正午强光效果时可以调到 0.5~0.6,阴影更实;做清晨薄雾效果时可以降到 0.15 左右。我有个习惯:在时间轴上做动态日照时,会按太阳高度角联动调整darkness——太阳越低,阴影越淡。这属于"物理上不完全正确但观感很好"的小技巧,实际项目里挺吃香。
4. 动态日照:让太阳走起来,阴影跟着走
静态日照截图只是起点,很多项目真正要的是"太阳随时间移动、阴影实时跟着变"。这块 Cesium 支持得很好,但有几个隐藏的坑。
4.1 时钟的三个参数决定一切
太阳位置由时钟驱动,涉及三个参数:当前时间、时间流速、是否播放。
viewer.clock.currentTime = Cesium.JulianDate.fromIso8601('2024-06-21T02:00:00Z'); viewer.clock.multiplier = 3600; // 1 秒真实时间 = 1 小时场景时间 viewer.clock.shouldAnimate = true;multiplier建议设成 1800 到 3600,也就是半秒到一秒走一小时。设得太大(比如 86400),太阳是转得快了,但阴影贴图每帧都在重算,帧率会掉得很明显。我实测过一个城市级倾斜摄影场景,multiplier从 3600 提到 21600,帧率从 45 左右掉到 25 上下。
时间范围建议落在白天,也就是当地时间的 6 点到 18 点之间。用一个ClockRange.LOOP_START让它在设定的区间里循环,比让它一直往前跑更好控:
viewer.clock.startTime = Cesium.JulianDate.fromIso8601('2024-06-21T00:00:00Z'); viewer.clock.stopTime = Cesium.JulianDate.fromIso8601('2024-06-21T12:00:00Z'); viewer.clock.clockRange = Cesium.ClockRange.LOOP_START;4.2 用自定义 DirectionalLight 做"假太阳"
有一个需求很常见:我不想按真实太阳轨迹走,就想让阴影固定朝某个方向打,或者做一种艺术化的打光。这时候直接替换scene.light:
viewer.scene.light = new Cesium.DirectionalLight({ direction: new Cesium.Cartesian3(-0.5, -0.5, -0.7) });这个操作会让阴影方向完全由你控制,跟时间轴脱钩。做建筑日照分析演示的时候特别有用——你想展示同一个建筑在四个朝向上的阴影变化,直接替换 direction 就行,不用去算具体几点钟太阳在哪。
注意:一旦替换了
scene.light,Cesium 就不会再自动把太阳方向同步给它,所以如果你后续又想让太阳接管,需要手动重新赋值或者重新构造。这不是 bug,是设计如此。
还有个常见的"相机头灯"写法,让光始终跟着相机方向,保证任何角度看过去都有明暗层次:
viewer.scene.light = new Cesium.DirectionalLight({ direction: viewer.scene.camera.directionWC });代价是阴影方向会跟着你转视角乱跑,只在纯展示场景用,不要用在日照分析里。
4.3 光照淡出与夜间过渡
做全球或者大范围场景时,会碰到一个问题:摄像机拉远之后,远处的地球开始出现明暗分界,但倾斜摄影那一块区域还亮着,视觉上很跳。这是lightingFadeOutDistance系列参数的地盘:
viewer.scene.globe.lightingFadeOutDistance = 10000000; viewer.scene.globe.lightingFadeInDistance = 20000000;城市级场景(几十公里范围)基本不用动这两个值,动了反而可能出现奇怪的"半亮"过渡带。我的建议是:场景范围小于 100 公里,这两个参数保持默认,别碰。
另外,如果时间轴走到了夜间,整个场景会变暗,倾斜摄影上的自发光材质(如果有)会显得特别突兀。这时候可以配合scene.highDynamicRange做一下色调映射,或者干脆在时钟上限制范围,不让它走到夜间隔壁去。
5. 大范围倾斜摄影的性能账:我实测的几个档位
阴影是好东西,但它真的费性能。倾斜摄影动辄几百万面片,再加上阴影贴图那两遍渲染,帧率掉下去是必然的。这一节给一些量化参考,都是在同一台开发机、同一份城市级倾斜摄影数据上测的,只能当量级参考,具体还得看你的数据。
5.1 阴影参数与帧率对照
测试条件:倾斜摄影约 8GB 数据量,视点高度 300 米,视野内可见约 200 栋建筑。
| 配置 | 关键参数 | 平均帧率 | 观感评价 |
|---|---|---|---|
| 不开阴影 | shadowMap.enabled = false | 60 | 画面很平,无立体感 |
| 基础阴影 | size=1024, maximumDistance=2000 | 52 | 有影,边缘有锯齿 |
| 推荐配置 | size=2048, maximumDistance=2000, softShadows=true | 42 | 边缘柔和,观感好 |
| 高质量 | size=4096, maximumDistance=5000, softShadows=true | 24 | 边缘锐利,明显卡顿 |
结论很清楚:size是性能杀手,从 2048 到 4096 帧率几乎腰斩。softShadows的代价大概在 10% 左右,性价比很高,建议一直开着。
maximumDistance从 5000 降到 2000,帧率回升明显,而且因为精度集中,阴影边缘反而更好看。这个参数是我最推荐优先调的一个。
5.2 分级阴影:近处精细,远处关闭
只靠单个参数调,总会在"近处不够好"和"远处太浪费"之间妥协。真正有效的做法是按对象分档配置。
具体思路是:把倾斜摄影按区域拆成多个 tileset,近景区域用ENABLED+ 高分辨率,远景区域用RECEIVE_ONLY。
// 近景区块:完整阴影 nearTileset.shadows = Cesium.ShadowMode.ENABLED; // 远景区块:只接收,不投影 farTileset.shadows = Cesium.ShadowMode.RECEIVE_ONLY;这么做还有个额外好处:远景区块不参与阴影贴图渲染,阴影贴图里的有效像素全给了近处,等于变相提升了分辨率。
如果你的数据不支持按区域拆分,还有个替代方案——监听相机高度,动态切换参数:
viewer.scene.preRender.addEventListener(() => { const h = viewer.camera.positionCartographic.height; if (h > 3000) { viewer.shadowMap.maximumDistance = 1000; } else if (h > 1000) { viewer.shadowMap.maximumDistance = 3000; } else { viewer.shadowMap.maximumDistance = 5000; } });注意:
preRender里不要做重计算,赋值这种轻操作没问题,但如果里面写了几何运算或者遍历,会直接拖垮帧率。我见过有人在preRender里遍历所有 tileset 改样式,帧率掉到个位数。
5.3 其它几个容易被忽略的省性能开关
除了阴影参数本身,还有几个开关会间接影响阴影场景的帧率。
tileset.maximumScreenSpaceError控制瓦片加载精度。开着阴影的时候,我一般会提到 20 左右,比默认的 16 稍微降一点精度,换来的是更少的瓦片参与阴影渲染。画面损失很小,帧率收益挺明显。
tileset.skipLevelOfDetail打开后会跳过低精度层级直接加载高清瓦片,在有阴影的场景里可能造成"阴影突然出现"的跳变,建议关掉。
还有scene.globe.enableLighting打开后,地形明暗计算也是开销。如果你的场景是纯局部、地形占比很小,其实可以考虑关掉它、只用阴影,画面观感差异不大。这个属于按需取舍,不是通用建议。
6. 阴影不出来时,按这个顺序往下查
前面讲的是"怎么配",但实际项目里更多时候是"配了没效果"。我把这套排查顺序固化成了一份清单,基本上从上往下一路看下来,问题都能定位到。
6.1 六步排查链路
第一步,看时钟。把viewer.clock.currentTime打出来,确认落在白天。如果当前系统时间是晚上,太阳在地球背面,整个场景就是黑的,怎么看都不像有阴影。这一步最容易排查也最容易被忽略,我至少遇到三次是"其实只是天黑了"。
第二步,看全局开关。viewer.shadowMap.enabled是不是true,viewer.shadowMap.isSupported是不是true。后者在某些设备上会是false,说明当前 WebGL 环境不支持深度纹理,阴影功能直接不可用。这种情况要检查浏览器版本和显卡驱动。
第三步,看光照开关。如果画面是"一片死白",先开viewer.scene.globe.enableLighting = true。没有光照计算的时候,即使有阴影贴图,观感上也很难看出立体感。
第四步,看对象开关。tileset.shadows是不是ENABLED。这里有个检查技巧:把值改成RECEIVE_ONLY,如果画面出现变化(比如模型不再投影但开始接收),说明属性生效了,问题在别处;如果完全没变化,说明 tileset 可能压根没加载成功。
第五步,看距离。视点高度如果超过shadowMap.maximumDistance,阴影会被整体裁掉。把摄像机拉近到 1 公里以内再看一眼,如果阴影出现了,那就是距离参数的问题。
第六步,看模型。前面几步都对还是没影,那就是模型几何本身的问题——法线异常、单面片、或者数据本身就没有法线信息。这时候需要拿一个简单的Model或者Box放在同一位置做对照,如果方块有影、倾斜摄影没影,可以确定是数据侧的问题。
6.2 常见现象与原因对照
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 地形有影,倾斜摄影没有 | tileset.shadows 为默认 DISABLED | 设为 ENABLED |
| 完全没有影,地形也平 | shadowMap.enabled 未开 | 构造时传 shadows: true |
| 画面一片死白,没有明暗 | globe.enableLighting 未开 | 设为 true |
| 阴影边缘全是锯齿 | size 太小或 maximumDistance 太大 | size 提至 2048,距离收窄 |
| 平面出现明暗条纹 | normalOffset 被关或法线异常 | 开 normalOffset,检查数据 |
| 晚上打开没有影 | 时钟落在夜间 | 锁定 currentTime 到白天 |
| 部分区块没影 | 该区块 tileset 漏设 shadows | 遍历所有 tileset 统一设置 |
| 树丛的影是实心方块 | alpha 贴图不参与阴影测试 | 该类对象设 RECEIVE_ONLY |
这张表我基本是贴在项目文档里的,接手新项目先对一遍,能省掉大量猜测时间。顺带说一句,这几点也是这个方向面试里经常被问到的内容——面试官问"倾斜摄影加载后没有阴影是怎么回事",答出"tileset.shadows 默认 DISABLED、需要配合 shadowMap 全局总闸、并且地形和模型分属两套开关",基本就答到点子上了。
提示:调试阶段建议把
viewer.shadowMap.size临时调到 1024,改参数后看变化更快,确定方向之后再往上调。分辨率高的时候每次改参数都要等渲染稳定,反而影响排查效率。
最后分享两个我自己在项目里固定下来的习惯。第一个是把光照阴影配置封装成一个函数,接收 viewer 和参数对象,内部统一处理时钟、全局开关、光照开关和所有 tileset 的 shadows 属性。这样迁移到新项目只要调一次,不会出现"某个区块漏设"这种事。第二个是准备两组预设参数,一组是"编辑态"(关阴影、低精度、帧率优先),一组是"出图态"(开阴影、高分辨率、softShadows、固定时间),做方案讨论的时候切编辑态,汇报截图的时候切出图态,效率差挺多的。
至于后续的扩展,这套光照阴影的框架还能往日照分析上走——按小时粒度采样太阳位置,算出某个立面的累计受照时长,做成热力色带叠在建筑上。这个我做过一个小版本,核心就是替换scene.light然后循环采样,配合倾斜摄影单体化之后按构件统计,思路和这篇文章讲的东西是一脉相承的。