cesium相机
引言:在 Cesium
这个强大的 3D 地理信息可视化引擎中,Camera
(相机)扮演着至关重要的角色。它就像我们观察三维地球场景的 "眼睛",通过对相机的操作,我们可以从不同角度、不同距离来审视地理空间数据。本文将为大家介绍 Cesium
中 Camera
相机常用的 API 及其使用方法。在文章的最后,还有一个根据经纬度实现相机漫游工具类的应用。
camera 基本概念
Cesium 中的 Camera 用于定义观察者在 3D 场景中的位置和视角。它决定了我们在屏幕上看到的内容,包括视野范围、观察点、朝向等。通过调用 Camera 的相关 API,我们可以实现相机的移动、旋转、缩放等操作,从而获得丰富多样的视觉体验。但是只用文字是没办法描述出从人的眼睛出发看到的这个视锥体,下面我用一张图片来形象的说明。
常用 API 介绍及使用
- setView(options):设置相机位置和朝向,该方法用于直接设置相机的位置和朝向。options 参数是一个对象,包含了相机的位置(position)、姿态等信息。具体使用如下:
js
this.viewer.camera.setView({
destination: position,// 位置:经度、纬度、高度 Cesium.Cartesian3.fromDegrees(116.404, 39.915, 1000),
orientation: { // 相机的姿态设置
heading: heading, // 航向角: Cesium.Math.toRadians(0) 方向角,0表示向北
pitch: pitch, // 俯仰角:Cesium.Math.toRadians(-90) 俯仰角,-90表示俯视
roll: roll // 翻滚角
}
});
// 在这个示例中,我们将相机位置设置在经度 116.404、纬度 39.915、高度 1000 米的地方,朝向为向北俯视。
- lookAt(target, offset):这是也是设置视角的位置,区别在于此方法让相机看向目标点(target),并通过偏移量(offset)来确定相机的位置。这里偏移量是指相对于目标点的偏移,决定了相机的位置。另外,相机就固定在这个目标点示例如下:
js
const target = Cesium.Cartesian3.fromDegrees(116.404, 39.915, 0);
const offset = new Cesium.Cartesian3(0, -1000, 500);
viewer.camera.lookAt(target, offset);
- flyTo(options):相机移动操作,该方法可以让相机平滑地飞行到指定位置,具有动画效果。options 参数可以包含目标位置(destination)、飞行时间(duration)等信息。这里着重介绍一下API属性列表,options的参数如下。
名称 | 类型 | 描述 |
---|---|---|
destination |
Cartesian3 / Rectangle | 相机在世界坐标系中的最终位置,或从顶视图可见的矩形。 |
orientation |
object | 可选,包含 direction 和 up 属性,或 heading、pitch 和 roll 属性的对象。默认情况下,在 3D 中方向指向帧的中心,在哥伦布视图中指向负 z 方向。在 3D 中上方向指向本地北方,在哥伦布视图中指向正 y 方向。在 2D 无限滚动模式下不使用方向。 |
duration |
number | 可选,飞行持续时间(以秒为单位)。如果省略,Cesium 会尝试根据飞行的距离计算理想的持续时间。 |
complete |
Camera.FlightCompleteCallback | 可选,飞行完成时执行的函数。 |
cancel |
Camera.FlightCancelledCallback | 可选,飞行被取消时执行的函数。 |
endTransform |
Matrix4 | 可选,飞行完成时相机所在参考系的变换矩阵。 |
maximumHeight |
number | 可选,飞行最高点的最大高度。 |
pitchAdjustHeight |
number | 可选,如果相机飞得高于该值,则在飞行过程中调整俯仰角以向下看,并保持地球在视口中。 |
flyOverLongitude |
number | 可选,地球上两点之间总有两条路径。此选项强制相机选择经过该经度的飞行方向。 |
flyOverLongitudeWeight |
number | 可选,仅当经过flyOverLongitude 指定的经度的路径不长于短路径乘以flyOverLongitudeWeight 时,才选择该路径。 |
convert |
boolean | 可选,是否将目的地从世界坐标转换为场景坐标(仅在不使用 3D 时相关)。默认为true 。 |
easingFunction |
EasingFunction.Callback | 可选,控制飞行持续时间内时间的插值方式。 |
示例: 执行这段代码后,相机会用 5 秒的时间平滑地飞行到经度 120.0、纬度 30.0、高度 2000 米的位置。
js
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(120.0, 30.0, 2000),
duration: 5 // 飞行时间为5秒
});
- flyHome(duration) :使相机飞回主页视图。使用Camera#.DEFAULT_VIEW_RECTANGLE设置 3D 场景的默认视图、2D 和哥伦布视图。
duration
指飞行持续的时间。类似于项目初始化后点击右上角home
按钮恢复视角。 示例:
js
viewer.camera.flyHome(3);
// 3秒钟恢复视角
- rotate(axis, angle):相机旋转操作,该方法使相机绕指定轴(axis)旋转指定角度(angle)。示例:
js
setTimeout(() => {
const axis = new cesium.Cartesian3(0, 1, 0);
const angle = cesium.Math.toRadians(90);
viewer.camera.rotate(axis, angle);
}, 3000)
至于想获取相机的位置,姿态(heading、patch、roll)信息,可以直接访问相机camera
的对应的静态属性。示例如下:
js
const { heading, pitch, roll, position } = viewer.camera
console.log(heading, pitch, roll, position);
camera 视角漫游工具类
实现思路:根据漫游路径的经纬度,算出相机在这个点到下个点的飞行姿态,再利用动画帧requestAnimationFrame
驱动相机的位置,姿态更新,从而实现飞行漫游的效果。由于漫游事件和经纬度数组限制,需要对给定的漫游路径的经纬度数组进行差值计算,具体就是在这个动画帧调用结束到下个动画帧调用开始前这段时间内利用lerp方法
计算差值位置,根据线性差值返回的位置,跟下一个点的位置计算相机的姿态,通过setView
方法更新相机的位置和飞行姿态。详细代码示例和注释如下:
js
// 导入Cesium库,用于3D地理可视化
import * as cesium from 'cesium'
/**
* 漫游控制类,用于在Cesium场景中实现相机沿路径点的平滑飞行
*/
export class Roaming {
/**
* 构造函数,初始化漫游相关属性
* @param {cesium.Viewer} viewer - Cesium的Viewer实例
*/
constructor(viewer) {
this.viewer = viewer; // 保存Cesium视图实例
this.isRoaming = false; // 是否 是否正在漫游的状态标志
this.isPaused = false; // 是否暂停的状态标志
this.startTime = null; // 漫游开始的时间戳
this.pauseTime = null; // 暂停开始的时间戳
this.totalPauseTime = 0; // 累计暂停的总时间(毫秒)
this.waypoints = []; // 路径点数组,存储漫游的关键节点
this.flightTime = 30; // 默认飞行时间30秒
this.currentProgress = 0; // 当前进度(0-1),表示飞行完成的比例
this.currentPosition = null; // 当前位置和姿态信息
this.animationId = null; // 动画帧ID,用于控制动画循环
// 姿态平滑相关属性
this.previousHeading = 0; // 上一帧的航向角(用于平滑过渡)
this.previousPitch = cesium.Math.toRadians(-30); // 上一帧的俯仰角(默认略微向下看)
// 最大转向速率限制,用于控制急转弯时的平滑度
this.maxHeadingChangeRate = cesium.Math.toRadians(60); // 最大每秒转向60度
}
/**
* 开始飞行漫游
* @param {Array} waypoints - 路径点数组,每个点需包含position属性(Cartesian3类型)
* @param {Number} flightTime - 总飞行时间(秒),可选,默认30秒
*/
startRoaming(waypoints, flightTime) {
// 验证路径点:至少需要两个路径点才能形成路径
if (!waypoints || waypoints.length < 2) {
console.error("至少需要两个路径点才能开始漫游");
return;
}
// 验证每个路径点的格式是否正确
waypoints.forEach((waypoint, index) => {
if (!waypoint || !waypoint.position) {
console.error(`路径点 ${index} 格式不正确,缺少position属性`);
return;
}
});
// 停止当前可能正在进行的漫游,确保状态干净
this.endRoaming();
// 初始化漫游参数
this.waypoints = waypoints;
this.flightTime = flightTime || 30; // 使用传入的飞行时间或默认值
this.startTime = performance.now(); // 记录开始时间(高精度时间戳)
this.totalPauseTime = 0; // 重置暂停时间
this.currentProgress = 0; // 重置进度
this.isRoaming = true; // 标记为正在漫游
this.isPaused = false; // 标记为未暂停
// 计算初始姿态(从第一个点看向第二个点)
const firstHeading = this.calculateHeading(waypoints[0].position, waypoints[1].position);
this.previousHeading = firstHeading;
this.previousPitch = this.calculatePitch(waypoints[0].position, waypoints[1].position);
// 开始动画循环
this.updateRoaming();
}
/**
* 更新漫游状态,计算当前位置和姿态,驱动相机移动
* 这是动画循环的核心方法,通过requestAnimationFrame持续调用
*/
updateRoaming() {
// 如果不在漫游状态或已暂停,则不执行更新
if (!this.isRoaming || this.isPaused) return;
// 计算已流逝的时间(扣除暂停时间)
const currentTime = performance.now();
const elapsedTime = (currentTime - this.startTime - this.totalPauseTime) / 1000;
// 计算当前进度(0到1之间)
this.currentProgress = elapsedTime / this.flightTime;
// 如果飞行结束(进度达到或超过1)
if (this.currentProgress >= 1) {
this.currentProgress = 1; // 确保进度不超过1
this.endRoaming(); // 结束漫游,清理资源
// 最后更新一次位置到终点
const { position, heading, pitch, roll } = this.getPositionAndOrientation(this.currentProgress);
this.viewer.camera.setView({
destination: position,
orientation: {
heading: heading,
pitch: pitch,
roll: roll
}
});
return;
}
// 根据当前进度计算位置和姿态
const { position, heading, pitch, roll } = this.getPositionAndOrientation(this.currentProgress);
// 更新相机位置和姿态
this.viewer.camera.setView({
destination: position,
orientation: {
heading: heading,
pitch: pitch,
roll: roll
}
});
// 保存当前状态,用于暂停后恢复和下一次平滑过渡
this.currentPosition = { position, heading, pitch, roll, progress: this.currentProgress };
this.previousHeading = heading;
this.previousPitch = pitch;
// 继续请求下一帧动画,形成循环
this.animationId = requestAnimationFrame(() => this.updateRoaming());
}
/**
* 根据进度获取当前位置和姿态(核心计算方法)
* @param {Number} progress - 进度(0到1),0表示起点,1表示终点
* @returns {Object} 包含position(位置), heading(航向角), pitch(俯仰角), roll(翻滚角)的对象
*/
getPositionAndOrientation(progress) {
// 特殊情况:只有一个路径点时,保持静止
if (this.waypoints.length === 1) {
return {
position: this.waypoints[0].position,
heading: 0,
pitch: cesium.Math.toRadians(-30), // 默认向下30度
roll: 0
};
}
// 计算当前处于哪两个路径点之间
const totalSegments = this.waypoints.length - 1; // 总段数 = 路径点数 - 1
const segmentProgress = progress * totalSegments; // 计算在所有段中的总进度
const segmentIndex = Math.min(Math.floor(segmentProgress), totalSegments - 1); // 当前段索引
const localProgress = segmentProgress - segmentIndex; // 在当前段中的进度(0-1)
// 获取当前段的起点、终点,以及下一段的终点(用于平滑转向)
const start = this.waypoints[segmentIndex];
const end = this.waypoints[segmentIndex + 1];
const nextEnd = this.waypoints[segmentIndex + 2] || end; // 最后一段没有下一段,用当前终点
// 计算位置插值(使用缓动函数使运动更自然)
const position = cesium.Cartesian3.lerp(
start.position, // 起点位置
end.position, // 终点位置
this.easeInOut(localProgress), // 应用缓动后的进度
new cesium.Cartesian3() // 存储结果的对象
);
// 计算当前段和下一段的航向角(相机朝向)
const currentHeading = this.calculateHeading(position, end.position); // 当前指向终点的方向
const nextHeading = segmentIndex < totalSegments - 1
? this.calculateHeading(end.position, nextEnd.position) // 下一段的方向
: currentHeading; // 最后一段保持当前方向
// 计算当前段和下一段的俯仰角(上下视角)
const currentPitch = this.calculatePitch(position, end.position); // 当前段的俯仰角
const nextPitch = segmentIndex < totalSegments - 1
? this.calculatePitch(end.position, nextEnd.position) // 下一段的俯仰角
: currentPitch; // 最后一段保持当前俯仰角
// 判断是否为急转弯(用于调整转向策略)
const headingDiff = Math.abs(this.angleDifference(currentHeading, nextHeading));
const isSharpTurn = headingDiff > cesium.Math.toRadians(45); // 大于45度视为急转弯
// 急转弯时提前开始转向,正常转弯稍晚开始,使转向更平滑
const turnStartThreshold = isSharpTurn ? 0.3 : 0.6; // 0.3表示在当前段30%处开始转向
// 计算目标姿态 - 接近段末尾时,平滑过渡到下一段的方向
let targetHeading, targetPitch;
// 在接近段末尾且不是最后一段时,开始过渡到下一段的方向
if (localProgress > turnStartThreshold && segmentIndex < totalSegments - 1) {
// 计算混合因子(0-1),控制当前段和下一段方向的混合比例
const mixFactor = isSharpTurn
? Math.min((localProgress - turnStartThreshold) / (1 - turnStartThreshold), 1) // 急转弯过渡更快
: (localProgress - turnStartThreshold) / (1 - turnStartThreshold); // 正常转弯过渡较慢
// 混合当前段和下一段的方向
targetHeading = this.lerpAngle(currentHeading, nextHeading, mixFactor);
targetPitch = cesium.Math.lerp(currentPitch, nextPitch, mixFactor);
} else {
// 正常情况下,保持指向当前段的终点
targetHeading = currentHeading;
targetPitch = currentPitch;
}
// 计算每帧允许的最大航向变化(限制转向速度,避免突变)
const frameTime = this.flightTime / totalSegments / 60; // 估计每帧时间(秒)
const maxHeadingChange = this.maxHeadingChangeRate * frameTime;
// 平滑姿态过渡的插值因子(急转弯时更快响应)
const headingLerpFactor = isSharpTurn ? 0.3 : 0.1; // 航向角插值因子
const pitchLerpFactor = isSharpTurn ? 0.2 : 0.1; // 俯仰角插值因子
// 计算新航向角,限制最大变化量(防止转向过快)
let heading = this.lerpAngle(this.previousHeading, targetHeading, headingLerpFactor);
const headingChange = this.angleDifference(heading, this.previousHeading);
// 如果航向变化超过最大值,则限制在最大值内
if (Math.abs(headingChange) > maxHeadingChange) {
const direction = headingChange > 0 ? 1 : -1; // 转向方向
heading = this.normalizeAngle(
this.previousHeading + direction * maxHeadingChange
);
}
// 计算俯仰角(平滑过渡)
const pitch = cesium.Math.lerp(this.previousPitch, targetPitch, pitchLerpFactor);
// 计算翻滚角(转弯时倾斜,增强真实感)
const roll = isSharpTurn ? this.calculateBank(heading, this.previousHeading) : 0;
return { position, heading, pitch, roll };
}
/**
* 计算倾斜角度(转弯时的侧倾,增强飞行真实感)
* @param {Number} currentHeading - 当前航向角(弧度)
* @param {Number} previousHeading - 上一帧航向角(弧度)
* @returns {Number} 倾斜角度(弧度),左转为负,右转为正
*/
calculateBank(currentHeading, previousHeading) {
const headingChange = this.angleDifference(currentHeading, previousHeading);
// 根据转向速度计算倾斜角度,最大15度(限制倾斜幅度)
const maxBank = cesium.Math.toRadians(15);
return cesium.Math.clamp(headingChange * 2, -maxBank, maxBank);
}
/**
* 计算两个角度之间的最小差值(考虑角度的周期性,0-2π)
* @param {Number} a - 角度1(弧度)
* @param {Number} b - 角度2(弧度)
* @returns {Number} 最小差值(弧度),范围在[-π, π]
*/
angleDifference(a, b) {
let diff = a - b;
// 将差值归一化到[-π, π]范围
diff = ((diff + Math.PI) % (2 * Math.PI)) - Math.PI;
return diff;
}
/**
* 计算当前点指向目标点的航向角(适配Cesium 1.128版本)
* @param {cesium.Cartesian3} currentPosition - 当前位置(三维坐标)
* @param {cesium.Cartesian3} targetPosition - 目标位置(三维坐标)
* @returns {Number} 航向角(弧度),0表示正北,顺时针增加
*/
calculateHeading(currentPosition, targetPosition) {
// 将三维坐标转换为地理坐标(经度、纬度、高度)
const currentCartographic = cesium.Cartographic.fromCartesian(currentPosition);
const targetCartographic = cesium.Cartographic.fromCartesian(targetPosition);
// 提取经纬度(弧度)
const startLon = currentCartographic.longitude;
const startLat = currentCartographic.latitude;
const endLon = targetCartographic.longitude;
const endLat = targetCartographic.latitude;
// 使用球面三角法计算方位角(航向角)
const dLon = endLon - startLon; // 经度差
const y = Math.sin(dLon) * Math.cos(endLat);
const x = Math.cos(startLat) * Math.sin(endLat) -
Math.sin(startLat) * Math.cos(endLat) * Math.cos(dLon);
let heading = Math.atan2(y, x); // 计算初始航向角
// 转换为0到2π范围(Cesium航向角定义)
return this.normalizeAngle(heading);
}
/**
* 角度归一化,将角度限制在0到2π之间
* @param {Number} angle - 弧度值
* @returns {Number} 归一化后的弧度值(0 ≤ angle < 2π)
*/
normalizeAngle(angle) {
angle = angle % (2 * Math.PI); // 取模运算
if (angle < 0) {
angle += 2 * Math.PI; // 负数时加上2π,确保在0-2π范围内
}
return angle;
}
/**
* 计算俯仰角(基于高度差和水平距离)
* @param {cesium.Cartesian3} currentPosition - 当前位置
* @param {cesium.Cartesian3} targetPosition - 目标位置
* @returns {Number} 俯仰角(弧度),向下为负,向上为正
*/
calculatePitch(currentPosition, targetPosition) {
// 将位置转换为地理坐标(包含高度信息)
const currentCartographic = cesium.Cartographic.fromCartesian(currentPosition);
const targetCartographic = cesium.Cartographic.fromCartesian(targetPosition);
// 计算水平距离(忽略高度差,仅考虑平面距离)
const horizontalDistance = cesium.Cartesian3.distance(
cesium.Cartesian3.fromRadians(currentCartographic.longitude, currentCartographic.latitude, 0),
cesium.Cartesian3.fromRadians(targetCartographic.longitude, targetCartographic.latitude, 0)
);
// 计算高度差(目标高度 - 当前高度)
const heightDifference = targetCartographic.height - currentCartographic.height;
// 避免除以零(当前点与目标点水平重合时)
if (horizontalDistance < 1e-6) {
return this.previousPitch; // 使用上一帧的俯仰角
}
// 根据高度差和水平距离计算俯仰角(atan2(对边, 邻边))
let pitch = Math.atan2(heightDifference, horizontalDistance);
// 限制俯仰角范围,避免过度俯仰导致视角异常
const minPitch = cesium.Math.toRadians(-60); // 最大下视角60度
const maxPitch = cesium.Math.toRadians(30); // 最大上视角30度
return cesium.Math.clamp(pitch, minPitch, maxPitch); // 限制在范围内
}
/**
* 角度插值(考虑角度的周期性,选择最短路径插值)
* @param {Number} start - 起始角度(弧度)
* @param {Number} end - 目标角度(弧度)
* @param {Number} t - 插值因子(0-1),0返回start,1返回end
* @returns {Number} 插值后的角度(弧度)
*/
lerpAngle(start, end, t) {
// 计算最短路径的角度差(避免绕远路)
let diff = end - start;
if (diff > Math.PI) {
diff -= 2 * Math.PI; // 右侧距离更远,从左侧插值
} else if (diff < -Math.PI) {
diff += 2 * Math.PI; // 左侧距离更远,从右侧插值
}
// 插值后归一化角度
return this.normalizeAngle(start + diff * t);
}
/**
* 缓动函数,使运动先加速后减速,更自然
* @param {Number} t - 0到1之间的进度
* @returns {Number} 缓动后的进度(0到1)
*/
easeInOut(t) {
// 使用正弦曲线缓动:t=0→0,t=0.5→0.5,t=1→1,曲线平滑
return 0.5 * (1 - Math.cos(Math.PI * t));
}
/**
* 暂停或继续漫游
* @param {Boolean} continueFlag - true为继续漫游,false为暂停漫游
*/
pauseOrContinue(continueFlag) {
// 如果不在漫游状态,不执行操作
if (!this.isRoaming) return;
if (continueFlag && this.isPaused) {
// 继续漫游:更新开始时间,扣除已暂停的时间
this.isPaused = false;
this.startTime = performance.now() - (this.currentProgress * this.flightTime * 1000) - this.totalPauseTime;
this.updateRoaming(); // 重新启动动画循环
} else if (!continueFlag && !this.isPaused) {
// 暂停漫游:记录暂停时间,取消动画帧
this.isPaused = true;
this.pauseTime = performance.now();
if (this.animationId) {
cancelAnimationFrame(this.animationId); // 停止动画循环
this.animationId = null;
}
}
}
/**
* 结束漫游,清理状态和资源
*/
endRoaming() {
// 如果正在漫游或已暂停,执行清理
if (this.isRoaming || this.isPaused) {
this.isRoaming = false; // 标记为不在漫游
this.isPaused = false; // 标记为未暂停
// 取消动画帧,停止循环
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
}
}
使用示例:
js
// 测试数据 1
const points = [
// 北京天安门附近
{ lng: 116.3913, lat: 39.9075, height: 100 },
// 北京故宫附近
{ lng: 116.3972, lat: 39.9163, height: 90 },
// 北京景山公园附近
{ lng: 116.4075, lat: 39.9217, height: 80 },
// 北京后海附近
{ lng: 116.3940, lat: 39.9315, height: 70 }
];
// 转换为Cesium的Cartesian3坐标(漫游类要求的格式)
const waypoints = points.map(point => ({
position: cesium.Cartesian3.fromDegrees(
point.lng, // 经度(度)
point.lat, // 纬度(度)
point.height // 高度(米)
)
}));
// 测试数据 2
// const waypoints = [
// { position: Cesium.Cartesian3.fromDegrees(116.4, 39.9, 50) }, // 起点
// { position: Cesium.Cartesian3.fromDegrees(116.45, 39.9, 80) }, // 上升
// { position: Cesium.Cartesian3.fromDegrees(116.5, 39.9, 40) } // 下降
// ];
// 测试数据 3
// const waypoints = [
// { position: Cesium.Cartesian3.fromDegrees(116.4, 39.9, 1000) }, // 起点
// { position: Cesium.Cartesian3.fromDegrees(116.5, 39.9, 1000) }, // 向东
// { position: Cesium.Cartesian3.fromDegrees(116.5, 40.0, 1000) }, // 向北(转弯)
// { position: Cesium.Cartesian3.fromDegrees(116.4, 40.0, 1000) } // 向西(转弯)
// ];
// 开始漫游
roaming.startRoaming(waypoints, 60);
// 暂停或继续漫游
roaming.pauseOrContinue(flag);
// 结束漫游
roaming.endRoaming();
总结:
Cesium
的 Camera API
提供了丰富的相机操控方式,可灵活控制位置、朝向等以实现多样化场景观察,而上述漫游类通过路径插值、姿态过渡等技术实现了相机飞行的平滑自然感,还支持状态控制,适用于路径漫游等场景,二者结合能助力创造精彩的可视化效果。如果感觉文章有帮助,还请一键三连!