Skip to content

前言

cesium原始裁切api用法相对繁琐,因此不乏有博文探讨实现这个功能的过程,却鲜有循序渐进来讨论实现步骤的。这次正好接到了这样的需求,借机会仔细钻研了相关代码,在此记录。如果有任何错误或优化建议请评论交流,感谢!

注意1.117版本后ceium引入了ClippingPolygon接口,将只用传入经纬度就可轻松完成模型和地形的裁剪,但是旧版本的裁切过程仍然值得钻研,因为这个过程涉及向量、矩阵等相关知识,了解这些有助于我们解决特定问题,产生对三维渲染更高的认知。

先来完成一个最简单的裁切吧

编码实现

image.png

js
//以下代码省略了模型加载,“tileset”变量就是cesium的Cesium3DTileset对象
let clippingPlanes = new Cesium.ClippingPlaneCollection({
    planes: [
        new Cesium.ClippingPlane(new Cesium.Cartesian3(0.0, 1.0, 0.0), 0.0)
    ],
});
tileset.clippingPlanes = clippingPlanes;

image.png 大雁塔变一半了,以上简单的几行代码便实现了模型裁剪。

分析代码

js
//Cesium.ClippingPlaneCollection用来创建裁切面集合
let clippingPlanes = new Cesium.ClippingPlaneCollection({
    planes: [
        //Cesium.ClippingPlane用来创建一个裁切面
        //参数1:向量,定义裁切方向
        //参数2:原点到裁剪面的距离
        new Cesium.ClippingPlane(new Cesium.Cartesian3(0.0, 1.0, 0.0), 0.0)
    ],
});
//将clippingPlanes赋值到tileset.clippingPlanes属性便可完成裁切
tileset.clippingPlanes = clippingPlanes;

1.我们上面提到了“原点”,指的就是3dtiles模型的中心位置,可以用tileset.boundingSphere.center获取到,它是一个Cesium.Cartesian3对象。绘制出来看看吧!

js
let point = viewer.entities.add({
    position: tileset.boundingSphere.center,
    point: {
        pixelSize: 20,
        color: Cesium.Color.RED
    }
});

image.png 2.接着来变动下裁切api参数,来具体看看裁切参数如何控制模型裁切。

js
//原始
new Cesium.ClippingPlane(new Cesium.Cartesian3(1.0, 0.0, 0.0), 0.0)

image.png

js
//修改裁切方向
new Cesium.ClippingPlane(new Cesium.Cartesian3(-1.0, 0.0, 0.0), 0.0)

image.png

js
//修改裁切与原点的距离
new Cesium.ClippingPlane(new Cesium.Cartesian3(1.0, 0.0, 0.0), 50)

image.png

js
//修改裁切与原点的距离为负数
new Cesium.ClippingPlane(new Cesium.Cartesian3(1.0, 0.0, 0.0), -100)

image.png 通过上面的连续参数变化再观察结果,我们已经对Cesium.ClippingPlane有了大概的认知。

多方向裁剪

上一步进行了的最简单的单个方向的裁剪,但还不足以应对业务场景,大多数时候我们需要对模型进行多个方向共同裁剪。

ClippingPlaneCollectionplanes属性里可有多个裁切面,它们共同裁切的部分在视觉上才会被裁切掉。

js
let clippingPlanes = new Cesium.ClippingPlaneCollection({
        planes: []
});

为了直观显示被裁切部分,我们先来画一个辅助观察线,将原始模型轮廓勾勒出来。

js
let polyline = viewer.entities.add({
    polyline: {
        positions: Cesium.Cartesian3.fromDegreesArray([
            [
                108.93926811421908,
                34.22438547864019
            ],
            [
                108.94287411021485,
                34.22436686085846
            ],
            [
                108.94287046023939,
                34.22036037693199
            ],
            [
                108.93927795274405,
                34.220393757689635
            ],
            [
                108.93926811421908,
                34.22438547864019
            ],
        ].flat()),
        clampToGround: true,
        width: 5,
        material: Cesium.Color.RED
    }
});

image.png

裁切左边

js
new Cesium.ClippingPlane(new Cesium.Cartesian3(1.0, 0.0, 0.0), -50.0),

image.png

裁切右边

js
new Cesium.ClippingPlane(new Cesium.Cartesian3(-1.0, 0.0, 0.0), -20.0),

image.png

结合起来

js
let clippingPlanes = new Cesium.ClippingPlaneCollection({
    planes: [
        new Cesium.ClippingPlane(new Cesium.Cartesian3(1.0, 0.0, 0.0), -50.0),
        new Cesium.ClippingPlane(new Cesium.Cartesian3(-1.0, 0.0, 0.0), -20.0),
    ],
});

image.png 通过上图可以很直观的看到两个裁切面共同裁切的部才会在显示上被裁切。

裁剪一个矩形区域

js
let clippingPlanes = new Cesium.ClippingPlaneCollection({
    planes: [
        new Cesium.ClippingPlane(new Cesium.Cartesian3(1.0, 0.0, 0.0), -50.0),//裁左边
        new Cesium.ClippingPlane(new Cesium.Cartesian3(-1.0, 0.0, 0.0), -10.0),//裁右边
        new Cesium.ClippingPlane(new Cesium.Cartesian3(0.0, -1.0, 0.0), -20.0),//裁上边
        new Cesium.ClippingPlane(new Cesium.Cartesian3(0.0, 1.0, 0.0), -90.0),//裁下边
    ],
});

image.png

动态计算裁剪

至此我们已基本完成了裁剪功能,但在实际业务中,手动去指定向量和原点与裁剪面的距离是不可取的,这样做效率极低、不可复用且容易产生误差。更合理的方式是通过指定需要裁剪的边界,自动计算出裁剪面。

我们以下面的梯形裁切为例,完成功能封装。 image.png

使用动态计算的方法裁切第一个面

image.png 最终的效果如上图所示,其中黄色箭头就是裁切向量示意,可以看到它与第一个边垂直。 计算步骤:

  • 根据模型中心创建一个由局部坐标系到世界坐标系转换的矩阵
js
let transform = Cesium.Transforms.eastNorthUpToFixedFrame(tileset.boundingSphere.center)

原理:Cesium.Transforms.eastNorthUpToFixedFrame会返回一个矩阵,该矩阵可以将以传入的坐标为原点的中北上坐标(ENU)转为世界坐标(ECEF)。它的内部原理大概是先根据ENU坐标轴到ECEF坐标轴沿X、Y、Z轴的旋转角度创建一个 3x3 的旋转矩阵,定义 ENU坐标轴与 ECEF坐标轴之间的对齐关系,再将旋转矩阵扩展为 4x4 的变换矩阵,并加入ECEF坐标的平移部分,同时处理旋转和平移。

  • 计算矩阵的逆
js
let inverseTransform = Cesium.Matrix4.inverseTransformation(transform, new Cesium.Matrix4());

原理: 逆矩阵的计算相对复杂一些,感兴趣的可以自行查找计算方式。简单概括下逆矩阵:有一个坐标,将它乘以一个变换矩阵A,得到了一个新坐标,将新坐标再乘以一个矩阵B,新坐标回到了原来的位置,矩阵B便是是矩阵A的逆。因此我们可以通过这个逆矩阵将世界坐标转为模型上的局部坐标。

  • 将起点和终点与逆矩阵相乘转为局部坐标
js
let start = Cesium.Cartesian3.fromDegrees(108.94095117642311, 34.223433413926635);
let end = Cesium.Cartesian3.fromDegrees(108.94054991527706, 34.22265289766609);
let startLocal = Cesium.Matrix4.multiplyByPoint(inverseTransform, start, new Cesium.Cartesian3());
let endLocal = Cesium.Matrix4.multiplyByPoint(inverseTransform, end, new Cesium.Cartesian3());

原理: Cesium.Matrix4.multiplyByPoint是一个简单的矩阵乘标量运算,计算过程如下所示。第四个值为齐次坐标,可直接忽略掉。

  • 计算起点和终点形成的向量
js
let vectorA = Cesium.Cartesian3.subtract(endLocal, startLocal, new Cesium.Cartesian3())

原理: Cesium.Matrix4.subtract将两个坐标相减,这里可以得到两个坐标形成的向量,计算过程如下所示。

  • 创建一个指向正上方的向量
js
let vectorB = Cesium.Cartesian3.UNIT_Z;//等价于Cesium.Cartesian3(0,0,1)
  • 将两个向量叉乘得到法向量
js
let normal = Cesium.Cartesian3.cross(vectorA, vectorB, new Cesium.Cartesian3())

原理: Cesium.Cartesian3.cross将两个向量叉乘,这里可以得到同时垂直于两个坐标的向量(这就是最终的裁剪方向),即法向量。计算过程如下所示。

  • 归一化向量
js
normal = Cesium.Cartesian3.normalize(normal, normal);

原理: Cesium api最终接收的是归一化向量,因此需要用Cesium.Cartesian3.normalize对向量归一化,即保持向量方向不变的条件下,将模长置为1,计算方式为向量除以对应模长,如下所示。

  • 创建裁剪
js
let clippingPlane = Cesium.Plane.fromPointNormal(startLocal, normal);
let clippingPlanes = new Cesium.ClippingPlaneCollection({
    planes: [clippingPlane],
});
tileset.clippingPlanes = clippingPlanes;

我们使用Cesium.Plane的fromPointNormal方法来创建裁剪面,传入向量和裁剪面上点,即可得到裁剪面对象。

封装为函数

js
/**
* 创建裁剪面
* @param {Cesium.Cartesian3} start 起点
* @param {Cesium.Cartesian3} end 终点
* @param {Cesium.Matrix4} matrix 世界坐标到局部坐标的变化矩阵
*/
function createClippingPlane(start, end, matrix) {
    let startLocal = Cesium.Matrix4.multiplyByPoint(matrix, start, new Cesium.Cartesian3())
    let endLocal = Cesium.Matrix4.multiplyByPoint(matrix, end, new Cesium.Cartesian3());
    let vectorA = Cesium.Cartesian3.subtract(endLocal, startLocal, new Cesium.Cartesian3());
    let vectorB = Cesium.Cartesian3.UNIT_Z;
    let normal = Cesium.Cartesian3.cross(vectorA, vectorB, new Cesium.Cartesian3())
    normal = Cesium.Cartesian3.normalize(normal, normal)
    let clippingPlane = Cesium.Plane.fromPointNormal(startLocal, normal);
    return clippingPlane
}



let transform = Cesium.Transforms.eastNorthUpToFixedFrame(tileset.boundingSphere.center)
let inverseTransform = Cesium.Matrix4.inverseTransformation(transform, new Cesium.Matrix4());
//注意向量方向,确保这里的坐标是逆时针。
let clipPath = [
    [
        108.94095117642311,
        34.223433413926635
    ],
    [
        108.94054991527706,
        34.22265289766609
    ],
    [
        108.94121314239943,
        34.221921279160185
    ],
    [
        108.94224254514727,
        34.22219027009056
    ],
    [
        108.94095117642311,
        34.223433413926635
    ],
]
let planes = [];
clipPath.forEach((item, index) => {
    let next = clipPath[index + 1];
    if (next) {
        let plane = createClippingPlane(
            Cesium.Cartesian3.fromDegrees(...item),
            Cesium.Cartesian3.fromDegrees(...next),
            inverseTransform
        );
        planes.push(plane);
    }
})
const clippingPlanes = new Cesium.ClippingPlaneCollection({
    planes,
});
tileset.clippingPlanes = clippingPlanes;

大功告成:

image.png

引出一个问题

通过上面一系列裁切相关知识学习,这种裁切方式会天然的引出一个问题,那就是不支持凹多边形的裁切。针对于这个问题,目前能查到的方式是修改cesium源码,具体还没研究过。正好我这次接到的需求是裁切一个凹多边形,在下班路上愁苦怎么实现时,突然想到一个投机取巧的办法,代价嘛就是要牺牲一点点的性能和代码的优雅度。

实现效果:

image.png实现过程

  1. 初次裁切(红色区域)
    首先,对3DTiles数据进行初次裁切,移除红色区域的部分。这一步骤会直接从显示中去除一个完整的矩形区域,确保红色部分完全被裁剪掉。
  2. 二次裁切与加载(蓝色区域)
    再次加载3DTiles数据,并应用外裁切只保留蓝色区域。通过设置unionClippingRegionstrue,会使每个裁切面都能独立执行裁切操作,而不是只裁切各裁切面共同裁切的部分。这样,便可以实现外裁切。

image.png

总的来说就是将同一个模型加载两次,对其中一个进行内裁切,另一个进行外裁切,两个模型共同拼接起来完成了这个功能。实在算不上优雅,可以当做应急方案暂用。

凹多边形裁切示例代码:

js
/**
 * 创建裁剪面
 * @param {Cesium.Cartesian3} start 起点
 * @param {Cesium.Cartesian3} end 终点
 * @param {Cesium.Matrix4} matrix 世界坐标到局部坐标的变化矩阵
 */
function createClippingPlane(start, end, matrix) {
  let startLocal = Cesium.Matrix4.multiplyByPoint(
    matrix,
    start,
    new Cesium.Cartesian3()
  );
  let endLocal = Cesium.Matrix4.multiplyByPoint(
    matrix,
    end,
    new Cesium.Cartesian3()
  );
  let vectorA = Cesium.Cartesian3.subtract(
    endLocal,
    startLocal,
    new Cesium.Cartesian3()
  );
  let vectorB = Cesium.Cartesian3.UNIT_Z;
  let normal = Cesium.Cartesian3.cross(
    vectorA,
    vectorB,
    new Cesium.Cartesian3()
  );
  normal = Cesium.Cartesian3.normalize(normal, normal);
  let clippingPlane = Cesium.Plane.fromPointNormal(startLocal, normal);
  return clippingPlane;
}
Cesium.Cesium3DTileset.fromUrl("./dayanta/tileset.json").then((tileset) => {
  viewer.scene.primitives.add(tileset);
  viewer.zoomTo(tileset);
  let transform = Cesium.Transforms.eastNorthUpToFixedFrame(
    tileset.boundingSphere.center
  );
  let inverseTransform = Cesium.Matrix4.inverseTransformation(
    transform,
    new Cesium.Matrix4()
  );
  let clipPath = [
    [108.94076267646125, 34.22291486517907],
    [108.94077655772648, 34.221796980200025],
    [108.94249227963245, 34.221758841419174],
    [108.94240874997999, 34.22291332640661],
    [108.94076267646125, 34.22291486517907],
  ];
  let planes = [];
  clipPath.forEach((item, index) => {
    let next = clipPath[index + 1];
    if (next) {
      let plane = createClippingPlane(
        Cesium.Cartesian3.fromDegrees(...item),
        Cesium.Cartesian3.fromDegrees(...next),
        inverseTransform
      );
      planes.push(plane);
    }
  });
  let clippingPlanes = new Cesium.ClippingPlaneCollection({
    planes,
  });
  tileset.clippingPlanes = clippingPlanes;
});

Cesium.Cesium3DTileset.fromUrl("./dayanta/tileset.json").then((tileset) => {
  viewer.scene.primitives.add(tileset);
  viewer.zoomTo(tileset);
  let transform = Cesium.Transforms.eastNorthUpToFixedFrame(
    tileset.boundingSphere.center
  );
  let inverseTransform = Cesium.Matrix4.inverseTransformation(
    transform,
    new Cesium.Matrix4()
  );
  let clipPath = [
    [108.94189260319062, 34.22175802839497],
    [108.94249227963245, 34.221758841419174],
    [108.94244363892413, 34.222418734654426],
    [108.94191357891921, 34.222419481573795],
    [108.94189260319062, 34.22175802839497],
  ];
  clipPath.reverse();
  let planes = [];
  clipPath.forEach((item, index) => {
    let next = clipPath[index + 1];
    if (next) {
      let plane = createClippingPlane(
        Cesium.Cartesian3.fromDegrees(...item),
        Cesium.Cartesian3.fromDegrees(...next),
        inverseTransform
      );
      planes.push(plane);
    }
  });
  let clippingPlanes = new Cesium.ClippingPlaneCollection({
    planes,
    unionClippingRegions: true,
  });
  tileset.clippingPlanes = clippingPlanes;
});

总结

本文由浅入深的介绍了Cesium模型裁切相关知识,因个人能力有限,难免出现错误或纰漏,如有更优雅、更简洁的实现方式,或者错误指正请评论区交流。希望本文对各位开发者有所启发。

持续分享求关注