从零实现GJK碰撞检测算法:原理、代码与优化详解
2026/7/17 8:00:52 网站建设 项目流程

1. 项目概述:为什么我们要手写GJK碰撞检测?

在游戏开发中,物理引擎是让虚拟世界“活”过来的核心。无论是角色跳跃、子弹命中,还是箱子堆叠倒塌,背后都离不开精确且高效的碰撞检测。Unity和Cocos Creator等主流引擎都内置了强大的物理系统,比如Bullet、PhysX或内置的轻量引擎,它们像黑盒一样为我们处理了所有复杂的物理计算。这带来了便利,但也带来了两个问题:一是性能开销,尤其是在移动端或需要处理大量动态物体的场景;二是“黑盒”带来的不可控性,当遇到奇怪的穿透、抖动或性能瓶颈时,我们往往难以深入排查。

这就是为什么我们需要深入引擎内部,亲手实现一个核心的碰撞检测算法——GJK(Gilbert–Johnson–Keerthi)算法。GJK算法是处理凸体碰撞检测的“瑞士军刀”,它高效、优雅,是许多商业物理引擎(包括你用的Unity和Cocos Creator)的基石之一。通过这个项目,你不仅能获得一个可独立运行、零依赖的碰撞检测模块,用于特定性能敏感场景,更能彻底理解碰撞检测的本质。当你的游戏出现诡异的物理Bug时,这份底层知识将成为你排查问题的“火眼金睛”。本文将带你从零开始,在Unity(C#)和Cocos Creator(TypeScript)两种环境中,手把手实现GJK算法,并附上完整、可直接运行的代码。

2. GJK算法核心思想与数学基础拆解

在动手写代码之前,我们必须先吃透GJK算法的灵魂。它之所以强大,是因为它用一种非常聪明的方式,将“两个凸体是否相交”这个几何问题,转化为了一个在闵可夫斯基差(Minkowski Difference)形成的凸包中,寻找是否包含原点的单纯形(Simplex)问题。

2.1 核心概念:闵可夫斯基差与支撑函数

想象一下,你有两个形状A和B。闵可夫斯基差A - B的定义是:从B中任意取一点,从A中任意取一点,用A的点减去B的点,得到的所有可能结果构成的集合。用数学表达就是:MinkowskiDiff = {a - b | a ∈ A, b ∈ B}

这个新形状有一个关键特性:如果A和B相交,那么原点(0,0)一定在A - B这个集合的内部。如果A和B刚好相切,原点就在边界上。如果它们分离,原点就在集合外部。

GJK算法的目标,就是判断原点是否在这个A - B集合内。但我们不可能真的去计算这个无限点的集合。这时就需要第二个核心工具:支撑函数(Support Function)

给定一个方向向量d,形状A的支撑点Support(A, d)是在这个方向上投影最远的点。对于凸多边形,就是所有顶点中,点乘d结果最大的那个顶点。支撑函数的计算非常快。

对于闵可夫斯基差A - B,其支撑点可以通过分别计算A和B的支撑点来高效获得:Support(A-B, d) = Support(A, d) - Support(B, -d)。注意方向,对于B我们要取反方向-d

2.2 算法流程:单纯形迭代与原点包含性检查

GJK算法是一个迭代过程,它试图在A - B形成的凸包中构建一个包含原点的单纯形。在2D中,单纯形可以是点、线段或三角形。

  1. 初始化:选择一个初始搜索方向(通常可以是A的中心指向B的中心的向量)。创建一个空的单纯形列表。
  2. 迭代: a. 根据当前搜索方向d,利用支撑函数计算A - B上的一个新点p。 b. 将这个新点p加入单纯形。 c. 判断当前单纯形是否包含原点。在2D中: - 如果单纯形是一个点,显然不包含原点(除非该点就是原点,那意味着A和B有一个公共点)。 - 如果单纯形是一条线段,我们需要判断原点是否在线段所在的直线上,并且在线段的两端点之间。这可以通过向量叉积和点积来判断。 - 如果单纯形是一个三角形,我们需要判断原点是否在三角形内部。这可以通过计算重心坐标或连续的同向性(利用叉积判断原点是否在每条边的同一侧)来实现。 d. 如果单纯形包含原点,则算法结束,返回碰撞(true)。 e. 如果单纯形不包含原点,我们需要修正单纯形,剔除掉那些对包含原点没有贡献的“最远点”,并计算一个新的搜索方向d,这个方向应该指向原点,并且垂直于当前单纯形中离原点最近的部分(例如,对于线段,就是垂直于该线段并指向原点一侧的法线)。 f. 如果新的支撑点p在方向d上的投影距离已经不再增加(即新点没有比之前更靠近原点),说明我们已经到达了A - B凸包的边界,且原点不在其内。算法结束,返回无碰撞(false)

这个迭代过程通常收敛得非常快,在几步之内就能得出结论,这也是GJK高效的原因。

实操心得:理解“修正单纯形”和“计算新方向”是理解GJK的关键。在2D中,当单纯形是线段且原点不在其上时,新方向就是垂直于线段并指向原点的向量。你需要确保这个方向是“远离”当前线段的,这样才能在下一步探索新的区域。

3. 手把手实现:Unity (C#) 版本完整代码解析

我们将在一个Unity的C#脚本中实现完整的2D GJK碰撞检测。为了清晰,我们会创建一个GJKCollisionDetector静态工具类。

3.1 数据结构定义与支撑函数实现

首先,我们需要定义一些基础数据结构。在Unity中,我们可以直接使用Vector2,但为了教学清晰,我们定义自己的PointShape结构。

using UnityEngine; using System.Collections.Generic; public static class GJKCollisionDetector { // 表示一个2D形状,由一系列顶点(按顺时针或逆时针顺序)构成 public class Shape { public Vector2[] vertices; public Shape(Vector2[] verts) { vertices = verts; } // 支撑函数:返回在给定方向d上最远的顶点 public Vector2 GetSupportPoint(Vector2 d) { float maxDot = float.NegativeInfinity; Vector2 supportPoint = vertices[0]; foreach (var v in vertices) { float dot = Vector2.Dot(v, d); if (dot > maxDot) { maxDot = dot; supportPoint = v; } } return supportPoint; } } // 表示闵可夫斯基差上的一个点,同时记录它来自A和B的原始顶点(用于后续计算碰撞信息,如穿透向量) public struct MinkowskiPoint { public Vector2 point; // A - B 的结果 public Vector2 fromA; public Vector2 fromB; } }

3.2 GJK核心迭代逻辑实现

接下来是算法的核心。我们将迭代过程封装在一个函数中。

public static bool CheckCollision(Shape shapeA, Shape shapeB) { // 1. 初始化方向:从A的中心指向B的中心(简单估算) Vector2 centerA = CalculateCenter(shapeA); Vector2 centerB = CalculateCenter(shapeB); Vector2 d = centerB - centerA; if (d == Vector2.zero) d = Vector2.right; // 如果中心重合,给一个默认方向 // 2. 获取初始支撑点,构建初始单纯形(一个点) MinkowskiPoint simplexPoint = GetMinkowskiSupport(shapeA, shapeB, d); List<MinkowskiPoint> simplex = new List<MinkowskiPoint> { simplexPoint }; // 3. 调整初始方向,指向原点 d = -simplexPoint.point; // 从第一个支撑点指向原点 // 4. 开始迭代(设置最大迭代次数防止无限循环,理论上很少超过10次) int maxIterations = 20; for (int i = 0; i < maxIterations; i++) { // 获取新的支撑点 MinkowskiPoint newPoint = GetMinkowskiSupport(shapeA, shapeB, d); // 如果新点在当前方向上的投影没有越过原点(点乘<=0),则不可能包含原点 if (Vector2.Dot(newPoint.point, d) <= 0) { return false; // 分离 } simplex.Add(newPoint); // 处理单纯形,判断是否包含原点,并更新单纯形和方向d if (HandleSimplex(ref simplex, ref d)) { return true; // 碰撞 } } // 理论上不应走到这里,出于安全返回false Debug.LogWarning("GJK reached max iterations."); return false; } // 计算形状的近似中心(用于初始化方向) private static Vector2 CalculateCenter(Shape shape) { Vector2 sum = Vector2.zero; foreach (var v in shape.vertices) sum += v; return sum / shape.vertices.Length; } // 计算闵可夫斯基差上的支撑点 private static MinkowskiPoint GetMinkowskiSupport(Shape a, Shape b, Vector2 d) { Vector2 supportA = a.GetSupportPoint(d); Vector2 supportB = b.GetSupportPoint(-d); // 对B取反方向 return new MinkowskiPoint { point = supportA - supportB, fromA = supportA, fromB = supportB }; }

3.3 单纯形处理与方向更新(算法核心中的核心)

HandleSimplex函数是GJK的“大脑”。在2D中,单纯形最多有3个点(三角形)。

private static bool HandleSimplex(ref List<MinkowskiPoint> simplex, ref Vector2 d) { // 根据单纯形中点的数量进行不同处理 if (simplex.Count == 2) { // 单纯形是一条线段 [a, b],其中b是最后加入的新点 return ProcessLine(ref simplex, ref d); } else if (simplex.Count == 3) { // 单纯形是一个三角形 [a, b, c],其中c是最后加入的新点 return ProcessTriangle(ref simplex, ref d); } // simplex.Count == 1 的情况在外部循环中处理 return false; } private static bool ProcessLine(ref List<MinkowskiPoint> simplex, ref Vector2 d) { // 假设simplex = [A, B],其中B是较新的点 Vector2 a = simplex[0].point; Vector2 b = simplex[1].point; // 向量:AB, AO (O是原点,即Vector2.zero) Vector2 ab = b - a; Vector2 ao = -a; // 从A指向原点 // 判断原点相对于线段AB的位置 // 使用叉积的标量结果(在2D中,相当于 perp dot product) // 如果原点在AB的“左侧”(假设AB方向从A到B),我们需要向这个方向搜索 if (Vector2.Dot(Perp(ab), ao) > 0) { // 原点在AB的一侧,新的搜索方向是垂直于AB并指向原点的一侧 d = TripleProduct(ab, ao, ab); // 等效于 Perp(ab) 投影到AO方向 } else { // 原点在AB的“右侧”或线上,我们只需要考虑点A // 将单纯形缩减为只包含A,方向指向原点 simplex.RemoveAt(1); // 移除B d = ao; } return false; // 线段不可能包含原点(除非原点在线段上,但概率极低,这里简化处理) } private static bool ProcessTriangle(ref List<MinkowskiPoint> simplex, ref Vector2 d) { // 假设simplex = [A, B, C],其中C是最新加入的点 Vector2 a = simplex[0].point; Vector2 b = simplex[1].point; Vector2 c = simplex[2].point; // 计算边的法线方向(垂直于边并指向三角形外部) Vector2 ab = b - a; Vector2 ac = c - a; Vector2 ao = -a; // 检查原点是否在边AB的外侧 Vector2 abPerp = Perp(ab); if (Vector2.Dot(abPerp, ao) > 0) { // 原点在AB的外侧,移除点C,将单纯形退化为线段AB,并更新方向 simplex.RemoveAt(2); // 移除C d = TripleProduct(ab, ao, ab); // 指向AB外侧垂直于AB的方向 return false; } // 检查原点是否在边AC的外侧 Vector2 acPerp = Perp(ac); if (Vector2.Dot(acPerp, ao) > 0) { // 原点在AC的外侧,移除点B,将单纯形退化为线段AC simplex.RemoveAt(1); // 移除B d = TripleProduct(ac, ao, ac); return false; } // 如果原点既不在AB外侧,也不在AC外侧,那么它就在三角形ABC内部 return true; // 碰撞发生! } // 工具函数:计算2D向量的垂直向量(逆时针旋转90度) private static Vector2 Perp(Vector2 v) { return new Vector2(-v.y, v.x); } // 工具函数:三重积 (a × b) × c,在2D中用于求垂直于a的向量在c方向上的投影,常用于计算新的搜索方向 private static Vector2 TripleProduct(Vector2 a, Vector2 b, Vector2 c) { // 在2D中, (a × b) 是一个标量,但我们可以用点积模拟:Perp(a) * (b · Perp(c)) - Perp(c) * (b · Perp(a)) // 一个更直接且稳定的实现是:Perp( Vector2.Dot(a, c) * b - Vector2.Dot(a, b) * c ) // 但为了清晰和GJK经典描述,我们使用: Perp( Vector2.Dot(Perp(a), b) * Perp(a) ) 的变体。 // 这里实现一个简化稳定版:返回垂直于a的向量,且其方向与 (b 在垂直于a的方向上的分量) 相同。 float dot = Vector2.Dot(a, b); float dot2 = Vector2.Dot(a, c); return dot * c - dot2 * b; }

3.4 在Unity中的使用示例

创建一个测试脚本,挂载到空物体上,运行即可在Gizmos中看到碰撞检测结果。

using UnityEngine; public class GJKTest : MonoBehaviour { public Transform shapeATransform; public Vector2[] shapeAVerticesLocal = new Vector2[] { new Vector2(-1, -1), new Vector2(1, -1), new Vector2(0, 1) }; // 三角形 public Transform shapeBTransform; public Vector2[] shapeBVerticesLocal = new Vector2[] { new Vector2(-0.5f, -0.5f), new Vector2(0.5f, -0.5f), new Vector2(0.5f, 0.5f), new Vector2(-0.5f, 0.5f) }; // 矩形 void Update() { // 将本地顶点转换到世界空间 Vector2[] worldVertsA = TransformVertices(shapeAVerticesLocal, shapeATransform); Vector2[] worldVertsB = TransformVertices(shapeBVerticesLocal, shapeBTransform); GJKCollisionDetector.Shape shapeA = new GJKCollisionDetector.Shape(worldVertsA); GJKCollisionDetector.Shape shapeB = new GJKCollisionDetector.Shape(worldVertsB); bool isColliding = GJKCollisionDetector.CheckCollision(shapeA, shapeB); Debug.Log("Collision: " + isColliding); } private Vector2[] TransformVertices(Vector2[] localVerts, Transform trans) { Vector2[] worldVerts = new Vector2[localVerts.Length]; for (int i = 0; i < localVerts.Length; i++) { worldVerts[i] = trans.TransformPoint(localVerts[i]); } return worldVerts; } void OnDrawGizmos() { if (shapeATransform == null || shapeBTransform == null) return; DrawShapeGizmo(shapeAVerticesLocal, shapeATransform, Color.green); DrawShapeGizmo(shapeBVerticesLocal, shapeBTransform, Color.blue); // 在Scene视图中实时计算并显示碰撞结果 Vector2[] worldVertsA = TransformVertices(shapeAVerticesLocal, shapeATransform); Vector2[] worldVertsB = TransformVertices(shapeBVerticesLocal, shapeBTransform); GJKCollisionDetector.Shape shapeA = new GJKCollisionDetector.Shape(worldVertsA); GJKCollisionDetector.Shape shapeB = new GJKCollisionDetector.Shape(worldVertsB); bool isColliding = GJKCollisionDetector.CheckCollision(shapeA, shapeB); Gizmos.color = isColliding ? Color.red : Color.white; Gizmos.DrawWireSphere(Vector3.zero, 0.1f); // 在原点画个小球示意 } void DrawShapeGizmo(Vector2[] verts, Transform trans, Color col) { Gizmos.color = col; for (int i = 0; i < verts.Length; i++) { Vector3 worldPosCurrent = trans.TransformPoint(verts[i]); Vector3 worldPosNext = trans.TransformPoint(verts[(i + 1) % verts.Length]); Gizmos.DrawLine(worldPosCurrent, worldPosNext); } } }

4. 手把手实现:Cocos Creator (TypeScript) 版本完整代码解析

在Cocos Creator中,我们使用TypeScript实现。逻辑与C#版本完全一致,只是语法和API有所不同。我们将在Cocos Creator的脚本组件中实现。

4.1 类型定义与支撑函数

创建一个新的TypeScript脚本,例如GJKCollision.ts

import { _decorator, Component, Vec2, Color, Graphics } from 'cc'; const { ccclass, property } = _decorator; // 定义形状类 export class Shape { vertices: Vec2[]; constructor(verts: Vec2[]) { this.vertices = verts; } // 支撑函数 getSupportPoint(d: Vec2): Vec2 { let maxDot = -Infinity; let supportPoint = this.vertices[0]; for (const v of this.vertices) { let dot = v.dot(d); if (dot > maxDot) { maxDot = dot; supportPoint = v; } } return supportPoint.clone(); } } // 闵可夫斯基点结构 export interface MinkowskiPoint { point: Vec2; fromA: Vec2; fromB: Vec2; } // GJK碰撞检测器 export class GJKCollisionDetector { static checkCollision(shapeA: Shape, shapeB: Shape): boolean { // 1. 初始化方向 let centerA = this.calculateCenter(shapeA); let centerB = this.calculateCenter(shapeB); let d = centerB.subtract(centerA); if (d.equals(Vec2.ZERO)) { d = Vec2.RIGHT; } // 2. 获取初始支撑点 let simplexPoint = this.getMinkowskiSupport(shapeA, shapeB, d); let simplex: MinkowskiPoint[] = [simplexPoint]; // 3. 调整方向指向原点 d = simplexPoint.point.negative(); // 等价于 Vec2.ZERO.subtract(simplexPoint.point) // 4. 开始迭代 const maxIterations = 20; for (let i = 0; i < maxIterations; i++) { let newPoint = this.getMinkowskiSupport(shapeA, shapeB, d); // 检查新点是否在方向d上超越了原点 if (newPoint.point.dot(d) <= 0) { return false; // 分离 } simplex.push(newPoint); if (this.handleSimplex(simplex, d)) { return true; // 碰撞 } // handleSimplex会修改simplex和d } console.warn("GJK reached max iterations."); return false; } private static calculateCenter(shape: Shape): Vec2 { let sum = new Vec2(0, 0); for (const v of shape.vertices) { sum.add(v); } return sum.multiplyScalar(1 / shape.vertices.length); } private static getMinkowskiSupport(a: Shape, b: Shape, d: Vec2): MinkowskiPoint { let supportA = a.getSupportPoint(d); let negD = d.negative(); let supportB = b.getSupportPoint(negD); return { point: supportA.subtract(supportB), fromA: supportA, fromB: supportB }; } private static handleSimplex(simplex: MinkowskiPoint[], d: Vec2): boolean { if (simplex.length === 2) { return this.processLine(simplex, d); } else if (simplex.length === 3) { return this.processTriangle(simplex, d); } return false; } private static processLine(simplex: MinkowskiPoint[], d: Vec2): boolean { let a = simplex[0].point; let b = simplex[1].point; let ab = b.subtract(a); let ao = a.negative(); // 原点指向a的向量,我们需要的是a指向原点的向量,即ao的负值 // 注意:这里ao是a指向原点的向量,即 Vec2.ZERO.subtract(a) ao = new Vec2(-a.x, -a.y); // 计算垂直于ab的向量(法线) let abPerp = new Vec2(-ab.y, ab.x); // 逆时针旋转90度 if (abPerp.dot(ao) > 0) { // 原点在ab的“左侧”,新方向是垂直于ab并指向原点的一侧 // 使用三重积近似计算方向: abPerp.dot(ao) * abPerp // 但更标准的GJK实现是: d = tripleProduct(ab, ao, ab); d.set(this.tripleProduct(ab, ao, ab)); } else { // 原点在ab的“右侧”或线上,退化到点a simplex.splice(1, 1); // 移除b d.set(ao); } return false; } private static processTriangle(simplex: MinkowskiPoint[], d: Vec2): boolean { let a = simplex[0].point; let b = simplex[1].point; let c = simplex[2].point; let ab = b.subtract(a); let ac = c.subtract(a); let ao = new Vec2(-a.x, -a.y); // 检查边AB let abPerp = new Vec2(-ab.y, ab.x); if (abPerp.dot(ao) > 0) { // 原点在AB外侧,移除C,退化到线段AB simplex.splice(2, 1); d.set(this.tripleProduct(ab, ao, ab)); return false; } // 检查边AC let acPerp = new Vec2(-ac.y, ac.x); if (acPerp.dot(ao) > 0) { // 原点在AC外侧,移除B,退化到线段AC simplex.splice(1, 1); d.set(this.tripleProduct(ac, ao, ac)); return false; } // 原点在三角形ABC内 return true; } // 三重积辅助函数: (a × b) × c 在2D中的计算 // 返回一个垂直于a的向量,方向由b和c决定。常用形式: (a · c) * b - (a · b) * c private static tripleProduct(a: Vec2, b: Vec2, c: Vec2): Vec2 { let ac = a.dot(c); let ab = a.dot(b); // result = ac * b - ab * c let result = new Vec2(); Vec2.multiplyScalar(b, ac, result); let temp = new Vec2(); Vec2.multiplyScalar(c, ab, temp); return result.subtract(temp); } // 计算垂直向量 private static perp(v: Vec2): Vec2 { return new Vec2(-v.y, v.x); } }

4.2 在Cocos Creator场景中的测试组件

创建一个新的组件脚本GJKTestComponent.ts并挂载到一个空节点上。

import { _decorator, Component, Vec2, Color, Graphics, Node } from 'cc'; import { Shape, GJKCollisionDetector } from './GJKCollision'; const { ccclass, property } = _decorator; @ccclass('GJKTestComponent') export class GJKTestComponent extends Component { @property(Node) shapeANode: Node = null!; @property(Vec2) shapeAVerticesLocal: Vec2[] = [new Vec2(-50, -50), new Vec2(50, -50), new Vec2(0, 50)]; @property(Node) shapeBNode: Node = null!; @property(Vec2) shapeBVerticesLocal: Vec2[] = [new Vec2(-30, -30), new Vec2(30, -30), new Vec2(30, 30), new Vec2(-30, 30)]; private _graphics: Graphics = null!; start() { // 获取或创建Graphics组件用于绘制 this._graphics = this.getComponent(Graphics) || this.addComponent(Graphics); this._graphics.lineWidth = 2; } update() { if (!this.shapeANode || !this.shapeBNode) return; let worldVertsA = this.transformVertices(this.shapeAVerticesLocal, this.shapeANode); let worldVertsB = this.transformVertices(this.shapeBVerticesLocal, this.shapeBNode); let shapeA = new Shape(worldVertsA); let shapeB = new Shape(worldVertsB); let isColliding = GJKCollisionDetector.checkCollision(shapeA, shapeB); // 可以在控制台输出或更新UI文本 // console.log(`Collision: ${isColliding}`); this.drawShapes(isColliding); } private transformVertices(localVerts: Vec2[], node: Node): Vec2[] { let worldVerts: Vec2[] = []; for (let localVert of localVerts) { let worldPos = new Vec2(); // 将本地坐标(相对于节点)转换到世界坐标(这里简化处理,假设节点没有旋转缩放,只有位移) // 实际项目中应考虑节点的完整变换矩阵 worldPos.x = node.worldPosition.x + localVert.x; worldPos.y = node.worldPosition.y + localVert.y; worldVerts.push(worldPos); } return worldVerts; } private drawShapes(isColliding: boolean) { this._graphics.clear(); // 绘制形状A this._graphics.strokeColor = Color.GREEN; this.drawShape(this.shapeAVerticesLocal, this.shapeANode); // 绘制形状B this._graphics.strokeColor = Color.BLUE; this.drawShape(this.shapeBVerticesLocal, this.shapeBNode); // 在碰撞时绘制一个红色的X if (isColliding) { this._graphics.strokeColor = Color.RED; this._graphics.moveTo(-10, -10); this._graphics.lineTo(10, 10); this._graphics.moveTo(-10, 10); this._graphics.lineTo(10, -10); this._graphics.stroke(); } } private drawShape(localVerts: Vec2[], node: Node) { if (localVerts.length === 0) return; let firstWorldPos = this.localToWorld(localVerts[0], node); this._graphics.moveTo(firstWorldPos.x, firstWorldPos.y); for (let i = 1; i < localVerts.length; i++) { let worldPos = this.localToWorld(localVerts[i], node); this._graphics.lineTo(worldPos.x, worldPos.y); } // 闭合图形 this._graphics.lineTo(firstWorldPos.x, firstWorldPos.y); this._graphics.stroke(); } private localToWorld(localVert: Vec2, node: Node): Vec2 { return new Vec2(node.worldPosition.x + localVert.x, node.worldPosition.y + localVert.y); } }

在Cocos Creator编辑器中,创建两个空节点作为shapeANodeshapeBNode,并将GJKTestComponent脚本挂载到画布或一个管理节点上,将这两个空节点拖拽赋值。运行后,你就能看到两个形状的轮廓,当它们重叠时,屏幕中心会绘制一个红色的X表示碰撞。

5. 关键细节、优化与常见问题排查

实现基础GJK只是第一步。要让它在实际项目中可用,还需要处理很多细节。

5.1 穿透向量(EPA)与碰撞响应

GJK只能告诉你“是否碰撞”。对于物理响应(如物体弹开),我们还需要知道“碰撞深度”和“碰撞法线”,即一个将物体推出重叠区域的最小平移向量(MTV)。这需要另一个算法:EPA(Expanding Polytope Algorithm)。EPA在GJK找到碰撞(单纯形包含原点)后,以该单纯形为起点,不断扩展多边形,直到找到A - B凸包上离原点最近的边,该边的法线方向即碰撞法线,原点到该边的距离即穿透深度。

实操心得:对于简单的游戏,如果不需要非常精确的物理响应,有时可以用GJK迭代过程中得到的最后一个单纯形(靠近原点的边)来近似估算碰撞法线和深度,虽然不精确但计算量小。对于需要精确物理的场景,必须实现EPA。

5.2 性能优化要点

  1. 缓存支撑点:对于旋转/缩放不变的静态或运动缓慢的物体,可以缓存其世界空间顶点,避免每帧都进行坐标变换。
  2. 快速拒绝:在执行GJK前,先进行粗略的碰撞检测,如轴对齐包围盒(AABB)或包围球检测。这可以快速过滤掉明显不相交的物体对,节省大量计算。
  3. 方向缓存:GJK迭代中,上一次的搜索方向d对于下一帧的检测通常是一个很好的初始方向(因为物体运动具有连贯性)。可以在物体对之间缓存这个方向。
  4. 顶点数优化:对于复杂凸多边形,可以使用凸包算法(如Graham Scan)先简化顶点,或者使用预先计算好的凸包近似(如8边形或12边形)来代替高精度模型进行碰撞检测。

5.3 常见问题与调试技巧

  1. 算法陷入无限循环或提前退出

    • 原因:通常是由于浮点数精度误差导致的方向计算错误,或者TripleProduct函数实现有误,产生了零向量或方向错误。
    • 排查:在迭代中打印出每一步的单纯形顶点和搜索方向d。确保d不为零向量。可以给d一个极小的最小值(如(1e-10, 1e-10))来避免除零或方向退化。
    • 修复:在ProcessLineProcessTriangle中,当计算出的新方向d长度接近0时,可以尝试使用一个备选方向,比如垂直于当前边的法线,或者直接使用原点指向最近点的向量。
  2. 检测结果不稳定(闪烁)

    • 原因:当两个物体刚好相切或非常接近时,浮点误差可能导致一帧检测为碰撞,下一帧检测为分离。
    • 解决:引入一个微小的“容差”(epsilon)。例如,在判断点积if (Vector2.Dot(newPoint.point, d) <= 0)时,可以改为if (Vector2.Dot(newPoint.point, d) <= epsilon),其中epsilon是一个很小的正数(如1e-6)。这相当于给碰撞检测增加了一个微小的“皮肤”,使结果更稳定。
  3. 对于凹多边形无效

    • 核心限制:GJK算法只适用于凸体。如果你的形状是凹的,必须将其分解为多个凸体的组合(凸分解),然后对每一对凸体分别进行GJK检测。
    • 工具:可以使用诸如Bayazit分解等算法进行自动凸分解,或者在建模时直接使用凸体碰撞器。
  4. 坐标变换错误

    • 现象:形状在屏幕上显示正确,但碰撞检测区域错位。
    • 检查:确保传递给GJK算法的顶点坐标是在同一个坐标系(通常是世界坐标系)下的。仔细检查TransformVertices函数,确保它正确应用了物体的位置、旋转和缩放。在Cocos Creator示例中,我们简化了变换,实际项目应使用节点的worldMatrix进行完整变换。
  5. EPA实现中的数值稳定性

    • 难点:EPA在寻找最近边时,需要计算点到线的距离,并处理可能出现的退化三角形(面积接近零)。
    • 技巧:在计算距离时,使用叉积的绝对值除以边长,避免除零。对于面积过小的三角形,直接舍弃或进行特殊处理。同样,引入容差来应对浮点误差。

通过亲手实现GJK,你获得的不只是一个碰撞检测工具,更是对物理引擎底层逻辑的深刻理解。这份理解能让你在遇到Unity或Cocos Creator内置物理引擎的怪异行为时,不再束手无策,而是有能力去分析、验证甚至定制自己的解决方案。记住,所有复杂的系统都是由这些基础的算法构建而成的,掌握了基础,你就拥有了拆解复杂问题的能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询