news 2026/10/2 13:58:22

基于 Cesium + 天地图的三维地理可视化:以“岳麓山名人墓“为例,详解地形夸张、自动环绕与标签碰撞避让

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于 Cesium + 天地图的三维地理可视化:以“岳麓山名人墓“为例,详解地形夸张、自动环绕与标签碰撞避让

一、背景与效果预览

做 geographic 类可视化时,最常遇到的三个工程难点是:

  1. 地形"看不见":岳麓山平均海拔只有 300m 左右,在椭球地球上几乎是一个"平面突起",不夸张根本看不出山脉起伏;
  2. 标注"悬浮":开了地形夸张之后,手算的高程和渲染网格对不齐,POI 标签飘在半空;
  3. 标签"打架":名人墓密集分布,旋转视角时成片标签重叠,可读性归零。

本文用一个完整可运行的 Demo(岳麓山名人墓三维展示)把这三个问题一次性解决,并给出逐行拆解的工程实现。最终效果:

  • 地形夸张 ×3,岳麓山轮廓立体清晰;
  • 标注用CLAMP_TO_GROUND钳制到渲染后地表,绝不悬浮;
  • 点"开始旋转",相机绕山脉平缓环绕(约 90 秒一圈,不眩晕);
  • 开启"标签避让"后,重叠面板自动向上抬升错开,全部可读。

以下截图均来自本实例(本地运行index.html,检索范围岳麓山周边),未做任何后期修饰。


二、技术选型与环境

能力选型说明
三维引擎天地图定制版 Cesium(Cesium.Map)国内直接使用,无需翻墙
影像底图天地图img_wWeb 墨卡托影像
地形高程天地图GeoTerrainProvider真实 DEM
地名注记天地图GeoWTFS三维地名,随地形起伏
Token天地图开发者密钥免费申请

注意:天地图的Cesium.Map是对Cesium.Viewer的封装,API 与官方 Cesium 基本一致;GeoTerrainProvider、GeoWTFS是天地图扩展类,需引用天地图 CDN 的Cesium.js。


三、工程结构

index.html # 容器 + UI + 引入天地图 Cesium js/main.js # 主逻辑(本文重点) js/DragLable.js # 标牌拖拽组件(位置偏移管理)

index.html的最小骨架:

<!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="utf-8"/><title>岳麓山名人墓三维展示</title><linkrel="stylesheet"href="https://api.tianditu.gov.cn/cdn/demo/sanwei/static/cesium/Widgets/widgets.css"/><style>#cesiumContainer{position:absolute;inset:0;}</style></head><body><divid="cesiumContainer"></div><divid="ui"><buttonid="btnSpin">开始旋转</button><label><inputtype="checkbox"id="chkCollision"/>标签避让</label></div><!-- 天地图 Cesium(含 GeoTerrainProvider / GeoWTFS) --><scriptsrc="https://api.tianditu.gov.cn/cdn/demo/sanwei/static/cesium/Cesium.js"></script><scriptsrc="js/DragLable.js"></script><scriptsrc="js/main.js"></script></body></html>

四、核心一:地形夸张 + 贴地标注

4.1 地形夸张与遮挡

这是"看得清山脉"的前提。两行配置解决:

// 开启地形夸张(数值越大越夸张,1 为原始地形)viewer.scene.globe.terrainExaggeration=3;// 启用贴地深度检测:POI 与地形正确遮挡,旋转视角时位置不漂移viewer.scene.globe.depthTestAgainstTerrain=true;

depthTestAgainstTerrain = true是关键中的关键:它让 billboard 参与 GPU 深度测试,位于山体之后的标注会被地形自然遮住,而不是"穿透"显示在最上层。

4.2 地形 Provider

vartoken="你的天地图Key";vartdtUrl='https://t{s}.tianditu.gov.cn/';varsubdomains=['0','1','2','3','4','5','6','7'];// 构造多子域地形地址(负载均衡)varterrainUrls=subdomains.map(function(s){returntdtUrl.replace('{s}',s)+'mapservice/swdx?T=elv_c&tk='+token;});viewer.terrainProvider=newCesium.GeoTerrainProvider({urls:terrainUrls});

4.3 为什么"手算高程"会悬浮?

直觉做法是:globe.getHeight()取真实高程,再乘以terrainExaggeration,作为 billboard 的绝对高度。但在天地图这套 Cesium 里,渲染网格(着色器内缩放)与getHeight返回值的夸张处理并不一致——要么getHeight已经含夸张,要么网格本身未缩放,结果就是标注被放到约 3 倍高度处,肉眼可见地"飘在半空"。

4.4 正确做法:CLAMP_TO_GROUND钳制到地表

把"算高度"这件事交给 Cesium 自己。Billboard 加一个heightReference,引擎会自动把实体钳制到渲染后的地形表面(已包含夸张),与高度计算彻底解耦:

varpin=viewer.entities.add({position:groundPos,// 高度字段被钳制忽略,仅用经纬度billboard:{image:poiDotImage,width:DOT_SIZE,height:DOT_SIZE,pixelOffset:newCesium.Cartesian2(0,0),// 关键:钳制到渲染后地表,无论地形怎么夸张都不悬浮heightReference:Cesium.HeightReference.CLAMP_TO_GROUND}});

工程经验:凡是"必须贴着地形"的标注,优先用CLAMP_TO_GROUND,不要自己算高度。手算高度只在需要做屏幕投影锚点(碰撞布局、背面剔除)时保留,且它只影响投影,不再决定垂直位置,因此即便有误差也不会导致悬浮。


五、核心二:自动环绕旋转(绕点飞行)

"自动旋转"本质是让相机围绕一个目标点,按固定仰角、匀速转动。Cesium 提供了camera.lookAt(target, HeadingPitchRange)这一组合拳。

5.1 状态与参数

varisSpinning=false;varspinTarget=null;varspinHeading=0;varspinPitch=Cesium.Math.toRadians(-35);// 固定俯仰角,俯视山脉varspinRange=8000;// 环绕半径(米)varspinSpeed=Cesium.Math.toRadians(4);// 角速度:4°/秒 ≈ 90 秒一圈var_lastSpinTime=null;

5.2 每帧按"真实时间差"推进角度

旋转必须基于**真实时间差(dt)**推进,而不是"每帧 +固定值",否则高刷新率屏幕转得快、低刷新率转得慢。在preRender里做:

viewer.scene.preRender.addEventListener(function(){if(!isSpinning||!spinTarget)return;varnow=performance.now();if(_lastSpinTime===null){_lastSpinTime=now;return;}// 起转首帧不跳变vardt=(now-_lastSpinTime)/1000;// 秒_lastSpinTime=now;spinHeading+=spinSpeed*dt;// 角速度 × 时间viewer.camera.lookAt(spinTarget,newCesium.HeadingPitchRange(spinHeading,spinPitch,spinRange));});

5.3 开始 / 停止

functionstartSpin(target){spinTarget=target||getViewerCenter()||// 以视野中心为环绕目标Cesium.Cartesian3.fromDegrees(112.935,28.169);spinRange=Cesium.Cartesian3.distance(viewer.camera.position,spinTarget);_lastSpinTime=null;// 重置计时,避免起转跳变isSpinning=true;document.getElementById('btnSpin').textContent='停止旋转';}functionstopSpin(){isSpinning=false;viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);// 解除锁定,恢复自由控制document.getElementById('btnSpin').textContent='开始旋转';}

getViewerCenter()用屏幕中心射线拾取地形,保证绕的是"你正在看的那座山",而不是椭球面上的一个假点:

functiongetViewerCenter(){varcanvas=viewer.scene.canvas;varcenter=newCesium.Cartesian2(canvas.clientWidth/2,canvas.clientHeight/2);varray=viewer.camera.getPickRay(center);varpos=viewer.scene.globe.pick(ray,viewer.scene);returnCesium.defined(pos)?pos:viewer.camera.pickEllipsoid(center,viewer.scene.globe.ellipsoid);}


六、核心三:标签碰撞避让(不隐藏,只错位)

名人墓彼此离得很近,旋转时标签必然重叠。我们的策略是不隐藏、只抬升:重叠的面板在静止位置基础上逐级向上平移,直到在屏幕上错开。

6.1 屏幕投影 +重叠检测

核心思路:把每个标注的地面锚点投影到屏幕坐标系,得到面板矩形的左上角;再用经典的轴对齐包围盒相交判定,决定是否与已布局的面板重叠。

varPANEL_RAISE_STEP=22;// 每次额外上移像素varPANEL_MAX_RAISE=12;// 最大上移次数(封顶,防止无限抬升)functionupdatePoiVisibility(){varkept=[];// 已布局面板的屏幕矩形poiEntities.forEach(function(o){varsp=viewer.scene.cartesianToCanvasCoordinates(o.groundPos,newCesium.Cartesian2());if(!Cesium.defined(sp)){// 位于相机背面 → 整体隐藏o.origin.billboard.show=false;o.sign.billboard.show=false;return;}o.origin.billboard.show=true;o.sign.billboard.show=true;if(!collisionEnabled)return;// 未开启避让:保持原偏移varw=o.panelW,h=o.panelH,base=o.baseOffset;varraise=0,steps=0,overlap=true,rect=null;while(steps<=PANEL_MAX_RAISE){// 面板锚点"左中":x = 锚点 + 偏移;y 中心再叠加垂直避让量varleft=sp.x+base.x;varcenterY=sp.y-base.y-raise;rect={x:left,y:centerY-h/2,w:w,h:h};overlap=kept.some(function(k){// AABB 不相交判定(取反即为相交)return!(rect.x+rect.w<k.x||rect.x>k.x+k.w||rect.y+rect.h<k.y||rect.y>k.y+k.h);});if(!overlap)break;// 不再重叠,定稿raise+=PANEL_RAISE_STEP;steps++;// 仍重叠,继续上移}dragLable.applyOffset(o,newCesium.Cartesian2(base.x,base.y+raise));kept.push(rect);});}

6.2 何时刷新?

相机每次移动(含自动旋转)都要重算——在postRender里加一个"相机位移"节流即可,避免每帧空转:

var_lastCamPos=null;viewer.scene.postRender.addEventListener(function(){varcp=viewer.camera.position;if(_lastCamPos&&Cesium.Cartesian3.equalsEpsilon(cp,_lastCamPos,1e-3))return;_lastCamPos=Cesium.Cartesian3.clone(cp,_lastCamPos);updatePoiVisibility();});

被山体遮挡的"隐藏"由 GPU 深度测试负责(见 4.1),CPU 这里只管"屏幕上是否可见 + 是否重叠",职责清晰、性能可控。


七、名人墓数据集与标注渲染

把通用检索换成一份内置的岳麓山名人墓数据(坐标均为约值,建议在工程中按测绘资料校正):

// 岳麓山名人墓(坐标为近似位置,实际部署请校正)varYUELU_TOMBS=[{name:'黄兴墓',lon:112.9365,lat:28.1695,who:'辛亥革命元勋,中华民国开国元勋'},{name:'蔡锷墓',lon:112.9338,lat:28.1710,who:'护国将军,"再造共和"之功臣'},{name:'焦达峰墓',lon:112.9345,lat:28.1680,who:'辛亥长沙起义领袖'},{name:'陈天华姚宏业墓',lon:112.9352,lat:28.1672,who:'革命宣传家,《猛回头》作者'},{name:'禹之谟墓',lon:112.9370,lat:28.1700,who:'近代资产阶级革命家'},{name:'刘道一墓',lon:112.9330,lat:28.1665,who:'同盟会会员,萍浏醴起义烈士'},{name:'蒋翊武墓',lon:112.9360,lat:28.1688,who:'武昌起义重要组织者'}];

用 Canvas 动态生成"圆点 + 名称胶囊"两张图(2x 高清,显示时scale=0.5),再分别挂为 billboard。原点圆点:

functioncreatePoiDotImage(){varS=2,D=14*S;varcanvas=document.createElement('canvas');canvas.width=canvas.height=D;varctx=canvas.getContext('2d'),c=D/2;varglow=ctx.createRadialGradient(c,c,D*0.28,c,c,c);// 柔光外圈glow.addColorStop(0,'rgba(0,160,255,0.35)');glow.addColorStop(1,'rgba(0,160,255,0)');ctx.fillStyle=glow;ctx.beginPath();ctx.arc(c,c,c,0,Math.PI*2);ctx.fill();ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(c,c,c-1.5,0,Math.PI*2);ctx.fill();varcore=ctx.createRadialGradient(c-c*0.3,c-c*0.3,c*0.15,c,c,c-3);core.addColorStop(0,'#9beeff');core.addColorStop(0.5,'#22b7f5');core.addColorStop(1,'#0072c6');ctx.fillStyle=core;ctx.beginPath();ctx.arc(c,c,c-3,0,Math.PI*2);ctx.fill();returncanvas.toDataURL('image/png');}

名称面板(圆角胶囊 + 深蓝渐变)思路相同,使用createPoiNameImage(name),返回{ url, w, h }(w/h为显示尺寸,供碰撞布局估算屏幕矩形)。

添加标注

functionaddPoiToCesium(t){vargroundPos=Cesium.Cartesian3.fromDegrees(t.lon,t.lat);// 高度被 CLAMP 忽略varpin=viewer.entities.add({position:groundPos,billboard:{image:poiDotImage,width:14,height:14,heightReference:Cesium.HeightReference.CLAMP_TO_GROUND},description:'<div style="min-width:200px"><b>'+t.name+'</b><br/>'+(t.who||'')+'</div>'});varpanelImg=createPoiNameImage(t.name);varcard=viewer.entities.add({position:groundPos,billboard:{image:panelImg.url,scale:0.5,horizontalOrigin:Cesium.HorizontalOrigin.LEFT,verticalOrigin:Cesium.VerticalOrigin.CENTER,heightReference:Cesium.HeightReference.CLAMP_TO_GROUND}});varo=dragLable.addEntity(pin,card,null,newCesium.Cartesian2(0,0));o.panelW=panelImg.w;o.panelH=panelImg.h;o.lon=t.lon;o.lat=t.lat;o.groundPos=groundPos;o.name=t.name;poiEntities.push(o);}// 初始化:飞到岳麓山上空并标注functioninitTombScene(){viewer.camera.flyTo({destination:Cesium.Cartesian3.fromDegrees(112.935,28.169,9000),orientation:{heading:0,pitch:Cesium.Math.toRadians(-55),roll:0}});YUELU_TOMBS.forEach(addPoiToCesium);updatePoiVisibility();}initTombScene();

最后给出完整的视频链接,感兴趣的朋友可以自由查看,也欢迎大家来长沙打卡,来岳麓山打卡。

Cesium盘点岳麓山和上面的那些人

八、踩坑总结(重点)

  1. 地形夸张下标注悬浮:不要手算getHeight × exag,直接heightReference: CLAMP_TO_GROUND。渲染网格与getHeight的夸张语义在该 Cesium 版本里不一致,手算必飘。
  2. 标注穿透山体:必须depthTestAgainstTerrain = true,否则 billboard 永远画在最上层,看起来像"悬浮穿透"。
  3. 自动旋转眩晕/跳变:用lookAt + HeadingPitchRange,角度基于performance.now()的时间差推进;起转首帧重置计时,避免瞬间跳一大段。
  4. 停止旋转后相机失控:stopSpin必须camera.lookAtTransform(Matrix4.IDENTITY)解除锁定。
  5. 标签重叠:CPU 端用屏幕投影 + AABB 做"抬升避让",不隐藏;被地形遮挡的隐藏交给 GPU 深度测试,两者分离,性能与可读性兼顾。
  6. 地形未加载时高度采样为 0:tileLoadProgressEvent在瓦片加载完成后清空高度缓存并重算groundPos,避免初始贴地错位。

九、结语

"地形夸张 + 自动旋转 + 标签碰撞"是三维地理展示的三件套。本文以岳麓山名人墓为切口,给出了可直接落地的 Cesium 实现:贴地靠CLAMP_TO_GROUND,环绕靠lookAt/HeadingPitchRange,避让靠屏幕投影 + AABB。三者组合后,既能看清山脉脉络,又能在动态旋转中保持标签清晰可读。

代码已脱敏整理,核心逻辑均来自生产级 Demo。如果你在做红色景点、名人故居、陵园纪念碑等同类项目,这套骨架可以直接复用。


本文示例代码基于天地图定制版 Cesium,部分扩展类(GeoTerrainProvider/GeoWTFS)为天地图专有,请引用其官方 CDN。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 13:57:47

DeepSeek LeetCode 139.单词拆分 Python3实现

LeetCode 139「单词拆分」要求判断字符串 s 能否被拆分成字典 wordDict 中的一个或多个单词&#xff08;可重复使用&#xff09;。思路&#xff1a;动态规划 定义 dp[i] 表示 s 的前 i 个字符&#xff08;即 s[0:i]&#xff09;能否被成功拆分。初始状态&#xff1a;dp[0] T…

作者头像 李华
网站建设 2026/10/2 13:57:07

从Attention到BERT:双向预训练语言模型到底解决了什么问题

从一次文本分类的困境说起 几年前我做过一个情感分类的小任务&#xff0c;数据量不大&#xff0c;大概几千条中文评论。当时第一反应是用 Word2Vec 训练词向量&#xff0c;再套一个 LSTM 做分类。结果跑出来准确率一直卡在 80% 出头&#xff0c;怎么调都上不去。 问题出在哪&am…

作者头像 李华
网站建设 2026/10/2 13:55:37

中南智能工控教育|学完 PLC 别迷茫!带你看透工控圈各类岗位真实日常

中南智能工控教育知道不少学员结束 PLC 课程后&#xff0c;手上练过一堆样机案例&#xff0c;正想有一份好工作来大施拳脚。各大公司招聘标题五花八门&#xff0c;电气技术员、调试工程师、非标工程师看得眼花缭乱&#xff0c;岗位名字听着高大上&#xff0c;实际上班到底干点啥…

作者头像 李华
网站建设 2026/10/2 13:55:20

yanshee 机器人开发实战:从树莓派到Python编程

1. 开箱即玩&#xff1a;你的第一台智能伙伴要是你心里头对学习机器人编程这点事挺感兴趣, 可是又觉得那些厂子里头的机械手臂, 还有那套特别难搞的ROS系统, 门槛实在是在天上飞, 太高了的话, 那么这架机器人恐怕那就是专门为你一个人量着身体尺寸打造出来的梦中情机。我第一次…

作者头像 李华
网站建设 2026/10/2 13:53:14

等保2.0可信验证动态实现与合规实践全解析

做了几年等保测评整改&#xff0c;我明显感觉到大家都卡在同一个地方&#xff1a;防火墙、堡垒机、日志审计这些传统安全设备谁都会买&#xff0c;但每次聊到“可信验证”&#xff0c;连干了多年安全的老人都容易含糊其辞。等保2.0里面对这块的要求写得明明白白&#xff0c;核心…

作者头像 李华
网站建设 2026/10/2 13:53:13

tlb finish_asid_transition

finish_asid_transition 是 AMD 广播 TLB 失效&#xff08;Broadcast TLB Invalidation&#xff09;补丁集中的关键同步函数。它的核心任务是&#xff1a;在完成一次广播 TLB 刷新后&#xff0c;确认所有正在运行该进程的 CPU 都已经切换到了新的全局 ASID&#xff0c;然后清除…

作者头像 李华