1-凸包简介
凸包 (Convex Hull) :能包裹一组点集的最小的凸向包围体。
以二维点集为例,其凸包可以想象为在所有点外侧拉紧橡皮筋形成的轮廓。

常见的凸包算法:
| 算法 | 时间复杂度 | 特点 | 适用场景 |
|---|---|---|---|
| 蛮力法 | O(n3) | 枚举所有边判断,理论教学,不实用 | 仅学习原理 |
| Graham 扫描 | O(nlogn) | 极角排序 + 栈 | 经典入门算法 |
| Andrew's Monotone Chain 单调链算法 | O(nlogn) | 坐标排序,实现最简单、稳定 | 工程首选 |
| Jarvis 步进(礼品包裹法) | O(nh),h为凸包顶点数 | 类似包礼物,h 小时很快 | 稀疏凸包场景 |
| QuickHull | 期望 O(nlogn),最坏O(n2) | 分治,类似快速排序 | 大规模点云 |
图形学开发中优先使用Andrew's Monotone Chain 算法,代码简洁、数值稳定性好、不易出现极角精度问题。
接下来我们会详细说一下计算二维凸包和三维凸包的常用方法。
2-二维凸包
接下来我们会用Andrew's Monotone Chain 算法计算二维凸包。
2-1-计算原理

已知:二维顶点集合A到G
求:二维顶点集合的凸包
解:
1.排序。
按照顶点的x 位置的大小排序,若x 相同,就按照y 值的大小排序。

2.按顺序计算下凸包,即ADG。

计算原理是用按照排序,从前向后,用相邻的3个顶点构建2条向量进行叉乘运算,用叉乘结果判断是否满足凸包条件。
比如:AB叉乘AC,若结果小于0,把点B扔掉,然后用AC叉乘AD,......;否则,保留点B,然后用BC叉乘BD,......。
3.按顺序计算上凸包,即GFBA。
原理同上。

4.将下凸包和上凸包合在一起,就是二维顶点集合的凸包。
2-2-代码实现
1.随机生成二维点集。
ini
function randomPoints(count: number): Point2[] {
const out: Point2[] = [];
const span = 9;
for (let i = 0; i < count; i++) {
out.push({id: nextId++,
x: (Math.random() * 2 - 1) * span,
y: (Math.random() * 2 - 1) * span,
});
}
return out;
}
const points = randomPoints(10);
2.计算二维点集的凸包。
ini
const convexHull=monotoneChain(points)
monotoneChain 是计算二维凸包的方法,代码如下:
ini
export type Point2 = { x: number; y: number };
function cross(o: Point2, a: Point2, b: Point2): number {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
function compareXY(a: Point2, b: Point2): number {
if (a.x !== b.x) return a.x - b.x;
return a.y - b.y;
}
function pointKey(p: Point2): string {
return `${p.x},${p.y}`;
}
function dedupeSorted(points: Point2[]): Point2[] {
const out: Point2[] = [];
const seen = new Set<string>();
for (const p of points) {
const key = pointKey(p);
if (seen.has(key)) continue;
seen.add(key);
out.push(p);
}
return out;
}
/** Andrew 单调链:排序 O(n log n),建链 O(n) */
export function monotoneChain(input: Point2[]): Point2[] {
if (input.length === 0) return [];
// 1. 按 x(再按 y)排序并去重
const sorted = dedupeSorted([...input].sort(compareXY));
if (sorted.length <= 2) return [...sorted];
// 2. 下凸包:左 → 右,保持严格左转
const lower: Point2[] = [];
for (const p of sorted) {
while (lower.length >= 2) {
const o = lower[lower.length - 2]!;
const a = lower[lower.length - 1]!;
if (cross(o, a, p) <= 0) lower.pop();
else break;
}
lower.push(p);
}
// 3. 上凸包:右 → 左;跳过下链内部点,保留两端
const upper: Point2[] = [];
const lowerInterior = new Set(lower.slice(1, -1));
for (let i = sorted.length - 1; i >= 0; i--) {
const p = sorted[i]!;
if (lowerInterior.has(p)) continue;
while (upper.length >= 2) {
const o = upper[upper.length - 2]!;
const a = upper[upper.length - 1]!;
if (cross(o, a, p) <= 0) upper.pop();
else break;
}
upper.push(p);
}
// 4. 合并(去掉重复端点);全共线则退化为最左 ↔ 最右
const result = [...lower.slice(0, -1), ...upper.slice(0, -1)];
return result.length >= 2
? result
: [sorted[0]!, sorted[sorted.length - 1]!];
}
解释一下上面的while 逻辑。
while 的作用是在将新点压入栈前,不断丢掉会破坏凸包的点。
以下图为例,判断下凸包。

ABC 是下凸包,所以ABC可以被压入栈;
BCD 不是下凸包,所以C 舍弃,但D 在压入栈前,还得再往前判断是否存在破坏凸包的点。

ABD 不是下凸包,所以B舍弃,此时压入栈的就是AD。

3-三维凸包
接下来我们会用增量构造的方法计算凸包。
增量构造(beneath-beyond):从四面体出发,逐点扩展为多面的凸包。
3-1-增量构造的实现逻辑
1.已知随机三维点。

2.在三维点中找到体积最大的四面体。

3.遍历四面体之外的其它顶点,若顶点在四面体中,跳过;否则,找到四面体中朝向此顶点的面。

在上图中,红色的顶点可以理解为我们的眼睛,我们可以看见的面就是可见面,否是就是不可见面。
4.在四面体中,找到可见面和不可见面的边界线。
边界线满足的条件是:一段边界线有2个相邻面,一个面可见,一个面不可见。

5.删除可见面。将边界线连向红色顶点,构建新的三角面,与之前的不可见面构成新的几何体。

6.重复步骤3,遍历完所有顶点,便可以增量构造的方式得到三维凸包。

3-2-三维凸包的代码实现
1.为顶点标注id
ini
/** 空间点;id 用于可视化追踪,不参与几何计算。 */
export type Point3 = {
x: number;
y: number;
z: number;
id: number;
};
/** 当前输入点集 */
let points: Point3[] = [];
/** 点 id 自增计数,保证可视化可稳定追踪每个点 */
let nextId = 1;
function randomPoints(count: number): Point3[] {
const out: Point3[] = [];
const span = 6;
for (let i = 0; i < count; i++) {
out.push({
id: nextId++,
x: (Math.random() * 2 - 1) * span,
y: (Math.random() * 2 - 1) * span,
z: (Math.random() * 2 - 1) * span,
});
}
return out;
}
const points = randomPoints(12);
上面的顶点是随机生成的,在实际工作中,它是取自某个模型的。
2.计算顶点集合的凸包
ini
const faceList=incrementalHull(points)
incrementalHull 就是用增量构造的方式计算凸包的方法,其整体代码如下:
ini
/**
* 3D 凸包增量构造(beneath-beyond)
* 去重 → 最大体积不共面四点建初始四面体 → 逐点:可见面 → 地平线 → 删面 → 三角化
*/
export type Point3 = {
x: number;
y: number;
z: number;
id: number;
};
/** 三角面:三点按外法向右手定则排序 */
export type Face3 = {
a: Point3;
b: Point3;
c: Point3;
};
const EPS = 1e-9;
type Vec3 = [number, number, number];
function sub(a: Point3, b: Point3): Vec3 {
return [a.x - b.x, a.y - b.y, a.z - b.z];
}
function cross3(u: Vec3, v: Vec3): Vec3 {
return [
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
];
}
function dot(u: Vec3, v: Vec3): number {
return u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
}
function len2(u: Vec3): number {
return dot(u, u);
}
/** ((b-a)×(c-a))·(p-a);外法向朝外时 >0 表示 p 在外侧 */
export function orient(a: Point3, b: Point3, c: Point3, p: Point3): number {
return dot(cross3(sub(b, a), sub(c, a)), sub(p, a));
}
function pointKey3(p: Point3): string {
return `${p.x},${p.y},${p.z}`;
}
function faceKey(f: Face3): string {
const ids = [f.a.id, f.b.id, f.c.id].sort((x, y) => x - y);
return ids.join('-');
}
function dedupe(points: Point3[]): Point3[] {
const out: Point3[] = [];
const seen = new Set<string>();
for (const p of points) {
const key = pointKey3(p);
if (seen.has(key)) continue;
seen.add(key);
out.push(p);
}
return out;
}
function centroidOfPoints(pts: Point3[]): Point3 {
let x = 0;
let y = 0;
let z = 0;
for (const p of pts) {
x += p.x;
y += p.y;
z += p.z;
}
const n = pts.length || 1;
return { id: -1, x: x / n, y: y / n, z: z / n };
}
function uniqueVertices(faces: Face3[]): Point3[] {
const map = new Map<number, Point3>();
for (const f of faces) {
map.set(f.a.id, f.a);
map.set(f.b.id, f.b);
map.set(f.c.id, f.c);
}
return [...map.values()];
}
function orientFaceOutward(face: Face3, interior: Point3): Face3 {
if (orient(face.a, face.b, face.c, interior) > 0) {
return { a: face.a, b: face.c, c: face.b };
}
return face;
}
function orientAllOutward(faces: Face3[], interior: Point3): Face3[] {
return faces.map((f) => orientFaceOutward(f, interior));
}
/** 选取体积最大的不共面四点 */
function findInitialTetra(
points: Point3[],
): { tetra: [Point3, Point3, Point3, Point3]; rest: Point3[] } | null {
if (points.length < 4) return null;
let i0 = 0;
let i1 = 1;
let bestD = -1;
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const d = len2(sub(points[i]!, points[j]!));
if (d > bestD) {
bestD = d;
i0 = i;
i1 = j;
}
}
}
if (bestD <= EPS) return null;
const a = points[i0]!;
const b = points[i1]!;
const ab = sub(b, a);
let i2 = -1;
let bestArea = -1;
for (let i = 0; i < points.length; i++) {
if (i === i0 || i === i1) continue;
const area = len2(cross3(ab, sub(points[i]!, a)));
if (area > bestArea) {
bestArea = area;
i2 = i;
}
}
if (i2 < 0 || bestArea <= EPS) return null;
const c = points[i2]!;
let i3 = -1;
let bestVol = -1;
for (let i = 0; i < points.length; i++) {
if (i === i0 || i === i1 || i === i2) continue;
const vol = Math.abs(orient(a, b, c, points[i]!));
if (vol > bestVol) {
bestVol = vol;
i3 = i;
}
}
if (i3 < 0 || bestVol <= EPS) return null;
const used = new Set([i0, i1, i2, i3]);
const tetra: [Point3, Point3, Point3, Point3] = [
points[i0]!,
points[i1]!,
points[i2]!,
points[i3]!,
];
const rest = points.filter((_, i) => !used.has(i));
return { tetra, rest };
}
function makeTetraFaces(
p0: Point3,
p1: Point3,
p2: Point3,
p3: Point3,
): Face3[] {
const interior = centroidOfPoints([p0, p1, p2, p3]);
const raw: Face3[] = [
{ a: p0, b: p1, c: p2 },
{ a: p0, b: p2, c: p3 },
{ a: p0, b: p3, c: p1 },
{ a: p1, b: p3, c: p2 },
];
return orientAllOutward(raw, interior);
}
function edgeKey(u: Point3, v: Point3): string {
return u.id < v.id ? `${u.id}_${v.id}` : `${v.id}_${u.id}`;
}
type HorizonEdge = { from: Point3; to: Point3 };
/** 地平线:恰连接一个可见面与一个不可见面的边 */
function findHorizon(faces: Face3[], visible: Face3[]): HorizonEdge[] {
const visibleSet = new Set(visible.map(faceKey));
type EdgeRec = {
from: Point3;
to: Point3;
visible: boolean;
};
const byEdge = new Map<string, EdgeRec[]>();
for (const f of faces) {
const vis = visibleSet.has(faceKey(f));
const directed: Array<[Point3, Point3]> = [
[f.a, f.b],
[f.b, f.c],
[f.c, f.a],
];
for (const [from, to] of directed) {
const key = edgeKey(from, to);
const list = byEdge.get(key) ?? [];
list.push({ from, to, visible: vis });
byEdge.set(key, list);
}
}
const horizon: HorizonEdge[] = [];
for (const recs of byEdge.values()) {
if (recs.length !== 2) continue;
const [r0, r1] = recs;
if (r0!.visible === r1!.visible) continue;
const visRec = r0!.visible ? r0! : r1!;
horizon.push({ from: visRec.from, to: visRec.to });
}
return horizon;
}
function facesFromHorizon(
horizon: HorizonEdge[],
eye: Point3,
interior: Point3,
): Face3[] {
return horizon.map((e) =>
orientFaceOutward({ a: e.from, b: e.to, c: eye }, interior),
);
}
/** 校验:所有顶点应在每个面的内侧或面上 */
export function validateHull(faces: Face3[], eps = 1e-6): boolean {
if (faces.length < 4) return faces.length === 0;
const verts = uniqueVertices(faces);
const interior = centroidOfPoints(verts);
for (const f of faces) {
if (orient(f.a, f.b, f.c, interior) > eps) return false;
}
for (const f of faces) {
for (const p of verts) {
if (p.id === f.a.id || p.id === f.b.id || p.id === f.c.id) continue;
if (orient(f.a, f.b, f.c, p) > eps) return false;
}
}
return true;
}
/** 增量构造三维凸包,返回三角面集 */
export function incrementalHull(input: Point3[]): Face3[] {
if (input.length === 0) return [];
const points = dedupe(input);
if (points.length < 4) return [];
const initial = findInitialTetra(points);
if (!initial) return [];
const [p0, p1, p2, p3] = initial.tetra;
let faces = makeTetraFaces(p0, p1, p2, p3);
for (const eye of initial.rest) {
const visible = faces.filter((f) => orient(f.a, f.b, f.c, eye) > EPS);
if (visible.length === 0) continue;
const horizon = findHorizon(faces, visible);
if (horizon.length < 3) continue;
const visibleKeys = new Set(visible.map(faceKey));
const kept = faces.filter((f) => !visibleKeys.has(faceKey(f)));
const interior = centroidOfPoints([...uniqueVertices(kept), eye]);
const added = facesFromHorizon(horizon, eye, interior);
faces = [...kept, ...added];
}
faces = orientAllOutward(faces, centroidOfPoints(uniqueVertices(faces)));
return faces;
}
incrementalHull 方法涉及的代码有点多,咱们详细说一下。
4-incrementalHull 代码详解
4-1-顶点去重,检验顶点数量
对于位置一样的顶点,只取一个。
若去重后的顶点数量小于4个,返回\[\]。
ini
if (input.length === 0) return [];
const points = dedupe(input);
if (points.length < 4) return [];
dedupe 是顶点去重方法。
javascript
function pointKey3(p: Point3): string {
return `${p.x},${p.y},${p.z}`;
}
function dedupe(points: Point3[]): Point3[] {
const out: Point3[] = [];
const seen = new Set<string>();
for (const p of points) {
const key = pointKey3(p);
if (seen.has(key)) continue;
seen.add(key);
out.push(p);
}
return out;
}
此方法将顶点位置变成字符串,然后做为Set 值,判断点位是否相同。
3.若去重后的顶点数量小于4个,返回\[\]。
ini
if (points.length < 4) return [];
4-2-获取顶点中最大的四面体
ini
const initial = findInitialTetra(points);
if (!initial) return [];
findInitialTetra 方法就是获取最大的四面体的方法。
ini
function findInitialTetra(
points: Point3[],
): { tetra: [Point3, Point3, Point3, Point3]; rest: Point3[] } | null {
if (points.length < 4) return null;
let i0 = 0;
let i1 = 1;
let bestD = -1;
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const d = len2(sub(points[i]!, points[j]!));
if (d > bestD) {
bestD = d;
i0 = i;
i1 = j;
}
}
}
if (bestD <= EPS) return null;
const a = points[i0]!;
const b = points[i1]!;
const ab = sub(b, a);
let i2 = -1;
let bestArea = -1;
for (let i = 0; i < points.length; i++) {
if (i === i0 || i === i1) continue;
const area = len2(cross3(ab, sub(points[i]!, a)));
if (area > bestArea) {
bestArea = area;
i2 = i;
}
}
if (i2 < 0 || bestArea <= EPS) return null;
const c = points[i2]!;
let i3 = -1;
let bestVol = -1;
for (let i = 0; i < points.length; i++) {
if (i === i0 || i === i1 || i === i2) continue;
const vol = Math.abs(orient(a, b, c, points[i]!));
if (vol > bestVol) {
bestVol = vol;
i3 = i;
}
}
if (i3 < 0 || bestVol <= EPS) return null;
const used = new Set([i0, i1, i2, i3]);
const tetra: [Point3, Point3, Point3, Point3] = [
points[i0]!,
points[i1]!,
points[i2]!,
points[i3]!,
];
const rest = points.filter((_, i) => !used.has(i));
return { tetra, rest };
}
详细解释一下findInitialTetra 方法的计算步骤。
1.找到距离最远的2个点,即a,b
ini
let i0 = 0;
let i1 = 1;
let bestD = -1;
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const d = len2(sub(points[i]!, points[j]!));
if (d > bestD) {
bestD = d;
i0 = i;
i1 = j;
}
}
}
if (bestD <= EPS) return null;
const a = points[i0]!;
const b = points[i1]!;
const ab = sub(b, a);
sub 是向量减法。
perl
function sub(a: Point3, b: Point3): Vec3 {
return [a.x - b.x, a.y - b.y, a.z - b.z];
}
len2 是利用相同向量的点积计算向量长度的平方。
scss
function len2(u: Vec3): number {
return dot(u, u);
}
function dot(u: Vec3, v: Vec3): number {
return u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
}
2.找到与ab 构成最大三角形的点,即c。
ini
let i2 = -1;
let bestArea = -1;
for (let i = 0; i < points.length; i++) {
if (i === i0 || i === i1) continue;
const area = len2(cross3(ab, sub(points[i]!, a)));
if (area > bestArea) {
bestArea = area;
i2 = i;
}
}
if (i2 < 0 || bestArea <= EPS) return null;
const c = points[i2]!;
三角形的面积可以用三角形两边叉乘后的长度计算。
perl
const area = len2(cross3(ab, sub(points[i]!, a)));
cross3 是三维向量的叉乘方法。
less
function cross3(u: Vec3, v: Vec3): Vec3 {
return [ u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
];
}
3.找到与abc 构成最大四面体的点。
ini
let i3 = -1;
let bestVol = -1;
for (let i = 0; i < points.length; i++) {
if (i === i0 || i === i1 || i === i2) continue;
const vol = Math.abs(orient(a, b, c, points[i]!));
if (vol > bestVol) {
bestVol = vol;
i3 = i;
}
}
if (i3 < 0 || bestVol <= EPS) return null;
四面体的体积是四面体三条相邻边的行列式的绝对值的一半。
css
const vol = Math.abs(orient(a, b, c, points[i]!));
上面的代码没有取半,是因为这只是做对比,不取半也行。
orient 是行列式的计算方法,其几何概念是用三条向量计算以其为边平行六面体的有向体积。
less
/** ((b-a)×(c-a))·(p-a);外法向朝外时 >0 表示 p 在外侧 */
export function orient(a: Point3, b: Point3, c: Point3, p: Point3): number {
return dot(cross3(sub(b, a), sub(c, a)), sub(p, a));
}
4.返回最大四面体的顶点,以及顶点集合中的其它顶点。
ini
const used = new Set([i0, i1, i2, i3]);
const tetra: [Point3, Point3, Point3, Point3] = [
points[i0]!,
points[i1]!,
points[i2]!,
points[i3]!,
];
const rest = points.filter((_, i) => !used.has(i));
return { tetra, rest };
4-3-根据四面体的顶点生成法线朝外的三角面
ini
const [p0, p1, p2, p3] = initial.tetra;
let faces = makeTetraFaces(p0, p1, p2, p3);
makeTetraFaces 是根据四面体顶点生成法线朝外的三角面的方法。
yaml
function makeTetraFaces(
p0: Point3,
p1: Point3,
p2: Point3,
p3: Point3,
): Face3[] {
const interior = centroidOfPoints([p0, p1, p2, p3]);
const raw: Face3[] = [
{ a: p0, b: p1, c: p2 },
{ a: p0, b: p2, c: p3 },
{ a: p0, b: p3, c: p1 },
{ a: p1, b: p3, c: p2 },
];
return orientAllOutward(raw, interior);
}
1.用centroidOfPoints 方法获取四面体的质心。
ini
function centroidOfPoints(pts: Point3[]): Point3 {
let x = 0;
let y = 0;
let z = 0;
for (const p of pts) {
x += p.x;
y += p.y;
z += p.z;
}
const n = pts.length || 1;
return { id: -1, x: x / n, y: y / n, z: z / n };
}
2.构建三角面
yaml
const raw: Face3[] = [
{ a: p0, b: p1, c: p2 },
{ a: p0, b: p2, c: p3 },
{ a: p0, b: p3, c: p1 },
{ a: p1, b: p3, c: p2 },
];
此时三角面的法线并不一定是朝外的。
3.用orientAllOutward(raw, interior) 方法矫正所有三角面的法线朝向。
javascript
function orientFaceOutward(face: Face3, interior: Point3): Face3 {
if (orient(face.a, face.b, face.c, interior) > 0) {
return { a: face.a, b: face.c, c: face.b };
}
return face;
}
function orientAllOutward(faces: Face3[], interior: Point3): Face3[] {
return faces.map((f) => orientFaceOutward(f, interior));
}
在orientFaceOutward 方法里可以看到,若质心与三角形构成的行列式大于0,则表示三角形的法线朝内了。此时,需要将三角面中的任意2点置换一下。
4-4-增量构造多面体
遍历四面体之外的其它顶点,让每个顶点与四面体增量构造成多面体。
ini
for (const eye of initial.rest) {
const visible = faces.filter((f) => orient(f.a, f.b, f.c, eye) > EPS);
if (visible.length === 0) continue;
const horizon = findHorizon(faces, visible);
if (horizon.length < 3) continue;
const visibleKeys = new Set(visible.map(faceKey));
const kept = faces.filter((f) => !visibleKeys.has(faceKey(f)));
const interior = centroidOfPoints([...uniqueVertices(kept), eye]);
const added = facesFromHorizon(horizon, eye, interior);
faces = [...kept, ...added];
}
eye 是四面体之外的其它顶点。之所以叫eye ,是因为它要看向四面体,将四面体分成可见面和不可见面。
1.寻找可见面。
ini
const visible = faces.filter((f) => orient(f.a, f.b, f.c, eye) > EPS);
if (visible.length === 0) continue;
可见面的判断原理就是eye 与三角面构成的行列式的有向性。
若没有找到可见面,则说明eye 在四面体中,要continue,继续遍历其它的点。
2.寻找可见面和不可见面的边界线。
ini
const horizon = findHorizon(faces, visible);
if (horizon.length < 3) continue;
findHorizon 是寻找边界线的方法。
ini
function findHorizon(faces: Face3[], visible: Face3[]): HorizonEdge[] {
const visibleSet = new Set(visible.map(faceKey));
type EdgeRec = {
from: Point3;
to: Point3;
visible: boolean;
};
const byEdge = new Map<string, EdgeRec[]>();
for (const f of faces) {
const vis = visibleSet.has(faceKey(f));
const directed: Array<[Point3, Point3]> = [
[f.a, f.b],
[f.b, f.c],
[f.c, f.a],
];
for (const [from, to] of directed) {
const key = edgeKey(from, to);
const list = byEdge.get(key) ?? [];
list.push({ from, to, visible: vis });
byEdge.set(key, list);
}
}
const horizon: HorizonEdge[] = [];
for (const recs of byEdge.values()) {
if (recs.length !== 2) continue;
const [r0, r1] = recs;
if (r0!.visible === r1!.visible) continue;
const visRec = r0!.visible ? r0! : r1!;
horizon.push({ from: visRec.from, to: visRec.to });
}
return horizon;
}
解释一下findHorizon 方法的逻辑。
① 生成可见面的key集合
go
const visibleSet = new Set(visible.map(faceKey));
面的key 是按照顶点的位置排序算出的。所以只要面的顶点位置一样,其key 就一样。
javascript
function faceKey(f: Face3): string {
const ids = [f.a.id, f.b.id, f.c.id].sort((x, y) => x - y);
return ids.join('-');
}
②收集多面体中每条边的数据。
ini
const byEdge = new Map<string, EdgeRec[]>();
for (const f of faces) {
const vis = visibleSet.has(faceKey(f));
const directed: Array<[Point3, Point3]> = [
[f.a, f.b],
[f.b, f.c],
[f.c, f.a],
];
for (const [from, to] of directed) {
const key = edgeKey(from, to);
const list = byEdge.get(key) ?? [];
list.push({ from, to, visible: vis });
byEdge.set(key, list);
}
}
在上面的代码中,先遍历多面体的面,获取每个面的可见性,并声明面的边集合;然后遍历边集合,以边为key,向byEdge 中写入集合数据。
byEdge 的数据结构:
css
{
edge1 key:[
{from, to, visible:相邻面A 的可见性},
{from, to, visible:相邻面B 的可见性}
],
edge2 key:[
{from, to, visible:相邻面C 的可见性},
{from, to, visible:相邻面D 的可见性}
],
...
}
edgeKey 方法可以获取边的唯一key。
javascript
function edgeKey(u: Point3, v: Point3): string {
return u.id < v.id ? `${u.id}_${v.id}` : `${v.id}_${u.id}`;
}
edgeKey 是根据顶点id 做的排序,所以不同面的公共边的key 值相同。
byEdge 可以记录每条边的相邻面的可见性。
③ 根据byEdge 数据找到可见面和不可见面的边界线。
一段边界线有2个相邻面,一个面可见,一个面不可见。
ini
const horizon: HorizonEdge[] = [];
for (const recs of byEdge.values()) {
if (recs.length !== 2) continue;
const [r0, r1] = recs;
if (r0!.visible === r1!.visible) continue;
const visRec = r0!.visible ? r0! : r1!;
horizon.push({ from: visRec.from, to: visRec.to });
}
return horizon;
3.根据eye 和边界线构成新的面。
ini
const visibleKeys = new Set(visible.map(faceKey));
const kept = faces.filter((f) => !visibleKeys.has(faceKey(f)));
const interior = centroidOfPoints([...uniqueVertices(kept), eye]);
const added = facesFromHorizon(horizon, eye, interior);
visibleKeys 是可见面的key 集合。
根据visibleKeys 从faces 中剔除可见面,剩下的就是不可见面kept。
根据不可见面和eye 可以算出新的凸多面体的质心interior。
使用facesFromHorizon方法,根据质心、边界线和eye 生成新的法线朝外的三角面added。
less
function facesFromHorizon(
horizon: HorizonEdge[],
eye: Point3,
interior: Point3,
): Face3[] {
return horizon.map((e) =>
orientFaceOutward({ a: e.from, b: e.to, c: eye }, interior),
);
}
新的三角面就是遍历边界线horizon,连接eye生成的。当然,orientFaceOutward 方法还会根据interior 使面的法线朝外。
4.将不可见面和新面合成新的多面体。
ini
faces = [...kept, ...added];
4-5-确保法线朝外
为了避免数据误差,可以在再统一执行一遍orientAllOutward 方法,确保法线朝外。
ini
faces = orientAllOutward(faces, centroidOfPoints(uniqueVertices(faces)));
return faces
总结
这一章我们使用Andrew's Monotone Chain 算法计算了二维凸包,使用增量构造的方法计算了三维凸包。
这两种凸包算法都是非常经典、常用的,还有许多其它的凸包算法,我就不再一一赘述了,大家可以按需拓展。
在实际工作中,凸包算法的用处很多,比如简化模型,制作模型的包围体,用于碰撞检测等。