天地图 Cesium 加载两步路 KML 轨迹:三维还原岳麓山「爱心线」徒步(附行走模拟完整代码)

一、前言与效果预览

1.1 为什么要做「轨迹三维回放」

做户外、运动、文旅类的可视化项目时,轨迹(Track) 几乎是绕不开的核心数据。与其展示一条冷冰冰的折线,不如让一个小人沿着轨迹在三维地球上走一遍------路线、地形、爬升、时间一起呈现,观感与信息量都会好很多。

好在国内主流的户外 App(两步路、六只脚等)都支持把行程导出为 KML ,而 KML 中的 gx:Track 节点恰好同时包含三样关键信息:逐点坐标、逐点高程、逐点时间戳。有了它们,就能把"一条静态轨迹"变成"一段可以按时间轴回放的三维动画"。

本文以长沙 岳麓山「爱心线」 为例------这是一条约 8.8 km 、累计爬升约 486 m 的环形徒步路线,走完在地图上正好是一个心形。我们用 天地图 Cesium 加载两步路导出的 KML,完整实现一套轨迹三维回放,包括:解析、贴地绘制、途经点标注、沿轨迹行走、视角跟随、最高点停留环绕、途经点到达提示,以及相机防钻地。

1.2 最终效果一览

打开页面后,你将看到:

  • 三维地球自动飞到岳麓山上空,叠加天地图影像、地形起伏与三维地名;
  • 一条青色发光的贴地路线绘制出来,俯瞰就是一颗「爱心」;
  • 沿线分布着途经点圆点(起点绿、终点红、最高点金、其余青),左侧面板同步列出每个点的「累计里程 + 途径时间」;
  • 点「开始行走」,一个小人沿轨迹匀速前进,头部标签实时显示海拔 + 时间,身后留下一条橙色的「已走轨迹」;
  • 走到最高点 会先停留、再环视一圈;走到每个途经点会弹出「到达 XX 时间 HH:MM:SS」并停顿 2 秒,左侧列表同步高亮并自动滚动;
  • 视角可在「自由 / 第一 / 前行」之间切换,任何视角下相机都不会钻到地下。

图1:页面加载后的效果,贴地轨迹沿地形起伏,俯瞰呈心形。


二、环境准备与工程结构

2.1 技术选型与环境

能力 选型 说明
三维引擎 天地图定制版 Cesium(Cesium.Map,版本 1.108) 国内直连,无需翻墙
影像底图 天地图 img_w Web 墨卡托影像
地形高程 天地图 GeoTerrainProvider 真实 DEM
三维地名 天地图 GeoWTFS 随地形起伏
轨迹数据 两步路导出的 KML(gx:Track) 含坐标 + 高程 + 时间

需要说明的是,天地图的 Cesium.Map 是对官方 Cesium.Viewer 的封装,API 与官方基本一致;GeoTerrainProvider、GeoWTFS 是天地图扩展类,必须引用其 CDN 上的 Cesium.js。只要在页面 <head> 引入天地图的 Cesium 及插件脚本,后续代码与官方 Cesium 大同小异。

2.2 工程结构

复制代码
case20260924/
├── yuelushan.html            # 页面骨架 + 引入天地图 Cesium 与脚本
├── yuelushan.kml             # 两步路导出的轨迹(gx:Track)
├── css/
│   └── style.css             # 面板样式
└── js/
    ├── yuelushan-track.js    # 由 KML 生成的轨迹数据(内联,支持 file:// 直接打开)
    └── yuelushan-route.js    # 主逻辑:解析 KML + 绘制 + 行走模拟

整个项目纯前端、零构建 :把 Token 换成自己的,直接双击 yuelushan.html 就能跑。唯一的"非标"之处是那个 yuelushan-track.js,下面单独说明。

2.3 一个关键小技巧:让 file:// 也能加载数据

当页面用双击打开 (即 file:// 协议)时,浏览器出于安全策略会禁止 fetch / XMLHttpRequest 读取本地文件 。这意味着直接 fetch('yuelushan.kml') 会失败,整个轨迹就加载不出来。

项目的解法是:把 KML 预先转成一个 JS 文件 ,用一个全局变量承载数据,再用 <script> 引入。由于 <script src> 不受上述限制,file:// 下也能顺利拿到数据:

javascript 复制代码
// js/yuelushan-track.js
window.YUELUSHAN_TRACK = {
  distance: 8806.11,
  elevationGain: 485.53,
  lons: [112.9425526, /* ... 共 786 个 ... */],
  lats: [28.1905932,  /* ... */],
  eles: [43.94,       /* ... */],
  times: [1773279055000, /* ... */]
};

主逻辑里则做一个降级处理 :优先 fetch KML 文件(走服务器时可用),失败则回退到内联的 window.YUELUSHAN_TRACK。这样无论 file:// 还是 http:// 都能跑。


三、轨迹数据:解析两步路 KML(gx:Track)

3.1 KML 长什么样

两步路导出的 KML 里,轨迹藏在 gx:Track 节点下,形如:

xml 复制代码
<gx:Track>
  <when>2026-03-12T01:30:55Z</when>
  <gx:coord>112.9425526 28.1905932 43.94</gx:coord>
  <when>2026-03-12T01:30:55Z</when>
  <gx:coord>112.9425839 28.1905091 43.94</gx:coord>
  ...
</gx:Track>

两个要点务必记牢:一是 <gx:coord> 的内容是 「经度 纬度 高程」 (空格分隔),顺序是 lon / lat / ele ,不是常见的 lat / lon;二是 <when> 是 ISO8601 时间戳,与 coord 一一对应。

3.2 解析坐标、高程与时间

用 DOMParser 把 KML 文本解析为 XML 文档即可。注意 gx: 属于 Google 扩展命名空间,取节点时要优先用带命名空间的方法:

javascript 复制代码
var GX_NS = 'http://www.google.com/kml/ext/2.2';

// 解析 KML 文本,返回 { doc, lons, lats, eles, times }
function parseGxTrack(kmlText) {
	var doc = new DOMParser().parseFromString(kmlText, 'application/xml');

	// gx:coord 节点(优先按命名空间取,兼容不同解析器)
	var coords = doc.getElementsByTagNameNS(GX_NS, 'coord');
	if (!coords.length) coords = doc.getElementsByTagName('gx:coord');
	if (!coords.length) coords = doc.getElementsByTagName('coord');

	var lons = [], lats = [], eles = [];
	for (var i = 0; i < coords.length; i++) {
		var seg = coords[i].textContent.trim().split(/\s+/);   // "经度 纬度 高程"
		if (seg.length < 2) continue;
		lons.push(parseFloat(seg[0]));
		lats.push(parseFloat(seg[1]));
		eles.push(seg.length > 2 ? parseFloat(seg[2]) : 0);
	}

	// 时间戳 <when>(ISO8601)→ epoch 毫秒(与坐标一一对应,用于显示"时间")
	var whens = doc.getElementsByTagNameNS(GX_NS, 'when');
	if (!whens.length) whens = doc.getElementsByTagName('when');
	var times = [];
	for (var w = 0; w < whens.length; w++) {
		var ms = Date.parse(whens[w].textContent.trim());
		if (!isNaN(ms)) times.push(ms);
	}

	return { doc: doc, lons: lons, lats: lats, eles: eles, times: times };
}

踩坑提醒 :getElementsByTagName('coord') 在部分浏览器里取不到带命名空间的 gx:coord,必须优先用 getElementsByTagNameNS(GX_NS, 'coord');否则会得到空数组、路线画不出来。

3.3 累计里程与途经点采样

拿到逐点坐标后,先沿椭球面逐点累加距离 ,得到累计里程数组 cum 与总里程 total。Cesium 的 Cartesian3.distance 直接给出两点间的球面弦长,对徒步轨迹来说精度足够:

javascript 复制代码
function cumulativeDistance(lons, lats) {
	var cum = [0], total = 0;
	for (var i = 1; i < lons.length; i++) {
		var a = Cesium.Cartesian3.fromDegrees(lons[i - 1], lats[i - 1]);
		var b = Cesium.Cartesian3.fromDegrees(lons[i], lats[i]);
		total += Cesium.Cartesian3.distance(a, b);
		cum.push(total);
	}
	return { cum: cum, total: total };
}

有了里程,就能按固定间隔 (本文取 500 m)采样出「途经点」,并把全程最高海拔点也一并纳入:

javascript 复制代码
var WAYPOINT_INTERVAL = 500;   // 米

function sampleWaypointIndices(lons, lats, eles) {
	var c = cumulativeDistance(lons, lats);
	var idx = [0], last = 0;
	for (var i = 1; i < lons.length; i++) {
		if (c.cum[i] - c.cum[last] >= WAYPOINT_INTERVAL) { idx.push(i); last = i; }
	}
	if (idx[idx.length - 1] !== lons.length - 1) idx.push(lons.length - 1);  // 补上终点

	// 找出最高海拔点并纳入途经点
	var top = 0;
	for (var j = 1; j < eles.length; j++) if (eles[j] > eles[top]) top = j;
	if (idx.indexOf(top) === -1) {
		idx.push(top);
		idx.sort(function (a, b) { return a - b; });
	}
	return { indices: idx, cum: c.cum, total: c.total, topIndex: top };
}

这样采样出来的途经点,既覆盖了固定里程,又保证了"最高点"这个有叙事意义的位置一定会被标注------本文最终得到 20 个途经点。


四、三维呈现:贴地路线与途经点标注

4.1 贴地路线绘制

路线必须贴着地形 ,否则开启地形夸张后会飘在半空、或在视觉上"穿透"山体。关键就是 clampToGround: true。为了更醒目,我们用「深色描边 + 青色发光主线」两层叠加:描边负责在浅色底图上拉出对比,主线负责视觉焦点。

javascript 复制代码
function drawRoute(lons, lats) {
	var flat = [];
	for (var i = 0; i < lons.length; i++) { flat.push(lons[i], lats[i]); }
	var positions = Cesium.Cartesian3.fromDegreesArray(flat);   // 一次性批量生成,比逐点 fromDegrees 更快

	// 外描边(深色底衬,增强对比)
	viewer.entities.add({
		name: '路线描边',
		polyline: {
			positions: positions,
			width: 12,
			material: Cesium.Color.fromCssColorString('#03203a').withAlpha(0.55),
			clampToGround: true            // 贴地:随地形起伏(含夸张),不悬空
		}
	});

	// 主线(发光青色)
	viewer.entities.add({
		name: '岳麓山徒步路线',
		polyline: {
			positions: positions,
			width: 6,
			material: new Cesium.PolylineGlowMaterialProperty({
				glowPower: 0.22,
				color: Cesium.Color.fromCssColorString('#22b7f5')
			}),
			clampToGround: true
		}
	});
}

4.2 途经点圆点与标签

每个途经点用一个 billboard(圆点图)+ label(名称)标注。圆点图用 Canvas 现场绘制(高清 2 倍),按类型区分颜色。关键点 :圆点要 heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,让引擎把它钳制到"渲染后的地表",即便地形被夸张也不会悬浮。

javascript 复制代码
viewer.entities.add({
	position: Cesium.Cartesian3.fromDegrees(lon, lat),   // 高度由 CLAMP 决定
	billboard: {
		image: dotImg,                                   // Canvas 生成的圆点图(起点绿/终点红/最高点金/其余青)
		width: 16, height: 16,
		heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
	},
	label: {
		text: labelText,
		font: 'bold 13px sans-serif',
		style: Cesium.LabelStyle.FILL_AND_OUTLINE,
		heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
	}
});

4.3 面板与列表渲染

左侧面板列出每个途经点的「名称 + 累计里程 + 途径时间」。其中"途径时间"有两种取法:若 KML 带时间戳,就按里程占比在首末时间之间线性映射出真实钟点;若没有时间戳,则按模拟时长估算。这样即便轨迹没有时间信息,列表也不会空着。

javascript 复制代码
// 途经点"途径时间":按该点在全程中的里程占比 f 推算到达时刻
function formatWaypointTime(f, timesArr) {
	if (timesArr && timesArr.length > 1) {           // 有 KML 时间戳:按真实时间线性映射
		var ms = timesArr[0] + f * (timesArr[timesArr.length - 1] - timesArr[0]);
		var d = new Date(Math.round(ms));
		return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
	}
	var el = Math.round(f * WALK_DURATION);          // 否则按模拟时长估算
	return pad2(Math.floor(el / 60)) + ':' + pad2(el % 60);
}

五、重头戏:小人沿轨迹行走的三维回放

5.1 总体思路

这一部分是全篇的核心。核心思想可以浓缩成一句话:把「累计里程」映射到一段可观看的模拟时间(WALK_DURATION 秒),用 SampledPositionProperty 描述行人位置,交给 viewer.clock 驱动推进;行人的图标、模型、以及"已走轨迹"都贴地,不悬浮。

之所以不直接按 KML 的真实时间回放,是因为真实徒步动辄一两个小时,观感太慢;把它"压缩"成 5 分钟(本文 WALK_DURATION = 300)更适合演示。而"按里程映射"而非"按点数映射",能保证小人匀速 前进(点数在平缓路段会更密集)。

5.2 关键帧:把里程映射为时间轴

我们先把每个轨迹点整理成关键帧 { t: 行进秒数, idx: 原始索引, d: 里程, e: 海拔 },其中 t = 该点里程占全程比例 × WALK_DURATION。随后在最高点 处插入一段「停留 + 环绕」的时间窗,让后续所有点整体后移------这样就能在不打断匀速的前提下实现停留。

javascript 复制代码
var WALK_DURATION = 300;     // 走完全程的模拟时长(秒,不含停留)------越大越慢
var SUMMIT_PAUSE  = 5;       // 最高点先停留 5 秒
var SUMMIT_ORBIT_SEC = 10;   // 再用 10 秒环绕最高点

// 逐点生成关键帧(示意,完整见源码 buildWalkKeys)
function buildWalkKeys(lons, lats, eles, sp) {
	var keys = [];
	for (var i = 0; i < lons.length; i++) {
		keys.push({
			t: WALK_DURATION * (sp.cum[i] / sp.total),   // 行进秒数:按里程占比
			idx: i,
			d: sp.cum[i],
			e: eles[i]
		});
	}
	// 在最高点位置插入"停留 + 环绕"的时间窗(后续点整体后移)......
	return keys;
}

运行时,用二分查找根据当前秒数定位所在关键帧区间,再在区间内做线性插值,得到当前里程、海拔与原始索引:

javascript 复制代码
function keyframeAtSec(sec) {
	if (sec <= walkKeys[0].t) return { i: 0, j: 0, f: 0 };
	var last = walkKeys.length - 1;
	if (sec >= walkKeys[last].t) return { i: last, j: last, f: 0 };
	var lo = 0, hi = last;
	while (lo < hi) { var mid = (lo + hi) >> 1; if (walkKeys[mid].t < sec) lo = mid + 1; else hi = mid; }
	var i = lo - 1;
	// 区间 [i, i+1],f 为区间内比例......
	return { i: i, j: i + 1, f: f };
}

5.3 用 SampledPositionProperty 驱动位置

Cesium 的 SampledPositionProperty 专门用于"按时间采样的位置",它会自动在样本之间插值,非常适合做轨迹回放。我们把每个关键帧的(时间、位置)加进去,位置用 KML 的高程(贴地由 CLAMP_TO_GROUND 负责):

javascript 复制代码
function initWalker(lons, lats, eles, times, cum, total) {
	// ......构建 walkKeys、walkStart / walkStop......

	walkProp = new Cesium.SampledPositionProperty();
	walkProp.setInterpolationOptions({
		interpolationDegree: 1,                              // 线性插值,轨迹更"顺"
		interpolationAlgorithm: Cesium.LinearApproximation
	});

	for (var i = 0; i < walkKeys.length; i++) {
		var k = walkKeys[i];
		var t = Cesium.JulianDate.addSeconds(walkStart, k.t, new Cesium.JulianDate());
		walkProp.addSample(t, Cesium.Cartesian3.fromDegrees(lons[k.idx], lats[k.idx], k.e));
	}

	// 行人实体:兜底图标 + 实时标签(3D 人物模型的处理见 5.5)
	walker = viewer.entities.add({
		name: '模拟行人',
		position: walkProp,
		billboard: {
			image: WALKER_FALLBACK_IMAGE,
			width: 40, height: 60,
			verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
			heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
		},
		label: { /* 海拔 + 时间,由 CallbackProperty 实时更新 */ }
	});

	// 时钟:把整段模拟框成 startTime ~ stopTime
	viewer.clock.startTime = walkStart.clone();
	viewer.clock.stopTime  = walkStop.clone();
	viewer.clock.currentTime = walkStart.clone();
	viewer.clock.clockRange = Cesium.ClockRange.CLAMPED;   // 到终点即停,不回绕
	viewer.clock.multiplier = 1;                           // 倍速
	viewer.clock.shouldAnimate = false;
}

5.4 开始 / 停止与「已走轨迹」

"开始行走"本质就是把时钟打开。这里加了一个贴心处理:如果当前已经走到终点,再点"开始"就从起点重播。

javascript 复制代码
function startWalk() {
	if (Cesium.JulianDate.greaterThanOrEquals(viewer.clock.currentTime, viewer.clock.stopTime)) {
		viewer.clock.currentTime = viewer.clock.startTime.clone();   // 已到终点则重播
	}
	viewer.clock.shouldAnimate = true;
	document.getElementById('btnWalk').innerText = '停止行走';
}
function stopWalk() {
	viewer.clock.shouldAnimate = false;
	document.getElementById('btnWalk').innerText = '开始行走';
}

"已走轨迹"则用 CallbackProperty 按当前进度切片,天然贴地;注意处理 idx < 1,避免生成零长度折线导致渲染告警:

javascript 复制代码
walkTrail = viewer.entities.add({
	polyline: {
		positions: new Cesium.CallbackProperty(function () {
			var idx = currentWalkIndex();
			if (idx < 1) return [walkCartesians[0], walkCartesians[0]];   // 避免零长度几何
			return walkCartesians.slice(0, idx + 1);
		}, false),
		width: 6,
		material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.2, color: Cesium.Color.ORANGE }),
		clampToGround: true
	}
});

5.5 人物朝向(ENU 参考系校准)

人物要"头朝行进方向"。做法是:在行人所在点建立一个 ENU 参考系 (X 指向东、Y 指向北、Z 指向天),把行进方向作为模型的局部 +X 、把竖直向上作为局部 +Z,据此构造旋转矩阵,再转成四元数。

javascript 复制代码
function walkerOrientation() {
	var p = walkProp.getValue(viewer.clock.currentTime);
	if (!Cesium.defined(p)) return undefined;
	var h = (_smoothHeading === null) ? walkerHeadingRad() : _smoothHeading;
	h += WALKER_YAW;                                        // 可校准偏航(模型轴向差异用)

	var enu = Cesium.Transforms.eastNorthUpToFixedFrame(p); // X=东, Y=北, Z=上
	var m3 = Cesium.Matrix4.getMatrix3(enu, new Cesium.Matrix3());
	var east  = Cesium.Matrix3.getColumn(m3, 0, new Cesium.Cartesian3());
	var north = Cesium.Matrix3.getColumn(m3, 1, new Cesium.Cartesian3());
	var up    = Cesium.Matrix3.getColumn(m3, 2, new Cesium.Cartesian3());

	// 行进方向(水平):east*sin h + north*cos h
	var forward = Cesium.Cartesian3.add(
		Cesium.Cartesian3.multiplyByScalar(east, Math.sin(h), new Cesium.Cartesian3()),
		Cesium.Cartesian3.multiplyByScalar(north, Math.cos(h), new Cesium.Cartesian3()),
		new Cesium.Cartesian3());
	Cesium.Cartesian3.normalize(forward, forward);

	var right = Cesium.Cartesian3.cross(up, forward, new Cesium.Cartesian3());
	Cesium.Cartesian3.normalize(right, right);

	var rot = new Cesium.Matrix3();
	Cesium.Matrix3.setColumn(rot, 0, forward, rot);         // 局部 +X = 前进
	Cesium.Matrix3.setColumn(rot, 1, right, rot);
	Cesium.Matrix3.setColumn(rot, 2, up, rot);              // 局部 +Z = 上
	return Cesium.Quaternion.fromRotationMatrix(rot);
}
// 朝向必须挂在"实体"上(回调每帧求值):
walker.orientation = new Cesium.CallbackProperty(walkerOrientation, false);

为避免在各段边界出现"猛然转向",可行进方位角做前视 + 低通滤波 :先取"前方 LOOKAHEAD_SEC 秒处"的方向作为目标航向,再用一个小时间常数的低通滤波平滑跟随。

坑点 :ModelGraphics 没有 orientation 属性,写在 model 里会被静默忽略。朝向必须设在 entity.orientation 上,ModelVisualizer 才会用它给模型定向。

5.6 三种跟随视角

视角分三档:自由视角 不干预相机;第一视角 把相机贴到行人头部前方;前行视角 把相机放在行人后上方跟随。核心是用 camera.lookAtTransform(frame, 局部偏移) ------先建立以行人为原点的局部坐标系,再用一个局部位移量把相机"挂"上去。

javascript 复制代码
function applyFollowView() {
	var p = walkProp.getValue(viewer.clock.currentTime);
	if (!Cesium.defined(p)) return;
	var h = _smoothHeading === null ? walkerHeadingRad() : _smoothHeading;
	var transform = Cesium.Transforms.headingPitchRollToFixedFrame(p,
		new Cesium.HeadingPitchRoll(h, 0, 0));

	if (viewMode === 'first') {
		// 第一视角:位于头部略前方,朝前行方向
		viewer.camera.lookAtTransform(transform, new Cesium.Cartesian3(FP_AHEAD, 0, FP_HEIGHT));
	} else {
		// 前行视角:后上方跟随
		viewer.camera.lookAtTransform(transform, new Cesium.Cartesian3(TP_AHEAD, TP_BACK, TP_HEIGHT));
	}
}

5.7 最高点停留与环绕

到达最高点时,让时钟冻结 (shouldAnimate = false)停留数秒,随后相机绕最高点环绕若干圈,环绕角度按真实时间差 推进(与帧率无关)。环绕结束后要还原初始参数 :相机恢复前行视角、multiplier 还原为开始行走时记录的值。

javascript 复制代码
// 每帧检测是否进入"最高点时间窗"
var sec = Cesium.JulianDate.secondsDifference(viewer.clock.currentTime, walkStart);
if (sec >= _summitFrom && sec <= _summitTo) {
	viewer.clock.shouldAnimate = false;             // 冻结时钟:匀速参数天然保持
	applySummitOrbit(sec - _summitFrom);            // 按已停留时长推进环绕角度
}

function applySummitOrbit(elapsed) {
	// 环绕角度按 elapsed / SUMMIT_ORBIT_SEC 推进,走满 SUMMIT_ORBIT_TURNS 圈
	var heading = Cesium.Math.TWO_PI * (elapsed / SUMMIT_ORBIT_SEC) * SUMMIT_ORBIT_TURNS;
	viewer.camera.lookAt(summitPos,
		new Cesium.HeadingPitchRange(heading, Cesium.Math.toRadians(-25), SUMMIT_ORBIT_RANGE));
}

5.8 途经点到达提示与列表同步高亮

预先构建一张「停留表」,记录每个途经点的到达秒数、标签与所在的列表行号。行走时每帧检测是否跨过下一个途经点:一旦跨过,就冻结时钟停留 2 秒、弹出提示、并高亮左侧对应行。

javascript 复制代码
if (_nextWp < _wpStops.length) {
	var sec = Cesium.JulianDate.secondsDifference(viewer.clock.currentTime, walkStart);
	if (sec >= _wpStops[_nextWp].t) {
		var wp = _wpStops[_nextWp++];
		viewer.clock.shouldAnimate = false;              // 冻结 2 秒
		_pauseUntil = Date.now() + wp.pause * 1000;
		showArrivalNotice(wp.label, walkTimeText());     // "📍 到达 P3 时间 10:07:22"
		highlightWaypointRow(wp.rowIndex);               // 列表高亮 + 滚动
	}
}
// 停留结束后恢复行走
if (_pauseUntil > 0 && Date.now() >= _pauseUntil) { _pauseUntil = 0; viewer.clock.shouldAnimate = true; }

左侧列表的高亮与滚动如下,scrollIntoView({ block: 'nearest' }) 会让滚动条刚好把当前行滚进可视区:

javascript 复制代码
function highlightWaypointRow(rowIndex) {
	_wpRows.forEach(function (r) { r.style.background = ''; r.style.boxShadow = ''; });
	var row = _wpRows[rowIndex];
	if (!row) return;
	row.style.background = 'linear-gradient(90deg,#fff3d6,#ffe1a0)';
	row.style.boxShadow = 'inset 3px 0 0 #ff8c1a';
	row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}

5.9 相机防钻地

地形夸张之后,某些视角(尤其第一视角贴地跟随)相机可能穿到地表以下。做法是:在 preRender 每帧把相机世界坐标换算成"经纬高",若低于「地表(含夸张)+ 保底高度」,就沿地理"上"方向把相机抬回来。

javascript 复制代码
var _camGroundMargin = 6;   // 相机距地表的保底高度(米)

function clampCameraAboveGround() {
	var cam = viewer.camera;
	var carto = Cesium.Cartographic.fromCartesian(cam.positionWC);
	if (!Cesium.defined(carto)) return;
	var gh = viewer.scene.globe.getHeight(carto);
	var terr = Cesium.defined(gh) ? gh * (viewer.scene.globe.terrainExaggeration || 1) : 0;
	if (carto.height < terr + _camGroundMargin) {
		viewer.camera.setView({
			destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, terr + _camGroundMargin),
			orientation: { heading: cam.heading, pitch: cam.pitch, roll: cam.roll }
		});
	}
}
viewer.scene.preRender.addEventListener(clampCameraAboveGround);

六、踩坑总结与运行方式

6.1 十个高频坑点

  1. gx:coord 取不到 :必须用 getElementsByTagNameNS('http://www.google.com/kml/ext/2.2', 'coord'),别只写 getElementsByTagName('coord')。
  2. 坐标顺序 :gx:coord 是 「经度 纬度 高程」 ,写成 fromDegrees(lat, lon) 会飞到另一个半球。
  3. file:// 读不到 KML :浏览器禁止 file:// 下 fetch/XHR 本地文件。解法是把 KML 转成 window.YUELUSHAN_TRACK = {...} 的 JS,用 <script> 引入;或起个本地静态服务器。
  4. 轨迹悬空 / 钻地 :折线、圆点、模型都要 clampToGround: true 或 heightReference: CLAMP_TO_GROUND。不要自己用 getHeight() 手算高度 ------地形夸张下 getHeight 与渲染网格的语义在该 Cesium 版本里不一致,手算必飘。
  5. PointGraphics 不受地形遮挡 :该定制版 Cesium 里点始终画在最上层。改用 billboard 才能被山体正确遮挡。
  6. 人物朝向 :朝向要设在 entity.orientation(ModelGraphics 无 orientation);用 ENU 参考系构造旋转矩阵,再用 WALKER_YAW 做 ±90° 校准。
  7. 模型看不见 vs 兜底图标 :若想"用 3D 人物模型替换图标",务必等模型真正就绪后再隐藏兜底图标 ;否则模型没加载出来、图标又已隐藏,就会"人物彻底消失"。稳妥做法是轮询场景中是否存在 ready === true 的 Cesium.Model,就绪后再隐藏。
  8. 停留不打断匀速 :途经点 / 最高点的停留用冻结时钟 (shouldAnimate=false)实现,SampledPositionProperty 的样本与倍速都不受影响,恢复后天然续走。
  9. 环绕后要还原 :最高点环绕会改动相机与倍速,结束时记得还原 开始行走时记录的 multiplier 与跟随视角,否则后续速度 / 视角全乱。
  10. 旋转与帧率无关 :环绕角度按 performance.now() 的真实时间差推进,而非"每帧 +固定值",否则高刷屏转得飞快。

6.2 运行方式

  1. 替换 token 为你自己的天地图 Key(申请地址,需勾选影像 / 地形 / 注记服务);
  2. 保持目录结构不变(yuelushan.html 同级有 css/、js/);
  3. 直接双击 yuelushan.html 即可(项目已把 KML 内联成 JS 以支持 file://);也可用静态服务器:

Cesium+KML还原岳麓山爱心线

操作:点「飞到路线」总览爱心线 → 选「前行视角」→ 点「开始行走」即可观看三维回放。

6.3 可扩展方向

这套骨架还可以继续生长:多轨迹叠加对比、按日期历史回放、按坡度 / 配速着色、轨迹导入导出、生成分享链接等。只要轨迹数据到手,剩下的就是三维表达。


七、结语

本文用「两步路 KML → 天地图 Cesium」串起了一条完整的三维轨迹回放 链路:解析 gx:Track、贴地绘制、里程 / 时间映射、SampledPositionProperty 驱动行走、三种跟随视角、最高点停留环绕、途经点提示与列表同步,以及相机防钻地。

把「岳麓山爱心线」这条环形轨迹在地图上还原出来,既是一次技术练手,也是户外轨迹可视化的一个通用骨架------稍加改造即可套用到越野跑、骑行、Citywalk 等各类场景。

如果觉得有用,欢迎点赞收藏,有问题在评论区交流~

相关推荐
夜郎king7 天前
基于 Cesium + 天地图的三维地理可视化:以“岳麓山名人墓“为例,详解地形夸张、自动环绕与标签碰撞避让
cesium·实景三维·人文历史
三维地图技术社区1 个月前
一张实景三维地图是怎么来的?从无人机航测到倾斜摄影建模的完整技术链路
gis·实景三维·三维可视化大屏·bim·无人机航测·倾斜摄影建模·空三解算
大势智慧6 个月前
使用低空大师进行无人机巡查一段时间后,如何确定重点整治区域?
人工智能·ai·无人机·实景三维·低空经济·事件预警
大势智慧7 个月前
大高差场景室内外一体化三维扫描的难点在哪?
实景三维·三维扫描·水库·大坝·难点·一体化
大势智慧8 个月前
大势智慧与土耳其合作发展中心、蕾奥规划签署土耳其智慧城市项目战略合作协议
人工智能·ai·智慧城市·三维建模·实景三维·发展趋势·创新
大势智慧10 个月前
学术报告 | 黄先锋:大遗址数字化关键技术和应用
数字化·实景三维·关键技术·三维模型·科技创新
大势智慧10 个月前
DasViewer 4.0 重磅发布,全面增强3DGS处理能力
3d·渲染·应用·效率·实景三维·高斯·成果
大势智慧1 年前
6.6重建大师常见空三问题
大数据·人工智能·科技·三维重建·实景三维
大势智慧2 年前
用DasViewer的时候3Dtiles 转osgb 可以直接指定目标坐标系吗?
3d·实景三维·三维模型