一、项目背景与核心问题

很多城市的地下管网就像城市的“血管”,供水、排水、燃气、电力这些管线全埋在地下,以前靠纸质图纸或者二维图管理,要么找错位置,要么挖断管线出事故。后来大家想做三维可视化,用Cesium这个专门做三维地球/地图的工具来展示,能直观看到管线的位置、埋深、走向,但做的时候遇到三个绕不开的难题:一是数据本身乱得没法直接用,二是管线半透明显示时和地面叠在一起乱成一团,三是想挖开地面看地下管线时,地面和管线的显示不同步。这篇就一步步说怎么解决这些问题。

二、数据预处理的难题解决

地下管网的数据来源特别杂,有的是测绘队用全站仪测的点,有的是CAD图里的线条,还有的是Excel里的坐标,直接丢给Cesium根本认不出来,还会出现管线断了、坐标不对、重复数据的问题,所以第一步必须把数据整理成Cesium能识别的格式。

2.1 数据清洗的核心步骤

首先得把所有数据统一成一套标准,比如坐标要转成Cesium用的WGS84坐标系(就是经纬度加海拔的那种),还要给每条管线加唯一编号、材质类型(是水管还是燃气管)、埋深这些属性。举个例子,以前拿到的是Excel里的管线点数据,格式是本地坐标,我们可以用Node.js写个小脚本处理,技术栈选Node.js + csv-parser + proj4(用来转坐标系),具体代码如下:

// 技术栈:Node.js v18 + csv-parser v3.0.0 + proj4 v2.9.0
const fs = require('fs');
const csv = require('csv-parser');
const proj4 = require('proj4');

// 1. 定义坐标系转换规则:把本地高斯坐标转成WGS84经纬度
// 高斯坐标的投影参数(根据项目实际修改,这里是示例)
const localProj = '+proj=tmerc +lat_0=0 +lon_0=114 +k=1 +x_0=500000 +y_0=0 +ellps=GRS80 +units=m +no_defs';
const wgs84Proj = 'EPSG:4326'; // Cesium默认的WGS84坐标系

// 2. 读取原始Excel转的CSV数据(假设CSV列是:id,start_x,start_y,end_x,end_y,type,depth)
const rawData = [];
fs.createReadStream('raw_pipeline.csv')
  .pipe(csv())
  .on('data', (row) => rawData.push(row))
  .on('end', () => {
    // 3. 清洗数据:转坐标、去重、补属性
    const cleanData = rawData.map(item => {
      // 转起点坐标
      const startCoord = proj4(localProj, wgs84Proj, [parseFloat(item.start_x), parseFloat(item.start_y)]);
      // 转终点坐标
      const endCoord = proj4(localProj, wgs84Proj, [parseFloat(item.end_x), parseFloat(item.end_y)]);
      // 补埋深属性:地下管线的海拔是“地面海拔 - 埋深”,这里假设地面海拔统一取100米(实际项目要结合地形数据)
      const startAlt = 100 - parseFloat(item.depth);
      const endAlt = 100 - parseFloat(item.depth);
      return {
        id: item.id,
        type: item.type,
        depth: item.depth,
        positions: [startCoord[0], startCoord[1], startAlt, endCoord[0], endCoord[1], endAlt] // Cesium要求的坐标格式:[lng,lat,alt,lng,lat,alt]
      };
    }).filter(item => item.id); // 过滤掉没有编号的无效数据

    // 4. 输出成Cesium能识别的JSON格式
    fs.writeFileSync('clean_pipeline.json', JSON.stringify(cleanData, null, 2));
    console.log('数据预处理完成,已生成clean_pipeline.json');
  });

这个脚本跑完后,就得到了干净的管线数据,每条管线的坐标、属性都规范,Cesium能直接加载。

2.2 预处理的注意事项

一是坐标转换一定要准确,不然管线位置会差几十米甚至上百米,建议找测绘队要准确的投影参数;二是埋深的计算要结合实际地形,如果项目有DEM(数字高程模型)数据,不能用统一的地面海拔,要单独查每个管线点对应的地面海拔再算地下海拔;三是要检查管线的连续性,比如相邻管线的端点坐标要尽量一致,避免加载后出现断裂。

三、透明度混合的难题解决

把预处理好的管线数据加载到Cesium后,会发现一个问题:地面是半透明的(方便看地下),管线也设成半透明的,结果叠在一起后颜色乱成一团,根本分不清哪是地面哪是管线。这是因为Cesium默认的透明度混合模式会把所有半透明物体的颜色随便混,没有层级顺序。

3.1 解决思路:调整混合模式与层级

要解决这个问题,得给不同的物体设不同的混合模式,还要控制它们的显示顺序,让管线永远在地面的上面(更靠近眼睛)。Cesium里的混合模式有好几种,我们用“加色混合”来处理管线,用“正常混合”处理地面,同时给管线设更高的显示优先级。

3.2 具体实现代码

还是拿之前的干净管线数据来加载,技术栈选Cesium 1.100.0,代码如下:

// 技术栈:Cesium 1.100.0
// 1. 初始化Cesium viewer
const viewer = new Cesium.Viewer('cesiumContainer', {
  terrain: Cesium.Terrain.fromWorldTerrain(), // 加载全球地形
  baseLayer: Cesium.ImageryLayer.fromWorldImagery() // 加载底图
});

// 2. 加载地面并设置半透明与混合模式
const ground = viewer.scene.primitives.add(new Cesium.Primitive({
  geometryInstances: new Cesium.GeometryInstance({
    geometry: Cesium.RectangleGeometry.fromRectangle({
      rectangle: Cesium.Rectangle.fromDegrees(113.9, 30.0, 114.1, 30.2), // 覆盖项目范围的矩形地面
      height: 100 // 地面海拔,和预处理时的地面海拔一致
    })
  }),
  appearance: new Cesium.MaterialAppearance({
    material: Cesium.Material.fromType('Color', {
      color: Cesium.Color.BLUE.withAlpha(0.3) // 地面设为蓝色半透明
    }),
    translucent: true,
    renderState: {
      depthTest: { enabled: true }, // 开启深度测试,让地面被遮挡时正常显示
      blend: {
        enabled: true,
        equationRgb: Cesium.BlendEquation.ADD, // 正常混合模式
        functionRgbSource: Cesium.BlendFunction.SOURCE_ALPHA,
        functionRgbDestination: Cesium.BlendFunction.ONE_MINUS_SOURCE_ALPHA
      }
    }
  })
}));

// 3. 加载管线并设置加色混合与高优先级
const pipelineData = JSON.parse(fs.readFileSync('clean_pipeline.json', 'utf8')); // 实际项目要异步加载
viewer.scene.primitives.add(new Cesium.Primitive({
  geometryInstances: pipelineData.map(item => new Cesium.GeometryInstance({
    geometry: new Cesium.PolylineGeometry({
      positions: Cesium.Cartesian3.fromDegreesArrayHeights(item.positions),
      width: 3 // 管线宽度
    }),
    id: item.id,
    attributes: {
      type: item.type,
      depth: item.depth
    }
  })),
  appearance: new Cesium.PolylineMaterialAppearance({
    material: Cesium.Material.fromType('Color', {
      color: Cesium.Color.RED.withAlpha(0.5) // 管线设为红色半透明
    }),
    translucent: true,
    renderState: {
      depthTest: { enabled: true },
      blend: {
        enabled: true,
        equationRgb: Cesium.BlendEquation.ADD, // 加色混合模式,让管线颜色更突出
        functionRgbSource: Cesium.BlendFunction.ONE,
        functionRgbDestination: Cesium.BlendFunction.ONE
      }
    }
  }),
  // 核心:设置管线的显示优先级比地面高,数值越大越先显示(越靠近眼睛)
  renderOrder: 100
}));

加色混合的原理是把两个物体的颜色值直接加起来,比如红色(RGB是255,0,0)和蓝色(0,0,255)加起来会变成紫色,而且因为管线的renderOrder设得比地面高,会优先显示在地面上面,不会被地面的半透明效果盖得看不清。

3.3 混合模式的注意事项

加色混合适合做发光、高亮的物体,比如管线可以用这个模式突出显示;如果是要做不透明的物体,就不要用这个模式;另外半透明物体的alpha值不能设得太低,不然会和其他物体混得太厉害,一般设0.3到0.6之间比较合适。

四、剖切联动的难题解决

剖切功能就是用户拉一个框,把框里的地面切掉,露出下面的管线。但做的时候会发现,地面切掉了,管线也跟着切掉了,或者地面没切掉,管线却露出来了,两者的显示不同步,这是因为地面和管线的剖切范围是分开控制的,没有联动起来。

4.1 解决思路:统一剖切规则

要让地面和管线的剖切同步,得给它们用同一个剖切范围,也就是当用户修改剖切范围时,地面和管线都用这个范围来判断自己要不要显示。Cesium里的Primitive有个show属性,还可以用CustomShader来控制物体的显示,我们可以写一个统一的剖切函数,让地面和管线都调用这个函数来判断是否显示。

4.2 具体实现代码

技术栈还是Cesium 1.100.0,我们先加一个剖切框的交互,再实现联动:

// 技术栈:Cesium 1.100.0
// 1. 定义全局剖切范围(这里用一个矩形作为示例,实际可以用任意形状)
let clipRegion = Cesium.Rectangle.fromDegrees(113.92, 30.02, 114.08, 30.18); // 初始剖切范围

// 2. 给地面加剖切逻辑
const groundClip = new Cesium.CustomShader({
  fragmentShaderText: `
    void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
      // 获取当前像素的经纬度
      vec2 lnglat = czm_degrees(fsInput.attributes.positionMC.xy);
      // 判断当前像素是否在剖切范围内:如果在范围内,显示;否则透明
      if (lnglat.x > ${clipRegion.west} && lnglat.x < ${clipRegion.east} && lnglat.y > ${clipRegion.south} && lnglat.y < ${clipRegion.north}) {
        material.alpha = 0.0; // 地面在剖切范围内的部分设为透明(切掉)
      } else {
        material.alpha = 0.3; // 不在范围内的部分保持半透明
      }
    }
  `
});
// 把地面的appearance换成带剖切逻辑的
ground.appearance = new Cesium.MaterialAppearance({
  material: Cesium.Material.fromType('Color', { color: Cesium.Color.BLUE.withAlpha(0.3) }),
  translucent: true,
  customShader: groundClip,
  renderState: { depthTest: { enabled: true } }
});

// 3. 给管线加同样的剖切逻辑
const pipelineClip = new Cesium.CustomShader({
  fragmentShaderText: `
    void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
      vec2 lnglat = czm_degrees(fsInput.attributes.positionMC.xy);
      // 管线的逻辑和地面相反:在剖切范围内的部分显示,不在的部分隐藏
      if (lnglat.x > ${clipRegion.west} && lnglat.x < ${clipRegion.east} && lnglat.y > ${clipRegion.south} && lnglat.y < ${clipRegion.north}) {
        material.alpha = 0.5; // 管线在剖切范围内的部分显示
      } else {
        material.alpha = 0.0; // 不在范围内的部分隐藏
      }
    }
  `
});
// 把管线的appearance换成带剖切逻辑的
pipelinePrimitive.appearance = new Cesium.PolylineMaterialAppearance({
  material: Cesium.Material.fromType('Color', { color: Cesium.Color.RED.withAlpha(0.5) }),
  translucent: true,
  customShader: pipelineClip,
  renderState: { depthTest: { enabled: true } }
});

// 4. 实现剖切框的交互:用户拖动剖切框时,更新剖切范围,同时更新地面和管线的CustomShader
viewer.cesiumWidget.screenSpaceEventHandler.setInputAction((movement) => {
  // 假设用户拖动剖切框的事件,这里简化处理,实际项目可以用Cesium的ScreenSpaceCameraController来实现拖动
  const newClip = Cesium.Rectangle.fromDegrees(113.92 + movement.endPosition.x * 0.0001, 30.02 + movement.endPosition.y * 0.0001, 114.08 + movement.endPosition.x * 0.0001, 30.18 + movement.endPosition.y * 0.0001);
  clipRegion = newClip;
  // 更新地面的CustomShader
  ground.appearance.customShader = new Cesium.CustomShader({
    fragmentShaderText: `
      void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
        vec2 lnglat = czm_degrees(fsInput.attributes.positionMC.xy);
        if (lnglat.x > ${clipRegion.west} && lnglat.x < ${clipRegion.east} && lnglat.y > ${clipRegion.south} && lnglat.y < ${clipRegion.north}) {
          material.alpha = 0.0;
        } else {
          material.alpha = 0.3;
        }
      }
    `
  });
  // 更新管线的CustomShader
  pipelinePrimitive.appearance.customShader = new Cesium.CustomShader({
    fragmentShaderText: `
      void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
        vec2 lnglat = czm_degrees(fsInput.attributes.positionMC.xy);
        if (lnglat.x > ${clipRegion.west} && lnglat.x < ${clipRegion.east} && lnglat.y > ${clipRegion.south} && lnglat.y < ${clipRegion.north}) {
          material.alpha = 0.5;
        } else {
          material.alpha = 0.0;
        }
      }
    `
  });
}, Cesium.ScreenSpaceEventType.LEFT_DRAG);

这样一来,用户拖动剖切框时,地面和管线的显示逻辑都用同一个剖切范围,就不会出现不同步的问题了。

4.3 剖切联动的注意事项

一是剖切逻辑的坐标系要和数据的坐标系一致,不然会出现范围判断错误;二是如果项目用的是更复杂的剖切形状(比如多边形、立方体),可以调整CustomShader里的判断逻辑,比如用点到多边形的距离来判断是否在范围内;三是要考虑性能,如果管线数量特别多,每次更新剖切范围时不要重新创建所有管线的Primitive,只更新CustomShader就行。

五、应用场景、优缺点与注意事项

5.1 应用场景

最核心的场景是城市地下管网的运维管理,比如运维人员想找某段水管的位置,直接在三维地图上找,不用翻纸质图纸;还有管线施工前的规划,能直观看到施工区域下面有哪些管线,避免挖断;另外还有应急抢修,比如燃气泄漏时,能快速找到泄漏点的位置和周围的管线分布,制定抢修方案。

5.2 技术优缺点

优点是直观,比二维图更容易理解管线的空间关系;精度高,只要预处理时坐标准确,管线的位置和埋深都很准确;交互性强,用户可以放大、缩小、旋转、剖切,从不同角度看管线。缺点是性能要求高,如果管线数量特别多(比如上万条),加载和显示的速度会变慢;对数据的要求高,预处理不好的话,显示效果会很差;还有学习成本,虽然Cesium用起来不难,但要解决混合、剖切这些问题,还是得懂一些图形学的知识。

5.3 注意事项

一是数据安全,地下管网数据属于城市的敏感数据,要做好加密和权限控制;二是版本兼容,Cesium的不同版本之间可能会有API的变化,比如CustomShader在低版本的Cesium里不支持,所以要用1.90以上的版本;三是跨平台适配,比如在手机上显示时,要调整管线的宽度和透明度,保证在小屏幕上也能看清。

六、文章总结

用Cesium做地下管网三维可视化,核心是解决三个问题:数据预处理要把杂乱的数据转成Cesium能识别的格式,透明度混合要调整混合模式和显示顺序让管线和地面不冲突,剖切联动要统一地面和管线的剖切逻辑保证同步。只要把这三个问题解决好,就能做出一个好用的地下管网三维可视化系统,给城市的运维、施工、应急带来很大的便利。