基于 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

相关推荐
视觉人机器视觉1 天前
海康机器人3D 机器人引导 —— 空间基础篇一
3d·机器人
程序员林北北1 天前
【前端进阶之旅】Vue3 + Three.js 实战:从零构建交互式 3D 立方体场景
前端·javascript·vue.js·react.js·3d·typescript
deep_drink2 天前
【论文精读(三)】PointMLP:大道至简,无需卷积与注意力的纯MLP点云网络 (ICLR 2022)
人工智能·pytorch·python·深度学习·3d·point cloud
新缸中之脑2 天前
Tripo AI:构建游戏就绪的3D资产
人工智能·游戏·3d
小贺儿开发2 天前
Unity3D 文物互动大屏
3d·unity·实时互动·udp·socket·网络通信
LqKKsNUdXlA4 天前
两级三相光伏并网逆变器控制Matlab/Simulink仿真模型,mppt控制有扰动观察法和电...
3d
Highcharts.js5 天前
什么是散点图?一文学会Highcharts散点图的核心特性与3D扩展应用
javascript·3d·开发文档·散点图·highcharts·图表类型
ai_xiaogui5 天前
【腾讯开源】Hunyuan3D-Motion 实战:从 26GB 大模型环境配置到 AIStarter 一键本地部署全指南
3d·混元3d-motion·3d动画生成·腾讯混元开源模型·aistarter一键部署·fbx模型导出·pytorch环境配置
niuniudengdeng6 天前
一种基于高维物理张量与XRF实景复刻的一步闭式解工业级3D打印品生成模型
人工智能·python·数学·算法·3d