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

一、背景与效果预览

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

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

本文用一个完整可运行的 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盘点岳麓山和上面的那些人

八、踩坑总结(重点)

  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。

相关推荐
前端小崔14 天前
Three.js 与 Cesium 融合实战问题汇总
前端·three.js·cesium
洛兮银儿15 天前
前端页面引入cesium
前端·cesium
GISBox17 天前
数字孪生项目必备:RVT模型转3DTiles格式的高效解决方案
gis·cesium·格式转换·服务发布·gisbox·rvt切片·3dtiles格式
ToTacview17 天前
Web 可视化卡顿的归因方法论:以“流畅一段、停顿一下“为例的代码级根治
cesium
夜郎king19 天前
基于天地图 Cesium 三维服务实现三维地球及地球自转实践
数据分析·cesium·3d地图·三维地球
luckystar513~21 天前
Navara是WebGIS的第三条路吗?
gis·cesium·mapbox·地图引擎·地理可视化
灵境(虚幻知音)1 个月前
WebGL 不支持>1px的线绘制,那 Cesium 的带宽度的线是怎么画出来的?
webgl·cesium·着色器
ToTacview1 个月前
WebGL 没有几何着色器,那 Cesium 的粗线是怎么画出来的?
cesium
用户61595868000221 个月前
Cesium 入门系列(四):区域高亮显示的两种实现方案
cesium