基于 Cesium 3D Tiles 的局部压平方案解析

在做城市三维或者地形改造的时候,经常会遇到这样的需求:只把模型中的某一块区域压平 / 切平,比如在大雁塔周边挖出一块儿空地、做广场或者站点平台。这类需求有几个难点:
- 模型是 3D Tiles,不是简单网格,很难直接改源模型;
- 希望是"交互式"的:用户自己在场景里画出区域;
- 只影响目标区域内的顶点,其他地方保持原状;
- 最好还能通过 UI 实时调压平高度。
这篇文章就结合你这份 App.vue 里的代码,从整体到细节梳理一下:如何用 Cesium + 自定义 Shader,实现对 3D Tiles 局部区域进行压平。
1. 总体思路:用 Shader 改顶点高度,而不是改模型
这套方案的思想非常简单但高效:
- 让用户在三维场景中画出一个多边形区域(比如一个任意形状的地块)。
- 把多边形三角化,拆成一组三角形。
- 在自定义顶点着色器里,逐个顶点判断:当前顶点是否落在这些三角形组成的区域内。
- 如果在区域内,就把这个顶点的 z 坐标强制改成某个统一高度
u_offsetHeight。 - 通过 UI 控件(dat.gui)实时调整
u_offsetHeight,从而实现压平高度的可视化调节。
核心思想就是一句话:
不动模型本身,只在 GPU 顶点着色阶段,对特定区域内的顶点进行"高度重写"。
2. 场景基础:Cesium Viewer + 3D Tiles
代码中首先是很标准的 Cesium 初始化过程:
ts
viewer = new Viewer(cesiumContainer.value, {
infoBox: false,
shouldAnimate: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
animation: false,
timeline: false,
selectionIndicator: false,
})
viewer.scene.postProcessStages.fxaa.enabled = true
viewer.cesiumWidget.creditContainer.setAttribute('style', 'display: none;')
tileset = await Cesium3DTileset.fromUrl('/dayanta/tileset.json')
viewer.scene.primitives.add(tileset)
viewer.zoomTo(tileset)
这里做了几件小事:
- 去掉多余 UI 控件,只保留纯净的 3D 视图;
- 开启 FXAA 抗锯齿,让模型边缘更顺滑;
- 加载本地 3D Tiles 模型
/dayanta/tileset.json; - 相机自动飞到模型范围。
这些都是压平功能的"前置环境",真正的核心逻辑在后面的交互工具和自定义 Shader 中。
3. 交互绘制压平区域:从"用户画多边形"到"一堆三角形"
3.1 交互工具:InteractiveRegionTool
ts
const interactiveRegionTool = new InteractiveRegionTool(viewer)
这里封装了一个 InteractiveRegionTool,作用很明确:
- 让用户在场景中逐点点击,绘制一个闭合多边形区域;
- 把绘制结果通过事件
DrawEndEvent抛出来。
配合 dat.gui 做两个按钮:
ts
const params = {
draw: function () {
interactiveRegionTool.clear()
interactiveRegionTool.activate()
},
clear: function () {
interactiveRegionTool.clear()
tileset.customShader.destroy();
tileset.customShader = getShader(0);
},
u_offsetHeight: 0,
}
draw():清空上一次绘制结果,进入"绘制模式"。clear():清掉区域,同时清除当前 Shader,恢复到"无压平"的状态。
3.2 结束绘制:把多边形三角化
用户画完多边形后,会触发:
ts
interactiveRegionTool.DrawEndEvent.addEventListener((e: any) => {
let points = e.map((item) => [item.x, item.y]).flat(Infinity);
const triangles = earcut(points)
let trianglePoints = triangles.map((idx) => {
return e[idx];
});
tileset?.customShader?.destroy()
tileset.customShader = getShader(trianglePoints.length / 3)
tileset.customShader.uniformMap.ploygon = function () {
return trianglePoints;
};
})
这段逻辑可以拆成三步:
-
把多边形顶点转成 earcut 需要的格式
e是绘制完返回的点数组,每个点包含x, y等坐标。points把这些点打平成一维数组:[x0, y0, x1, y1, ...]。
-
使用 earcut 对多边形进行三角化
tsconst triangles = earcut(points)triangles是索引数组,每 3 个数是一组三角形的顶点索引,例如[0,1,2, 0,2,3, ...];- 这样一个任意形状的多边形就被拆成若干个不重叠三角形。
-
根据索引反查真实顶点坐标
tslet trianglePoints = triangles.map((idx) => { return e[idx]; });trianglePoints是一个长度为triangleNum * 3的数组,按顺序存储所有三角形的顶点;- 每 3 个元素是一个三角形:
[p0, p1, p2, p3, p4, p5, ...]。
结论:
到此为止,我们已经拿到了"用户画的多边形区域被拆分后的所有三角形顶点",后面会把这些点塞进 Shader 里,让 GPU 自己判断"某个顶点是不是落在这些三角形之中"。
4. 自定义 Shader:动态生成"可变长度多边形判断"代码
4.1 动态创建 CustomShader
ts
tileset?.customShader?.destroy()
tileset.customShader = getShader(trianglePoints.length / 3)
tileset.customShader.uniformMap.ploygon = function () {
return trianglePoints;
};
- 根据三角形数量
triangleNum = trianglePoints.length / 3调用getShader(triangleNum); - 用新的
CustomShader替换 tileset 上的旧 Shader; - 通过
uniformMap.ploygon把三角形顶点数组传入 Shader。
4.2 getShader:按三角形数量拼接 GLSL 代码
ts
function getShader(triangleNum) {
const polygonUniform = triangleNum ? `uniform vec3 ploygon[${triangleNum * 3}];` : ''
const polygonLoop = triangleNum
? `
for (int i = 0; i < ${triangleNum * 3}; i += 3) {
vec4 pp1 = czm_inverseModel * vec4(ploygon[i], 1.0);
vec4 pp2 = czm_inverseModel * vec4(ploygon[i + 1], 1.0);
vec4 pp3 = czm_inverseModel * vec4(ploygon[i + 2], 1.0);
vec3 p1 = vec3(pp1.xy, 0.0);
vec3 p2 = vec3(pp2.xy, 0.0);
vec3 p3 = vec3(pp3.xy, 0.0);
bool b = PointInTriangle(vec3(modelPosition.xy, 0.0), p1, p2, p3);
if (b) {
flag = b;
break;
}
}
`
: ''
为什么要用字符串拼 GLSL?
- GLSL 对循环有一些限制,希望循环次数是编译期常量;
ploygon数组长度也必须是常量;- 所以这里通过 JS 拼字符串,把
triangleNum写死在 Shader 源码里(例如ploygon[30],循环for (int i = 0; i < 30; i += 3)),然后再交给 Cesium 创建CustomShader。
这样就保证 Shader 在编译阶段就知道要循环多少次、数组多大,避免运行期动态循环引起的问题。
5. 顶点着色器:判断顶点是否落在区域内并压平
PointInTriangle:点在三角形内的判断:
glsl
bool PointInTriangle(vec3 P, vec3 A, vec3 B, vec3 C) {
vec3 AB = A - B;
vec3 BC = B - C;
vec3 CA = C - A;
vec3 AP = A - P;
vec3 BP = B - P;
vec3 CP = C - P;
bool b1 = cross(AB, AP).z >= 0.0 && cross(BC, BP).z >= 0.0 && cross(CA, CP).z >= 0.0;
bool b2 = cross(AB, AP).z <= 0.0 && cross(BC, BP).z <= 0.0 && cross(CA, CP).z <= 0.0;
return b1 || b2;
}
- 这是一个常见的二维点在三角形内判断算法;
- 利用叉乘符号一致性来判断点是否在三边的同一侧;
这里是引用
5.2 坐标统一:都映射到"模型坐标系 xy 平面"
在 polygonLoop 中:
glsl
vec4 pp1 = czm_inverseModel * vec4(ploygon[i], 1.0);
...
vec3 p1 = vec3(pp1.xy, 0.0);
...
bool b = PointInTriangle(vec3(modelPosition.xy, 0.0), p1, p2, p3);
modelPosition:当前顶点在"模型坐标"下的位置;ploygon[i]:uniform 里传入的多边形顶点,先乘czm_inverseModel转回模型坐标;- 只保留 xy,z 置为 0,把所有点投影到同一平面上;
- 这样一来,无论世界坐标如何变化,顶点和多边形顶点的判断都在同一坐标系中完成。
5.3 压平动作:重写 positionMC.z
glsl
if (flag) {
vec4 hVector = vec4(0.0, 0.0, u_offsetHeight, 1.0);
vec4 hVectorWorld = u_modelMatrix * hVector;
vec4 mp = czm_inverseModel * hVectorWorld;
vsOutput.positionMC.z = mp.z;
}
flag:如果顶点落在任何一个三角形内,就会被置为true;u_offsetHeight:目标压平高度(在模型坐标里沿 z 轴的位移量);- 步骤解析:
- 在模型坐标系中构造高度向量
hVector; - 用
u_modelMatrix把它变换到世界坐标系; - 再用
czm_inverseModel转回到当前 Tile 的模型坐标系; - 取
mp.z作为这个"压平平面"的统一高度; - 把当前顶点的
positionMC.z直接替换为mp.z。
- 在模型坐标系中构造高度向量
为什么要绕一圈 model → world → model?
- 3D Tiles 的坐标体系比较复杂,根节点
computedTransform可能包含平移、旋转甚至缩放; - 直接简单地
positionMC.z += u_offsetHeight容易在不同 Tile 上出现高度不一致; - 通过
u_modelMatrix和czm_inverseModel的组合计算,可以让压平平面在世界空间中保持"水平",而不是受模型自身旋转的影响。