ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

分离轴定理(SAT)算法详解:Unity与Cocos Creator 2D凸多边形碰撞检测实战

分离轴定理(SAT)算法详解:Unity与Cocos Creator 2D凸多边形碰撞检测实战

1. 项目概述:为什么是SAT算法?

在2D游戏开发里,碰撞检测是物理交互的基石。无论是角色碰到墙壁、子弹击中敌人,还是两个物体发生挤压,背后都需要一套高效、准确的检测逻辑。市面上有很多方法,比如简单的包围盒(AABB)检测,或者更复杂的像素级检测。但当你需要处理任意凸多边形(比如一个旋转的三角形、一个不规则的六边形)之间的碰撞时,很多简单方法就力不从心了。

这时,分离轴定理(Separating Axis Theorem,简称SAT)就登场了。它不是什么新潮概念,而是图形学和物理引擎领域一个经典且强大的数学工具。我选择用它来做这个实战项目,核心原因就一个:它能以统一的数学方法,精确判断任意两个凸多边形是否相交,并且能计算出碰撞的深度和方向。这对于实现真实的物理反馈(比如推开物体、计算反弹)至关重要。

这个项目将带你从零开始,理解SAT的原理,并用两种最流行的2D游戏引擎——Unity和Cocos Creator——分别实现一套可运行的碰撞检测系统。无论你是Unity的忠实用户,还是Cocos Creator的开发者,都能从中获得可以直接复用到自己项目里的代码和思路。我们不止步于“检测是否碰撞”,还会深入到“如何分离已碰撞的物体”,这是实现一个可用物理系统的关键一步。

2. SAT算法核心原理拆解

要驾驭SAT,你得先忘掉“形状”这个概念,转而思考“投影”。SAT的核心思想非常直观:如果两个凸多边形没有发生碰撞,那么必定存在一条直线(轴),能将它们投影到这条直线上时,两个投影区间是分离的,没有重叠。

2.1 从“分离轴”到“投影重叠”

想象一下,你用手电筒照向两个物体,在墙上留下它们的影子。如果这两个影子在任何角度的手电筒照射下都完全分开,那说明这两个物体在真实空间里也肯定是分开的。反之,如果存在某个角度,它们的影子重叠了,那它们就很可能撞在了一起。SAT就是这个思想的数学化。

对于凸多边形,我们不需要检查所有无穷多个角度。一个关键定理是:对于两个凸多边形,只需要检查它们各自所有边的法线方向作为潜在的分离轴即可。这是因为,如果存在一条分离轴,它要么平行于某个多边形的某条边,要么平行于两个多边形各一条边的法线之一(对于多边形,边法线已经足够)。

所以,我们的算法步骤就清晰了:

  1. 为两个多边形A和B,收集所有需要检测的轴。通常是A的所有边法线,加上B的所有边法线。
  2. 将两个多边形分别投影到每一条检测轴上。
  3. 计算两个投影区间(一个最小值,一个最大值)在这条轴上的重叠量。
  4. 如果在任何一条轴上,投影区间没有重叠(重叠量 <= 0),那么可以立即断定两个多边形没有碰撞
  5. 如果在所有检测轴上,投影区间都有重叠,那么两个多边形发生了碰撞。并且,所有重叠量中的最小值,就是穿透深度,对应的轴就是最小平移向量(MTV)的方向。

注意:这里只讨论凸多边形。SAT适用于凸形状,因为凸形状的投影区间是连续的。凹多边形需要先分解成多个凸多边形组合来处理。

2.2 关键数学概念:投影、法线与区间重叠

1. 边的法线计算:对于一个由点P1(x1, y1)P2(x2, y2)构成的边,边的向量为E = P2 - P1 = (dx, dy)。在2D中,一条边的法线有两个方向(向内和向外)。我们通常取“左手法线”或“右手法线”并统一即可。一个常用的方法是:normal = (-dy, dx)(dy, -dx)。需要确保所有法线都进行归一化(长度为1),因为后续投影计算需要。

2. 点投影到轴上:给定一个轴(单位向量)axis (ax, ay)和一个点P (px, py),点P在轴上的投影标量值(可以理解为投影点到原点的带符号距离)就是点乘:proj = px * ax + py * ay。 对于一个多边形,我们遍历所有顶点,计算它们在轴上的投影值,找出最大值max和最小值min。这个[min, max]就是多边形在该轴上的投影区间。

3. 区间重叠判断:假设多边形A的投影区间为[minA, maxA],多边形B的为[minB, maxB]。 它们重叠的条件是:maxA >= minBmaxB >= minA。 重叠量overlap的计算为:overlap = Math.min(maxA, maxB) - Math.max(minA, minB)。 如果overlap > 0,表示重叠。这个overlap值在后面计算穿透深度时会用到。

理解了这个数学骨架,我们就有了实现SAT的全部理论基础。接下来,我们进入实战环节,看看在具体的游戏引擎中如何组织代码。

3. Unity引擎下的SAT实现详解

Unity虽然以3D见长,但其2D物理系统(Box2D集成)已经非常完善。然而,理解底层实现和自定义碰撞形状的需求始终存在。我们用原生的C#和Unity的数学库来实现SAT,能获得最大的灵活性和学习价值。

3.1 数据结构设计与多边形表示

在Unity中,我们通常用Vector2数组来表示一个多边形。为了封装数据和功能,创建一个SATPolygon类是个好主意。

using UnityEngine; using System.Collections.Generic; public class SATPolygon { public Vector2[] Vertices { get; private set; } public Vector2[] Normals { get; private set; } // 预计算的法线 public Transform Transform { get; set; } // 关联的变换组件,用于处理位置和旋转 public SATPolygon(Vector2[] localVertices, Transform trans) { Transform = trans; // 将本地顶点转换为世界坐标顶点 UpdateVertices(localVertices); CalculateNormals(); } // 当物体移动或旋转后,需要更新世界坐标下的顶点 public void UpdateVertices(Vector2[] localVertices) { Vertices = new Vector2[localVertices.Length]; for (int i = 0; i < localVertices.Length; i++) { Vertices[i] = Transform.TransformPoint(localVertices[i]); } } // 计算每条边的单位法线 private void CalculateNormals() { Normals = new Vector2[Vertices.Length]; for (int i = 0; i < Vertices.Length; i++) { Vector2 p1 = Vertices[i]; Vector2 p2 = Vertices[(i + 1) % Vertices.Length]; // 循环到第一个点 Vector2 edge = p2 - p1; // 获取垂直于边的单位法线。这里使用 ( -edge.y, edge.x ),这是一个左手法线。 Normals[i] = new Vector2(-edge.y, edge.x).normalized; } } // 获取多边形在当前所有检测轴上的投影极值 public void Project(Vector2 axis, out float min, out float max) { min = float.MaxValue; max = float.MinValue; foreach (var vertex in Vertices) { float proj = Vector2.Dot(vertex, axis); // 投影计算 if (proj < min) min = proj; if (proj > max) max = proj; } } }

实操心得:在CalculateNormals中统一法线方向(这里用了左手法线)至关重要。不一致的法线方向会导致后续重叠量计算符号错误,从而无法正确找到最小分离轴。另外,UpdateVertices方法需要在每帧碰撞检测前调用,以确保顶点数据是当前帧的世界坐标。对于静态物体,这是一个可以优化的点。

3.2 核心碰撞检测与响应逻辑

有了多边形类,我们就可以编写核心的SAT检测函数了。这个函数返回一个包含是否碰撞、穿透深度和最小分离轴信息的结构体。

public struct CollisionResult { public bool IsColliding; public float PenetrationDepth; public Vector2 MinimumTranslationVector; // MTV = 分离轴 * 穿透深度 } public static class SATCollision { public static CollisionResult CheckCollision(SATPolygon polyA, SATPolygon polyB) { CollisionResult result = new CollisionResult(); result.IsColliding = true; result.PenetrationDepth = float.MaxValue; Vector2 smallestAxis = Vector2.zero; // 检查多边形A的所有边法线 if (!CheckAxes(polyA, polyB, ref result.PenetrationDepth, ref smallestAxis)) { result.IsColliding = false; return result; } // 检查多边形B的所有边法线 if (!CheckAxes(polyB, polyA, ref result.PenetrationDepth, ref smallestAxis)) { result.IsColliding = false; return result; } // 如果所有轴都重叠,则发生碰撞 result.MinimumTranslationVector = smallestAxis * result.PenetrationDepth; // 确保MTV的方向是从A指向B(或反之),这取决于你希望推开哪个物体。 // 一个简单的判断:计算从A中心到B中心的向量,如果与MTV点积为负,则反转MTV。 Vector2 centerA = GetCenter(polyA.Vertices); Vector2 centerB = GetCenter(polyB.Vertices); Vector2 direction = centerB - centerA; if (Vector2.Dot(direction, result.MinimumTranslationVector) < 0) { result.MinimumTranslationVector = -result.MinimumTranslationVector; } return result; } private static bool CheckAxes(SATPolygon polyToCheck, SATPolygon otherPoly, ref float minPenetration, ref Vector2 minAxis) { foreach (var axis in polyToCheck.Normals) { polyToCheck.Project(axis, out float minA, out float maxA); otherPoly.Project(axis, out float minB, out float maxB); // 计算重叠量 float overlap = Mathf.Min(maxA, maxB) - Mathf.Max(minA, minB); if (overlap <= 0) { // 在任何一条轴上发现不重叠,立即返回“未碰撞” return false; } // 记录最小的重叠量及对应的轴 if (overlap < minPenetration) { minPenetration = overlap; minAxis = axis; } } return true; // 所有被检查的轴都重叠 } private static Vector2 GetCenter(Vector2[] vertices) { Vector2 center = Vector2.zero; foreach (var v in vertices) center += v; return center / vertices.Length; } }

关键点解析

  1. CheckAxes函数:这是算法的核心循环。它遍历一组法线(来自polyToCheck),在每条轴上计算两个多边形的投影并检查重叠。一旦发现overlap <= 0,立即返回false,表示找到了分离轴,碰撞不成立。这是一种“快速拒绝”策略,能提升性能。
  2. 最小穿透深度:在所有重叠的轴中,我们记录最小的overlap值。这个值就是两个多边形需要被分开的最小距离,即穿透深度。
  3. 最小平移向量(MTV)最小穿透深度 * 对应的分离轴单位向量就得到了MTV。这个向量指明了将两个物体分开所需的最短方向和距离。
  4. MTV方向的修正:最后一步方向修正非常重要。原始的SAT算法找到的轴和深度没有指明应该移动哪个物体。通过计算两中心点的方向向量,并与MTV做点积,我们可以确保MTV的方向总是将物体从“重叠”状态推向“分离”状态。通常,我们固定移动其中一个物体(如polyA),那么MTV的方向应该使得polyA沿着这个方向移动后,能远离polyB。

3.3 在Unity中集成与测试

创建一个MonoBehaviour脚本来使用我们的SAT系统。我们可以用LineRendererGizmos来可视化多边形和碰撞结果。

public class SATTest : MonoBehaviour { public SATPolygon polygonA; public SATPolygon polygonB; public Vector2[] localVertsA; // 在Inspector中编辑的本地顶点 public Vector2[] localVertsB; void Start() { // 假设这个脚本挂在两个不同的GameObject上 polygonA = new SATPolygon(localVertsA, transform); // polygonB 需要在另一个物体上初始化 } void Update() { // 更新顶点(如果物体移动了) polygonA.UpdateVertices(localVertsA); // polygonB.UpdateVertices(localVertsB); // 执行碰撞检测 CollisionResult result = SATCollision.CheckCollision(polygonA, polygonB); if (result.IsColliding) { Debug.Log($"碰撞发生!穿透深度:{result.PenetrationDepth}, MTV: {result.MinimumTranslationVector}"); // 可视化MTV Debug.DrawRay(GetCenter(polygonA.Vertices), result.MinimumTranslationVector, Color.red); // 实际响应:例如,将polygonA的位置加上MTV来分离 // transform.position += (Vector3)result.MinimumTranslationVector; } } void OnDrawGizmos() { if (polygonA != null && polygonA.Vertices != null) { Gizmos.color = Color.green; DrawPolygonGizmos(polygonA.Vertices); } // 绘制polygonB... } void DrawPolygonGizmos(Vector2[] verts) { for (int i = 0; i < verts.Length; i++) { Gizmos.DrawLine(verts[i], verts[(i + 1) % verts.Length]); } } }

注意事项:在Update中直接修改transform.position来响应碰撞可能会与Unity自身的物理系统或变换层级产生冲突。在实际项目中,这通常由你自己的物理引擎模块或角色控制器来处理。这里演示的是最直接的数学响应。

4. Cocos Creator引擎下的SAT实现迁移

Cocos Creator使用TypeScript/JavaScript作为开发语言,其数学计算主要依赖于Vec2等内置模块。实现逻辑与Unity版本完全一致,只是API和语法有所不同。这恰恰体现了SAT算法作为纯数学方案的优越性——与引擎无关。

4.1 TypeScript中的多边形类

在Cocos Creator中,我们通常将组件脚本挂载在节点上。我们创建一个SATPolygon.ts组件。

import { _decorator, Component, Vec2, v2, Node } from 'cc'; const { ccclass, property } = _decorator; @ccclass('SATPolygon') export class SATPolygon extends Component { // 在编辑器中设置的本地顶点坐标(相对于节点中心) @property([Vec2]) localVertices: Vec2[] = []; // 世界坐标下的顶点缓存 private _worldVertices: Vec2[] = []; // 边法线缓存 private _normals: Vec2[] = []; start() { this.updateGeometry(); } update(deltaTime: number) { // 如果节点可能移动或旋转,每帧更新 this.updateGeometry(); } // 更新世界坐标顶点和法线 updateGeometry() { const node = this.node; this._worldVertices = this.localVertices.map(v => { // 将本地坐标转换为世界坐标 const worldPos = v.clone(); // 应用节点的旋转和缩放 // 注意:这里简化处理,假设节点缩放是均匀的,且无倾斜。复杂情况需要完整矩阵变换。 const angle = node.angle * Math.PI / 180; const cosA = Math.cos(angle); const sinA = Math.sin(angle); const scale = node.scale.x; // 假设均匀缩放 const x = v.x * scale * cosA - v.y * scale * sinA + node.position.x; const y = v.x * scale * sinA + v.y * scale * cosA + node.position.y; return v2(x, y); }); this.calculateNormals(); } // 计算法线 private calculateNormals() { this._normals = []; const count = this._worldVertices.length; for (let i = 0; i < count; i++) { const p1 = this._worldVertices[i]; const p2 = this._worldVertices[(i + 1) % count]; const edge = p2.subtract(p1); // edge = p2 - p1 // 左手法线: (-edge.y, edge.x) const normal = v2(-edge.y, edge.x); normal.normalize(); // 单位化 this._normals.push(normal); } } // 获取世界顶点 get worldVertices(): Vec2[] { return this._worldVertices; } // 获取法线 get normals(): Vec2[] { return this._normals; } // 投影到给定轴上,返回最小和最大值 project(axis: Vec2, out: { min: number, max: number }) { let min = Number.MAX_VALUE; let max = -Number.MAX_VALUE; for (const vertex of this._worldVertices) { const proj = vertex.dot(axis); // 点乘计算投影 if (proj < min) min = proj; if (proj > max) max = proj; } out.min = min; out.max = max; } // 获取多边形中心(近似,用于MTV方向修正) getCenter(): Vec2 { const center = v2(0, 0); for (const v of this._worldVertices) { center.add(v); } center.multiplyScalar(1 / this._worldVertices.length); return center; } }

踩坑提醒:Cocos Creator中节点的angle属性是角度制,而Math.sin/cos需要弧度制,记得转换。另外,Vec2subtractmultiplyScalar等方法可能会修改原对象,在计算法线时,使用clone()或创建新对象来避免意外修改原始数据是个好习惯。上面的顶点变换是简化版,对于非均匀缩放或倾斜,需要使用节点的完整世界变换矩阵node.worldMatrix

4.2 碰撞检测管理器与响应

我们再创建一个SATCollisionManager.ts组件,挂载在场景中的某个管理节点上,负责每帧检测指定多边形之间的碰撞。

import { _decorator, Component, Vec2, v2, director, Director } from 'cc'; import { SATPolygon } from './SATPolygon'; const { ccclass, property } = _decorator; interface CollisionResult { isColliding: boolean; penetrationDepth: number; mtv: Vec2; // Minimum Translation Vector } @ccclass('SATCollisionManager') export class SATCollisionManager extends Component { @property([SATPolygon]) polygons: SATPolygon[] = []; start() { // 可以在这里手动添加多边形引用,或通过查找获取 } update(deltaTime: number) { // 简单的两两检测,复杂度O(n²),多边形多时需要优化(如空间划分) for (let i = 0; i < this.polygons.length; i++) { for (let j = i + 1; j < this.polygons.length; j++) { const result = this.checkCollision(this.polygons[i], this.polygons[j]); if (result.isColliding) { console.log(`多边形 ${i} 与 ${j} 碰撞!深度:${result.penetrationDepth}`); // 可视化MTV const centerA = this.polygons[i].getCenter(); const endPos = centerA.add(result.mtv); director.getScene()?.getComponentInChildren(/* 你的绘图组件 */); // 实际响应:例如,移动其中一个节点 // const nodeToMove = this.polygons[i].node; // nodeToMove.setPosition(nodeToMove.position.x + result.mtv.x, nodeToMove.position.y + result.mtv.y); } } } } checkCollision(polyA: SATPolygon, polyB: SATPolygon): CollisionResult { const result: CollisionResult = { isColliding: true, penetrationDepth: Number.MAX_VALUE, mtv: v2(0, 0) }; let smallestAxis = v2(0, 0); // 检查A的法线 if (!this.checkAxes(polyA, polyB, result, smallestAxis)) { result.isColliding = false; return result; } // 检查B的法线 if (!this.checkAxes(polyB, polyA, result, smallestAxis)) { result.isColliding = false; return result; } // 所有轴都重叠,计算最终MTV result.mtv = smallestAxis.multiplyScalar(result.penetrationDepth); // 修正MTV方向,使其从A指向B(即移动A能离开B) const centerA = polyA.getCenter(); const centerB = polyB.getCenter(); const direction = centerB.subtract(centerA); if (direction.dot(result.mtv) < 0) { result.mtv = result.mtv.negate(); } return result; } private checkAxes(polyToCheck: SATPolygon, otherPoly: SATPolygon, result: CollisionResult, smallestAxis: Vec2): boolean { const projA = { min: 0, max: 0 }; const projB = { min: 0, max: 0 }; for (const axis of polyToCheck.normals) { polyToCheck.project(axis, projA); otherPoly.project(axis, projB); const overlap = Math.min(projA.max, projB.max) - Math.max(projA.min, projB.min); if (overlap <= 0) { return false; // 找到分离轴 } if (overlap < result.penetrationDepth) { result.penetrationDepth = overlap; smallestAxis.set(axis); // 记录当前最小重叠轴 } } return true; // 所有被检查轴都重叠 } }

性能与优化提示

  1. 预计算与缓存SATPolygon中的_normals_worldVertices在顶点数据不变且节点未变换时无需每帧更新。可以在updateGeometry中增加脏检查。
  2. 避免GC(垃圾回收):在update循环中频繁创建Vec2或对象(如{min, max})会引发GC,影响性能。可以考虑复用对象池。上面代码中,将投影结果对象projAprojB作为参数传入,而不是在函数内部创建,就是一种优化。
  3. 空间划分:当场景中有大量多边形时,O(n²) 的两两检测是不可接受的。必须引入空间划分算法,如四叉树、网格或BVH(包围体层次),只对可能发生碰撞的物体对进行SAT检测。

5. 常见问题、优化与扩展方向

在实际应用SAT算法时,你肯定会遇到一些典型问题。这里记录了我踩过的一些坑和对应的解决方案。

5.1 浮点数精度与容差处理

计算机浮点数计算存在精度误差。两个理论上刚好接触的多边形,其投影重叠量可能是一个极小的负数(如-1e-7)。如果严格按照overlap <= 0判断,会被误判为分离。

解决方案:引入一个微小的容差值(epsilon)。

float epsilon = 1e-5f; // 或 0.001f,根据你的世界尺度调整 if (overlap <= epsilon) { // 视为分离 return false; } // 记录重叠量时,可以减去epsilon,避免“零深度”碰撞 float effectiveOverlap = overlap - epsilon; if (effectiveOverlap < minPenetration) { minPenetration = effectiveOverlap; minAxis = axis; }

5.2 穿透深度过深与“隧道效应”

在高速运动的物体中(比如一颗高速子弹),可能在一帧内从完全分离直接运动到深度穿透,甚至完全穿过另一个物体。这就是“隧道效应”。单纯的每帧静态SAT检测无法捕捉到这种情况。

解决方案

  1. 连续碰撞检测(CCD):这超出了基础SAT的范围。一种简化思路是,不仅检测物体当前帧的位置,还检测从上一帧到当前帧的整个运动线段(或 swept shape)是否与目标物体相交。这需要更复杂的数学,通常使用GJK算法或对运动包围体进行SAT检测。
  2. 限制速度:在游戏设计上,限制物体的最大速度,使其每帧最大移动距离小于其自身尺寸和障碍物尺寸,可以很大程度上避免隧道效应。
  3. 子步采样:将一帧的时间分成多个子步(sub-step),在每个子步内进行碰撞检测和响应。这能提高时间分辨率,但会增加计算量。

5.3 性能优化实战技巧

  1. 粗检测先行:在进行精确的SAT检测前,先用廉价的包围球(Sphere)或轴向包围盒(AABB)进行快速拒绝。如果两个物体的包围体都不相交,那它们的多边形肯定不相交。
  2. 法线缓存与更新策略:如果多边形形状不变(比如一个固定的三角形障碍物),其本地法线可以预先计算好,无需每帧计算。世界坐标下的法线需要根据旋转进行变换,但不必重新计算叉积。
  3. SAT检测本身优化
    • 提前退出:我们的代码已经实现了,一旦发现分离轴立即返回。
    • 轴排序(启发式):不按固定顺序检查轴,而是根据上一帧的最小分离轴或物体的相对运动方向,优先检查最可能成为分离轴的几个方向。这能提高平均检测速度。
    • 支持点缓存:在投影计算时,不必遍历所有顶点找最大/最小值。对于凸多边形,在给定轴方向上的最大/最小投影点(支持点)可以通过“登山算法”或类似方法更快找到,尤其是顶点数很多时。

5.4 扩展到其他形状

SAT算法不仅限于多边形。

  • 圆形:圆形可以视为拥有无穷多条法线的“形状”。但检测圆形与多边形碰撞时,只需检查:1) 多边形所有边法线;2) 从多边形中心指向圆心的轴。圆形与圆形碰撞更简单,只需比较圆心距离与半径之和。
  • 胶囊体:可以分解为两个半圆和中间一个矩形,分别进行SAT检测,或者用更专门的算法。
  • 复合形状(Concave Shapes):必须先将凹多边形三角化或分解为多个凸多边形(凸分解),然后分别对每个凸部分进行SAT检测。

实现一个完整的2D物理引擎是庞大的工程,但SAT为你提供了处理任意凸形状碰撞的坚实核心。从理解原理,到在Unity和Cocos Creator中实现,再到处理边界情况和性能考量,这个过程本身就是一个极好的游戏开发进阶训练。当你看到自己编写的代码能让两个自定义形状的物体准确地碰撞、推开时,那种成就感是使用现成物理引擎无法比拟的。

返回列表