27 Eylül 2026 Pazar

MonoGame 中的 2D 碰撞检测:从基本矩形到零分配像素完美系统




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 例程:

  1. 静态颜色数组缓存: GetData 在纹理加载时仅调用一次,并存储在 Dictionary<Texture2D, Color[]> 中。
  2. AABB Early Exit Guard: 如果 Bounds.Intersects(other.Bounds) 为 false,我们立即退出。在检查单个像素之前就消除了 99% 的检查。
  3. 纹理图集和SourceRectangle支持: 使用源矩形偏移处理打包到纹理图集中的精灵。
  4. 计算的重叠窗口: 我们仅循环两个精灵之间的精确交叉矩形(Math.Max(a.Top, b.Top)等)。
  5. Alpha 短路: 如果 Sprite A 的像素是透明的 (A <= 20),则完全跳过 Sprite B。
  6. 属性提升: 在嵌套循环中访问虚拟属性(“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()”或“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 项目中实现这些模式!


商店链接和资源


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