一、背景与效果预览
做 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 的最小骨架:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>岳麓山名人墓三维展示</title>
<link rel="stylesheet" href="https://api.tianditu.gov.cn/cdn/demo/sanwei/static/cesium/Widgets/widgets.css" />
<style>#cesiumContainer{position:absolute;inset:0;}</style>
</head>
<body>
<div id="cesiumContainer"></div>
<div id="ui">
<button id="btnSpin">开始旋转</button>
<label><input type="checkbox" id="chkCollision" /> 标签避让</label>
</div>
<!-- 天地图 Cesium(含 GeoTerrainProvider / GeoWTFS) -->
<script src="https://api.tianditu.gov.cn/cdn/demo/sanwei/static/cesium/Cesium.js"></script>
<script src="js/DragLable.js"></script>
<script src="js/main.js"></script>
</body>
</html>
四、核心一:地形夸张 + 贴地标注
4.1 地形夸张与遮挡
这是"看得清山脉"的前提。两行配置解决:
js
// 开启地形夸张(数值越大越夸张,1 为原始地形)
viewer.scene.globe.terrainExaggeration = 3;
// 启用贴地深度检测:POI 与地形正确遮挡,旋转视角时位置不漂移
viewer.scene.globe.depthTestAgainstTerrain = true;
depthTestAgainstTerrain = true 是关键中的关键:它让 billboard 参与 GPU 深度测试,位于山体之后的标注会被地形自然遮住,而不是"穿透"显示在最上层。
4.2 地形 Provider
js
var token = "你的天地图Key";
var tdtUrl = 'https://t{s}.tianditu.gov.cn/';
var subdomains = ['0','1','2','3','4','5','6','7'];
// 构造多子域地形地址(负载均衡)
var terrainUrls = subdomains.map(function (s) {
return tdtUrl.replace('{s}', s) + 'mapservice/swdx?T=elv_c&tk=' + token;
});
viewer.terrainProvider = new Cesium.GeoTerrainProvider({ urls: terrainUrls });
4.3 为什么"手算高程"会悬浮?
直觉做法是:globe.getHeight() 取真实高程,再乘以 terrainExaggeration,作为 billboard 的绝对高度。但在天地图这套 Cesium 里,渲染网格(着色器内缩放)与 getHeight 返回值的夸张处理并不一致 ------要么 getHeight 已经含夸张,要么网格本身未缩放,结果就是标注被放到约 3 倍高度处,肉眼可见地"飘在半空"。
4.4 正确做法:CLAMP_TO_GROUND 钳制到地表
把"算高度"这件事交给 Cesium 自己。Billboard 加一个 heightReference,引擎会自动把实体钳制到渲染后的地形表面(已包含夸张),与高度计算彻底解耦:
js
var pin = viewer.entities.add({
position: groundPos, // 高度字段被钳制忽略,仅用经纬度
billboard: {
image: poiDotImage,
width: DOT_SIZE,
height: DOT_SIZE,
pixelOffset: new Cesium.Cartesian2(0, 0),
// 关键:钳制到渲染后地表,无论地形怎么夸张都不悬浮
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
}
});
工程经验:凡是"必须贴着地形"的标注,优先用
CLAMP_TO_GROUND,不要自己算高度。手算高度只在需要做屏幕投影锚点 (碰撞布局、背面剔除)时保留,且它只影响投影,不再决定垂直位置,因此即便有误差也不会导致悬浮。
五、核心二:自动环绕旋转(绕点飞行)
"自动旋转"本质是让相机围绕一个目标点,按固定仰角、匀速转动。Cesium 提供了 camera.lookAt(target, HeadingPitchRange) 这一组合拳。
5.1 状态与参数
js
var isSpinning = false;
var spinTarget = null;
var spinHeading = 0;
var spinPitch = Cesium.Math.toRadians(-35); // 固定俯仰角,俯视山脉
var spinRange = 8000; // 环绕半径(米)
var spinSpeed = Cesium.Math.toRadians(4); // 角速度:4°/秒 ≈ 90 秒一圈
var _lastSpinTime = null;
5.2 每帧按"真实时间差"推进角度
旋转必须基于**真实时间差(dt)**推进,而不是"每帧 +固定值",否则高刷新率屏幕转得快、低刷新率转得慢。在 preRender 里做:
js
viewer.scene.preRender.addEventListener(function () {
if (!isSpinning || !spinTarget) return;
var now = performance.now();
if (_lastSpinTime === null) { _lastSpinTime = now; return; } // 起转首帧不跳变
var dt = (now - _lastSpinTime) / 1000; // 秒
_lastSpinTime = now;
spinHeading += spinSpeed * dt; // 角速度 × 时间
viewer.camera.lookAt(
spinTarget,
new Cesium.HeadingPitchRange(spinHeading, spinPitch, spinRange)
);
});
5.3 开始 / 停止
js
function startSpin(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 = '停止旋转';
}
function stopSpin() {
isSpinning = false;
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); // 解除锁定,恢复自由控制
document.getElementById('btnSpin').textContent = '开始旋转';
}
getViewerCenter() 用屏幕中心射线拾取地形,保证绕的是"你正在看的那座山",而不是椭球面上的一个假点:
js
function getViewerCenter() {
var canvas = viewer.scene.canvas;
var center = new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2);
var ray = viewer.camera.getPickRay(center);
var pos = viewer.scene.globe.pick(ray, viewer.scene);
return Cesium.defined(pos) ? pos : viewer.camera.pickEllipsoid(center, viewer.scene.globe.ellipsoid);
}

六、核心三:标签碰撞避让(不隐藏,只错位)
名人墓彼此离得很近,旋转时标签必然重叠。我们的策略是不隐藏、只抬升:重叠的面板在静止位置基础上逐级向上平移,直到在屏幕上错开。
6.1 屏幕投影 +重叠检测
核心思路:把每个标注的地面锚点投影到屏幕坐标系,得到面板矩形的左上角;再用经典的轴对齐包围盒相交判定,决定是否与已布局的面板重叠。
js
var PANEL_RAISE_STEP = 22; // 每次额外上移像素
var PANEL_MAX_RAISE = 12; // 最大上移次数(封顶,防止无限抬升)
function updatePoiVisibility() {
var kept = []; // 已布局面板的屏幕矩形
poiEntities.forEach(function (o) {
var sp = viewer.scene.cartesianToCanvasCoordinates(o.groundPos, new Cesium.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; // 未开启避让:保持原偏移
var w = o.panelW, h = o.panelH, base = o.baseOffset;
var raise = 0, steps = 0, overlap = true, rect = null;
while (steps <= PANEL_MAX_RAISE) {
// 面板锚点"左中":x = 锚点 + 偏移;y 中心再叠加垂直避让量
var left = sp.x + base.x;
var centerY = 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, new Cesium.Cartesian2(base.x, base.y + raise));
kept.push(rect);
});
}
6.2 何时刷新?
相机每次移动(含自动旋转)都要重算------在 postRender 里加一个"相机位移"节流即可,避免每帧空转:
js
var _lastCamPos = null;
viewer.scene.postRender.addEventListener(function () {
var cp = viewer.camera.position;
if (_lastCamPos && Cesium.Cartesian3.equalsEpsilon(cp, _lastCamPos, 1e-3)) return;
_lastCamPos = Cesium.Cartesian3.clone(cp, _lastCamPos);
updatePoiVisibility();
});
被山体遮挡的"隐藏"由 GPU 深度测试负责(见 4.1),CPU 这里只管"屏幕上是否可见 + 是否重叠",职责清晰、性能可控。
七、名人墓数据集与标注渲染
把通用检索换成一份内置的岳麓山名人墓数据(坐标均为约值,建议在工程中按测绘资料校正):
js
// 岳麓山名人墓(坐标为近似位置,实际部署请校正)
var YUELU_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。原点圆点:
js
function createPoiDotImage() {
var S = 2, D = 14 * S;
var canvas = document.createElement('canvas');
canvas.width = canvas.height = D;
var ctx = canvas.getContext('2d'), c = D / 2;
var glow = 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();
var core = 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();
return canvas.toDataURL('image/png');
}
名称面板(圆角胶囊 + 深蓝渐变)思路相同,使用 createPoiNameImage(name),返回 { url, w, h }(w/h 为显示尺寸,供碰撞布局估算屏幕矩形)。
添加标注
js
function addPoiToCesium(t) {
var groundPos = Cesium.Cartesian3.fromDegrees(t.lon, t.lat); // 高度被 CLAMP 忽略
var pin = 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>'
});
var panelImg = createPoiNameImage(t.name);
var card = 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
}
});
var o = dragLable.addEntity(pin, card, null, new Cesium.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);
}
// 初始化:飞到岳麓山上空并标注
function initTombScene() {
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。


