简介:面向WebGIS、可视化大屏及三维仿真开发者的Cesium与Three.js整合示例包,聚焦在地球模型上加载并展示自定义三维模型这一核心场景。资源通过可运行的示例工程,演示了从引入Cesium和Three.js库、初始化Cesium Viewer,到利用GLTFLoader/OBJLoader加载外部模型,再到Cesium经纬度坐标系与Three.js世界坐标系的换算、模型与地球表面的同步、交互与动画触发等关键实现环节,覆盖两大引擎配合的主要难点与踩坑点。压缩包文件大小约4.8MB,下载页未给出具体文件总数与类型明细,解压后建议先查看目录结构与说明文件,通常包含示例页面、模型资源及相关脚本。当前已有444人学习下载,适合有一定JavaScript基础、希望快速将精细模型迭加到三维地球场景中的前端或GIS开发者。通过该示例既可收获可复用的整合模板,也能理解坐标转换、相机同步等常用技巧,可作为后续二次开发的脚手架参考。 拿到一个 CesiumThreejs.zip 压缩包,第一反应是:终于有人把 Cesium 和 Three.js 整成一个能直接跑的工程了。搞 GIS 三维的人都知道,Cesium 处理地球、地形、影像、矢量切片很强,但想在场景里加雷达扫描、动态水面、可视域分析这些特效,原生 API 做起来非常费劲;Three.js 恰恰在这些方面灵活得多。把两者集成到同一个 WebGL 上下文里,就能同时享受 Cesium 的地理精度和 Three.js 的特效自由。这篇文章就从这样一个压缩包说起,讲讲 Cesium + Three.js 共享 GL 上下文的核心机制、实际能做的功能,以及离线数据、模型加载、锯齿问题这些让人头大的细节。
1. 这个包解决的到底是个什么问题
1.1 为什么不能只靠 Cesium 或只靠 Three.js
很多项目一开始会有个错觉:既然 Cesium 能加载 3D Tiles,那特效也用 Cesium 写不就行了?真做起来就发现不对劲。Cesium 的强项是地理坐标系、全球地形、影像服务、3D Tiles 调度,这些能力扎实到几乎没有替代品。但它对自定义着色器、粒子系统、后期特效的支持很弱,写一个雷达扫描遮罩都要折腾半天。
Three.js 反过来,它的场景图、材质系统、后处理管线非常自由,高斯泼溅、局部雨、流动水面这些效果都有成熟方案。但它没有地球坐标系概念,也没有地形和影像数据源。你不可能用 Three.js 从一个经纬度坐标开始,把整个地球瓦片调度出来。
实际项目里的诉求往往是:一个大屏既要看到真实地球和地形,又要在指定位置叠加雷达扫描、动态光照、可交互的三维模型,甚至做可视域和天际线分析。这种情况下单一引擎要么做不出来,要么性能烂到没法看。CesiumThreejs.zip 这类集成方案,本质是把两个引擎按各自强项分工,再用共享上下文让它们跑在同一帧里。
1.2 共享 GL 上下文 vs 两个渲染器并存
集成方案里最核心的决策不是“用不用 Three.js”,而是“怎么把两个渲染器叠到一起”。最简单的想法是页面里放两个 canvas,一个跑 Cesium,一个跑 Three.js,再用 CSS 叠起来。这样逻辑上互不干扰,但问题非常明显:
- 浏览器对 WebGL 上下文数量有限制(一般 8 到 16 个),开着两个大场景很容易触发上限。
- 两个 canvas 各自渲染,无法共享深度缓冲,Three.js 模型和 Cesium 地形的遮挡关系对不上。
- GPU 状态、纹理、几何体无法复用,内存和显存都白白浪费。
共享 GL 上下文则完全不同。Cesium 创建完 WebGL 上下文后,把同一个上下文直接交给 Three.js 的 WebGLRenderer,让两个渲染器共用同一份颜色缓冲、深度缓冲和 GL 状态。Cesium 先画地球和地形,Three.js 再往上叠模型和特效,两边的深度关系能保持一致,最终呈现在一个 canvas 里,性能也更好。
| 对比项 | 双 canvas 独立渲染 | 共享 GL 上下文 |
|---|---|---|
| 实现难度 | 低 | 中 |
| 深度遮挡同步 | 难 | 可同步 |
| 浏览器上下文限制 | 容易触发 | 只占一个 |
| 坐标系对齐 | 麻烦 | 方便 |
| 性能开销 | 高 | 低 |
| 版本升级风险 | 低 | 中 |
1.3 哪种情况才需要这种集成方案
不是每个 Cesium 项目都需要 Three.js。如果只是看地形、量测、加载几个 3D Tiles 模型,原生 Cesium 完全够用。需要把两个引擎拼起来的场景通常有这些特征:
- 要在真实地球坐标上叠加雷达扫描、扇形遮罩、粒子雨等动态特效。
- 要对建筑物模型做可视域、天际线、剖切这类实时分析,并把结果叠加到地形影像上。
- 要用 Three.js 加载高斯泼溅、精细角色模型、复杂动画,又不想放弃 Cesium 的全球定位能力。
- 要做仿真录屏,要求特效和地球在同一个画面里稳定输出。
如果你手头的项目命中两三条,那就值得认真研究共享 GL 上下文这条路。
2. 核心机制:Cesium 与 Three.js 共享 GL 上下文怎么做
2.1 一句话说清共享上下文原理
WebGL 本身是一个状态机。一个 canvas 实例对应一个 WebGL 上下文,多个渲染器只要拿到同一个上下文,就能连续往同一块画布上绘制。Cesium 初始化的过程中已经创建了一个上下文,Three.js 不需要再创建新的 canvas,直接把管理权交给 Three.js 的 WebGLRenderer 即可。
需要注意,Cesium 的viewer.scene.context不是原生 WebGL 上下文,而是一个包装对象。真正传给 Three.js 的应该是它内部的_gl属性。这个属性属于私有 API,升级 Cesium 版本时要留意,建议在初始化处做一次防御性检查。
2.2 最小集成代码骨架
先看一段能跑通的最小代码,感受一下集成方式:
// 创建 Cesium Viewer 时开启抗锯齿,否则后面 three 场景锯齿严重 const viewer = new Cesium.Viewer('cesiumContainer', { contextOptions: { webgl: { antialias: true, alpha: true, powerPreference: 'high-performance' } }, requestRenderMode: false }); // 从 Cesium 拿到底层 GL 上下文 const cesiumContext = viewer.scene.context; const gl = cesiumContext._gl; // 私有属性,升级时留意 // 把同一个 context 传给 Three.js const threeRenderer = new THREE.WebGLRenderer({ canvas: viewer.canvas, context: gl, antialias: false // 抗锯齿已经由 Cesium 初始化时控制 }); threeRenderer.autoClear = false; // 关键:不能清掉 Cesium 的画面 threeRenderer.setPixelRatio(window.devicePixelRatio); // 在 Cesium 每帧渲染完成后,叠加渲染 Three.js 场景 viewer.scene.postRender.addEventListener(() => { syncCameraFromCesiumToThree(viewer, camera3D); threeRenderer.render(threeScene, camera3D); });这段代码的核心是threeRenderer.autoClear = false。如果保持默认的true,Three.js 渲染前会把颜色缓冲和深度缓冲全部清空,地球就没了。改成false后,Three.js 会在 Cesium 已经画好的画面上继续叠加。
相机同步也是一个关键点。比较省事的做法是用 Cesium 的相机参数构造 Three.js 相机:把 Cesium 相机的位置转成 Three.js 世界坐标,把 Cesium 的旋转矩阵映射到 Three.js 相机上。另一种做法是在 Three.js 里维护一个局部坐标系,通过Cesium.Transforms.eastNorthUpToFixedFrame把模型放到地球表面。
2.3 深度缓冲同步:最容易翻车的环节
共享上下文之后,第一个遇到的大坑往往是深度关系错乱。表现是:Three.js 模型要么穿透地球,要么被地形完全遮挡,怎么调相机都没用。
原因在于 Cesium 渲染完地形后,深度缓冲里已经写入了地形深度。如果 Three.js 渲染时不清深度,它会基于上一帧残留的深度做测试,结果自然不对;如果清了深度,又失去了和地形做遮挡关系的基础。
我踩过几次坑之后的稳定做法是:放弃直接操作 WebGL 深度缓冲,改为“渲染到纹理再叠加”的后处理思路。把 Three.js 场景渲染到一张 RenderTarget,再通过 Cesium 的后处理阶段(viewer.scene.postProcessStages)把这张纹理合成到最终画面上。这样深度同步交给 Cesium 自己的后处理机制处理,跨版本升级不容易崩。
如果你只是要在 Cesium 上叠加几个简单的 Three.js 模型,也可以简单地把 Three.js 相机 near/far 设置得很贴近 Cesium 相机,但遇到地形起伏大的场景,遮挡关系还是会出问题。所以一般项目做到后期,都会切到渲染到纹理的方案。
2.4 threejs 185 锯齿问题的真实原因
热搜词里三条不离 threejs 185 版本锯齿问题,很多人的第一反应是换抗锯齿算法,其实真正的原因在共享上下文上。
Cesium 创建 WebGL 上下文时,antialias参数决定了 MSAA 是否开启。如果创建 Viewer 时没有配置contextOptions,默认可能没开 MSAA,或者开了但 Three.js 后面又在渲染器里设置了antialias: false,导致最终画面出现明显锯齿。Three.js 自己再怎么设antialias: true都没用,因为上下文已经创建完成,抗锯齿选项无法事后修改。
正确做法是在创建 Cesium Viewer 时就开启antialias: true,然后 Three.js 侧保持antialias: false。如果开了还是觉得锯齿明显,只能走 FXAA 后处理,或者把渲染分辨率调高再加 CSS 缩放。另外要注意,WebGL2 和 WebGL1 的 MSAA 行为不完全一样,建议统一走 WebGL2,性能更好,后期处理也更灵活。
还有一个小坑:Three.js 从某个版本开始对context参数做了严格校验,传入的上下文类型不对会直接抛错。集成时最好锁定两个库的版本,不要盲目升级。
3. 集成后能做的效果:雷达扫描、动态水面、可视域分析
3.1 雷达扫描效果实现步骤
Cesium 官方没有雷达扫描组件,社区方案大多是贴图转圈,效果很生硬。用 Three.js 写一个 ShaderMaterial 实现真正的扇区扫描,效果会好很多。
核心思路是创建一个圆形平面(PlaneGeometry),在片元着色器里计算每个像素到圆心的距离和角度,通过step或smoothstep生成扫描区域的边缘。然后把圆形平面对齐到 Cesium 里的目标坐标,每帧更新角度 uniform,就能看到扫描线匀速旋转。
const scanMaterial = new THREE.ShaderMaterial({ uniforms: { center: { value: new THREE.Vector2(0, 0) }, radius: { value: 500.0 }, angle: { value: 0.0 }, angleRange: { value: 1.2 }, // 扫描张角,单位弧度 color: { value: new THREE.Color(0x00ffaa) } }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform vec2 center; uniform float radius; uniform float angle; uniform float angleRange; uniform vec3 color; varying vec2 vUv; void main() { vec2 dir = vUv - center; float dist = length(dir); float alpha = 1.0 - smoothstep(0.0, radius, dist); float polarAngle = atan(dir.y, dir.x); float diff = abs(mod(polarAngle - angle, 6.28318) - 3.14159); float edge = smoothstep(angleRange, 0.0, diff); gl_FragColor = vec4(color, alpha * edge * (1.0 - dist / radius)); } `, transparent: true, depthWrite: false });这段着色器在实际项目里可以直接当模板用。要注意的是,depthWrite必须设为false,否则扫描面片会遮挡后面的模型。渲染顺序也要调成后置,保证扫描效果始终浮在场景上层。
3.2 动态光照与动态水面参数注意点
动态光照在 Cesium 里做不太顺手,因为 Cesium 的光照是为全球地形设计的。到了 Three.js 里,加一个DirectionalLight就能解决大多数问题。关键是方向要对齐,否则物体的阴影和地球光照方向会打架。
我一般从 Cesium 的czm_sunDirection获取当前太阳方向,然后同步到 Three.js 的光源位置。做法是:在每帧更新时读取 Cesium 的太阳方向向量,转成 Three.js 光源的position,这样白天晚上光照会自然变化,场景看起来是一致的。
动态水面则需要做两层处理:一是法线扰动,二是边缘透明度。法线扰动用两张法线贴图做 UV 偏移叠加,模拟水波流动;边缘透明度处理是为了让水面和地形边界自然地融合,不然会出现一个明显的矩形贴片。参数上,waveHeight控制波动幅度,frequency控制波纹密度,实际调的时候注意“高度别太夸张”,否则远处的山体倒影会穿帮。
GPU 局部雨效果也是同一个思路。在指定包围盒内生成粒子,粒子落地前淡出,用点精灵纹理渲染。这个效果不要在requestRenderMode: true下跑,因为 Cesium 只在交互时重绘,粒子动画会被卡住。
3.3 可视域分析与天际线分析的实现思路
可视域分析是很多规划项目的标配。实现思路不复杂:以视点为中心,向四周发射射线,检测射线和地形、建筑物的交点。能被地形或建筑挡住的就是不可见区域,没被挡住的就是可见区域,最后把结果投影到地表。
用 Cesium 做这一步,可以先用Cesium.sampleTerrainMostDetailed采样周边地形高度,再人工判断遮挡;如果场景里有精细建筑物模型,就用 Three.js 的Raycaster做射线检测。两者结合效率更高,Cesium 负责大地形,Three.js 负责局部精细模型。
天际线分析稍微复杂一点。从视点向水平方向环视一圈发射射线,检测建筑物遮挡的仰角边界,把每条射线的最大仰角连起来,就是城市天际轮廓线。这个功能在建筑高度的日照分析、景观视线分析里特别有用。同样是射线检测,但要注意射线数量,别一次发太多,否则帧率掉到没法看。我一般用 180 到 360 条射线,低频更新,而不是每帧重算。
3.4 模型节点、姿态控制与高斯泼溅
Cesium 加载 3D Tiles 后,要控制模型某个部件(比如雷达天线的旋转、机械臂的姿态),可以用model.getNodeById。关键是要等模型加载完成再操作,否则拿不到节点。加载完成后,通过修改节点矩阵实现动画。
Three.js 侧的姿态控制更直接,模型本身就是场景图节点。常见做法是把 Three.js 模型挂到一个THREE.Group上,再用Cesium.Transforms.eastNorthUpToFixedFrame生成固定坐标矩阵,把 Group 的位置对齐到地球表面。注意 Cesium 的 HeadingPitchRoll 转四元数的公式,不要搞错轴向,否则模型会头朝下。
高斯泼溅模型是最近的热门方向,Cesium 原生不支持。但通过共享上下文,可以用 Three.js 加载高斯泼溅点云数据,再用相同的地理坐标对齐到 Cesium 场景。这样既能保留 Cesium 的地形精度,又能渲染高画质的高斯泼溅场景。性能上建议单独管理 LOD,别一开始就把整个模型全部加载进显存。
4. 离线地图、地形与 MVT 数据避坑
4.1 离线地图本地瓦片怎么配
很多项目需要在内网部署,不能访问在线地图。最简单的方案是把在线瓦片下载到本地,通过 nginx 或 http-server 静态托管,Cesium 用UrlTemplateImageryProvider加载。
这样配置:
const offlineProvider = new Cesium.UrlTemplateImageryProvider({ url: 'http://localhost:8080/tiles/{z}/{x}/{y}.png', minimumLevel: 1, maximumLevel: 18 }); const viewer = new Cesium.Viewer('cesiumContainer', { baseLayer: Cesium.ImageryLayer.fromProviderAsync(offlineProvider) });这里有两个容易踩的坑。一个是跨域:直接双击 HTML 用 file 协议打开,很多浏览器会拦截本地资源,导致瓦片加载不出来。解决办法是本地起一个 http 服务,哪怕是python -m http.server都行。另一个是瓦片范围:如果本地的瓦片只覆盖某个城市,必须给UrlTemplateImageryProvider设置rectangle,否则 Cesium 会在全球范围请求瓦片,白白增加很多 404。
4.2 1.terrain 地形文件为什么加载不出来
热搜词里问到“切好的离线地形 1.terrain 格式”,这个坑我印象很深。Cesium 原生的离线地形格式是 quantized-mesh-tiles,扩展名一般是.terrain,但这里说的1.terrain其实是很多国产切片工具(比如 CesiumLab)导出的小文件块,内部数据结构和 Cesium 原生 quantized-mesh 不一定完全兼容。
加载地形时用CesiumTerrainProvider:
const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl('http://localhost:8080/terrain/', { requestVertexNormals: true, requestWaterMask: true }); viewer.terrainProvider = terrainProvider;如果加载后地形是平的,优先检查两件事:一是瓦片服务目录结构对不对,二是requestVertexNormals是否请求了法线数据。离线地形如果没生成法线,光照效果会非常差,山体看起来像纸片。水淹效果需要requestWaterMask: true,但很多离线切片工具根本不生成水罩数据,开启也没用。
我的建议是尽量转成 quantized-mesh-tiles 标准格式,不要长期依赖某个工具的私有地形块,不然换个工具替换地形时又要重复踩坑。
4.3 MVT 数据在 Cesium 里的三种加载路线
Cesium 不支持直接加载 MVT 矢量瓦片,但业务里经常需要叠加道路、地块边界、行政区划。通常有三种做法:
- MVT 转 GeoJSON,再用
GeoJsonDataSource加载。这种方式最简单,但几十 MB 的 GeoJSON 塞给浏览器,卡顿几乎不可避免。 - 把 MVT 拆解后写入 Cesium 的
CustomDataSource,按需构建 entity。性能好一些,但要自己写解析逻辑。 - 前端用
geojson-vt做动态切片,再结合GeoJsonDataSource按视角范围加载。适合中等数据量。
我实际项目里推荐第二种路线。解析 MVT 的 pbf 数据后,只把当前视野范围内的要素构建成 entity,视野外就销毁。这样数据量再大也能撑住。
另外注意,MVT 用的坐标是 Web Mercator,Cesium 直接使用时要先转成经纬度或 Cartesian3,否则要素会跑到奇怪的位置。
4.4 仿真录屏与 GPU 局部雨效果的性能问题
做仿真录屏,最常见的问题是录出来的视频“一顿一顿”,但界面看着挺流畅。原因多半是requestRenderMode开启了,Cesium 只在相机变化时重绘,Three.js 的粒子、水面动画每帧都在变化,却没有触发重绘。
解决方法是:在录屏期间手动调用viewer.scene.requestRender(),或者把requestRenderMode临时设为false。录制用canvas.captureStream()加MediaRecorder就行,但要注意设置合理的码率,否则动态水面会糊成一片。我这边的经验是视频比特率设 8 Mbps 以上,帧率 30 或 60,效果比较稳。
性能方面,共享上下文后 GPU 压力集中在一次 draw call 链路上。局部雨粒子不要一上来就 10 万粒子,先用 2 万左右调效果,再逐步加。动态水面纹理分辨率 1024 通常够了,2048 加上粒子特效常常会把中端显卡直接拉爆。
5. 高频问题排查与版本踩坑记录
5.1 高频问题速查表
| 问题 | 常见原因 | 解决思路 |
|---|---|---|
| 页面黑屏或初始化卡死 | 共享上下文时 Three.js 拿到了错误的 context,或者上下文类型不匹配 | 检查viewer.scene.context._gl是否存在,传参前先打日志确认 |
| Three.js 模型穿透地球 | 深度缓冲不同步 | 使用渲染到纹理再叠加的方案,不要直接操作深度缓冲 |
| 边缘锯齿严重 | Cesium 创建上下文时没开 MSAA | 在contextOptions里开启antialias: true |
| 雷达扫描面片遮挡模型 | 扫描面片深度写入未关闭 | 设置depthWrite: false,并调整渲染顺序 |
| 模型漂移,位置对不上 | 坐标系转换错误 | 用eastNorthUpToFixedFrame建立局部坐标系 |
| 点击拾取不到 Three.js 模型 | Cesium 的 pick 不认识 Three.js 场景 | 单独用 Three.js Raycaster 处理点击 |
| 本地瓦片加载失败 | file 协议跨域或缺少 CORS 头 | 本地起 http 服务,配置静态资源目录 |
| 1.terrain 地形加载后是平的 | 数据格式与 Cesium 原生不兼容 | 转成 quantized-mesh-tiles 格式 |
| 录屏掉帧 | requestRenderMode阻止了连续重绘 | 录制期间手动requestRender(),或临时关闭 requestRenderMode |
5.2 版本锁定与上下文丢失处理
Cesium 和 Three.js 的 API 变化都很频繁。Cesium 升一个版本,scene.context内部结构可能有变化;Three.js 升一个版本,WebGLRenderer 的构造参数行为也可能调整。我在项目里直接把两个版本锁死在 package.json 里,不用^号,避免同事npm install拉到新版本后出现莫名其妙的兼容问题。
另一个容易忽略的是 WebGL 上下文丢失。笔记本合盖、显卡驱动升级、系统资源紧张都可能触发webglcontextlost事件。一旦丢失,共享的上下文整个失效,Cesium 和 Three.js 都得重新初始化。比较稳的做法是监听webglcontextlost事件,阻止默认行为,然后在webglcontextrestored时重建 Three.js 渲染器和场景资源。
5.3 一点个人体会
从我自己的项目经历来说,CesiumThreejs.zip 这种集成方案其实不是把两个库硬塞在一起,而是先想清楚哪些功能放 Cesium、哪些放 Three.js。我的分工习惯是:地理空间分析、地形影像、全球坐标系一律走 Cesium;粒子、特效、精细模型、后处理走 Three.js;共享上下文只负责把两者按正确的渲染顺序合到一起。
先把坐标系、深度、版本锁死,后面功能堆得再多也不容易出大乱子。如果你也正在搞 Cesium + Three.js 集成,建议先用一个最小 demo 跑通共享上下文,再往上加雷达扫描、动态水面这些效果。别一开始就抄一个很大的工程,否则出了问题根本不知道是哪个环节的问题。这个思路,比直接套用一个完整压缩包要省心得多。
本文还有配套的精品资源,点击获取