ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SuperMap iClient3D动态日照阴影实现:从时钟驱动到性能调优

SuperMap iClient3D动态日照阴影实现:从时钟驱动到性能调优

1. 动态日照阴影到底在解决什么问题——先从项目现场说起

前阵子帮朋友接了个城市设计辅助决策的小项目,需求一句话就能讲清楚:规划地块上拟建几栋高层住宅,甲方想看不同楼栋在冬至日、大暑日分别能挡住多少阳光,而且要“看得见”光是怎么随时间移动的,不是拿一张截图糊弄过去。于是就想到了用 SuperMap iClient3D for WebGL 来做动态日照阴影效果。说实话,这个需求在三维GIS里非常典型,但真正动手做的时候,发现网上能查到的资料大多停留在“开启阴影”“查看ShadowMode”这种浅层用法,很少有人把太阳轨迹、时钟驱动、性能开销和踩坑一起讲透。

我先把结论撂在这:SuperMap iClient3D for WebGL 完全有能力做出效果很好的实时动态日照阴影,核心思路是利用它继承自 Cesium 的时钟(Clock)系统,把高精度太阳位置计算和阴影贴图渲染联动起来,再用clock.onTick事件逐帧或逐秒推进时间,让阴影自然流动。整个过程不涉及任何自定义着色器,关键是对几个 API 的理解要到位,以及知道数据、场景和性能之间的取舍。

这篇文章适合谁看?正在做三维城市、数字孪生、规划辅助决策的开发者;被领导要求“把影子动起来”但不知道从哪下手的人;以及已经被阴影性能问题折磨到怀疑人生的兄弟。我会把从原理、代码到调优、排障的完整链路写出来,你可以直接拿去当开发参考。

2. 底层原理:太阳位置、时钟和阴影贴图是怎么串起来的

2.1 太阳不是美术做出来的,是算出来的

很多人在做三维场景时习惯性想把“阳光”当成一个可拖动的灯光对象来处理,这其实是个认知误区。SuperMap iClient3D for WebGL 里的太阳,本质上是一个由天文算法驱动的方向光源,它的位置完全由当前时刻和观察地点的经纬度决定,不允许你手动拖一个点光源放在楼顶。这个设计从物理上讲非常合理——日照阴影分析要的就是严格符合真实太阳轨迹的效果,而不是“看起来差不多”。

框架内部通过太阳赤纬、时角等参数计算太阳在特定经纬度、特定时刻的高度角和方位角,然后生成平行光方向。这里的平行光方向就是阴影渲染的核心输入。我们不需要知道框架用的到底是哪一套天文算法,只需要明白一件事:只要改变场景时间,太阳方向就会变,阴影方向就会跟着变。这也是“动态”能够实现的物理基础。

2.2 三个开关:光照、阴影和地形阴影的关系

在 SuperMap iClient3D for WebGL 里,要把阴影真实地渲染出来,至少涉及三个开关,很多人只开一个然后就发现问题了。

第一个是viewer.scene.globe.enableLighting,这个开关控制地球表面的光照响应。打开后,地形和模型会根据光线方向产生明暗变化,注意,这里只有明暗,不产生投影。第二个是viewer.shadows,这是整个场景的阴影总开关。第三个是viewer.terrainShadows,它控制地形本身是否参与阴影投射和接收。三个开关的关系可以这样理解:enableLighting决定“有没有光”,shadows决定“谁产生影子”,terrainShadows决定“地形要不要被影子覆盖”。

我见过不少项目只开了viewer.shadows = true,然后发现模型是亮的,但地面一片空白,没有影子,原因就是terrainShadows还处于禁用状态。也有项目反过来,打开了enableLighting但没打开shadows,发现场景只是变暗了,根本没有阴影轮廓。所以动手之前,先把这三个开关的状态想清楚,后面排查问题能省一大半时间。

2.3 ShadowMap:阴影贴图的工作原理

动态阴影效果在 WebGL 里几乎都是通过 Shadow Map(阴影贴图)实现的。它的逻辑不复杂,但你可以把它想象成“从太阳的视角拍一张深度照片”:框架先把场景整体渲染到一张深度纹理上,记录离太阳最近的那些表面的距离,然后再从人眼视角渲染场景时,逐像素比较自己到光源的距离和这张深度照片上的记录,如果自己更远,说明被遮挡了,就画成阴影。

这套机制里最关键的两个可调参数就是阴影贴图的分辨率和采样过滤方式。分辨率越高,阴影边缘越锐利,但开销越大;过滤方式决定了阴影边缘是硬边还是柔边。SuperMap iClient3D for WebGL 对这部分做了封装,我们不需要去手写 shader,但要理解这些概念,因为后面所有性能调优都建立在这几个参数上。

2.4 Clock 才是“动态”二字的灵魂

Cesium 体系里有一个贯穿始终的概念——Clock。你可以把它理解成一个虚拟时钟,它有自己的当前时间、开始时间、结束时间和运行倍速。场景里的太阳位置、星星位置、地球自转全部由这个虚拟时钟驱动。viewer.clock就是那个时钟实例,viewer.clockViewModel则是对外暴露可绑定的状态。

动态日照阴影的实现,思路就是让这个虚拟时钟持续走动,每走一步,太阳方向更新一次,阴影贴图重新渲染一帧,人眼看到的就是阴影在缓慢移动。如果想让时间走得快,就调大multiplier;想让阴影动得连续,要注意不要把这个倍速调得太过,否则每帧之间阴影跨度太大,视觉上就会出现跳变。

3. 动手实现:一个可用的动态日照阴影代码骨架

3.1 环境和数据准备

我平时用的是 SuperMap iClient3D for WebGL 的常规包版本,数据是常见的三维切片(S3M)格式加上倾斜摄影模型。做日照阴影测试时,建议先用一个小范围场景,楼体模型放在平坦地形上,标高和地理位置要准确,因为太阳轨迹计算依赖经纬度,坐标错了影子方向和长度全不对。

有一个数据问题值得特别提醒:如果你的建筑模型是直接放在地表上方贴地的,一定要确认模型底部高程和地形匹配,否则阴影会悬空或者陷进地里。我习惯在服务端把模型的高程信息处理好,保证模型整体贴地但不穿透,这比在前端硬调位置要可靠得多。

3.2 三行代码开启基础阴影

先把最核心的三行代码给出来。这是整个效果的基础,后面的时间驱动都是建立在这三行之上的。

viewer.scene.globe.enableLighting = true; viewer.shadows = true; viewer.terrainShadows = Cesium.ShadowMode.ENABLED;

写完这三行之后,你在场景里应该能看到一个静态的阴影效果,影子方向和当前viewer.clock.currentTime对应的太阳位置吻合。这里我建议你先用Cesium.JulianDate.fromDate(new Date("2024-12-22T10:00:00+08:00"))把当前时间设置成一个具体的冬至上午十点,方便验证影子方向。

有两点要说明一下。一是ShadowMode.ENABLED只影响地形,如果你希望在特定模型上也启用类似机制,需要保证模型所在图层支持阴影计算。二是如果你用的是旧版本 SDK,有些 API 名可能不完全一致,最好到当前版本的接口文档里确认一下viewer.terrainShadows是否存在,如果没有就退而求其次只开viewer.shadows。

3.3 让时间流动起来:Clock 的配置方法

静态阴影看得见之后,接下来就是“动”起来。这一步的核心是配置时钟区间和步进。

const start = Cesium.JulianDate.fromDate(new Date("2024-12-22T06:00:00+08:00")); const stop = Cesium.JulianDate.fromDate(new Date("2024-12-22T18:00:00+08:00")); viewer.clock.startTime = start; viewer.clock.stopTime = stop; viewer.clock.currentTime = start; viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP; viewer.clock.multiplier = 60; // 每秒走一分钟,12小时动画约12分钟走完 viewer.clock.shouldAnimate = true;

这里multiplier是 60,代表现实时间每秒对应虚拟时间一分钟。如果整个动画要走完冬至日的 12 小时,就是 12 个现实分钟,体感比较舒适。如果你在做演示、汇报,建议这个值调到 120 到 300 之间,视觉效果更明显,也更赶时间。

clockRange设为LOOP_STOP表示走到stopTime后从头再来,这样演示的时候可以循环播放。如果你希望时间走到某个点停下来,可以改用CLAMPED。

3.4 用 onTick 驱动阴影平滑推进

时间在走,但如果什么都不做,场景并不会自动重新计算太阳方向。还要一个 Hook 把时钟的每个 Tick 和下一次渲染连起来。常规做法是监听clock.onTick,然后在回调里手动刷新一下光源相关状态。

viewer.clock.onTick.addEventListener(function (clock) { // 确保太阳方向随当前时间变化而更新 viewer.scene.globe.enableLighting = true; // 如果你有自定义光源,可以在这里更新光源方向 if (viewer.scene.light && viewer.scene.light instanceof Cesium.SunLight) { // 框架内的太阳光会自动读取当前时间,通常不需要手动设置 } // 针对某些版本,可以强制更新太阳方向 viewer.scene.sun.show = true; });

这里想分享一个经验:如果你发现时间在走但光影不动,先检查viewer.clock.shouldAnimate是不是 true;如果已经是 true 但依然不动,尝试不依赖回调而是通过设置viewer.timeline的当前时间来做驱动。onTick触发条件是时钟在动画状态,如果时钟被暂停,回调就不会触发,这时候需要检查是不是有别的代码把时钟停了。

3.5 一个完整的日落过程示例

为了让你更有体感,我给出一个能直接跑到“太阳从东到西、影子从长到短再到长”的简化代码。假设你已经加载了场景和建筑模型:

// 初始化场景基本阴影 viewer.scene.globe.enableLighting = true; viewer.shadows = true; viewer.terrainShadows = Cesium.ShadowMode.ENABLED; // 配置时钟模拟一天 const start = Cesium.JulianDate.fromDate(new Date("2024-06-21T05:30:00+08:00")); const stop = Cesium.JulianDate.fromDate(new Date("2024-06-21T19:30:00+08:00")); viewer.clock.startTime = start; viewer.clock.stopTime = stop; viewer.clock.currentTime = start; viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP; viewer.clock.multiplier = 120; viewer.clock.shouldAnimate = true; // UI联动:点击按钮加速/暂停 function togglePlay() { viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate; }

跑起来后,你会看到建筑的影子随着太阳方位角变化,从清晨朝向西北缓缓转到傍晚朝向东北,长度也从长变短再变长。这个过程如果方向反了,说明地理坐标或时间没配对,比如用了东八区的时间但数据位置却在其他时区,太阳轨迹就会出现明显偏差。

4. 性能实测与调优:阴影不是白来的,是要花钱买的

4.1 阴影贴图的开销到底有多大

阴影效果的代价是真实的,不是免费的。我在一个中等规模场景上做过对比,建筑模型几十万面,地形范围大概几平方公里,开启阴影之后帧率从 50 掉到了 35 左右,掉幅约三成。如果场景更大、分辨率更高,掉得更狠。

阴影耗性能的根本原因在于 Shadow Map 需要从光源视角完整渲染一遍场景。也就是说,原本每帧只需要渲染一遍画面,开了阴影之后至少渲染两遍:一遍生成阴影贴图,一遍生成最终画面。场景越复杂,这第二遍的代价越高。

如果项目运行在配置不高的机器上,我建议不要盲目追求“全场所有建筑都有精细阴影”。可以选一个动态范围限制方案,比如让镜头焦点区域的模型参与阴影计算,远处模型不参与或用较低精度。当前版本里这个控制不一定在框架 API 上直接露出来,所以实践中更多是靠管理可见性来实现。

4.2 级联阴影 vs 单层阴影:怎么选

Cesium 体系中的阴影映射默认有一种级联阴影(CSM)的实现思路,把视锥体按深度切分成多层,分别计算不同范围的阴影贴图。这样做的好处是近距离阴影清晰、远距离阴影逐步模糊,视觉体验接近游戏引擎的效果。

但级联阴影也有代价:多层贴图意味着更多的渲染Pass和更多的显存占用。如果你的项目主要看城市大范围鸟瞰视角,单层阴影 + 较大贴图范围就够用;如果经常推近看楼间距、看庭院阴影,那就需要级联阴影来保证近处阴影不糊。

我个人的建议是:大场景演示用默认配置,单层阴影即可,把资源省给地形和模型加载;如果是做日照分析专题图,近景查看比例高,可以显式开启更细的阴影模式,并在需求层面明确“是否需要每帧实时更新阴影”,至少可以降刷新频率。

4.3 实测的性能对照表

以下是我在自己机器上(台式机、中等独立显卡)跑同一组数据时的实测对比,配置仅供参考,让你心里有个谱:

配置项帧率表现阴影观感适用场景
不开阴影50 fps无投影加载预览、快速浏览
开启基础阴影(默认贴图)38 fps影子清晰,边缘略锐动态演示、方案比选
开启阴影 + 调低贴图分辨率43 fps影子边缘轻微锯齿大范围地形场景
开启阴影 + 全屏抗锯齿30 fps影子柔和,更真实近景日照分析截图

根据我的经验,如果帧率低于 25 且操作明显卡顿,优先检查是不是阴影贴图开了 4096 甚至 8192 分辨率。这种情况下降到 2048,画面观感几乎不受太大影响,但性能能回血 10% 到 15%。很多人一上来就追求最高画质,结果实际项目里根本跑不动,反而把阴影功能砍掉了——这是很可惜的。

4.4 移动端和轻量部署的取舍

如果你的项目最终要跑在平板或者普通笔记本上,那要提前做好性能预算。移动端 GPU 的填充率有限,阴影贴图的每个像素都在花钱,所以我见过很多移动端项目干脆只开光照不开阴影,或者只在镜头停留在某个角度时才临时打开阴影。

一个非常实用的小技巧是:根据镜头高度动态开关阴影。鸟瞰大范围时关闭阴影省性能,推到近景需要观察楼间距时再打开。这虽然不完全算“动态日照”,但配合时钟驱动后,在演示场景里已经能应付绝大多数规划沟通需求。我在实际项目里用这个策略,从全局 40 帧掉到近景 30 帧,属于可以接受的范围。

5. 最容易翻车的几个坑和完整排查链路

5.1 坑一:时间在走,太阳和阴影却纹丝不动

这是大家最常遇到的问题。表现是代码全部写完,时钟也在跑,连currentTime都变了,但是场景里的光影像钉子一样定住。当时我排查这个坑时走了不少弯路,最后发现原因其实很朴素:shouldAnimate虽为 true,但有个初始化操作把clock.clockRange设置成了CLAMPED且currentTime已经越过stopTime,时钟直接到了结束点,不再前进。

完整的排查顺序建议是:先打印viewer.clock.currentTime在onTick里有没有变化;再确认当前时间在 start 和 stop 之间;再确认viewer.clock.shouldAnimate === true;最后检查是否有第三方代码动了currentTime。这四步走完,基本能定位到问题位置。

还有一类场景也要注意,就是浏览器标签页被切到后台后,浏览器本身会限制定时器频率,导致onTick长时间不触发。这种情况不是代码问题,是浏览器机制,重回前台时会自动恢复,不用过度纠结。

5.2 坑二:阴影和地形交界处出现“闪缝”或“断层”

地面上一栋楼,楼底和地形接触的位置,有时候会出现一道肉眼可见的亮缝,就好像楼是飘在地面上一样。这个问题的根因我在前面提到过——模型高程没有和地形完美贴合,或者是阴影贴图精度不够,导致接触面的深度测试出现误差。

解决思路分两步。第一步是数据处理,在原始模型制作时确保建筑底部的底面和地表高程精确匹配,或者至少让底部微低于地表几厘米,避免悬空。第二步是调整阴影贴图的深度偏移(Depth Bias),适当的偏移可以让阴影边缘的深度比较不会因为精度问题出现细碎闪烁。具体参数在当前版本的 API 里以内部设置形式存在,没有暴露一个公开的属性,所以主要还是靠数据侧解决。

5.3 坑三:模型整体发黑,像烧焦一样

开了enableLighting之后,某些模型会变得特别暗,即使太阳在头顶,颜色也怪怪的。这其实是材质的光照响应问题。模型的 PBR 材质如果法线方向不对,或者纹理自发光属性为零,就会在平行光下显得非常暗。你可以简单理解为:模型表面没有把照到它身上的光“反射”回相机,于是人眼看着就是黑的。

排查时可以先把enableLighting关掉,如果模型恢复正常颜色,说明问题出在光照参数上。此时需要检查模型的材质设置,看看是否因为法线翻转导致光源方向计算错误。这种问题通常要在建模阶段解决,前端代码能做的很有限。如果你急着演示,可以暂时把enableLighting调成适用亮度的较低值,而不是完全关闭,这样既有一定光照感,又不会黑过头。

5.4 坑四:阴影边缘锯齿严重,或者快速闪动

这属于 Shadow Map 的老朋友——阴影锯齿(Shadow Acne)和边缘走样。很多人在写代码时都喜欢把viewer.shadows = true当成万能开关,但一旦看到锯齿就开始怀疑框架不行,其实这是阴影贴图的固有问题。

缓解锯齿有几种思路:一是降低阴影覆盖范围,让贴图像素更密集地覆盖可见区域,这在某些版本里可以通过调整相机远近距离间接实现;二是把阴影效果呈现的粒度做大,比如只开启区域性的方向阴影而不是整场景的小尺寸阴影;三是在最终渲染时打开抗锯齿选项,这对阴影边缘的改善非常明显。如果你只需要“截图给甲方看”,甚至可以临时调大抗锯齿等级,画面干净很多。

6. 从阴影到日照分析:辅助功能的接入思路

6.1 日照时长统计的思路

动态日照阴影只是最直观的视觉表现,但真正的项目需求往往不止于此。比如甲方经常问:“这栋楼一天能有几个小时被太阳晒到?”这需要把视觉阴影和计时统计结合起来。

我的做法是,在前端页面上加一个统计按钮,点击后让时钟以较快速度跑一整天,比如multiplier = 3000,每到一个时间点,就采样目标楼体外立面某个片元是否处于阴影中。这个采样可以利用阴影贴图结果,也可以在简化场景下用“太阳方向 + 遮挡体轮廓”做估算。前者精度高但性能消耗大,后者能给出近似结果,更适合演示。

想要快速实现时,可以通过scene.pickPosition或Cartesian3计算目标点和太阳位置,判断两点连线是否被建筑物遮挡。这种算法虽然不如 ShadowMap 精确,但胜在可控性高,而且可以离线跑批量计算。

6.2 阴影叠加到专题分析图层

日照阴影动态播放在演示时很直观,但我们经常还需要把它和历史分析成果叠加在同一视图里。比如把某时段的阴影范围画成一个多边形贴在地面上,然后用半透明颜色填充,这样不依赖 3D 阴影也能展示日照遮挡范围。

做法也不复杂:遍历当前时刻的所有建筑物,对每个建筑轮廓做基于太阳方向的投影几何计算,取所有建筑的投影并集,再叠加到场景里。这里比较容易忽略的细节是,地形起伏不能只当作平面来处理。在起伏地形上,建筑投影末端位置要根据地形高度做修正,否则投影范围和真实阴影在斜坡处会有可见偏差。如果你用的是平地场景,按平面处理问题不大,但遇到山地项目,最好还是采用基于地形高度做逐点投影延伸的方式。

6.3 和后端计算的联动:什么时候用前端,什么时候用服务端

很多团队做到最后会发现,动态日照阴影在前端展示很炫,但要完成精确的日照时长统计、冬至日累计日照小时数、遮阳率这些计算,前端实时算法并不够精准。这个问题的本质是:实时渲染要保证流畅度,必然会对物理过程做一些近似;而分析计算需要的是确定性结果。

我目前比较推荐的做法是,前端做“量级正确”的实时演示,后端做“精确无误”的离线计算。后端的计算可以利用高精度天文算法和几何体求交,算出关键楼栋在关键日期的日照时段表,再把结果表传到前端,前端用动态阴影做视觉印证。两者配合,既满足领导“想看影子动”的体验要求,也满足分析报告“用数据说话”的专业要求。

经验小结和一点建议

做动态日照阴影这个功能,回头看我踩过的坑,最大的教训其实是:尽量先理解底层机制,再去写代码。很多人上来就抄 API,结果遇到问题只能一个个试,效率很低。建议你在写代码前,先把阳光方向、时间驱动、阴影贴图这三条线在脑子里串明白,再动手就顺了。

另外一个小建议是:动态日照阴影在项目汇报时效果很好,但不要让它成为系统的全部卖点。真正有用的往往是它背后的分析数据——日照时长、阴影范围变化、建筑间距对采光的影响。把好看的动效和数据结合起来,这个功能才算真正扎进业务里。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进