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 #PerformanceOptimiz盤#GameProgramming`
Hiç yorum yok:
Yorum Gönder