简介:面向Cesium与VUE开发者,这是一套解决三维场景中地形开挖功能的完整组件方案,代码未加密、未压缩,可直接调用运行,帮助快速实现地形裁剪、局部开挖与效果展示。压缩包共6个文件,包含Vue组件、三个核心JavaScript逻辑模块及两张效果预览图,整体仅148KB,轻量紧凑,便于直接集成到项目。目前已有2621人学习研究。源码将开挖逻辑拆分为独立JS与Vue组件,职责划分清晰,配合底部和侧面的预览图可直观核对开挖区域形态;同时支持根据实际需求调整开挖边界与深度,方便迁移到GIS、智慧城市等三维可视化场景。使用中如遇问题,可通过文章评论或私信获得作者及时回复。
1. 地形开挖组件的设计思路:为什么选择裁剪而非挖洞
在Cesium中做地形开挖,第一反应可能是用GlobeSampleHeight采样高度,然后修改地形数据。但这种方式在运行时几乎不可行,因为地形瓦片是按LOD动态加载的,改一处高度会牵动整块网格重算。这套基于Cesium+VUE的TerrainExcavation组件,走的是另一条路:用ClippingPlanes对Globe做实时裁剪,让地形直接“塌陷”出指定多边形区域。源码里包含TerrainExcavation.vue、TerrainExcavation.js以及CreatePolygonOnGround.js和ReminderTip.js,代码没有加密,把Vue组件文件引入现有Cesium项目,传入viewer实例就能跑起来。它不是简单的弹窗工具,而是把绘制多边形、生成裁剪平面、拖拽编辑、重置清理都串成完整交互链路的工程组件。适合要做土方量估算、地下管线可视化、矿山边坡分析的WebGIS项目,你不需要手写Cesium的裁剪数学,但看完它的实现,你能清楚知道每个参数是怎么影响最终开挖效果的。
2. Cesium地形开挖核心:ClippingPlanes与集合裁剪的实现
2.1 从多边形成环到ClippingPlane集合
地形开挖在Cesium里的本质是:用一组平面去切地形体的模型坐标,被切掉的那一侧就不再渲染。每个ClippingPlane有一个法线和一个距离,distance为0时平面穿过原点,正值表示沿法线方向平移。对地形来讲,我们需要的是一组竖向的平面围成的无限延伸的竖井,顶部不切,所以这组平面要都垂直于地面。
常见做法是把用户在地图上画的多边形点序列转成Cesium.Cartesian3,然后用每个相邻点计算三角形面片的法线。这里有个坑:直接取多边形的positions再逐个做向量叉积,得到的法线方向可能朝外也可能朝内,跟顶点顺序有关。CreatePolygonOnGround.js里做的事情就是在画完多边形后,用SceneTransforms.worldToWindowCoordinates把三维点映射到屏幕坐标,再围绕屏幕中心判断点是顺时针还是逆时针,统一翻转法线方向,保证挖出来的坑是“往下”而不是“往上凸”。
2.2 代码实现与参数说明
下面是我在实际项目里整理过的最小可运行裁剪逻辑,去掉了监听事件,只保留核心:
function createClippingPlanes(positions) { // positions 是开挖多边形的地表笛卡尔坐标数组 const planes = []; for (let i = 0; i < positions.length; i++) { const nextIndex = (i + 1) % positions.length; const cur = positions[i]; const next = positions[nextIndex]; // 该边垂直向上方向的向量:顶点向上一点 const up = Cesium.Cartesian3.UNIT_Z; const right = Cesium.Cartesian3.subtract(next, cur, new Cesium.Cartesian3()); // 法线 = 右向量 x 上向量 const normal = Cesium.Cartesian3.cross(right, up, new Cesium.Cartesian3()); Cesium.Cartesian3.normalize(normal, normal); // 若法线点向多边形外侧,则反转 const center = Cesium.BoundingSphere.fromPoints(positions).center; const toCenter = Cesium.Cartesian3.subtract(center, cur, new Cesium.Cartesian3()); if (Cesium.Cartesian3.dot(normal, toCenter) < 0) { Cesium.Cartesian3.negate(normal, normal); } planes.push(new Cesium.ClippingPlane(normal, 0.0)); } return planes; }逻辑说明:这里用每条边的方向向量和单位上向量做叉积,得到的法线同时垂直于边和竖直方向,也就是竖直平面的法线。再用多边形中心点判断法线是否朝外,如果朝内就取反,保证每个平面都朝多边形外侧裁剪。最后交给ClippingPlane的distance统一不给值,是因为地形本身原点在椭球中心,我们后续还要把平面集合放到地形模型矩阵的局部坐标里,真正的偏移量在组件内用modelMatrix转换处理。
参数说明:positions必须是从viewer.scene.pickEllipsoid或Cesium.Cartesian3.fromDegreesArray得到的笛卡尔坐标,不能是经纬度数组。法线必须单位化,否则裁剪平面会出现不可预测的偏移。BoundingSphere计算中心比Cesium.Cartesian3.midpoint更稳定,尤其凹多边形时。
2.3 开挖策略:凹多边形与凸包处理
如果你在页面上画了一个“L”形区域,直接用上面的循环生成平面,凹进去的地方会出现“剪过头”的问题。因为ClippingPlanes对每个平面都是全地形裁切,凹多边形的内部在几何上无法用半平面交集完美表示。处理方式有两种:
- 把凹多边形拆分成多个凸多边形,每个凸多边形单独生成一组裁剪平面,顺序分组,应用到同一个
Globe上。 - 更省性能的办法:对用户输入的点做一次三角剖分,把三角形当作凸区域逐个子开挖。
TerrainExcavation.js里采用的是先判断多边形凹凸性,凹多边形自动按耳朵裁剪法(Ear Clipping)拆分。这一点在demo里看不出来,但当你画一个“C”形地块时就会看到clippingPolygons数组长度大于1。遇到这种场景,我的调试经验是在拆分后打印每组的点数,如果某组小于3说明剖分失败,回退到凸包算法。
2.4 与3DTiles裁剪的差异说明
很多初看这个组件的人会问:为什么不能调用现成的ClippingPlanes集合直接给3DTiles用?因为地形的Globe是一个曲面,它内部的tile节点在屏幕空间被剪裁后,边缘会露出地底下方的纹理;而3DTiles是三角网,模型有明确的包围盒。所以地形开挖的平面集合在使用时还需要加一个modelMatrix,把裁剪平面从椭球经纬度坐标转换到世界坐标。TerrainExcavation.js中有一句关键的tileClippingPlanes.modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(center),作用就是让平面集合“贴”在地表指定位置。去掉这句,你的坑会开在错误的地方甚至完全不生效。
3. Vue中封装地形开挖组件的通信与状态管理
3.1 组件划分与props/emit设计
原始的demo把功能拆成TerrainExcavation.vue、TerrainExcavation.js和ReminderTip.js,这个拆分是有讲究的:.vue文件只负责模板、按钮和viewer的注入,.js文件保持纯函数,便于单测和复用。我的建议是外层再包一层parent component,把viewer对象放到provide/inject里,而不是用ref去穿透多层。
props设计建议如下:
| prop名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| viewer | Object | 必填 | Cesium的Viewer实例 |
| editable | Boolean | true | 开挖后允许拖拽编辑 |
| clipHeight | Number | 0 | 从地表往下裁剪的高度(米),默认0表示挖穿 |
| edgeColor | Color | Cesium.Color.YELLOW | 开挖洞口边缘描边颜色 |
3.2 组件挂载时初始化
<template> <div class="terrain-excavation"> <button @click="startDraw" :disabled="drawing">开始绘制</button> <button @click="clearAll" v-if="clippingPlanes">清除开挖</button> </div> </template> <script> import * as Cesium from 'cesium'; import { createClippingPlanes, createPolygonOnGround } from './TerrainExcavation.js'; export default { name: 'TerrainExcavation', props: { viewer: { type: Object, required: true }, clipHeight: { type: Number, default: 0 } }, data() { return { drawing: false, positions: [], clippingPlanes: null, gfxGroundPolygon: null }; }, methods: { startDraw() { this.drawing = true; const handler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas); handler.setInputAction((movement) => { const cartesian = this.viewer.scene.pickEllipsoid(movement.position); if (!cartesian) return; this.positions.push(cartesian); }, Cesium.ScreenSpaceEventType.LEFT_CLICK); handler.setInputAction(() => this.finishDraw(handler), Cesium.ScreenSpaceEventType.RIGHT_CLICK); }, finishDraw(handler) { handler.destroy(); this.drawing = false; if (this.positions.length < 3) return; const planes = createClippingPlanes(this.positions); const center = Cesium.BoundingSphere.fromPoints(this.positions).center; this.clippingPlanes = new Cesium.ClippingPlaneCollection({ planes: planes, modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(center) }); this.viewer.scene.globe.clippingPlanes = this.clippingPlanes; // 画一个地表区域,标记开挖范围 this.gfxGroundPolygon = createPolygonOnGround(this.viewer, this.positions); } } }; </script>逻辑说明:startDraw里注册了左键选点、右键完成绘制;每次左键点地形时用pickEllipsoid拿到的笛卡尔坐标直接存入positions。绘制完成后调用createClippingPlanes生成平面集合,再用eastNorthUpToFixedFrame生成以开挖区域中心为原点的模型矩阵。最后把clippingPlanes赋给scene.globe.clippingPlanes,这一步才真正开始裁剪地形。
参数说明:这里clipHeight在demo中默认挖到地心,如果只挖一定深度,需要在ClippingPlane的distance上减去高度偏移并在modelMatrix上叠加平移矩阵,简单做法是modelMatrix.multiply(Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, 0, -clipHeight)))。注意不能在CLICK事件里不加判断地连续接收,否则双击会记录两个点,demo源码里用drawing标志和事件销毁来控制。
3.3 组件销毁时清理裁剪
这类组件最常见的bug是路由跳转后,旧viewer的globe.clippingPlanes还残留,导致下次打开地图地形被切掉一块。TerrainExcavation.vue的beforeDestroy里必须做三件事:
beforeDestroy() { if (this.viewer && this.viewer.scene && this.viewer.scene.globe) { this.viewer.scene.globe.clippingPlanes = undefined; } if (this.gfxGroundPolygon) { this.viewer.entities.remove(this.gfxGroundPolygon); } }这里clippingPlanes必须设为undefined而不是拷贝空集合,因为Cesium内部会监听这个属性变化,空集合也能被渲染一次,产生一秒的闪烁。如果你的项目用的是Vue 3 + Composition API,建议把TerrainExcavation.js里的纯函数放进onUnmounted也没问题,核心原则是大对象viewer不要直接放进reactive,会让响应式追踪开销变大。
4. 交互编辑与多边形拖拽:让开挖范围可改
4.1 拖拽点的生成与更新
只画一次多边形的裁剪没多大意思,工程场景下需要反复调整边界。TerrainExcavation.js里用CallbackProperty实现地表多边形的动态更新,类似这样:
function createPolygonOnGround(viewer, positions) { const polygon = viewer.entities.add({ polygon: { hierarchy: new Cesium.CallbackProperty(() => { return new Cesium.PolygonHierarchy(positions); }, false), material: Cesium.Color.RED.withAlpha(0.3), classificationType: Cesium.ClassificationType.BOTH } }); return polygon; }CallbackProperty的第一个参数是回调函数,每次渲染时Cesium会要求它返回最新的几何数据。这里注意false表示该属性不随时间变化,但Cesium仍会在相机移动时重新调用,因为我们每次拖拽点都会修改positions数组本身,所以不需要主动刷新。若把第二个参数设为true,它会被当成时间相关属性,会导致每一帧都重新创建多边形,性能明显下降。
4.2 拖拽逻辑的实现坑点
一般来说,我给每个位置点添加一个PointGraphics,再用ScreenSpaceEventHandler接收左键按下事件,通过viewer.scene.pick判断是否拾取到点。如果拾取到,进入拖拽状态,在MOUSE_MOVE事件里用pickEllipsoid更新点位坐标。注意不要用Camera.pickEllipsoid,那只能拾取屏幕中心,要用scene.pickEllipsoid。
拖拽过程中有一个方向性问题:更新positions[i]后,需要同步更新clippingPlanes里对应平面的法线。许多从网上copy的代码只更新地面多边形,裁剪面不更新,结果就是拖拽完地上挖的洞还是原来位置。我一般把createClippingPlanes重新执行一遍,然后用clippingPlanes.removeAll()再add新平面集合;直接给clippingPlanes.planes = newPlanes会报Uncaught TypeError,因为planes属性是只读的。
4.3 编辑状态下的Undo/Redo简化方案
组件源码里没有封装撤销,但我强烈建议在调用finishDraw前把positions.slice()深拷贝一份放进历史栈,别引用同一个数组。拖拽完成时记录快照,这样用户点撤销时,先把clippingPlanes移除,再回退快照并重新执行createClippingPlanes和eastNorthUpToFixedFrame。快照里除了点坐标,还要记录当时的clipHeight,不然撤销深度会变化。
5. 一个实用技巧:用Region遮罩让开挖边缘不再锯齿闪烁
最后聊一个很多人都会遇到却搜不到答案的问题:裁剪平面设置后,地形洞口边缘出现黑色锯齿,而且相机远离时会整片闪烁。这个现象不是Cesium渲染bug,而是裁剪平面集合作用于Globe时,没有同时限制地形LOD的细分。当地形瓦片越远,三角形越大,裁剪边界就显示为大锯齿;相机移动时瓦片细分变化,锯齿边会跟着跳。
我的处理办法是给开挖洞口盖一个“假面”多边形,用GroundPrimitive把洞口边缘围起来,同时对这个多边形开启material: Cesium.Material.fromType('Color')并设置translucent: false。由于地面多边形的classificationType默认可以盖住地形,裁剪边界就会被这个多边形的边缘压住,视觉上锯齿感大幅减轻。具体参数可以这样调整:
const edgePrimitive = new Cesium.GroundPrimitive({ geometryInstances: new Cesium.GeometryInstance({ geometry: new Cesium.PolygonGeometry({ polygonHierarchy: new Cesium.PolygonHierarchy(positions), vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT }) }), appearance: new Cesium.EllipsoidSurfaceAppearance({ material: Cesium.Material.fromType('Color', { color: Cesium.Color.YELLOW.withAlpha(0.6) }) }) });为验证是否有效,可以在camera.setView({destination: positions[0]})后把scene.globe.maximumScreenSpaceError调到1(默认2)观察边界。maximumScreenSpaceError越小瓦片切得越细,锯齿越小,但会影响整体加载性能。更可靠的做法是:拖拽结束、相机停稳后,主动更新globe.tileCacheSize并调用scene.requestRender()强制重绘。
另外一个实用判定:如果开挖后地形消失的部分不是你想挖的区域,先检查clippingPlanes.modelMatrix的中心点是否是positions的几何中心,而不是第一个点。这是大多数人复制eastNorthUpToFixedFrame(viewer.camera.positionWC)的错误写法导致的,组件demo里已经用BoundingSphere修正了,直接拿来用即可。最后建议你把ReminderTip.js里的提示做一次i18n硬化,因为默认提示是中文硬编码,在英文浏览器里不会影响功能,但测试人员容易误以为是国际化bug。
本文还有配套的精品资源,点击获取