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

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

在做城市三维或者地形改造的时候,经常会遇到这样的需求:只把模型中的某一块区域压平 / 切平,比如在大雁塔周边挖出一块儿空地、做广场或者站点平台。这类需求有几个难点:

  • 模型是 3D Tiles,不是简单网格,很难直接改源模型;
  • 希望是"交互式"的:用户自己在场景里画出区域;
  • 只影响目标区域内的顶点,其他地方保持原状;
  • 最好还能通过 UI 实时调压平高度。

这篇文章就结合你这份 App.vue 里的代码,从整体到细节梳理一下:如何用 Cesium + 自定义 Shader,实现对 3D Tiles 局部区域进行压平


1. 总体思路:用 Shader 改顶点高度,而不是改模型

这套方案的思想非常简单但高效:

  1. 让用户在三维场景中画出一个多边形区域(比如一个任意形状的地块)。
  2. 把多边形三角化,拆成一组三角形。
  3. 在自定义顶点着色器里,逐个顶点判断:当前顶点是否落在这些三角形组成的区域内
  4. 如果在区域内,就把这个顶点的 z 坐标强制改成某个统一高度 u_offsetHeight
  5. 通过 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;
  };
})

这段逻辑可以拆成三步:

  1. 把多边形顶点转成 earcut 需要的格式

    • e 是绘制完返回的点数组,每个点包含 x, y 等坐标。
    • points 把这些点打平成一维数组:[x0, y0, x1, y1, ...]
  2. 使用 earcut 对多边形进行三角化

    ts 复制代码
    const triangles = earcut(points)
    • triangles 是索引数组,每 3 个数是一组三角形的顶点索引,例如 [0,1,2, 0,2,3, ...]
    • 这样一个任意形状的多边形就被拆成若干个不重叠三角形。
  3. 根据索引反查真实顶点坐标

    ts 复制代码
    let 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 轴的位移量);
  • 步骤解析:
    1. 在模型坐标系中构造高度向量 hVector
    2. u_modelMatrix 把它变换到世界坐标系;
    3. 再用 czm_inverseModel 转回到当前 Tile 的模型坐标系;
    4. mp.z 作为这个"压平平面"的统一高度;
    5. 把当前顶点的 positionMC.z 直接替换为 mp.z

为什么要绕一圈 model → world → model?

  • 3D Tiles 的坐标体系比较复杂,根节点 computedTransform 可能包含平移、旋转甚至缩放;
  • 直接简单地 positionMC.z += u_offsetHeight 容易在不同 Tile 上出现高度不一致;
  • 通过 u_modelMatrixczm_inverseModel 的组合计算,可以让压平平面在世界空间中保持"水平",而不是受模型自身旋转的影响。

代码完整实现vue3+cesiumjs

相关推荐
zhooyu10 小时前
二维坐标转三维坐标的实现原理
c++·3d·opengl
twe775825814 小时前
3D IC封装的崭新视角:如何用3D动画揭示技术奥秘
科技·3d·制造·动画
syncon1219 小时前
激光轻松修复OLED手机屏幕绿线故障
科技·3d·制造
3DVisionary2 天前
捕捉亚毫米级裂纹演化!DIC技术为裂纹扩展与抗裂研究带来全新方案
人工智能·python·3d·应变测量·金属3d打印·dic精度检验方法·各向异性
xChive2 天前
ECharts3D图表 | 3D柱状图和3D饼图实现思路
前端·3d·echarts
云飞云共享云桌面2 天前
SolidWorks云电脑如何多人共享访问?
运维·服务器·人工智能·3d·自动化·云计算·电脑
cy_cy0022 天前
巨型水幕与细腻全息,有何技术区别?
科技·3d·人机交互·交互·软件构建
V搜xhliang02462 天前
目标检测YOLOv9、语义分割、3D点云PCL、SLAM、手眼标定
人工智能·深度学习·目标检测·计算机视觉·3d·知识图谱
Coovally AI模型快速验证2 天前
国产小龙虾方案实战:nanobot + 通义千问,钉钉上随时派活
人工智能·深度学习·学习·计算机视觉·3d
沙振宇2 天前
【Web】使用Vue3+PlayCanvas开发3D游戏(四)3D障碍物躲避游戏2-模型加载
游戏·3d·vue3·vite·playcanvas