一、背景与效果预览
做 geographic 类可视化时,最常遇到的三个工程难点是:
- 地形"看不见":岳麓山平均海拔只有 300m 左右,在椭球地球上几乎是一个"平面突起",不夸张根本看不出山脉起伏;
- 标注"悬浮":开了地形夸张之后,手算的高程和渲染网格对不齐,POI 标签飘在半空;
- 标签"打架":名人墓密集分布,旋转视角时成片标签重叠,可读性归零。
本文用一个完整可运行的 Demo(岳麓山名人墓三维展示)把这三个问题一次性解决,并给出逐行拆解的工程实现。最终效果:
- 地形夸张 ×3,岳麓山轮廓立体清晰;
- 标注用
CLAMP_TO_GROUND钳制到渲染后地表,绝不悬浮; - 点"开始旋转",相机绕山脉平缓环绕(约 90 秒一圈,不眩晕);
- 开启"标签避让"后,重叠面板自动向上抬升错开,全部可读。
以下截图均来自本实例(本地运行
index.html,检索范围岳麓山周边),未做任何后期修饰。
二、技术选型与环境
| 能力 | 选型 | 说明 |
|---|---|---|
| 三维引擎 | 天地图定制版 Cesium(Cesium.Map) | 国内直接使用,无需翻墙 |
| 影像底图 | 天地图img_w | Web 墨卡托影像 |
| 地形高程 | 天地图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盘点岳麓山和上面的那些人
八、踩坑总结(重点)
- 地形夸张下标注悬浮:不要手算
getHeight × exag,直接heightReference: CLAMP_TO_GROUND。渲染网格与getHeight的夸张语义在该 Cesium 版本里不一致,手算必飘。 - 标注穿透山体:必须
depthTestAgainstTerrain = true,否则 billboard 永远画在最上层,看起来像"悬浮穿透"。 - 自动旋转眩晕/跳变:用
lookAt + HeadingPitchRange,角度基于performance.now()的时间差推进;起转首帧重置计时,避免瞬间跳一大段。 - 停止旋转后相机失控:
stopSpin必须camera.lookAtTransform(Matrix4.IDENTITY)解除锁定。 - 标签重叠:CPU 端用屏幕投影 + AABB 做"抬升避让",不隐藏;被地形遮挡的隐藏交给 GPU 深度测试,两者分离,性能与可读性兼顾。
- 地形未加载时高度采样为 0:
tileLoadProgressEvent在瓦片加载完成后清空高度缓存并重算groundPos,避免初始贴地错位。
九、结语
"地形夸张 + 自动旋转 + 标签碰撞"是三维地理展示的三件套。本文以岳麓山名人墓为切口,给出了可直接落地的 Cesium 实现:贴地靠CLAMP_TO_GROUND,环绕靠lookAt/HeadingPitchRange,避让靠屏幕投影 + AABB。三者组合后,既能看清山脉脉络,又能在动态旋转中保持标签清晰可读。
代码已脱敏整理,核心逻辑均来自生产级 Demo。如果你在做红色景点、名人故居、陵园纪念碑等同类项目,这套骨架可以直接复用。
本文示例代码基于天地图定制版 Cesium,部分扩展类(GeoTerrainProvider/GeoWTFS)为天地图专有,请引用其官方 CDN。