MonoGame 中的 2D 碰撞检测:从基本矩形到零分配像素完美系统
在 Unity 或 Godot 等视觉引擎中构建 2D 游戏时,碰撞检测通常感觉像是一系列检查器复选框:您点击“BoxCollider2D”或“CircleCollider2D”,附加“Rigidbody”,并希望内部物理步骤不会在移动设备上卡顿。
然而,在 MonoGame 和 C# 中,您拥有完全的控制权。没有隐藏的物理开销,没有不需要的旋转惯性,也没有神秘的垃圾收集 (GC) 峰值窃取您的帧预算。
在 Arar Games,当我们构建 Blocked: Pixel Panzer 和 Paint Trek 时,我们的街机游戏循环需要在 Windows PC 和 Android 设备上以 60 至 120 FPS 的速度处理数百颗高速敌人子弹、爆炸的砖块网格、旋转坦克炮塔、战斗机飞越和粒子护盾。通用物理引擎是不可能的——我们需要一个专门构建的、分层的碰撞架构。
在这个全面的、代码驱动的指南中,我们将从 MonoGame 碰撞的绝对基础知识(“矩形.相交”与真正的“子弹”和“敌人”精灵)开始,并构建高级圆形检查、混合夹紧、反隧道光线投射、生产级像素完美碰撞和针对移动 GC 生存优化的零分配空间网格。
1. 基础:一个简单的 MonoGame Sprite 层次结构
在检测碰撞之前,我们需要清理游戏实体。在 MonoGame 中,实体基本上拥有位置、纹理和边界矩形。
以下是我们游戏中使用的基准实体架构:
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
public class Sprite
{
public Vector2 Position;
public Texture2D Texture;
public Color Tint = Color.White;
public bool IsActive = true;
// The raw Axis-Aligned Bounding Box (AABB)
public virtual Rectangle Bounds => new Rectangle(
(int)Position.X,
(int)Position.Y,
Texture != null ? Texture.Width : 0,
Texture != null ? Texture.Height : 0
);
public virtual void Draw(SpriteBatch spriteBatch)
{
if (!IsActive || Texture == null) return;
spriteBatch.Draw(Texture, Position, Tint);
}
}
现在让我们创建具体的“Player”、“Enemy”和“Bullet”类:
public class Bullet : Sprite
{
public Vector2 Velocity;
public int Damage = 25;
public void Update(GameTime gameTime)
{
Position += Velocity * (float)gameTime.ElapsedGameTime.TotalSeconds;
}
}
public class Enemy : Sprite
{
public int Health = 100;
public void TakeDamage(int damage)
{
Health -= damage;
if (Health <= 0)
{
IsActive = false;
}
}
}
public class Player : Sprite
{
public int Health = 100;
public bool IsInvulnerable = false;
public void TakeDamage(int damage)
{
if (IsInvulnerable) return;
Health -= damage;
}
}
有了这些实体,让我们从最简单的方法开始探索如何检测它们之间的碰撞。
2. 第 1 级:最简单的碰撞 – 矩形.相交 (AABB)
MonoGame 中最基本的 2D 碰撞检查是 轴对齐边界框 (AABB) 测试。术语“轴对齐”仅意味着矩形的边缘完全平行于屏幕的 \(X\) 和 \(Y\) 轴(无旋转)。
MonoGame 提供了一个快速的内置方法:“Rectangle.Intersects(Rectangle value)”。
Rectangle.Intersects 的底层工作原理
在表面之下,MonoGame 执行四个整数比较:
public bool Intersects(Rectangle value)
{
return value.Left < this.Right &&
this.Left < value.Right &&
value.Top < this.Bottom &&
this.Top < value.Bottom;
}
如果满足所有四个条件,则矩形重叠。即使其中一个条件失败,它们也会被一根空轴分开,并且不可能发生碰撞。
真实游戏代码:“Game1.Update”中的“Bullet”与“Enemy”
以下是在主 MonoGame“更新”循环中检查活动子弹列表和活动敌人列表之间的碰撞的方法:
public class Game1 : Game
{
private List<Bullet> _bullets = new List<Bullet>();
private List<Enemy> _enemies = new List<Enemy>();
protected override void Update(GameTime gameTime)
{
// 1. Update bullet and enemy positions
foreach (var bullet in _bullets) bullet.Update(gameTime);
// 2. Collision Check: Bullets vs Enemies
for (int b = _bullets.Count - 1; b >= 0; b--)
{
var bullet = _bullets[b];
if (!bullet.IsActive) continue;
for (int e = _enemies.Count - 1; e >= 0; e--)
{
var enemy = _enemies[e];
if (!enemy.IsActive) continue;
// The AABB check!
if (bullet.Bounds.Intersects(enemy.Bounds))
{
// Collision occurred!
enemy.TakeDamage(bullet.Damage);
bullet.IsActive = false;
// Remove inactive bullet immediately
_bullets.RemoveAt(b);
if (!enemy.IsActive)
{
_enemies.RemoveAt(e);
}
// A bullet can only hit one enemy; break the inner loop
break;
}
}
}
base.Update(gameTime);
}
}
**性能提示:**注意我们向后迭代(
for (int i = list.Count - 1; i >= 0; i--))!如果您使用“foreach”并尝试调用“_bullets.Remove(bullet)”,C# 会抛出“InvalidOperationException:集合已修改”。向后迭代可以安全地删除元素,而不会出现内存重新索引问题。
街机秘密:通过“Inflate”实现“Fair Hitboxes”
在像 Blocked: Pixel Panzer 这样的复古游戏中,精灵纹理通常包括透明边缘或天线尖刺。如果玩家的坦克因为子弹触及其纹理的空透明角而爆炸,玩家会感到受骗。
为了使碰撞感觉响应灵敏且公平,游戏使用“矩形.膨胀”在精灵内使用较小的 Hitbox:
public class EnemyTank : Enemy
{
// Shrink the bounding box by 6 pixels on all sides for fair collision
public override Rectangle Bounds
{
get
{
Rectangle raw = base.Bounds;
raw.Inflate(-6, -6); // Reduces width and height by 12px
return raw;
}
}
}
3. 2 级:圆与圆碰撞(旋转免疫)
矩形非常适合静态块和网格图块,但当精灵旋转时它们就会失败。当非方形宇宙飞船在 Paint Trek 中旋转时,轴对齐的边界框必须扩展以包围旋转的角,从而导致在空旷的空气中产生令人沮丧的“幻影碰撞”。
对于圆形小行星、寻的能量球和旋转航天器,边界圆是理想的解决方案。
平方根陷阱
当两个圆的圆心之间的距离小于或等于它们的半径之和时,它们就会发生碰撞:
\(\text{距离}(C_A, C_B) \le r_A + r_B\)
在代码中,计算欧几里德距离使用“Math.Sqrt”(或“Vector2.Distance”)。然而,在一个包含 200 个射弹的循环中计算平方根会消耗数百个不必要的 CPU 周期!
通过将距离平方与半径平方和进行比较,我们完全消除了平方根:
\(\text{距离平方} \le (r_A + r_B)^2\)
MonoGame 实现:Circle 与 Circle
public struct Circle
{
public Vector2 Center;
public float Radius;
public Circle(Vector2 center, float radius)
{
Center = center;
Radius = radius;
}
public bool Intersects(Circle other)
{
float radiusSum = this.Radius + other.Radius;
// MonoGame built-in Vector2.DistanceSquared
return Vector2.DistanceSquared(this.Center, other.Center) <= (radiusSum * radiusSum);
}
}
现在将其直接集成到一个实体中:
public class PaintTrekFighter : Sprite
{
public float CollisionRadius = 18f;
public Vector2 Center => Position + new Vector2(Texture.Width * 0.5f, Texture.Height * 0.5f);
public Circle BoundingCircle => new Circle(Center, CollisionRadius);
public bool CollidesWith(PaintTrekFighter other)
{
return this.BoundingCircle.Intersects(other.BoundingCircle);
}
}
零平方根,不受精灵旋转影响,速度快如闪电。
4. 第 3 级:混合形状 – 圆形与方框(MathHelper.Clamp)
当 Paint Trek 中的圆形宇宙飞船穿过矩形防御障碍的紧密迷宫时,或者当Blocked: Pixel Panzer 中的圆形子弹击中方块时会发生什么?
我们需要圆形与矩形碰撞。
钳位算法
策略是找到矩形上距离圆心最近的点,然后测试该最近点到圆心的距离是否小于圆的半径。
MonoGame 的 MathHelper.Clamp 使这变得微不足道:
public static class Collision2D
{
public static bool CircleIntersectsRectangle(Circle circle, Rectangle rect)
{
// Find the closest point on the rectangle to the circle center
float closestX = MathHelper.Clamp(circle.Center.X, rect.Left, rect.Right);
float closestY = MathHelper.Clamp(circle.Center.Y, rect.Top, rect.Bottom);
// Vector from closest point to circle center
float distanceX = circle.Center.X - closestX;
float distanceY = circle.Center.Y - closestY;
// Check squared distance against squared radius
float distanceSquared = (distanceX * distanceX) + (distanceY * distanceY);
return distanceSquared <= (circle.Radius * circle.Radius);
}
}
您现在可以以零分配和高精度测试玩家盾牌对矩形砖的影响!
5. 第 4 级:连续碰撞检测 (CCD) 和扫描射线
您是否曾经在游戏中发射超高速狙击弹或轨道炮激光,却发现子弹神奇地直接穿过薄薄的敌舰而没有造成任何伤害?
此错误称为隧道。
由于离散游戏按时间步长更新(\(\Delta t = 16.6\text{ms}\),60 FPS),因此以每秒 1,800 像素的速度移动的对象在单帧中移动 30 像素。如果敌人的船体只有 15 像素厚,则子弹在第 1 帧上位于敌人前面,而在第 2 帧上完全位于敌人后面。
Frame 1: [ Bullet ] ---> | Enemy Wall |
Frame 2: | Enemy Wall | ---> [ Bullet ]
(NO HIT DETECTED!)
解决方案:扫描线段与框(板法)
我们不是测试单个点,而是测试连接第 1 帧(previousPosition)上的子弹位置到第 2 帧(currentPosition)的整个线段。
以下是来自我们的姊妹篇 SpiralWar 的生产光线投射板相交方法:
public static class ContinuousCollision
{
public static bool IntersectsSweptRay(Vector2 rayStart, Vector2 rayEnd, Rectangle box, out Vector2 hitPoint)
{
hitPoint = Vector2.Zero;
Vector2 direction = rayEnd - rayStart;
float tMin = 0f;
float tMax = 1f;
// Clip against X slabs
if (MathF.Abs(direction.X) > 1e-6f)
{
float invX = 1f / direction.X;
float t1 = (box.Left - rayStart.X) * invX;
float t2 = (box.Right - rayStart.X) * invX;
if (t1 > t2) (t1, t2) = (t2, t1);
tMin = MathF.Max(tMin, t1);
tMax = MathF.Min(tMax, t2);
if (tMin > tMax) return false;
}
else if (rayStart.X < box.Left || rayStart.X > box.Right)
{
return false;
}
// Clip against Y slabs
if (MathF.Abs(direction.Y) > 1e-6f)
{
float invY = 1f / direction.Y;
float t1 = (box.Top - rayStart.Y) * invY;
float t2 = (box.Bottom - rayStart.Y) * invY;
if (t1 > t2) (t1, t2) = (t2, t1);
tMin = MathF.Max(tMin, t1);
tMax = MathF.Min(tMax, t2);
if (tMin > tMax) return false;
}
else if (rayStart.Y < box.Top || rayStart.Y > box.Bottom)
{
return false;
}
hitPoint = rayStart + direction * tMin;
return true;
}
}
在 Blocked: Pixel Panzer 中,玩家的连续 激光束技能 使用这种精确的光线投射来切穿一排排下降的方块,而不会错过任何一次碰撞。
6. 第 5 级:生产级完美像素碰撞检测
现在我们达到了 2D 精度的终极水平:像素完美碰撞。
在复古坦克射击游戏或宇宙飞船混战中,不规则形状(坦克桶、机翼、驾驶舱驾驶舱)被精灵纹理中的透明像素包围。当敌方导弹击中透明空间时,玩家会立即注意到。
像素完美碰撞检查重叠纹理的实际 Alpha(透明度)通道。如果两个非透明像素在同一世界坐标处重叠,则发生了真正的物理撞击。
致命错误:Update() 中的 GetData
许多教程指导初学者这样做:
// DO NOT DO THIS!
Color[] dataA = new Color[textureA.Width * textureA.Height];
textureA.GetData(dataA); // STALLS GPU, CREATES MASSIVE GC LAG!
在运行时调用“Texture2D.GetData()”会强制 CPU 等待 GPU 管道刷新,通过总线将纹理内存拉回。每帧对数十个精灵执行此操作将使您的游戏速度从 60 FPS 降低到 5 FPS,并立即触发 Android ANR!
Blocked: Pixel Panzer 中经过实战考验的解决方案
在 Blocked: Pixel Panzer 中,我们的 Sprite.cs 实现了一个围绕 5 个关键规则构建的超优化的 IntersectsPixel 例程:
- 静态颜色数组缓存:
GetData在纹理加载时仅调用一次,并存储在Dictionary<Texture2D, Color[]>中。 - AABB Early Exit Guard: 如果
Bounds.Intersects(other.Bounds)为 false,我们立即退出。在检查单个像素之前就消除了 99% 的检查。 - 纹理图集和
SourceRectangle支持: 使用源矩形偏移处理打包到纹理图集中的精灵。 - 计算的重叠窗口: 我们仅循环两个精灵之间的精确交叉矩形(
Math.Max(a.Top, b.Top)等)。 - Alpha 短路: 如果 Sprite A 的像素是透明的 (
A <= 20),则完全跳过 Sprite B。 - 属性提升: 在嵌套循环中访问虚拟属性(“Bounds”)会创建数千个结构副本。在进入循环之前,我们将它们存储在本地堆栈变量中。
这是完整的、可用于生产的代码:
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
public class AdvancedSprite : Sprite
{
private static readonly Dictionary<Texture2D, Color[]> _textureDataCache = new();
public Rectangle? SourceRectangle { get; set; }
public static Color[] GetCachedTextureData(Texture2D texture)
{
if (!_textureDataCache.TryGetValue(texture, out var data))
{
data = new Color[texture.Width * texture.Height];
texture.GetData(data);
_textureDataCache[texture] = data;
}
return data;
}
public bool IntersectsPixel(AdvancedSprite other)
{
// 1. Hoist bounds into local stack variables
Rectangle a = this.Bounds;
Rectangle b = other.Bounds;
// 2. Fast AABB Broad-Phase check
if (!a.Intersects(b)) return false;
// Fallback to rectangle check if textures are missing
if (this.Texture == null || other.Texture == null) return true;
// 3. Retrieve pre-cached color arrays (Zero GPU latency)
Color[] dataA = GetCachedTextureData(this.Texture);
Color[] dataB = GetCachedTextureData(other.Texture);
int aTexW = this.Texture.Width;
int bTexW = other.Texture.Width;
// Atlas / SourceRectangle offset mapping
bool aHasSrc = this.SourceRectangle.HasValue;
Rectangle aSrc = aHasSrc ? this.SourceRectangle.Value : Rectangle.Empty;
int aOriginX = aHasSrc ? aSrc.X : 0;
int aOriginY = aHasSrc ? aSrc.Y : 0;
int aSpanX = aHasSrc ? aSrc.Width : this.Texture.Width;
int aSpanY = aHasSrc ? aSrc.Height : this.Texture.Height;
bool bHasSrc = other.SourceRectangle.HasValue;
Rectangle bSrc = bHasSrc ? other.SourceRectangle.Value : Rectangle.Empty;
int bOriginX = bHasSrc ? bSrc.X : 0;
int bOriginY = bHasSrc ? bSrc.Y : 0;
int bSpanX = bHasSrc ? bSrc.Width : other.Texture.Width;
int bSpanY = bHasSrc ? bSrc.Height : other.Texture.Height;
// 4. Calculate the overlapping sub-rectangle
int top = Math.Max(a.Top, b.Top);
int bottom = Math.Min(a.Bottom, b.Bottom);
int left = Math.Max(a.Left, b.Left);
int right = Math.Min(a.Right, b.Right);
// 5. Scan only overlapping pixels
for (int y = top; y < bottom; y++)
{
// Row offsets hoisted outside the inner X loop!
int colorA_Y = aOriginY + (int)((y - a.Y) / (float)a.Height * aSpanY);
int colorB_Y = bOriginY + (int)((y - b.Y) / (float)b.Height * bSpanY);
int rowA = colorA_Y * aTexW;
int rowB = colorB_Y * bTexW;
for (int x = left; x < right; x++)
{
int colorA_X = aOriginX + (int)((x - a.X) / (float)a.Width * aSpanX);
// Early Bailout: If Pixel A is transparent, skip B entirely!
if (dataA[colorA_X + rowA].A <= 20) continue;
int colorB_X = bOriginX + (int)((x - b.X) / (float)b.Width * bSpanX);
// If Pixel B is also opaque, we have confirmed contact!
if (dataB[colorB_X + rowB].A > 20)
{
return true;
}
}
}
return false;
}
}
该例程在现代 Android 硬件和桌面系统上的执行时间不到 0.1 毫秒。
7. 第 6 级:宽相空间网格和零分配移动 GC
想象一下您的游戏有 100 个方块、60 颗子弹和 20 个敌人。如果您使用嵌套的“for”循环针对每个其他实体测试每个实体,您将执行:
\(\frac{180 \times 179}{2} = 16,110 \text{ 每帧检查!}\)
在 60 FPS 下,几乎每秒进行 1,000,000 次碰撞检查**。
要扩展您的游戏,您必须实施宽相空间分区。
统一空间哈希网格
我们将屏幕划分为由均匀单元格组成的 2D 网格(在 Blocked: Pixel Panzer 中,每个单元格为 150 美元 × 150$ 像素)。实体仅测试与驻留在同一网格单元中的其他实体的碰撞。
移动内存问题:GC 抖动
如果您的“SpatialGrid”每帧创建“new List
在 Android 的 Mono 运行时,这会触发频繁的 Gen-0 垃圾收集,从而导致:
mono 运行时:本机锁争用 (mono_class_is_subclass_)
游戏卡顿,Google Play 会用 ANR 警告标记您的游戏!
解决方案:可重复使用的暂存缓冲区
这是来自 Blocked: Pixel Panzer 中 CollisionManager.cs 的 零分配空间哈希网格:
public class SpatialGridManager
{
private const int CELL_SIZE = 150;
private readonly Dictionary<Point, List<Sprite>> _grid = new();
// Zero-GC: Reusable scratch buffers allocated ONCE at startup
private readonly List<Point> _scratchCells = new();
private readonly HashSet<Sprite> _scratchCheckedEntities = new();
public void BuildGrid(List<Sprite> entities)
{
// Clear lists without re-allocating new List objects
foreach (var list in _grid.Values)
{
list.Clear();
}
foreach (var entity in entities)
{
if (!entity.IsActive) continue;
GetOccupiedCells(entity.Bounds, _scratchCells);
foreach (var cell in _scratchCells)
{
if (!_grid.TryGetValue(cell, out var list))
{
list = new List<Sprite>();
_grid[cell] = list;
}
list.Add(entity);
}
}
}
private void GetOccupiedCells(Rectangle bounds, List<Point> outCells)
{
outCells.Clear();
int startX = bounds.Left / CELL_SIZE;
int startY = bounds.Top / CELL_SIZE;
int endX = bounds.Right / CELL_SIZE;
int endY = bounds.Bottom / CELL_SIZE;
for (int x = startX; x <= endX; x++)
{
for (int y = startY; y <= endY; y++)
{
outCells.Add(new Point(x, y));
}
}
}
public void CheckCollisions(Player player)
{
if (player.IsInvulnerable) return;
GetOccupiedCells(player.Bounds, _scratchCells);
_scratchCheckedEntities.Clear();
foreach (var cell in _scratchCells)
{
if (_grid.TryGetValue(cell, out var entitiesInCell))
{
foreach (var other in entitiesInCell)
{
if (other == player || _scratchCheckedEntities.Contains(other)) continue;
_scratchCheckedEntities.Add(other);
// 1. Broad-phase AABB test
if (player.Bounds.Intersects(other.Bounds))
{
// 2. High-precision Pixel-Perfect test
if (player is AdvancedSprite advPlayer && other is AdvancedSprite advOther)
{
if (advPlayer.IntersectsPixel(advOther))
{
player.TakeDamage(10);
}
}
}
}
}
}
}
}
通过重用“_scratchCells”和“_scratchCheckedEntities”,整个冲突步骤以每帧 0 字节的堆分配运行。
8. 总结比较表
| 技术 | 数学成本 | 旋转支撑 | 隧道安全吗? | MonoGame 的最佳用例 |
|---|---|---|---|---|
AABB(矩形.相交) |
超快(约 4 次整数比较) | 没有 | 没有 | 网格块、子弹宽相、UI |
圆(DistanceSquared) |
非常快(3 muls,0 sqrt) | 是(不变) | 没有 | 圆船、火球、能量球 |
圆形与盒子(夹子) |
快速(本地 MathHelper.Clamp) | 是的 | 没有 | 圆形播放器在紧密的墙壁上航行 |
| 扫描射线(平板 CCD) | 中等(参数光线投射) | 是的 | 是的 | 快速狙击子弹、轨道炮、激光 |
像素完美(IntersectsPixel) |
选择性(受限子区域) | 是的 | 没有 | 不规则的精灵轮廓,公平的碰撞箱 |
| 空间网格(零GC) | 宽相 (\(O(N^2) \to O(N)\)) | 不适用 | 不适用 | 密集海浪、弹幕地狱、Android 60 FPS |
真实世界制作展示:Arar Games
这些碰撞架构不是理论实验——它们是为我们商业发布的游戏提供动力的真正工程基础:
- 已屏蔽:Pixel Panzer: 我们在 Google Play 和 Microsoft Store 上推出的复古坦克生存街机游戏。它具有我们完整的两层碰撞系统:零分配空间哈希网格过滤掉落的方块,以及对坦克、战斗机、炮塔炮弹和 ColorWheel 元素弹药进行像素完美的接触检查。
- Paint Trek: 我们的快节奏太空射击游戏具有旋转圆碰撞、连续光线投射和扫掠导弹防御系统。
结论和后续步骤
MonoGame 使您能够设计完全符合游戏需求的碰撞检测。通过在快速 AABB 测试后面进行昂贵的 Pixel-Perfect 检查,利用 Squared Distances 进行圆,并通过 Reusable Scratch Buffers 消除垃圾收集,您可以在桌面和移动平台上提供控制台平滑的 60/120 FPS 性能。
在应用商店中查看我们的游戏,了解这些碰撞系统的实际应用,并立即开始在您自己的 MonoGame 项目中实现这些模式!
商店链接和资源
- 被阻止:Google Play 上的 Pixel Panzer: Android 下载
- 被阻止:Microsoft Store 上的 Pixel Panzer: Windows 版下载
- MonoGame 框架: monogame.net
SEO 关键词和标签
关键词: MonoGame 2D 碰撞检测、C# 游戏开发、Rectangle.Intersects MonoGame、像素完美碰撞 C#、空间哈希网格 MonoGame、扫掠射线连续碰撞、独立游戏性能优化、零分配游戏循环、Android MonoGame 优化、Arar Games、Blocked Pixel Panzer、Paint Trek。
#MonoGame #CSharp #GameDev #IndieDev #GamePhysics #DotNet #2DGameDev #MobileGameDev #PerformanceOptimization #CleanCode #BlockedPixelPanzer #PaintTrek #ArarGames #GameProgramming
Hiç yorum yok:
Yorum Gönder