做Cesium开发这几年被问得最多的问题除了单体化和模型压平就是阴影分析了。前阵子接到一个项目需求要在Cesium里对一片规划园区做阴影率分析说白了就是算清楚一天里每个区域被建筑遮挡的时间占比。这个需求在国内的规划审批、光伏选址、老旧小区改造里非常常见但网上能查到的完整实现思路很少很多朋友卡在不知道从哪下手。这篇文章我把自己在Cesium里实现阴影率分析的全过程拆开讲包括核心思路、太阳位置计算、阴影判定方法、性能优化以及实际项目中踩过的一些坑。适合刚接触Cesium、或者已经能跑通基础项目但想深入做空间分析的朋友参考文中的代码都是可以直接拿来改的。1. 阴影率分析是什么为什么偏偏选Cesium1.1 先搞清楚阴影率到底在算什么阴影率也叫日照遮挡率指的是在某个时间范围内某个空间位置被建筑物、山体、树木等遮挡的时间比例。举个直观的例子一块场地一天日照时长为8小时但实际能晒到太阳的时间只有4小时那这块场地的阴影率就是50%。在规划场景里阴影率分析通常关注三件事第一建筑物对周边场地的影响比如高层住宅有没有把隔壁小区的采光挡掉第二拟建建筑自身的日照条件比如光伏板安装位置的阴影遮挡情况第三不同季节、不同时段的阴影变化规律比如冬至日中午的阴影最长这个时候的整体遮挡情况通常最具参考价值。这里要注意一个容易混淆的概念阴影率和日照率是互补关系。如果某点一天内阴影率是30%日照率就是70%。不同行业习惯用不同的指标规划报批文件里通常写日照时数而光伏评估更喜欢用阴影率本质上算的都是同一件事只是表达角度不同。1.2 为什么这个需求适合用Cesium来做要做阴影率分析技术上需要满足三个基础条件能加载真实的地形和高程数据、能加载带空间位置的建筑模型尤其是3D Tiles、能计算任意时刻的太阳位置。这三个条件Cesium原生都满足。Cesium内置了基于天文算法的太阳位置计算Simon1994行星位置模型不需要自己引入额外的天文库对3D Tiles、glTF、倾斜摄影模型的支持非常成熟能直接加载主流建模软件导出的数据同时Cesium的WebGL渲染引擎本身就支持阴影贴图shadow map可以基于GPU逐帧生成实时阴影。另外Cesium是纯浏览器端运行的方案不需要安装桌面端GIS部署成Web服务之后规划人员打开浏览器就能操作。我做过的几个项目里甲方基本都是直接用Chrome看结果没有一个人装过专业软件。这一点在项目实际交付环节里的价值往往比技术本身更关键。1.3 阴影率分析和动态光照的关系如果只是看阴影效果Cesium里打开viewer.shadows true就够了那是最底层的动态光照能力。但阴影率分析要求的是数量化而不是可视化——你必须知道某个点在某时刻到底是处于阴影中还是阳光下并且把这个判断结果统计成比率。所以完整的阴影率分析本质上是Cesium动态光照能力叠加一层空间分析逻辑。动态光照负责把阴影正确地渲染出来空间分析逻辑负责把这些渲染结果转成可量化的数据。理解了这层关系你就明白为什么不能只靠打开阴影开关来交差——那只是第一步。2. 整体方案设计先定架构再谈代码2.1 两种实现路线的选型对比在Cesium里做阴影判定业内主要有两条技术路线基于阴影贴图Shadow Map的GPU路线和基于射线求交Ray Casting的CPU路线。基于阴影贴图的方案思路是开启Cesium的shadow map渲染从光源视角生成一张深度图再对目标区域逐像素做深度比较判断该点是否被遮挡。优点是精度高、与实时渲染效果完全一致能精确反映3D Tiles的每一个三角形细节缺点是实现复杂需要写CustomShader或者用RenderTarget做离屏渲染而且要拿到shadow map的深度纹理做分析对WebGL的掌握程度要求很高调试成本也不小。基于射线求交的方案思路是从目标点向太阳方向发射一条射线检测射线是否与场景中的模型相交。如果相交说明这个点在当前时刻被遮挡。优点是逻辑简单、可控性强单点判断的代码量很少特别适合对离散采样点做统计的阴影率分析缺点是如果场景模型三角形太多求交计算会非常耗时需要做空间索引和并行化优化。我最终选择的是射线求交方案。原因有三个阴影率分析不需要逐像素级别的精度按一定密度离散采样就够用了射线方案能很方便地按时间步长循环计算比如从早8点到晚6点每10分钟算一次逻辑非常清晰而且射线求交可以复用到其他的空间分析功能里比如通视分析、遮挡分析一次写好的代码后续改改就能用。2.2 数据准备模型、地形、坐标系三者要统一在动手写代码之前先把数据问题搞定。阴影率分析对数据的一致性要求非常高我见过太多项目最后算出来的结果不对追根溯源都是坐标系或者高程基准出了问题。第一建筑模型全部转成3D Tiles。不管是SketchUp的skp、3ds Max的max还是Revit的rvt先用工具转成glTF再用Cesium的3d-tiles-tools或者在线转换服务转成3D Tiles。模型必须带真实的地理坐标这一步在DCC软件里建模时就要设置好或者通过坐标平移方式转换到目标坐标系。很多人问Cesium能不能直接加载SU模型答案是能但不是直接加载正确路径是skp先导出为fbx或glTF再转3D Tiles中间不能跳步。第二地形和高程数据按需加载。如果是平原地区的规划项目区域比较大建议加载标准地形服务如果项目范围很小比如一个小区但精度要求很高可以把本地的高程TIFF处理成量化网格地形Quantized-Mesh-Terrain之后本地部署或者直接用3D Tiles形式的地形服务。第三所有数据统一到同一套坐标系下。Cesium默认使用WGS84如果原始数据是地方坐标系要用Cesium.Transforms.eastNorthUpToFixedFrame或者其他方式做坐标转换先统一到经纬度再交给Cesium渲染。这一步出错后面所有的阴影计算都没有意义而且这种错误往往非常隐蔽数据在画面上看是贴合在一起的但射线求交的结果完全不对。提示做阴影分析用的3D Tiles建议先确认模型地基高度是否贴合地面。模型悬空或者陷入地形都会直接导致阴影判定错误。如果发现模型和地形之间有缝隙优先调整模型本身的锚点高度而不是在代码里做偏移后者的维护成本很高。2.3 太阳位置计算Cesium内置模型够不够用阴影率分析最核心的时间维度就靠太阳位置来驱动。Cesium提供了Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthFixedFrame()接口传入时间JulianDate就能算出该时刻太阳在地球固定坐标系下的方向向量。这个内置模型在精度上足够满足规划分析需求。它基于低精度的行星历表计算出的太阳位置误差通常在0.01度以内对应到地面上大概几百米的太阳直射点偏移。对阴影率分析来说我们关心的不是太阳直射点在哪而是太阳光线的方位角和高角度这个精度的方向向量做射线发射完全没有问题。需要注意的是Cesium计算太阳位置返回的是地心固定坐标系下的方向不能直接拿来做射线方向需要先把太阳方向向量从ECEF坐标系转换到采样点所在的局部坐标系ENU。这个转换是很多新手容易踩坑的地方我会在后面的代码部分给出完整的处理方式。3. 核心代码实现从0到1搭一个可用的阴影率分析功能3.1 环境准备和基础场景搭建我用的开发环境是纯前端Vite加Cesium 1.117这些都是开源组件不需要额外的商业许可。如果你用的是Cesium ion就把Ion token配上如果完全离线部署就用本地静态资源方式加载Cesium的源码或构建产物。// 引入Cesium import * as Cesium from cesium; // 创建Viewer关闭掉不需要的组件 const viewer new Cesium.Viewer(cesiumContainer, { animation: false, // 关闭动画控件 timeline: false, // 关闭时间线 baseLayerPicker: false, // 关闭底图选择器 scene3DOnly: true, // 仅3D模式 shadows: true, // 开启阴影方便调试观察 }); // 加载3D Tiles建筑模型 const tileset await Cesium.Cesium3DTileset.fromUrl(/data/buildings/tileset.json); viewer.scene.primitives.add(tileset); // 如果有本地高程数据或地形服务也一并加载 viewer.scene.globe.depthTestAgainstTerrain true;这里有两个细节想提醒一下第一shadows: true会在场景中渲染实时阴影对性能有一定开销如果只是做分析、不需要给用户看阴影效果可以不开。但开着对调试非常有帮助能直观看到模型之间的遮挡关系我平时开发时都是开着最后交付线上版本再关掉。第二depthTestAgainstTerrain一定要打开尤其是结合地形做分析的时候否则射线和地形的求交结果会出现错乱。3.2 把太阳方向向量转换到局部坐标系接下来是太阳位置的计算和坐标转换。这一步是整个分析功能的方向保证不建议直接抄网上的老代码因为不同版本的Cesium在API上有差异而且很多老代码没有考虑坐标系转换的问题。// 给定一个Cesium时间JulianDate计算太阳在ECEF坐标系下的方向 function computeSunDirectionInENU(date, referPointCartographic) { // 1. 计算太阳在ECEF下的位置返回的是以地球中心为原点的位置向量 const sunPositionECEF Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthFixedFrame(date); // 2. 取地心到太阳的方向向量 const sunDirECEF Cesium.Cartesian3.normalize(sunPositionECEF, new Cesium.Cartesian3()); // 3. 建立参照点比如园区中心的ENU局部坐标系变换矩阵 const referPointCartesian Cesium.Cartographic.toCartesian(referPointCartographic); const enuMatrix Cesium.Transforms.eastNorthUpToFixedFrame(referPointCartesian); // 4. 将ECEF方向向量变换到ENU局部坐标系 const inverseMatrix Cesium.Matrix4.inverse(enuMatrix, new Cesium.Matrix4()); const sunDirLocal Cesium.Matrix4.multiplyByPointAsVector(inverseMatrix, sunDirECEF, new Cesium.Cartesian3()); // 此时 sunDirLocal 的X轴指向东Y轴指向北Z轴指向上 return Cesium.Cartesian3.normalize(sunDirLocal, new Cesium.Cartesian3()); }第4步用multiplyByPointAsVector而不是multiplyByPoint是因为我们要转换的是方向向量不是位置点。方向向量只受旋转影响不受平移影响如果用错方法算出来的太阳方向会带一个莫名其妙的偏移量阴影分布就会整体错位。这个API细节不留意的话排查起来非常痛苦。3.3 阴影判定的核心逻辑射线求交射线求交是整个功能的心脏。Cesium的Scene对象提供了pickFromRay方法可以检测一条射线和场景中所有可拾取对象的交点。我们只需要从采样点出发朝太阳方向发射一条足够长的射线看它是否命中3D Tiles模型。// 判断某个Cartesian3坐标点在给定时刻是否处于阴影中 function isPointInShadow(viewer, position, date, maxDistance 2000) { // 计算太阳方向局部坐标系 const cartographic Cesium.Cartographic.fromCartesian(position); const sunDirLocal computeSunDirectionInENU(date, cartographic); // 把局部坐标系的太阳方向转回ECEF // 因为Cesium的pickFromRay工作在ECEF坐标系 const enuMatrix Cesium.Transforms.eastNorthUpToFixedFrame(position); const sunDirECEF Cesium.Matrix4.multiplyByPointAsVector(enuMatrix, sunDirLocal, new Cesium.Cartesian3()); // 构造射线从采样点出发朝太阳方向 const ray new Cesium.Ray(position, sunDirECEF); const intersection viewer.scene.pickFromRay(ray); if (Cesium.defined(intersection)) { // 求交成功说明射线被模型挡住了 const hitDistance Cesium.Cartesian3.distance(position, intersection.position); return hitDistance maxDistance; // 只统计近距离遮挡 } return false; // 没有交点说明在阳光下 }这里有两个关键参数值得展开讲。第一个是maxDistance最大判定距离。理论上只要射线和场景任意物体相交就应判定为处于阴影中。但在真实场景里远处的山体、相邻片区的建筑也会被加载进场景如果你不需要考虑这些远处的遮挡就应该设置一个合理的上限距离。比如规划分析通常只关心园区内部建筑之间的遮挡那么把上限设为1到2公里就足够了。第二个是pickFromRay的性能特征。这个方法在每次调用时都会对场景做拾取内部走的是渲染对象级别的求交而不是遍历所有三角形所以单次调用的性能总体可控。但如果你的采样点数量很大比如上万个再乘以几十个时间切片总调用次数会非常可观这时候就必须考虑后续的性能优化策略了。3.4 离散采样和阴影率统计阴影率是一个统计值所以不能只看一个点一个时刻而是要在一个空间网格和时间序列上做扫描。完整的计算流程分三步。第一步在目标区域内生成采样点网格。通常的做法是把区域的外轮廓简化为一个矩形或者多边形然后按设定密度比如每5米一个点生成规则格网。格网密度直接决定结果的精细程度也直接影响计算耗时建议先按10米跑一遍确认结果整体趋势没问题再加密到5米或2米。function generateGridPoints(bounds, spacing) { const points []; const west bounds.west, south bounds.south; const east bounds.east, north bounds.north; // 经纬度差值步长换算1度约111公里这个粗略换算对网格生成够用 const stepLon spacing / 111000 / Math.cos(Cesium.Math.toRadians((south north) / 2)); const stepLat spacing / 111000; for (let lon west; lon east; lon stepLon) { for (let lat south; lat north; lat stepLat) { const cartographic Cesium.Cartographic.fromDegrees(lon, lat); // 可选利用地形高程采样将点贴到地表 const height viewer.scene.globe.getHeight(cartographic); cartographic.height height || 0; points.push(Cesium.Cartographic.toCartesian(cartographic)); } } return points; }第二步在时间轴上做循环。比如分析冬至日早上8点到下午18点每10分钟算一次一共61个时间切片。对每个时间切片、每个采样点调用一次isPointInShadow把结果累加。第三步统计每个采样点的阴影率。公式很简单阴影率等于处于阴影中的时间切片数除以总时间切片数。// 用一个对象数组管理每个采样点的统计结果 const samplePoints generateGridPoints(bounds, 5); const shadowCounts new Array(samplePoints.length).fill(0); const totalSlices 61; // 时间起点冬至日 8:00UTC时间需要换算成当地时区 let startDate Cesium.JulianDate.fromDate(new Date(2024-12-21T08:00:0008:00)); for (let i 0; i totalSlices; i) { const currentDate Cesium.JulianDate.addMinutes(startDate, i * 10, new Cesium.JulianDate()); for (let j 0; j samplePoints.length; j) { if (isPointInShadow(viewer, samplePoints[j], currentDate)) { shadowCounts[j]; } } } // 计算阴影率 const shadowRates shadowCounts.map(count count / totalSlices);这里有一个很容易忽略的细节Cesium的JulianDate默认是UTC时间而我们在中国做规划项目太阳位置的判断必须使用北京时间。如果直接把本地时间当成UTC传给太阳位置计算函数算出来的太阳方位会整体偏移大约8个小时对应的角度结果完全不能用。正确做法是在构造Date对象时带上时区偏移或者用Cesium.JulianDate.fromDate传入已经包含时区信息的Date实例。3.5 把阴影率渲染成热力图统计结果光有数据不行还得让用户看得明白。最直观的展示方式就是把阴影率映射成热力图叠加在场景上。在Cesium里做热力图常见做法有两种一种是用第三方库把采样点转成屏幕坐标后叠加Canvas另一种是把阴影率数值直接写进自定义图元用颜色渐变来表示。我更推荐后一种理由很直接它在三维场景里的位置是准确的视角转动时不会出现热力图与地面错位的问题而且每个点都响应拾取鼠标移上去可以直接显示阴影率数值对交互体验的提升很明显。// 把阴影率数值映射为颜色绿(低遮挡) - 黄(中等) - 红(高遮挡) function shadowRateToColor(rate) { if (rate 0.3) { return Cesium.Color.fromHsl(0.35, 0.8, 0.5); // 绿色 } else if (rate 0.6) { return Cesium.Color.fromHsl(0.15, 0.9, 0.5); // 黄色 } else { return Cesium.Color.fromHsl(0.0, 0.9, 0.5); // 红色 } } samplePoints.forEach((pos, i) { viewer.entities.add({ position: pos, point: { pixelSize: 6, color: shadowRateToColor(shadowRates[i]), outlineColor: Cesium.Color.WHITE, outlineWidth: 1, disableDepthTestDistance: Number.POSITIVE_INFINITY, } }); });这一步做完浏览器里就能看到一片被红黄绿覆盖的分析区域。配合Cesium的相机飞行和时间线用户可以自己转到任意角度去查看具体建筑的遮挡边界。在交付汇报的时候这个效果比干巴巴的Excel统计数据有说服力得多。4. 性能优化从能算到算得快4.1 采样密度和计算量到底怎么平衡性能问题在阴影率分析里是绕不开的。一个1平方公里的园区按2米密度采样就是25万个点按10分钟间隔、从早上8点到下午18点算一天是61个时间切片总计算量是25万乘以61约1525万次射线求交。这个量级的循环在浏览器里直接跑会出现明显的卡顿甚至假死。我的经验是先做一轮粗筛再在关键区域做精算。粗筛阶段用10米或20米的采样密度快速把整片区域的阴影率分布摸一遍找到阴影率变化剧烈的区域通常是建筑边缘和转角再用2米或更小的间距在这些局部区域单独加密计算。这样既能控制总计算量又能保证关键区域的分析精度。另外时间切片也不是越多越好。规划分析通常关注的是平均日照状态10分钟一个切片已经足够。如果业务只要求统计某个时间段的总阴影率时间切片设置成15分钟甚至30分钟都能接受计算量直接减半。4.2 射线求交的加速手段pickFromRay虽然方便但在大规模采样下性能不够理想。我在这类项目里常用两个加速手段。第一个是空间分区。把场景中的3D Tiles模型按包围盒拆分成若干个小的空间区域在射线求交前先做射线与包围盒的粗略相交判断只对相交区域内的模型做细节求交。这个逻辑在3D Tiles本身已经有空间索引的情况下收益明显因为你可以直接利用tileset内部每个瓦片的包围盒数据不需要额外维护一套空间索引结构。第二个是Web Worker并行。把采样点数组拆分成多份分发给多个Web Worker线程并行计算每个Worker内部只负责一部分点的射线求交。主线程只负责汇总结果。这样能把计算耗时缩短到原来的四分之一甚至更短具体取决于CPU核心数。注意Web Worker里不能直接访问viewer.scene所以要把求交逻辑封装成不依赖Cesium Viewer实例的纯函数通过消息传递传入关键的模型几何数据。4.3 大数据量下的显示优化当采样点超过几万个之后即使计算完成了前端渲染也会成为瓶颈。每个点用Entity添加在Cesium里会创建对应DrawCommand几万个Entity足够把帧率拖到个位数。这时候要换思路把分析结果合并到一个CustomDataSource里统一管理或者干脆把阴影率数值写入一张图片纹理用SingleTileImageryProvider叠加在分析区域上。后者在渲染性能上几乎无压力缺点是热力图的精细度受纹理分辨率限制。我实测下来的感受是如果只是临时查看分析结果几万个点用Entity还能接受如果需要长期在线使用建议走纹理叠加方案一劳永逸。我在一个实际交付的项目里就是把热力图烘焙到了一张2048乘2048的透明PNG上叠加到园区范围帧率一直保持在60帧效果非常稳定。5. 常见问题与排查技巧实录5.1 射线求交结果为空建筑去哪了这是最常遇到的问题明明模型在画面上渲染出来了但射线打过去就是没有交点。第一反应先检查pickFromRay的设置scene.pickFromRay只能拾取支持拾取的对象默认对3D Tiles是支持的但如果你用的模型是通过Primitive接口直接添加的而且设置了allowPicking: false那自然拾取不到。另一个常见原因是模型还没有Ready就去做分析。使用Cesium3DTileset.fromUrl之后模型是异步加载的内部瓦片是边加载边渲染的在加载完成之前就去射线求交会有大量瓦片还没就绪求交结果自然为空。解决办法是等tileset.allTilesLoaded事件触发后再开始分析或者至少等initialTilesLoaded。这个问题的表现很隐蔽因为画面上模型已经能看到一部分了但射线就是打不中。5.2 阴影率结果整体偏差方向不对如果算出来的阴影分布和实际光照方向差得很远十有八九是太阳方向向量没有正确转换到局部坐标系。我在3.2节里强调的multiplyByPointAsVector和multiplyByPoint的区别建议再回头确认一下。还有个简单的自检方法把Cesium的阴影显示打开然后在早上9点这个时间点把某个采样点的太阳方向在场景里画一条线出来看看这条线是不是和Cesium自己渲染的阴影方向一致。如果不一致方向计算一定有bug不用怀疑其他环节。另外还要检查一下时区问题。我在3.4节里提过这里再强调一次JulianDate的默认时区是UTC而太阳位置计算必须基于真实的地理时间。如果业务上要求按北京时间分析就要在构造时间时正确处理时区偏移否则所有时刻的阴影方向都会整体偏转。5.3 地形起伏导致的误判如果项目加载了起伏地形射线求交除了要检测建筑模型还必须检测地表。Cesium的pickFromRay默认只拾取场景中的图元不对地形做求交所以地形遮挡需要单独处理。一种方案是使用viewer.scene.globe.pick(ray, scene)来检测射线与地形的交点再结合建筑交点取最近的一个作为有效遮挡。这里要注意地形是LOD动态细化的远处的山头在当前细节层级下可能没有渲染出来导致求交漏判。稳妥的办法是针对分析区域单独加载高精度的量化网格地形并把地形的maximumLevel设置到足够大。如果你用的是高程TIFF本地发布的地形服务还要确认服务端的瓦片切到了足够的层级否则客户端再怎么设置也没用。5.4 分析结果精度够不够有朋友问过我这种基于离散采样的阴影率和用专业日照分析软件算出来的结果差多少。说实话在规则建筑场景下两者差异不大但遇到曲面屋顶、复杂建筑造型离散采样方案的误差会明显一些。如果业务上有严格的规范要求比如规划报批需要精确日照时数建议把采样间距控制在1米以内并把时间切片缩短到5分钟。如果只是做方案比选和前期分析10米间距加10分钟切片就足够了没必要把时间浪费在追求无谓的精度上。做这类项目一定要先和需求方确认清楚分析结果的使用目的是辅助决策还是行政审批两者的精度要求完全不在一个量级。我吃过这方面的亏一开始按最高精度做结果计算时间太长甲方等得不耐烦后来改成自适应精度方案才解决了问题。6. 和Cesium生态其他能力的组合玩法阴影率分析在Cesium生态里并不是孤立的功能。我做过的项目里经常把阴影率和单体化、鹰眼图、动态光照联动起来用。跟单体化联动把3D Tiles建筑做单体化之后每个建筑是一个独立对象可以给每个建筑额外附加自身对周边阴影贡献度的属性。这样分析结果出来之后不只是拿到一片热力图还能反查到某栋建筑是造成周边阴影率偏高的主要原因对规划优化非常有价值。跟鹰眼图联动在鹰眼图窗口中叠加阴影率热力图层用户通过鹰眼快速定位到高遮挡区域再在主视图中飞行过去查看细节。这种交互模式在汇报演示时特别加分因为决策者不用自己拖拽旋转3D场景一键定位就能看到问题区域。跟动态光照联动Cesium的时间轴本身就是围绕时间走的把阴影率分析的日期参数接到时间轴上用户拖动时间轴就能实时看到不同日期、不同时刻的阴影变化。这种活的动态光照演示效果比静态热力图直观得多领导看完基本都能理解阴影率的概念。另外如果你在做城市级尺度的项目还可以把阴影率结果汇总成统计数据叠加到Cesium加载的3D Tiles建筑上做城市级日照评估。这个方向在国内的城市更新、老旧小区改造项目里需求增长很快属于比较有潜力的应用方向。关于后续扩展我目前在尝试的方向是把阴影率分析结果结合倾斜摄影模型做三维可视化叠加同时研究基于WebGPU的实时阴影计算方案看看能不能把每帧的shadow map直接转为分析数据省去离屏渲染的来回拷贝。做出来了再写文章分享如果你也在研究这个方向欢迎多交流。
阅读完成 · 觉得有帮助?