Cesium 与 Three.js 融合实战:在数字地球上渲染自定义 3D 场景

一、为什么要把两者结合起来

Cesium 和 Three.js 是 WebGL 生态里两个定位完全不同的库。Cesium 专注于行星级地理可视化 ------ 它内置了 WGS84 椭球模型、地形、影像、3D Tiles,能把真实地球精确地搬进浏览器;而 Three.js 则是一个通用 3D 渲染框架,相机、灯光、材质、几何体、后期处理一应俱全,写起来非常顺手。

单独用 Cesium 时,如果你想放一个复杂的自定义几何体(比如用 LatheGeometry 车削出来的花瓶、或者程序化生成的异形雕塑),Cesium 的 Entity / Primitive API 写起来就比较别扭;反过来,只用 Three.js 又拿不到真实的地球底图和地理坐标系。把两者拼在一起,就能同时享受 "真实地球底图 + 自由 3D 创作" 的好处。

这篇文章分享一种双层 Canvas 叠加 + 相机矩阵同步的集成方案,思路清晰、代码量小,适合快速落地。

二、整体架构思路

核心思想只有两句话:

  1. 渲染层分离 :Cesium 和 Three.js 各自持有一个 WebGL 上下文,对应两个叠放的 <canvas>,Three.js 在上层且背景透明。
  2. 控制层统一:鼠标交互完全交给 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 相机内部用 matrixWorldmatrixWorldInverse 两个矩阵描述位姿,我们把 Cesium 的 inverseViewMatrixviewMatrix 按列主序填进去即可:

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 重新计算矩阵,把我们手动塞进去的值覆盖掉。

八、几个容易踩的坑

  1. 深度冲突:两个独立 WebGL 上下文不共享深度缓冲,Three.js 物体永远画在 Cesium 上面,不会被地球遮挡。如果需要物体被地形遮挡,得用 Cesium 原生 Primitive 或做深度纹理同步,这是双层方案的固有局限。
  2. 分辨率与性能 :Cesium 的 resolutionScale 可以调低来提升帧率;Three.js 侧建议开启 antialias
  3. 窗口 resize :两个渲染器都要监听 resize 并分别 setSize / 更新相机 aspect。
  4. 物体尺度:在米制坐标系下,Three.js 默认的单位 1 就是 1 米,记得把几何体放大到千米级,否则在 200km 高空看就是一个像素点。

九、总结

这套方案的本质是 "一个地球 + 一层透明描图纸" :Cesium 负责地理底图和交互,Three.js 负责自由建模,两者通过逐帧拷贝相机矩阵实现视角同步。坐标系对齐(Z-up/Y-up、经纬度转 Cartesian、ENU 朝向)和相机矩阵同步是两个核心技术点,其余都是常规初始化。

如果后续需要更深度的集成(比如共享深度缓冲、用 Cesium 的 Primitive 直接包装 Three.js 几何体),可以进一步研究 Cesium.Primitive + 自定义 Appearance 的方案,但对于大多数 "在地球上放几个自定义模型" 的场景,本文的双层叠加法已经足够用了。

相关推荐
李顿波1 小时前
Chrome 插件弹窗一直停留在初始的小尺寸 —— 你看到的小方块
前端·javascript·chrome
渣波1 小时前
React 移动端首页架构实战:从并发请求到防御性编程的深度解析
前端·javascript
a1117761 小时前
原生 Markdown 阅读与编辑器 开源项目
前端·开源·软件
laity171 小时前
python发光表白爱心(从零到一实现)
前端·后端
程序员爱钓鱼1 小时前
Rust impl详解:为Struct定义方法与关联函数
前端·后端·rust
用户059540174461 小时前
Qdrant 召回不一致踩坑实录:跑了 300 次测试才发现是索引没刷新
前端·css
Highcharts.js2 小时前
Highcharts 主流前端框架无缝集成指南
前端·vue.js·前端框架·highcharts·可视化图表
JAVA面经实录9172 小时前
网络编程基础(Java Web/分布式前置·完整版)(十一)
java·前端·网络
IT_陈寒2 小时前
搞不定JavaScript的数组去重?你可能漏了这两个坑
前端·人工智能·后端