1. 为什么要把 Cesium 和 Three.js 放在同一个场景里
1.1 两个引擎各自的强项与短板
做过三维 GIS 项目的人多半有过这种纠结:项目里既要展示大范围地形、影像、倾斜摄影、3D Tiles 这类地理数据,又要在场景里塞进精细的 BIM 模型、工业设备、动画特效、自定义材质。单用 Cesium,地理坐标系、LOD 调度、全球地形这些它天生就强,但一旦涉及高度定制的渲染效果、复杂的自定义着色器、非地理坐标的局部精细场景,写起来就束手束脚。单用 Three.js,渲染自由度高、生态丰富、社区示例多,可它没有地理坐标概念,没有瓦片调度,加载个几平方公里的倾斜摄影就能把内存吃满。
所以融合的核心动机很直接:让 Cesium 管"地球"和地理数据调度,让 Three.js 管"局部精细渲染和特效"。这不是为了炫技,而是被项目需求逼出来的。我接过一个园区数字孪生的活,场景里既有几十平方公里的地形和影像底图,又有一栋 BIM 小别墅需要做到构件级展示,还要在楼顶叠加动态光照和流动线效果。纯 Cesium 做 BIM 构件级交互很别扭,纯 Three.js 又扛不住大范围地理数据,最后只能走融合路线。
1.2 融合的三种主流思路对比
在动手之前,得先想清楚用哪种融合方式。市面上常见的做法有三种,各有取舍。
| 融合方式 | 实现原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 双 Canvas 叠加 | Cesium 一个 canvas,Three.js 一个 canvas,CSS 叠在一起 | 实现简单,互不干扰 | 两个相机要手动同步,深度关系难处理,性能开销大 | 快速验证、特效层与底图分离 |
| Cesium 作为 Three.js 的贴图 | Three.js 渲染地球,Cesium 离屏渲染当纹理 | 渲染统一 | 地理调度能力被削弱,等于放弃 Cesium 优势 | 不推荐 |
| Three.js 挂到 Cesium 场景 | 把 Three.js 的相机和 Cesium 相机同步,Three.js 的物体通过自定义 Primitive 注入 Cesium 渲染循环 | 共享深度、共享相机、性能好 | 需要理解 Cesium 的 Primitive 机制 | 生产环境首选 |
我实测下来,第三种是最稳的。它的关键点在于:Cesium 的Scene允许你插入自定义的Primitive,而 Three.js 的WebGLRenderer可以复用 Cesium 已经创建好的WebGLContext。这样两个引擎共用一套 WebGL 上下文,深度缓冲也是共享的,BIM 模型和地形之间就不会出现"谁盖住谁"的穿帮问题。
1.3 坐标系对齐是融合的第一道坎
融合最容易翻车的地方不是渲染,而是坐标。Cesium 用的是 WGS84 地理坐标系加 ECEF 地心直角坐标系,Three.js 用的是普通的右手笛卡尔坐标系。你要把一栋 BIM 小别墅放到园区某个经纬度上,就必须做坐标转换。
我的做法是:以 BIM 模型所在位置的一个基准点(比如别墅的西南角)作为局部坐标系原点,用 Cesium 的Transforms.eastNorthUpToFixedFrame求出该点的东-北-天(ENU)局部坐标系到 ECEF 的转换矩阵。Three.js 场景里所有物体的坐标都基于这个局部原点,最后统一乘上这个矩阵,就落到地球正确位置了。
// 基准点:别墅西南角的经纬度和高程 const origin = Cesium.Cartesian3.fromDegrees(116.397, 39.908, 50); // 求出 ENU 到 ECEF 的转换矩阵 const enuToEcef = Cesium.Transforms.eastNorthUpToFixedFrame(origin); // Three.js 里的局部坐标 (x, y, z) 转成 Cesium 世界坐标 function localToWorld(x, y, z) { const local = new Cesium.Cartesian3(x, y, z); return Cesium.Matrix4.multiplyByPoint(enuToEcef, local, new Cesium.Cartesian3()); }注意:ENU 坐标系里,X 轴指向东,Y 轴指向北,Z 轴指向天。而 Three.js 默认 Y 轴朝上。所以从建模软件导出的模型,往往需要先绕 X 轴旋转 -90 度,把 Z-up 转成 Y-up,否则模型会"躺"在地上。
2. 环境搭建与核心依赖选型
2.1 Cesium 的引入方式与版本选择
Cesium 的引入有几种方式:直接 script 标签引 CDN、npm 安装、或者用官方推荐的 Vite 插件。做融合项目我强烈建议用 npm 加构建工具,因为你要改 Cesium 的源码行为、要注入自定义 Primitive,CDN 那种黑盒方式根本没法调试。
npm install cesium three版本上,Cesium 建议用 1.1xx 之后的版本,这些版本对CustomPrimitive和Scene的扩展支持比较完善。Three.js 用 r150 以上,注意 r155 之后默认颜色管理变了,如果 BIM 模型颜色发灰,多半是色彩空间没配对。
import * as Cesium from 'cesium'; import * as THREE from 'three';有个坑要提前说:Cesium 默认会去访问它的在线资源服务加载默认影像和地形。如果项目在内网或者网络受限环境,一定要提前把Ion.defaultAccessToken处理掉,并且显式指定离线影像和地形,否则场景会一直转圈或者报资源访问失败。
2.2 复用 WebGL 上下文的关键代码
融合的核心就一段代码:让 Three.js 用 Cesium 的 canvas 和 context。
const viewer = new Cesium.Viewer('cesiumContainer', { // 关掉不需要的控件,减少干扰 animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, }); // 拿到 Cesium 的 canvas 和 WebGL 上下文 const canvas = viewer.canvas; const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); // Three.js 复用这个上下文 const renderer = new THREE.WebGLRenderer({ canvas: canvas, context: gl, }); renderer.autoClear = false; // 关键:不要让 Three.js 清屏,否则 Cesium 的画面会被擦掉autoClear = false这行是重中之重。Cesium 每帧先渲染地球和地理数据,然后轮到 Three.js 渲染 BIM 和特效。如果 Three.js 清屏,Cesium 渲染的东西就全没了。我第一次做的时候忘了这行,画面一片黑,排查了半天。
2.3 相机同步的实现细节
两个引擎要看到同一个世界,相机必须同步。Cesium 的相机是Camera对象,Three.js 是PerspectiveCamera。同步的核心是把 Cesium 相机的视图矩阵和投影矩阵搬到 Three.js。
function syncCamera() { // Cesium 相机的视图矩阵(世界坐标到相机坐标) const viewMatrix = viewer.camera.viewMatrix; // Cesium 相机的投影矩阵 const projectionMatrix = viewer.camera.frustum.projectionMatrix; // 转成 Three.js 的矩阵格式 const threeView = new THREE.Matrix4().fromArray( Cesium.Matrix4.toArray(viewMatrix) ); const threeProjection = new THREE.Matrix4().fromArray( Cesium.Matrix4.toArray(projectionMatrix) ); // 应用到 Three.js 相机 threeCamera.matrixWorldInverse.copy(threeView); threeCamera.projectionMatrix.copy(threeProjection); threeCamera.matrixWorld.copy(threeView).invert(); }这里有个细节:Cesium 的矩阵是列主序,Three.js 的fromArray默认也是列主序,所以能直接对应。但如果你发现 BIM 模型位置偏了或者朝向不对,八成是矩阵转置的问题,可以试着加.transpose()验证。
3. BIM 模型从建模到进场景的完整链路
3.1 BIM 模型格式选择与导出
BIM 模型常见的格式有 IFC、RVT、SKP 等。Cesium 和 Three.js 都不能直接吃这些格式,必须转换。我的经验是:BIM 模型先转成 glTF/GLB,再进场景。glTF 是 WebGL 生态的通用格式,Three.js 原生支持,Cesium 也能通过 3D Tiles 或者直接加载。
转换链路一般是:Revit 导出 IFC,用 IfcOpenShell 或者 Blender 的 BIM 插件处理,再导出 GLB。如果是 SketchUp 的模型(比如那个 BIM 小别墅文件),可以直接在 SketchUp 里导出 GLB,但要注意单位——SketchUp 默认是英寸或者毫米,导出时一定要统一成米,否则模型进场景会大得离谱或者小得看不见。
实操心得:导出 GLB 时勾选"应用修改器"和"导出材质",但不要勾选"压缩",压缩后的模型在 Three.js 里加载容易丢材质。模型面数控制在 50 万三角面以内,超过这个量级,即使有 LOD,浏览器也会卡。
3.2 模型轻量化与 LOD 处理
BIM 模型最大的问题是面数爆炸。一栋小别墅,如果每个螺栓、每根钢筋都建出来,轻松上百万面。进场景之前必须做轻量化。
轻量化有三个层次:一是删掉看不见的内部构件,二是对重复构件(比如窗户、栏杆)做实例化,三是生成多级 LOD。Three.js 里可以用THREE.LOD对象管理多级模型,根据相机距离切换。
const lod = new THREE.LOD(); lod.addLevel(highDetailModel, 0); // 0-50米用高模 lod.addLevel(midDetailModel, 50); // 50-200米用中模 lod.addLevel(lowDetailModel, 200); // 200米外用低模 scene.add(lod);Cesium 那边如果模型要参与地理调度,建议转成 3D Tiles。3D Tiles 自带 LOD 和空间索引,加载大场景时性能优势明显。转换工具可以用 Cesium 官方的 3D Tiles 工具链,把 GLB 切成瓦片。
3.3 模型节点与构件级交互
BIM 的价值在于构件级信息。用户点一扇门,要能弹出这扇门的型号、材质、供应商。这就要求模型在导出时保留节点结构,进场景后能通过射线拾取定位到具体构件。
Three.js 的Raycaster可以做拾取,但前提是模型的每个构件是独立的 Mesh,而不是合并成一个大 Mesh。导出 GLB 时,如果勾选了"合并网格",构件信息就丢了。所以导出时不要合并网格,让每个构件保持独立。
const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); canvas.addEventListener('click', (event) => { const rect = canvas.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, threeCamera); const intersects = raycaster.intersectObjects(bimModel.children, true); if (intersects.length > 0) { const hit = intersects[0].object; // hit.name 就是构件名,可以关联 BIM 属性数据 showComponentInfo(hit.name); } });这里有个坑:Cesium 的 canvas 上还叠着 Cesium 自己的拾取逻辑。如果点击事件同时被 Cesium 和 Three.js 处理,可能会冲突。我的做法是给 Three.js 的拾取加一个判断——先判断点击位置有没有 Three.js 物体,有就拦截事件,没有就交给 Cesium。
4. GIS 数据加载与场景调度实战
4.1 地形与影像底图的加载
GIS 场景的地基是地形和影像。Cesium 加载地形用CesiumTerrainProvider,影像用ImageryLayer。如果项目在内网,地形和影像都要离线部署。
// 离线地形 const terrain = await Cesium.CesiumTerrainProvider.fromUrl('/terrain/tileset.json'); viewer.terrainProvider = terrain; // 离线影像 const imagery = new Cesium.UrlTemplateImageryProvider({ url: '/imagery/{z}/{x}/{y}.png', }); viewer.imageryLayers.addImageryProvider(imagery);地形数据常见的是 DEM,Cesium 需要的是 quantized-mesh 格式。如果你手头是 GeoTIFF 的 DEM,得先用工具切成瓦片。影像数据如果是 MVT 格式(矢量瓦片),Cesium 也能加载,但需要额外的 MVT 解析支持。
注意:DEM 分割和投影转换是 GIS 数据处理的常见需求。DEM 按经纬度分块时,注意边界要重叠一个像素,否则拼接处会有裂缝。投影转换时,CAD 的 6 位坐标(比如 500000, 4000000)通常是高斯投影坐标,转经纬度要用对应的带号和中央经线,转错了位置会偏几百米。
4.2 3D Tiles 与倾斜摄影的加载
倾斜摄影是三维 GIS 的重头戏。Cesium 加载倾斜摄影用 3D Tiles,核心是Cesium3DTileset。
const tileset = await Cesium.Cesium3DTileset.fromUrl('/photogrammetry/tileset.json', { maximumScreenSpaceError: 16, // 数值越小越清晰,但性能开销越大 maximumNumberOfLoadedTiles: 1000, }); viewer.scene.primitives.add(tileset);maximumScreenSpaceError这个参数很关键。默认是 16,调小到 8 画面更精细但加载的瓦片数量翻倍,调大到 32 性能好但远处会糊。我一般根据项目硬件配置在 8 到 24 之间调。
倾斜摄影的单体化是个老大难问题。所谓单体化,就是让倾斜摄影里的每栋楼能单独选中、单独上色。Cesium 支持 3D Tiles 单体化,但需要数据生产阶段就做好分层分户,或者用裁剪面加动态贴图模拟。如果数据没做单体化,后期硬做效果很差。
4.3 动态光照与仿真效果
Cesium 自带动态光照,通过viewer.scene.globe.enableLighting = true开启,太阳位置会根据时间变化。但要做更精细的仿真,比如模拟一天中建筑阴影的变化,就需要结合 Three.js 的光照系统。
我的做法是:Cesium 管全局光照(太阳、天空),Three.js 管局部光照(建筑内部的灯光、设备发光)。两套光照系统通过共享相机和深度缓冲协调。Three.js 这边用DirectionalLight模拟太阳,方向从 Cesium 的太阳位置算出来。
// 从 Cesium 获取太阳方向 const sunPosition = Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame( viewer.clock.currentTime ); // 转成 Three.js 的平行光方向 const sunDir = new THREE.Vector3(sunPosition.x, sunPosition.y, sunPosition.z).normalize(); directionalLight.position.copy(sunDir.multiplyScalar(1000));流动线、雷达扫描、悬浮岛这些特效,用 Three.js 的着色器做最灵活。比如箭头流动线,本质是一条带纹理的线,纹理 UV 随时间偏移,就产生了流动感。
5. 性能优化与常见问题排查
5.1 帧率上不去的排查思路
融合场景性能问题,八成出在三个地方:绘制调用太多、瓦片加载太频繁、内存泄漏。
先看绘制调用。打开浏览器的性能面板,看每帧的 draw call 数量。超过 1000 就要警惕了。BIM 模型如果每个构件一个 draw call,一栋楼就能上千。解决办法是合批——把相同材质的构件合并成一个 Mesh。Three.js 的BufferGeometryUtils.mergeGeometries可以做这件事,但合并后构件级拾取就没了,需要额外维护一个映射表。
再看瓦片加载。Cesium 的maximumScreenSpaceError调太小,或者相机移动太快,会导致瓦片疯狂加载。可以限制maximumNumberOfLoadedTiles,并开启preloadWhenHidden控制预加载。
内存泄漏常见于反复创建销毁对象。Three.js 的几何体、材质、纹理用完要手动dispose(),Cesium 的 Primitive 移除后也要destroy()。我见过一个项目跑两小时就崩,最后查出来是每次点击都 new 一个材质,从不释放。
5.2 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 画面全黑 | Three.js 清了屏 | 检查 autoClear | 设 renderer.autoClear = false |
| BIM 模型位置偏移 | 坐标系没对齐 | 检查 ENU 矩阵 | 用 eastNorthUpToFixedFrame 重新计算 |
| 模型躺在地上 | Z-up 没转 Y-up | 检查模型旋转 | 绕 X 轴旋转 -90 度 |
| 模型颜色发灰 | 色彩空间不匹配 | 检查 outputColorSpace | 设 THREE.SRGBColorSpace |
| 拾取不到构件 | 网格被合并 | 检查导出设置 | 导出时不合并网格 |
| 帧率低于 30 | draw call 过多 | 看性能面板 | 合批或做 LOD |
| 瓦片加载卡顿 | SSE 太小 | 检查 maximumScreenSpaceError | 调到 16-24 |
| 内存持续增长 | 资源没释放 | 看内存曲线 | 手动 dispose 和 destroy |
| 地形有裂缝 | DEM 边界没重叠 | 检查瓦片切分 | 边界重叠一个像素 |
| 坐标偏几百米 | 投影带号错误 | 检查中央经线 | 用正确的带号转换 |
5.3 几个我踩过的坑
第一个坑是 Cesium 的默认旋转地球效果。项目启动时地球会转一下,如果此时 Three.js 的物体已经加进去了,会跟着一起转,位置全乱。解决办法是在viewer.scene.preRender里做相机同步,确保第一帧就对齐。
第二个坑是 Cesium 模型节点命名。从 Revit 导出的构件名可能带特殊字符或者中文,Three.js 加载后object.name可能被转义。做构件映射时,最好用 ID 而不是名字。
第三个坑是动态光照的性能。开启enableLighting后,地形阴影计算很吃性能。如果项目对帧率敏感,可以只在需要的时候开,或者用烘焙好的阴影贴图代替实时计算。
第四个坑是移动监控摄像头带 GIS 信息这种需求。摄像头位置是经纬度,视野是个锥体。在 Cesium 里画锥体用Cesium.ConeGraphics,但要让它朝向正确,得算方向向量。我一开始直接用 heading/pitch/roll,结果摄像头朝向总是差一点,后来改成用两个点(摄像头位置和目标点)算方向才准。
6. 从 Demo 到生产:工程化的一些建议
6.1 模块划分与代码组织
融合项目代码容易乱,因为两套引擎的 API 混在一起。我的组织方式是按职责分模块:cesium/目录放 Cesium 相关(地形、影像、瓦片),three/目录放 Three.js 相关(BIM、特效、光照),bridge/目录放融合逻辑(相机同步、坐标转换、拾取协调)。这样改哪块找哪块,不会牵一发动全身。
坐标转换的工具函数单独抽出来,因为到处都要用。我封装了一个CoordBridge类,提供localToWorld、worldToLocal、lngLatToLocal等方法,所有模块统一调用。
6.2 资源加载与进度管理
大场景资源多,加载要有进度反馈。Cesium 的瓦片加载有tileLoadProgress事件,Three.js 的 GLTF 加载有onProgress回调。把两者的进度加权合并,给用户一个总进度条。
资源加载顺序也有讲究。先加载地形和影像(地基),再加载 3D Tiles(中景),最后加载 BIM 和特效(近景)。这样用户先看到大场景轮廓,再逐步看到细节,体验比一次性全加载好。
6.3 后续可扩展的方向
这套融合框架搭好后,能扩展的方向很多。比如接入实时数据做动态模拟——传感器数据驱动 BIM 构件变色,或者车辆 GPS 数据驱动场景里的车模型移动。再比如做剖面分析,用 Cesium 的裁剪面切地形,用 Three.js 的裁剪面切 BIM,两者同步就能看到地下管线和地质的对应关系。
还有个方向是 WebGPU。Cesium 和 Three.js 都在往 WebGPU 迁移,等两边都稳定支持后,融合场景的性能还能再上一个台阶。不过现阶段 WebGPU 的兼容性还不够,生产项目还是老老实实用 WebGL。
我在实际项目里最大的体会是:融合不是目的,解决问题才是。如果项目只需要展示地理数据,别硬塞 Three.js;如果只需要展示 BIM,也别硬上 Cesium。只有当项目同时有大范围地理场景和局部精细渲染需求时,融合才是划算的。而且融合的复杂度不低,团队里最好有人懂 GIS,有人懂图形渲染,否则遇到坐标问题或者性能问题会卡很久。