Unity 2D游戏开发:从零实现高性能AABB碰撞检测系统
1. 项目概述:为什么我们要自己动手实现碰撞检测?
在Unity里做2D游戏,物理系统(Physics 2D)几乎是所有新手的首选。拖个Rigidbody 2D,加个Collider 2D,运行一下,两个物体就“Duang”地一声弹开了,简单又直观。但当你做的项目稍微复杂一点,比如是一个需要精确控制性能的移动端游戏,或者是一个对物理反馈有特殊要求的像素风游戏,你可能会开始觉得这套“黑盒”系统有点力不从心。性能开销、不可预测的微小抖动、复杂的参数调试,甚至是某些特定碰撞逻辑难以实现,都成了拦路虎。
这时候,回归基础,自己用C#实现一套碰撞检测系统,就从“没必要”变成了“很值得”。AABB(Axis-Aligned Bounding Box,轴对齐包围盒)就是踏入这个领域最经典、也最实用的第一块敲门砖。它的核心思想简单到极致:用一个矩形(在2D里)把物体包起来,判断两个矩形是否重叠。你别看它简单,市面上绝大多数2D游戏的碰撞检测(尤其是前期粗检测)都离不开它。它计算量极小,只需要几次简单的数值比较,性能极高,是优化游戏逻辑、实现特定物理效果的基石。
这篇文章,我就以一个老开发者的身份,带你彻底告别对Unity物理系统的“舒适区依赖”,从零开始,用最纯粹的C#代码,构建一套属于你自己的、高性能、可完全定制的2D AABB碰撞检测系统。我会把每一步的原理、每一行代码的意图,以及我在实际项目中踩过的坑、总结的技巧,毫无保留地分享给你。无论你是想深入理解游戏物理的底层逻辑,还是迫切需要优化项目性能,这篇文章都能给你一套可以直接“抄作业”的完整方案。
2. 核心原理与数学基础:AABB到底是什么?
在动手写代码之前,我们必须把原理吃透。AABB,全称Axis-Aligned Bounding Box,中文叫“轴对齐包围盒”。这个名字听起来有点唬人,但其实拆开看就很简单。
“包围盒”(Bounding Box):顾名思义,就是一个把物体包起来的盒子。在2D世界里,这个盒子就是一个矩形。我们并不关心物体内部复杂的形状(比如一个不规则的角色Sprite),我们只用一个尽可能贴合的矩形来代表它,用于快速的碰撞初筛。
“轴对齐”(Axis-Aligned):这是关键限制条件。意味着这个矩形的四条边,必须分别平行于世界坐标系的X轴和Y轴。它不能旋转。也就是说,这个矩形完全由四个值定义:最小X坐标(left或minX)、最大X坐标(right或maxX)、最小Y坐标(bottom或minY)、最大Y坐标(top或maxY)。
为什么非要“轴对齐”?因为这样判断两个矩形是否相交(碰撞)的算法会变得极其简单高效。我们不需要进行复杂的旋转矩阵计算或几何相交测试,只需要比较坐标。
分离轴定理(Separating Axis Theorem, SAT)在AABB上的简化应用:SAT是碰撞检测的通用理论,对于AABB来说,它被简化到了一个极致:如果两个AABB在所有轴(这里只有X轴和Y轴)上的投影都不重叠,那么它们一定没有碰撞;反之,如果在所有轴上的投影都重叠,那么它们就碰撞了。
对于轴对齐的矩形,在X轴上的投影就是[minX, maxX]这个区间,在Y轴上的投影就是[minY, maxY]这个区间。所以,判断两个AABB是否碰撞,就等价于判断两个区间在X轴上是否重叠并且在Y轴上是否重叠。
用逻辑表达就是:碰撞 = (RectA.minX < RectB.maxX) && (RectA.maxX > RectB.minX) && (RectA.minY < RectB.maxY) && (RectA.maxY > RectB.minY)
这个公式请你务必理解并记住,它是我们所有代码的灵魂。它比我们直觉上想的“计算矩形中心距离”或“比较四个角”要高效和准确得多。
注意:这里使用的比较是“小于”和“大于”,而不是“小于等于”和“大于等于”。这定义了一种“严格相交”的逻辑,即两个矩形刚好边贴边时,不算碰撞。这在很多游戏逻辑里是符合预期的(比如玩家刚好站在地面上)。如果你需要包含边接触的情况,将
<和>改为<=和>=即可。这个细节决定了角色移动的“手感”,非常重要。
3. 系统设计与数据结构:如何优雅地组织我们的碰撞体?
理解了原理,我们就要开始设计代码结构了。一个好的设计能让后续的扩展和维护事半功倍。我们不会去模仿Unity那套复杂的Component系统,而是设计一个轻量、清晰的数据结构。
首先,我们定义一个AABBCollider类,它代表一个AABB碰撞体。这个类需要存储什么信息?
- 位置和大小:这是核心。我们可以存储中心点(
Vector2 center)和半宽高(Vector2 extents),也可以存储最小点和最大点。存储中心点和半宽高在计算和更新时通常更方便。 - 关联的实体:这个碰撞体是属于哪个游戏对象的?我们通常需要一个引用(比如一个
GameObject或一个ID)来在发生碰撞时,知道是谁撞了谁。 - 碰撞层级和标签:不是所有物体都需要相互碰撞。玩家应该和敌人、墙壁碰撞,但不应该和奖励道具发生物理碰撞(而是触发)。我们需要一个简单的过滤系统,比如
LayerMask和Tag。
让我们来定义这个类:
using UnityEngine; /// <summary> /// 自定义的2D AABB碰撞体组件。 /// 建议挂载在需要检测碰撞的GameObject上。 /// </summary> public class AABBCollider : MonoBehaviour { // 碰撞体的中心点(相对于GameObject的Transform) [SerializeField] private Vector2 _centerOffset = Vector2.zero; // 碰撞体的半宽高(从中心到边的距离) [SerializeField] private Vector2 _size = Vector2.one; // 碰撞层级,用于过滤 public LayerMask collisionLayerMask = -1; // 默认与所有层碰撞 // 可选的标签,用于更精细的过滤或识别 public string collisionTag = ""; // 公共属性,方便外部获取计算后的世界坐标边界 public float MinX { get; private set; } public float MaxX { get; private set; } public float MinY { get; private set; } public float MaxY { get; private set; } // 一个标志位,表示这个碰撞体是静态的(如地形)还是动态的(如玩家、敌人) [SerializeField] private bool _isStatic = false; public bool IsStatic => _isStatic; /// <summary> /// 每帧更新碰撞体的世界坐标边界。 /// 对于静态物体,可以在Start中计算一次以优化性能。 /// </summary> public void UpdateBounds() { if (_isStatic) return; // 静态物体边界无需每帧更新 Vector2 worldCenter = (Vector2)transform.position + _centerOffset; // 注意:这里假设碰撞体不随物体旋转而旋转(符合AABB定义)。 // 如果物体旋转了,这个AABB就不再精确贴合,这是AABB的局限性。 // 对于有旋转的物体,需要重新计算一个能包裹住旋转后物体的、新的轴对齐包围盒,计算会更复杂。 MinX = worldCenter.x - _size.x; MaxX = worldCenter.x + _size.x; MinY = worldCenter.y - _size.y; MaxY = worldCenter.y + _size.y; } // 在Scene视图中绘制Gizmos,便于调试和设计关卡 private void OnDrawGizmosSelected() { Gizmos.color = Color.green; // 计算当前在编辑器中的边界(如果不在运行时,则使用transform.position) Vector2 currentCenter = Application.isPlaying ? new Vector2((MinX+MaxX)/2, (MinY+MaxY)/2) : (Vector2)transform.position + _centerOffset; Vector2 currentSize = Application.isPlaying ? new Vector2(MaxX-MinX, MaxY-MinY) : _size * 2; Gizmos.DrawWireCube(currentCenter, currentSize); } }设计解析与注意事项:
UpdateBounds方法:这是性能关键。动态物体每一帧都需要调用此方法来根据Transform的位置更新它的世界坐标边界。对于静态物体(如地图块、墙壁),我们可以在Start()或Awake()中计算一次,然后标记为_isStatic = true,这样就能避免每帧无意义的计算,这是巨大的性能优化点。- 旋转问题:这是AABB最大的局限性。我们的
UpdateBounds没有考虑物体的旋转。如果一个矩形精灵旋转了45度,用一个轴对齐的矩形去包裹它,会产生很大的空白区域(即“包围盒”变得不紧凑),导致检测精度下降,可能出现“没碰到却判定碰撞”的情况。对于需要旋转的物体,有更高级的包围盒(如OBB, Oriented Bounding Box)或直接使用多边形碰撞检测。在项目初期,如果旋转角度不大,可以适当增大_size来补偿;或者规定某些物体不允许旋转。 - Gizmos调试:
OnDrawGizmosSelected让我们在Unity编辑器里能直观地看到碰撞体的形状和范围,对于关卡设计和调试至关重要。一定要用。
接下来,我们需要一个“碰撞管理器”。它负责持有所有活跃的AABBCollider,并每帧执行碰撞检测。我们将其设计为一个单例类,方便全局访问。
4. 碰撞管理器的实现:核心检测算法与性能优化
碰撞管理器AABBCollisionManager是整个系统的大脑。它的核心任务很简单:收集所有碰撞体,然后检查它们两两之间是否发生了碰撞。但如何高效地完成“两两检查”,是区分业余和职业实现的关键。
最 naive 的方法是双重循环:
for (int i = 0; i < colliders.Count; i++) { for (int j = i + 1; j < colliders.Count; j++) { CheckCollision(colliders[i], colliders[j]); } }如果场景中有N个碰撞体,时间复杂度是O(N²)。当N=100时,需要检测4950次;N=1000时,是499500次!即使单次检测再快,这个数量级也是无法接受的。
因此,我们必须引入空间划分(Spatial Partitioning)来优化。对于2D游戏,最常用且简单的就是网格划分(Grid)或四叉树(Quadtree)。这里我们先实现一个简单的基于网格的粗筛,它足以应对大多数中小型2D项目,并且理解起来更直观。
思路:将游戏世界划分为一个个固定大小的格子(Cell)。每个AABBCollider根据其位置,被放入一个或多个格子中(如果碰撞体很大,可能跨越多个格子)。检测时,我们只需要检查同一个格子内以及相邻格子内的碰撞体对,从而避免与距离很远的物体进行不必要的检测。
让我们开始构建管理器:
using System.Collections.Generic; using UnityEngine; public class AABBCollisionManager : MonoBehaviour { public static AABBCollisionManager Instance { get; private set; } // 所有注册的动态碰撞体(静态碰撞体有单独的处理方式) private List<AABBCollider> _dynamicColliders = new List<AABBCollider>(); // 所有静态碰撞体,通常用于环境 private List<AABBCollider> _staticColliders = new List<AABBCollider>(); // 网格划分参数 [Header("Spatial Grid Settings")] [SerializeField] private Vector2 _gridWorldSize = new Vector2(50, 50); // 网格覆盖的世界范围 [SerializeField] private float _cellSize = 5f; // 每个格子的大小 private int _gridSizeX, _gridSizeZ; private List<AABBCollider>[,] _grid; private void Awake() { if (Instance != null && Instance != this) { Destroy(this); } else { Instance = this; InitializeGrid(); } } private void InitializeGrid() { _gridSizeX = Mathf.CeilToInt(_gridWorldSize.x / _cellSize); _gridSizeZ = Mathf.CeilToInt(_gridWorldSize.y / _cellSize); // 注意:我们用y表示世界高度,对应网格的Z维度 _grid = new List<AABBCollider>[_gridSizeX, _gridSizeZ]; for (int x = 0; x < _gridSizeX; x++) { for (int z = 0; z < _gridSizeZ; z++) { _grid[x, z] = new List<AABBCollider>(); } } Debug.Log($"碰撞网格初始化: {_gridSizeX}x{_gridSizeZ}, 单元格大小{_cellSize}"); } // 注册和注销碰撞体 public void RegisterCollider(AABBCollider collider) { if (collider.IsStatic) { _staticColliders.Add(collider); // 静态物体也需要放入网格,但只需要放一次 AddColliderToGrid(collider); } else { _dynamicColliders.Add(collider); } } public void UnregisterCollider(AABBCollider collider) { if (collider.IsStatic) { _staticColliders.Remove(collider); RemoveColliderFromGrid(collider); } else { _dynamicColliders.Remove(collider); RemoveColliderFromGrid(collider); } } // 核心更新循环 private void Update() { // 1. 清空动态物体所在的旧网格单元(简单做法:每帧清空整个网格并重新分配,对于动态物体不多的情况可以接受) ClearDynamicCells(); // 2. 更新所有动态碰撞体的边界 foreach (var collider in _dynamicColliders) { collider.UpdateBounds(); AddColliderToGrid(collider); } // 3. 执行碰撞检测 PerformCollisionChecks(); } private void ClearDynamicCells() { for (int x = 0; x < _gridSizeX; x++) { for (int z = 0; z < _gridSizeZ; z++) { _grid[x, z].RemoveAll(c => !c.IsStatic); // 只移除动态物体,保留静态物体 } } } private void AddColliderToGrid(AABBCollider collider) { // 计算碰撞体覆盖了哪些网格单元 int startX = Mathf.FloorToInt((collider.MinX + _gridWorldSize.x / 2) / _cellSize); int endX = Mathf.FloorToInt((collider.MaxX + _gridWorldSize.x / 2) / _cellSize); int startZ = Mathf.FloorToInt((collider.MinY + _gridWorldSize.y / 2) / _cellSize); // MinY对应世界Z int endZ = Mathf.FloorToInt((collider.MaxY + _gridWorldSize.y / 2) / _cellSize); // 钳制到网格范围内 startX = Mathf.Clamp(startX, 0, _gridSizeX - 1); endX = Mathf.Clamp(endX, 0, _gridSizeX - 1); startZ = Mathf.Clamp(startZ, 0, _gridSizeZ - 1); endZ = Mathf.Clamp(endZ, 0, _gridSizeZ - 1); for (int x = startX; x <= endX; x++) { for (int z = startZ; z <= endZ; z++) { if (!_grid[x, z].Contains(collider)) { _grid[x, z].Add(collider); } } } } private void RemoveColliderFromGrid(AABBCollider collider) { /* 实现逻辑与Add类似,反向操作 */ } // 核心碰撞检测逻辑 private void PerformCollisionChecks() { // 我们遍历所有网格单元 for (int x = 0; x < _gridSizeX; x++) { for (int z = 0; z < _gridSizeZ; z++) { var cellColliders = _grid[x, z]; int count = cellColliders.Count; // 检查当前单元内所有碰撞体对 for (int i = 0; i < count; i++) { for (int j = i + 1; j < count; j++) { var colA = cellColliders[i]; var colB = cellColliders[j]; // 层过滤 if (((1 << colA.gameObject.layer) & colB.collisionLayerMask) == 0) continue; if (((1 << colB.gameObject.layer) & colA.collisionLayerMask) == 0) continue; // 执行AABB检测 if (CheckAABBCollision(colA, colB)) { // 碰撞发生!这里可以触发事件 OnCollisionDetected(colA, colB); } } } } } } // 纯数学的AABB碰撞检测函数,这是整个系统的核心 public static bool CheckAABBCollision(AABBCollider a, AABBCollider b) { // 使用分离轴定理的简化版:检查在X轴和Y轴上是否都重叠 bool overlapX = a.MinX < b.MaxX && a.MaxX > b.MinX; bool overlapY = a.MinY < b.MaxY && a.MaxY > b.MinY; return overlapX && overlapY; } // 当检测到碰撞时,通知双方(这里用简单的消息发送,你可以替换成自己的事件系统) private void OnCollisionDetected(AABBCollider a, AABBCollider b) { a.gameObject.SendMessage("OnMyCollisionEnter", b, SendMessageOptions.DontRequireReceiver); b.gameObject.SendMessage("OnMyCollisionEnter", a, SendMessageOptions.DontRequireReceiver); // 更推荐的做法是使用C#事件(Event)或委托(Delegate),实现一个观察者模式,这样耦合度更低。 // 例如:a.OnCollisionEnter?.Invoke(b); } }代码详解与优化点:
- 网格坐标转换:
AddColliderToGrid方法中的计算(collider.MinX + _gridWorldSize.x / 2) / _cellSize是为了将世界坐标(假设原点在网格中心)转换为网格索引。你需要根据你的游戏世界坐标系来调整这个公式。如果你的世界原点在左下角,公式会更简单。 - 每帧清空网格:为了简化,我们对动态物体采用了每帧清空所属网格再重新添加的策略。这在动态物体数量不多(比如少于200个)时效率尚可。如果动态物体非常多,更高效的做法是记录每个物体上一帧所在的网格,只更新发生变化的位置。
- 检测范围:我们的
PerformCollisionChecks只检查了同一个网格单元内的物体。这可能会漏掉那些刚好位于两个大网格边界两侧的、发生碰撞的小物体。一个更健壮的做法是,检查一个物体所在网格及其周围8个邻居网格。这对于大小不超过一个网格单元的物体来说是足够的。我们的代码中,因为AddColliderToGrid已经将大物体放入所有覆盖的网格,所以同一单元检测对于完全在单元内的碰撞是有效的,但对于跨单元边界的碰撞,需要检查邻居单元。作为优化和简化,我们假设物体大小相对网格较小,且我们的检测频率(每帧)可以容忍偶尔漏过一帧的边界碰撞,或者可以通过稍微增大碰撞体来补偿。对于要求精确的项目,实现邻居检测是必要的。 - 层过滤:
PerformCollisionChecks中的层过滤代码((1 << colA.gameObject.layer) & colB.collisionLayerMask) == 0是Unity LayerMask的标准用法。它检查colB的层掩码是否包含了colA所在的层。我们要求双方都允许与对方碰撞,碰撞才会发生,这提供了最大的灵活性。
5. 碰撞响应与信息处理:检测到了,然后呢?
检测到碰撞只是第一步,更重要的是碰撞发生后该做什么。这就是碰撞响应。在Unity物理系统中,响应(如弹开、摩擦)是自动计算的。在我们的自定义系统里,我们需要自己来处理。
通常,碰撞响应分为几个步骤:
- 获取碰撞信息:不仅仅是知道“撞了”,还要知道“撞得有多深”、“从哪个方向撞的”。这对于把物体推开(解决穿透)至关重要。
- 解决穿透:在离散的帧检测中,物体经常已经相互嵌入(穿透)了。我们需要计算一个最小的位移向量,将物体分开。
- 应用物理效果:根据物体的属性(如弹性、质量)和碰撞信息,计算速度的变化(如反弹)。
- 触发游戏逻辑:比如玩家碰到敌人掉血,吃到金币加分。
让我们先实现第一步和第二步:计算碰撞法向量和穿透深度。这对于实现一个简单的角色控制器(比如不让玩家穿墙)是核心。
我们在AABBCollisionManager中添加一个更强大的检测函数,它不仅能返回是否碰撞,还能返回碰撞的详细信息。
public struct CollisionInfo2D { public AABBCollider other; public Vector2 normal; // 碰撞法线,指向从A指向B的方向(通常用于将A推离B) public float depth; // 穿透深度 public Vector2 point; // 碰撞点(近似值,对于AABB通常是重叠矩形的中心) } public static bool CheckAABBCollisionDetailed(AABBCollider a, AABBCollider b, out CollisionInfo2D info) { info = new CollisionInfo2D { other = b }; // 计算在X轴和Y轴上的重叠量 float overlapX = Mathf.Min(a.MaxX, b.MaxX) - Mathf.Max(a.MinX, b.MinX); float overlapY = Mathf.Min(a.MaxY, b.MaxY) - Mathf.Max(a.MinY, b.MinY); // 如果没有重叠,返回false if (overlapX <= 0 || overlapY <= 0) return false; // 计算穿透深度和法线:选择重叠量较小的轴作为分离轴 if (overlapX < overlapY) { // X轴重叠更小,从左右两侧推开 info.depth = overlapX; info.normal = a.Center.x < b.Center.x ? Vector2.left : Vector2.right; // A的中心在B的左边,则法线向右推A info.point = new Vector2( (a.Center.x < b.Center.x) ? a.MaxX : a.MinX, // 近似碰撞点 (a.MinY + a.MaxY + b.MinY + b.MaxY) / 4 // Y方向取中点 ); } else { // Y轴重叠更小,从上下两侧推开 info.depth = overlapY; info.normal = a.Center.y < b.Center.y ? Vector2.down : Vector2.up; // A的中心在B的下边,则法线向上推A info.point = new Vector2( (a.MinX + a.MaxX + b.MinX + b.MaxX) / 4, // X方向取中点 (a.Center.y < b.Center.y) ? a.MaxY : a.MinY ); } return true; }这个函数是解决穿透的关键。它通过比较X和Y方向的重叠量,找出“最容易分开”的方向(即重叠量最小的轴),然后给出一个沿着该轴的法线向量和需要移动的深度。例如,如果一个玩家矩形从上方落到一个平台矩形上,Y方向的重叠会小于X方向,法线会是Vector2.up,深度就是嵌入的像素值。我们只需要将玩家的位置加上info.normal * info.depth,就能瞬间将其推到平台表面,解决穿透。
现在,让我们创建一个简单的PlayerController来演示如何使用这套系统实现移动和碰撞。
using UnityEngine; [RequireComponent(typeof(AABBCollider))] public class SimplePlayerController : MonoBehaviour { public float moveSpeed = 5f; public float jumpForce = 10f; private Vector2 _velocity; private bool _isGrounded = false; private AABBCollider _myCollider; private void Start() { _myCollider = GetComponent<AABBCollider>(); if (_myCollider == null) { Debug.LogError("SimplePlayerController 需要 AABBCollider 组件!"); } // 注册到管理器(AABBCollider的Start或Awake中应该已经注册了,这里确保一下) // 通常注册应在AABBCollider中完成,这里只是演示逻辑。 } private void Update() { // 处理输入 float horizontal = Input.GetAxis("Horizontal"); _velocity.x = horizontal * moveSpeed; if (_isGrounded && Input.GetButtonDown("Jump")) { _velocity.y = jumpForce; _isGrounded = false; } // 应用重力(简单的模拟) _velocity.y += Physics.gravity.y * Time.deltaTime * 2; // 乘以2让重力强一点 // 计算位移 Vector2 displacement = _velocity * Time.deltaTime; // 在移动前,进行碰撞检测和解决 MoveAndCollide(displacement); } private void MoveAndCollide(Vector2 displacement) { // 我们采用多次迭代(步进)的方式来解决碰撞,提高稳定性,尤其是高速移动时 int iterations = 0; int maxIterations = 5; // 防止死循环 while (displacement.magnitude > 0.001f && iterations < maxIterations) { iterations++; // 假设我们暂时移动到这个位置 Vector2 proposedPosition = (Vector2)transform.position + displacement; // 更新碰撞体边界到假设位置(这里简化,实际应创建一个临时的边界计算) // 更严谨的做法是使用射线投射(Ray Casting)或连续碰撞检测(CCD),这里用离散的多次迭代模拟 _myCollider.UpdateBounds(); // 先基于当前位置更新 // 这里我们需要从管理器中获取所有可能与玩家碰撞的静态和动态物体(简化:假设管理器提供了方法) // 例如:var potentialColliders = AABBCollisionManager.Instance.GetNearbyColliders(_myCollider); // 为了示例,我们假设有一个列表 `allColliders` bool collisionOccurred = false; CollisionInfo2D earliestCollision = new CollisionInfo2D(); float earliestTime = 1f; // 归一化时间,1表示本帧结束 // 遍历所有潜在碰撞体(这里简化,实际应用空间划分优化后的列表) foreach (var otherCollider in AABBCollisionManager.Instance.GetAllColliders()) { if (otherCollider == _myCollider) continue; if (!AABBCollisionManager.CheckAABBCollision(_myCollider, otherCollider)) continue; CollisionInfo2D info; if (AABBCollisionManager.CheckAABBCollisionDetailed(_myCollider, otherCollider, out info)) { // 这是一个简单的响应:立即将玩家从穿透中推离 transform.position += (Vector3)(info.normal * info.depth * 1.01f); // 多推一点确保分离 collisionOccurred = true; // 根据法线方向更新状态(例如,如果法线向上,则认为落地) if (info.normal.y > 0.5f) { // 法线大致向上 _isGrounded = true; _velocity.y = Mathf.Min(_velocity.y, 0); // 重置Y速度,防止继续下沉 } // 如果撞到左右墙壁,X速度清零 if (Mathf.Abs(info.normal.x) > 0.5f) { _velocity.x = 0; } // 撞到头顶,Y速度清零(防止“顶头”后还向上飞) if (info.normal.y < -0.5f) { _velocity.y = Mathf.Min(_velocity.y, 0); } // 由于位置被修正,需要重新计算剩余位移(这是一个复杂话题,这里简化处理:跳出循环,下一帧再处理) // 更完善的实现需要计算碰撞时间(TOI)并滑动剩余位移。 break; } } if (!collisionOccurred) { // 没有碰撞,安全移动 transform.position += (Vector3)displacement; break; // 移动完成,退出循环 } // 如果发生碰撞,且被推开,我们需要基于新的位置重新计算可能与剩余位移产生的碰撞。 // 这里简化处理:直接清零位移,防止无限循环。实际项目需要更精细的“滑动”处理。 displacement = Vector2.zero; } // 迭代次数用尽警告(可能卡在角落) if (iterations >= maxIterations) { Debug.LogWarning("Player collision resolution reached max iterations."); } } // 接收碰撞事件的方法(需要与AABBCollisionManager.OnCollisionDetected中发送的消息名匹配) private void OnMyCollisionEnter(AABBCollider other) { // 这里可以处理游戏逻辑,比如碰到敌人掉血 // Debug.Log($"{gameObject.name} 撞到了 {other.gameObject.name}"); } }这段控制器代码是一个高度简化的示例,它演示了如何将碰撞检测集成到移动逻辑中。但它也暴露了自定义物理的复杂性:
- 穿透解决(Collision Resolution):我们采用的方法是“发现穿透,立即推开”。这可能导致物体被“弹开”而不是平滑滑动。更高级的方法是计算“碰撞时间(Time of Impact)”和“滑动向量(Sliding Vector)”。
- 多次迭代:
while循环用于处理一帧内可能发生的多次碰撞(比如同时撞到墙和地面)。maxIterations防止在复杂角落卡死。 - 性能:
GetAllColliders()在物体很多时非常慢。在实际项目中,你必须使用前面提到的网格系统,只获取玩家所在网格及相邻网格的碰撞体列表。管理器应该提供一个GetPotentialColliders(AABBCollider)的方法。 - 地面检测:我们通过法线
y > 0.5f来判断是否落地。这是一个简单粗暴但有效的方法。更稳健的做法是使用“地面射线”或记录上一帧是否在地面。
6. 高级话题、优化与避坑指南
实现了一个基础可用的系统后,我们来探讨一些进阶内容和实战中必然会遇到的“坑”。
6.1 处理高速移动物体:隧道效应(Tunneling)
这是离散碰撞检测(每帧检查一次)的经典问题。如果物体速度太快,一帧移动的距离超过了自身的尺寸,它就可能从另一个薄物体(比如一颗子弹从一堵墙中间)“穿”过去,因为上一帧在墙前,下一帧在墙后,中间帧没有检测到碰撞。
解决方案:
- 连续碰撞检测(CCD):不是检测两个静态形状是否重叠,而是检测从上一帧位置到当前帧位置形成的“运动线段”或“扫掠体(Swept Volume)”是否与目标碰撞体相交。对于AABB,就是检测一个“扫掠的矩形”。实现复杂度较高。
- 子步采样(Sub-stepping):将一帧的时间分成多个小步(如4步),每小步移动距离按比例减少,并进行碰撞检测。这能大大降低隧道效应发生的概率,但计算量成倍增加。
- 扩大碰撞体(Proxy Geometry):对于高速运动的物体(如子弹),使用一个比视觉模型更大的碰撞体,或者根据其速度动态延长碰撞体在运动方向上的范围。
- 射线投射(Raycasting):对于子弹这类小物体,可以不用AABB,而是用射线检测。从上一帧位置向当前帧位置发射一条射线,检测与环境的碰撞。这是处理高速小物体的最佳实践。
在我们的AABB系统中,对于玩家和普通敌人,使用子步采样是一个在效果和性能间不错的折中。可以在MoveAndCollide函数中,将位移分成2-4份,循环处理。
6.2 优化空间划分:从网格到四叉树
当游戏世界很大,物体分布极度不均匀时,均匀网格的效率会下降(很多空格子)。四叉树(Quadtree)是更适合2D动态场景的空间划分数据结构。它会根据区域内物体的密度动态地细分或合并节点。
四叉树的基本思想:
- 定义一个根节点,覆盖整个游戏世界。
- 如果一个节点内的物体数量超过某个阈值(比如10个),就将该节点均等分成四个子节点。
- 将物体重新分配到子节点中。
- 检测时,从根节点开始,递归地只检查与查询范围(如另一个碰撞体的包围盒)相交的节点内的物体。
实现一个完整的、支持动态更新的四叉树比网格复杂,但它能更好地适应物体分布,在大型开放世界或物体聚集区域(如大量敌人)的场景下性能优势明显。Unity的Physics2D系统内部就使用了类似的空间划分结构(通常是BVH,包围盒层次树)。
6.3 常见问题与调试技巧
- 物体抖动:当两个物体持续碰撞(如玩家站在地上),每帧的穿透解决可能会因为浮点数精度或法线计算的微小差异,导致物体在1个像素的范围内上下抖动。解决方法:引入一个微小的容差值(如0.001f),当穿透深度小于该值时,不进行位移修正;或者引入“睡眠”状态,对静止的物体停止物理计算。
- 卡在角落:如
PlayerController示例中,如果同时撞到两面墙的夹角,简单的逐轴推开可能会让物体卡住。需要更智能的解决策略,比如同时考虑多个碰撞约束,或者使用物理引擎中常见的“顺序脉冲解析(Sequential Impulse Resolution)”的简化版。 - Gizmos不显示:确保
AABBCollider脚本的OnDrawGizmosSelected方法正确,且在编辑器场景视图中勾选了“Gizmos”并选择了对应的游戏对象。 - 性能分析:在Unity Profiler中观察
AABBCollisionManager.Update和PerformCollisionChecks的耗时。如果耗时过高,首先检查是否对静态物体进行了每帧更新(不应该),其次检查网格大小和数量是否合理(格子太小=格子太多,管理开销大;格子太大=每个格子内物体多,检测次数多)。可以尝试在编辑器中可视化你的碰撞网格。
6.4 何时该用,何时不该用
适合使用自定义AABB系统的场景:
- 对性能有极致要求的移动端2D游戏(如休闲超休闲游戏)。
- 游戏物理需求简单,主要是阻挡和触发(如平台跳跃、RPG、卡牌游戏)。
- 需要实现非常特定、Unity物理系统难以实现的碰撞行为(如自定义的像素完美碰撞、网格地形碰撞)。
- 作为学习项目,深入理解游戏物理和优化。
不建议使用自定义AABB系统的场景:
- 需要复杂物理模拟的游戏(如愤怒的小鸟、物理沙盒)。Unity的物理系统经过多年优化,在稳定性和功能上远胜自研简单系统。
- 3D游戏。3D的AABB(即AABB,但扩展到三维)原理类似,但复杂度上升,且3D物理需求通常更复杂。
- 项目时间紧迫,且物理不是核心玩法。Unity物理系统能快速搭建原型。
自己实现碰撞检测是一次深刻的修炼。它让你从“使用者”变为“创造者”,真正理解游戏世界中物体交互的底层逻辑。虽然初期会遇到各种边界情况和性能挑战,但由此获得的系统设计能力、优化思维和对细节的掌控力,是单纯使用现成引擎无法比拟的。希望这篇长文能为你打下坚实的基础,当你下次在Unity中遇到物理系统的瓶颈时,能有信心掏出C#,打造一套更贴合自己项目需求的解决方案。