Three.js与Cesium如何融合?具体怎么实现?

GIS基础理论
Dr.GIS
wowwwai GIS研习社 · 工具流程与项目排障

很多 WebGIS 开发者都会遇到一个问题:Three.js与Cesium如何融合?具体怎么实现? Cesium 擅长三维地球、地形、影像和 GIS 坐标体系,Three.js 擅长自定义三维模型、粒子、材质和动画效果。两者融合的核心目标,是让 Three.js 的对象正确叠加到 Cesium 场景中,并且跟随 Cesium 相机、坐标和渲染循环同步变化。

引言:为什么要把 Three.js 与 Cesium 融合

在实际 WebGIS 项目中,单独使用 Cesium 可以完成三维地球、倾斜摄影、3D Tiles、地形和矢量数据展示。但如果你需要更复杂的三维视觉效果,例如设备动态动画、粒子扩散、流光线、建筑内部效果、复杂材质或自定义 shader,Three.js 会更灵活。

因此,Three.js 与 Cesium 融合常见于以下场景:

  • 在 Cesium 三维地球上叠加 Three.js 自定义模型。
  • 在城市三维场景中显示雷达扫描、光柱、热力扩散等特效。
  • 用 Cesium 管理 GIS 坐标和相机,用 Three.js 管理复杂三维对象。
  • 在已有 Cesium 项目中复用 Three.js 组件或动画效果。

这篇文章重点解决一个具体问题:如何在 Cesium 场景中正确叠加并同步渲染 Three.js 对象。文章不会停留在概念层面,而是给出可操作的实现思路、关键代码、常见坑和检查清单。

Three.js与Cesium融合 Cesium叠加Three.js对象实现流程
Three.js 与 Cesium 融合的关键是坐标转换、相机同步和渲染循环控制。

背景:Cesium 和 Three.js 各自适合做什么

在讨论具体实现前,需要先明确两者的职责边界。很多融合失败的问题,本质上是因为把 Cesium 和 Three.js 的工作范围混在了一起。

工具 更适合的任务 在融合方案中的角色
Cesium 三维地球、地形、影像、3D Tiles、GIS 坐标、相机导航 作为主场景,负责地图和空间参考
Three.js 自定义模型、材质、动画、粒子、后处理效果 作为叠加渲染层,负责复杂三维视觉对象
融合逻辑 坐标转换、相机同步、渲染循环同步 保证两个场景看起来在同一个三维空间中

推荐的思路是:Cesium 做主引擎,Three.js 做叠加层。也就是说,用户的地图浏览、相机视角、地球坐标、地形和底图都由 Cesium 管理;Three.js 只负责绘制需要额外表现力的三维对象。

原理:Three.js 与 Cesium 融合的三个关键点

1. 坐标系统必须统一

Cesium 使用的是地球空间坐标体系。常见输入是经纬度和高度,例如 WGS84 的 longitude、latitude、height。Cesium 内部会把它们转换为 Cartesian3,也就是地心地固坐标。

Three.js 默认使用普通三维笛卡尔坐标系,通常是 x、y、z。它本身不理解经纬度、椭球体和地理坐标。因此,Three.js 对象要放到 Cesium 地球上的某个位置,就必须把经纬度转换为 Three.js 可用的局部坐标。

在工程中常见做法是:

  • 选择一个经纬度点作为局部坐标原点。
  • 使用 Cesium 将经纬度转换为 Cartesian3。
  • 构造局部 East-North-Up 坐标系,简称 ENU 坐标系。
  • 将 Three.js 对象放入这个局部坐标空间中。

2. Three.js 相机必须跟随 Cesium 相机

如果只把 Three.js 对象放到页面上,而不处理相机同步,那么缩放、旋转、倾斜 Cesium 地球时,Three.js 对象会漂移、错位,甚至完全不跟随地图移动。

正确做法是:每一帧都读取 Cesium 当前相机的视图矩阵、投影矩阵或相机位置方向,然后同步给 Three.js 的 Camera。这样 Cesium 和 Three.js 才会从同一个视角观察场景。

3. 渲染循环必须同步

Cesium 有自己的渲染循环,Three.js 也有自己的 renderer。如果两者各自独立渲染,容易出现闪烁、遮挡异常、性能浪费和帧率不稳定。

常见方式是把 Three.js 渲染放到 Cesium 的 postRender 或 preRender 回调中,让 Three.js 在 Cesium 每帧渲染前后同步绘制。

步骤:Three.js 与 Cesium 如何融合,具体怎么实现

步骤一:准备页面结构

通常需要两个容器:一个给 Cesium,一个给 Three.js。Three.js 的 canvas 覆盖在 Cesium canvas 上方,并且关闭鼠标事件,让交互仍然由 Cesium 处理。

<div id="cesiumContainer"></div>
<div id="threeContainer"></div>

样式上要保证两个容器完全重叠:

#cesiumContainer {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
}

#threeContainer {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

pointer-events: none 很重要。否则 Three.js 的 canvas 会挡住 Cesium 的鼠标缩放、拖拽和点击事件。

步骤二:初始化 Cesium Viewer

先创建 Cesium Viewer,并把它作为主场景。实际项目中可以根据需要加载地形、影像、3D Tiles 或矢量图层。

const viewer = new Cesium.Viewer('cesiumContainer', {
  animation: false,
  timeline: false,
  baseLayerPicker: false,
  geocoder: false,
  sceneModePicker: false,
  navigationHelpButton: false,
  infoBox: false,
  selectionIndicator: false
});

viewer.scene.globe.depthTestAgainstTerrain = true;

如果 Three.js 对象需要和地形或三维模型产生空间关系,可以开启 depthTestAgainstTerrain。但要注意,这并不等于 Three.js 对象自动参与 Cesium 深度测试,两者是不同渲染系统。

步骤三:初始化 Three.js 场景

Three.js 需要独立的 Scene、Camera 和 WebGLRenderer。由于相机后面会跟随 Cesium 同步,这里的相机先创建出来即可。

const threeScene = new THREE.Scene();

const threeCamera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  1,
  100000000
);

const threeRenderer = new THREE.WebGLRenderer({
  alpha: true,
  antialias: true
});

threeRenderer.setSize(window.innerWidth, window.innerHeight);
threeRenderer.setPixelRatio(window.devicePixelRatio);
threeRenderer.autoClear = true;

document.getElementById('threeContainer').appendChild(threeRenderer.domElement);

这里的 alpha: true 用于让 Three.js 背景透明,从而叠加在 Cesium 三维地球之上。

步骤四:把经纬度位置转换为 Three.js 局部坐标

假设我们要在某个经纬度位置放置一个 Three.js 立方体。先定义中心点:

const originLng = 116.3913;
const originLat = 39.9075;
const originHeight = 100;

const originCartesian = Cesium.Cartesian3.fromDegrees(
  originLng,
  originLat,
  originHeight
);

然后构造局部坐标变换矩阵。Cesium 提供了 EastNorthUpToFixedFrame,可以得到某一点的局部东、北、上坐标系到地球固定坐标系的变换矩阵。

const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(originCartesian);

Three.js 不能直接使用 Cesium.Matrix4,需要把矩阵元素转换为 Three.js 的 Matrix4。注意矩阵顺序和坐标轴方向需要在实际项目中验证。

function cesiumMatrixToThreeMatrix(cesiumMatrix) {
  return new THREE.Matrix4().set(
    cesiumMatrix[0], cesiumMatrix[4], cesiumMatrix[8],  cesiumMatrix[12],
    cesiumMatrix[1], cesiumMatrix[5], cesiumMatrix[9],  cesiumMatrix[13],
    cesiumMatrix[2], cesiumMatrix[6], cesiumMatrix[10], cesiumMatrix[14],
    cesiumMatrix[3], cesiumMatrix[7], cesiumMatrix[11], cesiumMatrix[15]
  );
}

如果你的对象出现旋转方向不对、上下颠倒或位置偏移,优先检查矩阵转换和坐标轴定义。

步骤五:创建 Three.js 对象并放到 Cesium 对应位置

下面用一个立方体做示例。实际项目中可以替换为 GLTF 模型、粒子系统或自定义 shader 对象。

const geometry = new THREE.BoxGeometry(100, 100, 100);
const material = new THREE.MeshNormalMaterial();
const box = new THREE.Mesh(geometry, material);

const threeMatrix = cesiumMatrixToThreeMatrix(modelMatrix);
box.applyMatrix4(threeMatrix);

threeScene.add(box);

这里的 100 表示 Three.js 空间中的长度单位。融合时通常把它理解为米,但前提是你的局部坐标转换方式与米制尺度一致。

步骤六:同步 Three.js 相机与 Cesium 相机

这是融合中最关键的部分。Cesium 相机变化后,Three.js 相机必须同步更新。下面给出一种常见实现思路:从 Cesium 相机获取位置、方向和上方向,然后设置 Three.js 相机。

function syncThreeCameraWithCesium() {
  const cesiumCamera = viewer.camera;

  const position = cesiumCamera.positionWC;
  const direction = cesiumCamera.directionWC;
  const up = cesiumCamera.upWC;

  threeCamera.position.set(position.x, position.y, position.z);
  threeCamera.up.set(up.x, up.y, up.z);

  const target = new THREE.Vector3(
    position.x + direction.x,
    position.y + direction.y,
    position.z + direction.z
  );

  threeCamera.lookAt(target);

  const width = viewer.scene.canvas.clientWidth;
  const height = viewer.scene.canvas.clientHeight;

  threeCamera.aspect = width / height;
  threeCamera.fov = Cesium.Math.toDegrees(viewer.camera.frustum.fovy);
  threeCamera.near = viewer.camera.frustum.near;
  threeCamera.far = viewer.camera.frustum.far;
  threeCamera.updateProjectionMatrix();

  threeRenderer.setSize(width, height);
}

这段代码的目标不是“复制整个 Cesium 相机对象”,而是让 Three.js 从同样的位置、方向和投影参数观察场景。

步骤七:把 Three.js 渲染放进 Cesium 渲染循环

最后,在 Cesium 的 postRender 事件中同步相机并渲染 Three.js 场景。

viewer.scene.postRender.addEventListener(function () {
  syncThreeCameraWithCesium();

  box.rotation.z += 0.01;

  threeRenderer.clear();
  threeRenderer.render(threeScene, threeCamera);
});

这样,当用户缩放、旋转或倾斜 Cesium 地球时,Three.js 对象会跟随相机变化,并保持在对应地理位置附近。

步骤八:窗口大小变化时同步尺寸

实际项目中还需要监听窗口变化,否则浏览器窗口调整后会出现 Cesium 正常但 Three.js 拉伸、错位的问题。

window.addEventListener('resize', function () {
  const width = viewer.scene.canvas.clientWidth;
  const height = viewer.scene.canvas.clientHeight;

  threeCamera.aspect = width / height;
  threeCamera.updateProjectionMatrix();
  threeRenderer.setSize(width, height);
});

常见坑:Three.js 与 Cesium 融合时最容易出错的地方

1. Three.js 对象位置漂移

如果对象在地球上漂移,通常不是模型问题,而是坐标转换或相机同步问题。优先检查:

  • 经纬度是否使用 WGS84 坐标。
  • 高度单位是否为米。
  • Cesium.Cartesian3.fromDegrees 的参数顺序是否是 longitude、latitude、height。
  • 是否每一帧同步了 Three.js 相机。
  • 矩阵行列顺序是否转换正确。

2. 模型大小异常

Three.js 模型导入后可能非常大或非常小,原因通常是模型单位和地理空间尺度不一致。比如模型制作软件使用厘米,而 WebGIS 场景按米理解。

处理方式包括:

  • 在 Blender 或建模软件中统一模型单位。
  • 加载 GLTF 后统一设置 scale。
  • 在 Cesium 场景中用已知长度对象做参照。

3. 鼠标无法操作 Cesium

如果叠加 Three.js 后 Cesium 无法拖拽、缩放或点击,多半是 Three.js canvas 挡住了鼠标事件。给 Three.js 容器加上:

pointer-events: none;

4. Three.js 背景遮住 Cesium

如果页面只看到 Three.js 背景,看不到 Cesium 地球,需要检查 WebGLRenderer 是否开启透明背景:

const threeRenderer = new THREE.WebGLRenderer({
  alpha: true,
  antialias: true
});

同时不要给 Three.js Scene 设置不透明背景色。

5. 深度遮挡不完全正确

Three.js 与 Cesium 是两个不同的渲染系统。简单叠加时,Three.js 对象通常不会自动被 Cesium 地形、3D Tiles 或建筑正确遮挡。比如模型本应在山体背后,但仍然显示在前面。

如果项目对遮挡关系要求很高,需要考虑更复杂的方案,例如:

  • 使用 Cesium CustomShader 或 Primitive 实现特效。
  • 把对象转换为 Cesium 支持的 glTF、3D Tiles 或 Primitive。
  • 研究共享 WebGL 上下文和深度缓冲的高级方案。

方法比较:Cesium 原生实现、Three.js 叠加和深度集成怎么选

方案 适合场景 优点 限制
Cesium 原生 Entity 或 Primitive 点线面、模型、标注、简单动态效果 GIS 坐标友好,和 Cesium 场景集成稳定 复杂材质、粒子和后处理能力有限
Three.js canvas 叠加 Cesium 自定义模型、动画、粒子、视觉特效 实现相对清晰,Three.js 生态丰富 深度遮挡和坐标同步需要额外处理
共享 WebGL 上下文深度集成 高精度遮挡、复杂渲染管线、大型三维应用 理论上融合程度更高 实现复杂,维护成本高,对 Cesium 和 Three.js 版本敏感
转换为 3D Tiles 或 glTF 由 Cesium 加载 静态或半静态三维模型、大规模城市模型 更符合 Cesium 的 GIS 数据管理方式 动态特效和自定义渲染能力不如 Three.js 灵活

如果你的需求只是展示建筑、设备或静态模型,优先考虑 Cesium 原生 glTF 或 3D Tiles。如果你的需求是复杂动画、流光、粒子或特殊材质,再考虑 Three.js 与 Cesium 融合。

检查清单:融合前后应该逐项确认

  • Cesium 是否作为主场景管理相机和地图交互。
  • Three.js canvas 是否覆盖在 Cesium canvas 上方。
  • Three.js renderer 是否开启 alpha 透明背景。
  • Three.js 容器是否设置 pointer-events: none。
  • 经纬度是否为 longitude、latitude、height 顺序。
  • 高度单位是否与项目约定一致,通常为米。
  • 是否使用 ENU 局部坐标系处理对象方向。
  • 是否每一帧同步 Cesium 相机到 Three.js 相机。
  • 是否在 Cesium postRender 或 preRender 中渲染 Three.js。
  • 浏览器窗口变化后是否同步 renderer 尺寸和 camera aspect。
  • 模型比例、方向、轴向是否经过验证。
  • 是否明确了深度遮挡限制,避免误以为两套渲染自动融合。

FAQ:Three.js 与 Cesium 融合常见问题

Q1:Three.js 与 Cesium 融合一定要共享 WebGL 上下文吗?

不一定。大多数业务项目可以先采用“Cesium canvas + Three.js canvas 叠加”的方式,实现成本更低,也更容易调试。只有当你对深度遮挡、后处理管线和性能控制有非常高要求时,才需要考虑共享 WebGL 上下文。

Q2:为什么我的 Three.js 模型没有贴在 Cesium 地面上?

常见原因有三个:高度值不对、地形高程没有参与计算、模型自身原点不在底部。如果需要贴地,应先通过 Cesium 的地形采样获取真实地形高度,再把这个高度用于模型定位。

Q3:Cesium 里已经能加载 glTF,为什么还要用 Three.js?

如果只是加载普通 glTF 模型,直接用 Cesium 更简单。使用 Three.js 的主要原因是需要更复杂的动画、粒子、材质、后处理或已有 Three.js 组件复用。不要为了融合而融合。

Q4:Three.js 对象能不能被 Cesium 的建筑或地形遮挡?

简单 canvas 叠加方案下,遮挡通常不完整。Three.js 对象是覆盖绘制在 Cesium 画布之上的,不能天然共享 Cesium 的深度缓冲。如果遮挡是强需求,建议优先用 Cesium Primitive、CustomShader、3D Tiles 或更深入的渲染集成方案。

Q5:Three.js 与 Cesium 融合后性能很差怎么办?

先检查 Three.js 是否重复创建对象、材质和纹理。其次减少高面数模型、控制粒子数量、避免每帧做大量坐标转换。Cesium 端也要检查 3D Tiles 层级、地形、阴影和后处理是否过重。

Q6:坐标总是有几米到几十米偏差,应该查哪里?

先确认数据坐标系。Cesium 常用 WGS84 经纬度,如果你的数据来自 CGCS2000、Web Mercator、地方坐标或投影坐标,需要先做正确转换。其次检查高度基准,是椭球高、海拔高还是相对高度。

结论:推荐的融合思路

Three.js 与 Cesium 融合的实用思路是:Cesium 负责 GIS 空间、地图交互和三维地球,Three.js 负责复杂三维对象和视觉效果。具体实现时,重点不是简单把两个库放在同一个页面,而是处理好坐标转换、相机同步和渲染循环。

对于一般 WebGIS 项目,推荐先采用 Three.js canvas 叠加 Cesium canvas 的方案。它结构清晰、调试方便,适合模型动画、粒子特效和专题可视化。如果项目需要严格遮挡、大规模三维数据管理或长期维护,则应优先评估 Cesium 原生 Primitive、glTF、3D Tiles 或 CustomShader 方案。

记住一个判断原则:只要需求属于 GIS 空间管理,优先交给 Cesium;只要需求属于复杂三维表现,再考虑交给 Three.js。这样才能让 Three.js 与 Cesium 的融合既稳定,又真正服务于 WebGIS 业务。