一、为什么要把两者结合起来
Cesium 和 Three.js 是 WebGL 生态里两个定位完全不同的库。Cesium 专注于行星级地理可视化 ------ 它内置了 WGS84 椭球模型、地形、影像、3D Tiles,能把真实地球精确地搬进浏览器;而 Three.js 则是一个通用 3D 渲染框架,相机、灯光、材质、几何体、后期处理一应俱全,写起来非常顺手。
单独用 Cesium 时,如果你想放一个复杂的自定义几何体(比如用 LatheGeometry 车削出来的花瓶、或者程序化生成的异形雕塑),Cesium 的 Entity / Primitive API 写起来就比较别扭;反过来,只用 Three.js 又拿不到真实的地球底图和地理坐标系。把两者拼在一起,就能同时享受 "真实地球底图 + 自由 3D 创作" 的好处。
这篇文章分享一种双层 Canvas 叠加 + 相机矩阵同步的集成方案,思路清晰、代码量小,适合快速落地。
二、整体架构思路
核心思想只有两句话:
- 渲染层分离 :Cesium 和 Three.js 各自持有一个 WebGL 上下文,对应两个叠放的
<canvas>,Three.js 在上层且背景透明。 - 控制层统一:鼠标交互完全交给 Cesium,Three.js 不注册任何控制器,每帧从 Cesium 相机拷贝视图矩阵,保证两个场景看到的视角完全一致。
这样 Three.js 的 canvas 只是一个 "透明描图纸",盖在 Cesium 地球上面,物体看起来就像长在地球上一样。
三、HTML 骨架
准备两个容器,Cesium 在下、Three.js 在上:
html
预览
xml
<body>
<div id="cesiumContainer"></div>
<div id="threeContainer"></div>
</body>
关键 CSS:给 #threeContainer 加上 pointer-events: none,让鼠标事件穿透到下层的 Cesium,否则 Three.js 的 canvas 会吞掉所有交互。
四、初始化 Cesium:关掉默认渲染循环
Cesium 默认会自己跑 requestAnimationFrame,但我们要把渲染节奏握在自己手里,所以初始化时必须设置 useDefaultRenderLoop: false。同时把 UI 控件(时间轴、信息框、底图选择器等)统统关掉,保持画面干净:
javascript
运行
php
function initCesium() {
cesium.viewer = new Cesium.Viewer('cesiumContainer', {
useDefaultRenderLoop: false, // 关键:接管渲染循环
selectionIndicator: false,
infoBox: false,
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
// 用本地 TMS 影像,避免在线瓦片依赖
imageryProvider: new Cesium.TileMapServiceImageryProvider({
url: 'Assets/imagery/NaturalEarthII/',
maximumLevel: 5
})
});
// 飞到目标区域(北京附近,高度 200km)
const center = Cesium.Cartesian3.fromDegrees(115.73, 40.05, 200000);
cesium.viewer.camera.flyTo({
destination: center,
orientation: {
heading: 0,
pitch: Cesium.Math.toRadians(-60),
roll: 0
},
duration: 3
});
}
useDefaultRenderLoop: false 是整个方案的基石 ------ 只有关掉它,我们才能在自己的 loop 里先画 Cesium、再画 Three.js,实现逐帧同步。
五、初始化 Three.js:大远裁剪面
Three.js 这边就是标准三件套(Scene / Camera / Renderer),但有一个容易踩的坑:far 平面必须设得极大 。因为 Cesium 的坐标系单位是米,地球半径就有 637 万米,相机到物体的距离动辄几十万米,如果 Three.js 的 far 还停留在默认的 1000,物体直接被裁掉:
javascript
运行
ini
function initThree() {
const fov = 45;
const aspect = window.innerWidth / window.innerHeight;
three.scene = new THREE.Scene();
three.camera = new THREE.PerspectiveCamera(fov, aspect, 1, 10 * 1000 * 1000); // 10000km
three.renderer = new THREE.WebGLRenderer({ alpha: true }); // 透明背景
three.renderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('threeContainer').appendChild(three.renderer.domElement);
}
alpha: true 让 Three.js canvas 透明,这样下层的 Cesium 地球才能透出来。
六、创建 3D 对象:处理 Z-up 与 Y-up 的差异
这里有一个坐标系约定差异 必须处理:Three.js 默认是 Y-up(Y 轴朝上),而 Cesium 的局部 ENU 坐标系里 "向上" 对应的是 Z 轴方向。直接把 Three.js 物体丢进去会是躺着的。
常见做法是:在 Three.js 里把物体绕 X 轴旋转 90°,让它的 "本地朝上" 对齐到 Cesium 的 "径向朝外"。另外因为地球尺度巨大,物体的 scale 也要放大到千米级才看得见。
下面创建两个演示物体 ------ 一个车削几何体和一个十二面体:
javascript
运行
ini
function init3DObject() {
// 用一个边界框(WGS84 经纬度)描述物体在地球上的落点
const minWGS84 = [115.23, 39.55];
const maxWGS84 = [116.23, 41.55];
// --- 车削几何体 ---
const points = [];
for (let i = 0; i < 10; i++) {
points.push(new THREE.Vector2(Math.sin(i * 0.2) * 10 + 5, (i - 5) * 2));
}
const lathe = new THREE.Mesh(
new THREE.LatheGeometry(points),
new THREE.MeshNormalMaterial({ side: THREE.DoubleSide })
);
lathe.scale.setScalar(1500); // 放大到行星尺度
lathe.position.z += 15000; // 抬离地面
lathe.rotation.x = Math.PI / 2; // 对齐 Y-up
const latheGroup = new THREE.Group();
latheGroup.add(lathe);
three.scene.add(latheGroup);
// --- 十二面体 ---
const dodeca = new THREE.Mesh(
new THREE.DodecahedronGeometry(),
new THREE.MeshNormalMaterial()
);
dodeca.scale.setScalar(5000);
dodeca.position.z += 15000;
dodeca.rotation.x = Math.PI / 2;
const dodecaGroup = new THREE.Group();
dodecaGroup.add(dodeca);
three.scene.add(dodecaGroup);
// 登记到对象数组,供渲染循环做坐标变换
_3Dobjects.push({ mesh: latheGroup, minWGS84, maxWGS84 });
_3Dobjects.push({ mesh: dodecaGroup, minWGS84, maxWGS84 });
}
用 Group 包一层是为了让 "朝向调整" 和 "位置放置" 解耦 ------Group 负责在地球上定位和朝向,内部 mesh 负责自身的旋转缩放。
七、渲染循环:相机矩阵同步是核心
主循环非常简单,先画 Cesium 再画 Three.js:
javascript
运行
scss
function loop() {
requestAnimationFrame(loop);
cesium.viewer.render(); // 画地球底图
renderThreeObj(); // 画叠加物体
}
真正的技术含量在 renderThreeObj 里,要做两件事:
1. 把每个 Three.js 物体放到正确的经纬度,并让它 "站" 在球面上
思路是:把经纬度边界框的中心点转成 Cartesian3(地心直角坐标)作为物体位置;再用 "边界框左下角 → 左上角" 的方向作为 up 向量,这样物体就沿着经线方向竖立,自然背离地心。
javascript
运行
ini
for (const obj of _3Dobjects) {
const { minWGS84, maxWGS84, mesh } = obj;
// 中心点(地面高度)
const center = Cesium.Cartesian3.fromDegrees(
(minWGS84[0] + maxWGS84[0]) / 2,
(minWGS84[1] + maxWGS84[1]) / 2
);
// 中心点上方 1 米,用于 lookAt 确定朝向
const centerHigh = Cesium.Cartesian3.fromDegrees(
(minWGS84[0] + maxWGS84[0]) / 2,
(minWGS84[1] + maxWGS84[1]) / 2, 1
);
// 用左下角→左上角的方向作为 up 向量
const bottomLeft = new THREE.Vector3(...Cesium.Cartesian3.fromDegrees(minWGS84[0], minWGS84[1]));
const topLeft = new THREE.Vector3(...Cesium.Cartesian3.fromDegrees(minWGS84[0], maxWGS84[1]));
const latDir = new THREE.Vector3().subVectors(bottomLeft, topLeft).normalize();
mesh.position.copy(center);
mesh.lookAt(centerHigh.x, centerHigh.y, centerHigh.z);
mesh.up.copy(latDir);
}
更规范的做法是用 Cesium 的
Transforms.eastNorthUpToFixedFrame直接构造 ENU 局部坐标系矩阵,再赋给mesh.matrix,精度和稳定性更好。上面的 "边界框方向法" 是一种直观但近似的实现。
2. 把 Cesium 相机的视图矩阵直接拷贝给 Three.js 相机
这是让两个渲染器 "看到同一视角" 的关键。Three.js 相机内部用 matrixWorld 和 matrixWorldInverse 两个矩阵描述位姿,我们把 Cesium 的 inverseViewMatrix 和 viewMatrix 按列主序填进去即可:
javascript
运行
ini
three.camera.matrixAutoUpdate = false; // 禁止 Three.js 自动覆盖矩阵
const cvm = cesium.viewer.camera.viewMatrix; // 视图矩阵
const civm = cesium.viewer.camera.inverseViewMatrix; // 视图逆矩阵(=相机世界矩阵)
// 列主序填充
three.camera.matrixWorld.set(
civm[0], civm[4], civm[8], civm[12],
civm[1], civm[5], civm[9], civm[13],
civm[2], civm[6], civm[10], civm[14],
civm[3], civm[7], civm[11], civm[15]
);
three.camera.matrixWorldInverse.set(
cvm[0], cvm[4], cvm[8], cvm[12],
cvm[1], cvm[5], cvm[9], cvm[13],
cvm[2], cvm[6], cvm[10], cvm[14],
cvm[3], cvm[7], cvm[11], cvm[15]
);
// FOV 也要同步(Cesium 的 fovy 是弧度,Three.js 是角度)
three.camera.fov = Cesium.Math.toDegrees(cesium.viewer.camera.frustum.fovy);
three.camera.aspect = threeContainer.clientWidth / threeContainer.clientHeight;
three.camera.updateProjectionMatrix();
three.renderer.render(three.scene, three.camera);
注意 matrixAutoUpdate = false 必须设置,否则 Three.js 每帧会根据 position/rotation 重新计算矩阵,把我们手动塞进去的值覆盖掉。
八、几个容易踩的坑
- 深度冲突:两个独立 WebGL 上下文不共享深度缓冲,Three.js 物体永远画在 Cesium 上面,不会被地球遮挡。如果需要物体被地形遮挡,得用 Cesium 原生 Primitive 或做深度纹理同步,这是双层方案的固有局限。
- 分辨率与性能 :Cesium 的
resolutionScale可以调低来提升帧率;Three.js 侧建议开启antialias。 - 窗口 resize :两个渲染器都要监听
resize并分别setSize/ 更新相机 aspect。 - 物体尺度:在米制坐标系下,Three.js 默认的单位 1 就是 1 米,记得把几何体放大到千米级,否则在 200km 高空看就是一个像素点。
九、总结
这套方案的本质是 "一个地球 + 一层透明描图纸" :Cesium 负责地理底图和交互,Three.js 负责自由建模,两者通过逐帧拷贝相机矩阵实现视角同步。坐标系对齐(Z-up/Y-up、经纬度转 Cartesian、ENU 朝向)和相机矩阵同步是两个核心技术点,其余都是常规初始化。
如果后续需要更深度的集成(比如共享深度缓冲、用 Cesium 的 Primitive 直接包装 Three.js 几何体),可以进一步研究 Cesium.Primitive + 自定义 Appearance 的方案,但对于大多数 "在地球上放几个自定义模型" 的场景,本文的双层叠加法已经足够用了。