25 Eylül 2026 Cuma

2D-Kollisionserkennung in MonoGame: Von einfachen Rechtecken zu pixelgenauen Systemen ohne Zuordnung

2D-Kollisionserkennung in MonoGame: Von einfachen Rechtecken zu pixelgenauen Systemen ohne Zuordnung

Beim Erstellen von 2D-Spielen in visuellen Engines wie Unity oder Godot fühlt sich die Kollisionserkennung oft wie eine Reihe von Inspektor-Kontrollkästchen an: Sie legen einen „BoxCollider2D“ oder „CircleCollider2D“ an, hängen einen „Rigidbody“ an und hoffen, dass der interne Physikschritt auf Mobilgeräten nicht stottert.

In MonoGame und C# haben Sie jedoch die volle Kontrolle. Es gibt keinen versteckten physikalischen Aufwand, keine unerwünschte Rotationsträgheit und keine mysteriösen Garbage Collection (GC)-Spitzen, die Ihr Rahmenbudget stehlen.

Als wir bei Arar Games Blocked: Pixel Panzer und Paint Trek entwickelten, mussten unsere Arcade-Spielschleifen Hunderte von schnellen feindlichen Kugeln, explodierenden Ziegelgittern, rotierenden Panzertürmen, Vorbeiflügen von Kampfjets und Partikelschilden mit 60 bis 120 FPS auf Windows-PCs und Android-Geräten verarbeiten. Eine universelle Physik-Engine kam nicht in Frage – wir brauchten eine speziell entwickelte, abgestufte Kollisionsarchitektur.

In diesem umfassenden, codegesteuerten Leitfaden beginnen wir mit den absoluten Grundlagen der MonoGame-Kollision („Rectangle.Intersects“ mit echten „Bullet“- und „Enemy“-Sprites) und bauen auf erweiterte Kreisprüfungen, Mixed Clamping, Anti-Tunneling-Raycasts, Pixel-Perfect-Kollision in Produktionsqualität und Zero-Allocation Spatial Grids auf, die für das Überleben auf mobilen GCs optimiert sind.


1. Die Grundlage: Eine einfache MonoGame-„Sprite“-Hierarchie

Bevor wir Kollisionen erkennen können, benötigen wir saubere Spieleinheiten. In MonoGame besitzt eine Entität grundsätzlich eine Position, eine Textur und ein umgrenzendes Rechteck.

Hier ist die grundlegende Entitätsarchitektur, die in unseren Spielen verwendet wird:

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);
    }
}

Jetzt erstellen wir konkrete Klassen „Player“, „Enemy“ und „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;
    }
}

Wenn diese Entitäten vorhanden sind, wollen wir zunächst mit der einfachsten Methode untersuchen, wie Kollisionen zwischen ihnen erkannt werden können.


2. Ebene 1: Die einfachste Kollision – „Rectangle.Intersects“ (AABB)

Die grundlegendste 2D-Kollisionsprüfung in MonoGame ist der Axis-Aligned Bounding Box (AABB)-Test. Der Begriff „achsenausgerichtet“ bedeutet einfach, dass die Kanten des Rechtecks ​​vollständig parallel zu den Achsen \(X\) und \(Y\) des Bildschirms verlaufen (keine Drehung).

MonoGame bietet eine schnelle, integrierte Methode: „Rectangle.Intersects(Rectangle value)“.

Wie „Rectangle.Intersects“ unter der Haube funktioniert

Unter der Oberfläche führt MonoGame vier Ganzzahlvergleiche durch:

public bool Intersects(Rectangle value)
{
    return value.Left < this.Right &&
           this.Left < value.Right &&
           value.Top < this.Bottom &&
           this.Top < value.Bottom;
}

Wenn alle vier Bedingungen erfüllt sind, überlappen sich die Rechtecke. Wenn auch nur eine Bedingung fehlschlägt, werden sie durch eine leere Achse getrennt und es ist keine Kollision möglich.

Echter Gameplay-Code: „Bullet“ vs. „Enemy“ in „Game1.Update“.

So überprüfen Sie Kollisionen zwischen einer Liste aktiver Kugeln und einer Liste aktiver Feinde in Ihrer Haupt-MonoGame-„Update“-Schleife:

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);
    }
}

Leistungstipp: Beachten Sie, dass wir rückwärts iterieren (for (int i = list.Count - 1; i >= 0; i--))! Wenn Sie „foreach“ verwenden und versuchen, „_bullets.Remove(bullet)“ aufzurufen, löst C# eine „InvalidOperationException: Collection wurde geändert“ aus. Die Rückwärtsiteration ermöglicht das sichere Entfernen von Elementen ohne Probleme bei der Neuindizierung des Speichers.

Das Arcade-Geheimnis: „Fair Hitboxes“ über „Inflate“.

In Retro-Spielen wie Blocked: Pixel Panzer enthalten Sprite-Texturen oft transparente Ränder oder Antennenspitzen. Wenn der Panzer des Spielers explodiert, weil eine Kugel eine leere transparente Ecke seiner Textur berührt hat, fühlt sich der Spieler betrogen.

Damit sich Kollisionen reaktionsschnell und fair anfühlen, verwenden Spiele mithilfe von „Rectangle.Inflate“ eine kleinere Hitbox innerhalb des Sprites:

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. Stufe 2: Kreis-zu-Kreis-Kollision (Rotationsimmunität)

Rechtecke eignen sich hervorragend für statische Blöcke und Rasterkacheln, versagen jedoch, wenn Sprites rotieren. Wenn sich in Paint Trek ein nicht quadratisches Raumschiff dreht, muss sich ein an der Achse ausgerichteter Begrenzungsrahmen ausdehnen, um die sich drehenden Ecken einzuschließen, was zu frustrierenden „Phantomkollisionen“ in der Luft führt.

Für kreisförmige Asteroiden, zielsuchende Energiekugeln und rotierende Raumfahrzeuge sind Begrenzungskreise die ideale Lösung.

Die Quadratwurzelfalle

Zwei Kreise kollidieren, wenn der Abstand zwischen ihren Mittelpunkten kleiner oder gleich der Summe ihrer Radien ist:

\(\text{Abstand}(C_A, C_B) \le r_A + r_B\)

Im Code wird zur Berechnung der euklidischen Distanz „Math.Sqrt“ (oder „Vector2.Distance“) verwendet. Allerdings verbrennt die Berechnung von Quadratwurzeln in einer Schleife mit 200 Projektilen Hunderte unnötiger CPU-Zyklen!

Indem wir den Quadratabstand mit der Quadratradiussumme vergleichen, eliminieren wir die Quadratwurzel vollständig:

\(\text{DistanceSquared} \le (r_A + r_B)^2\)

MonoGame-Implementierung: „Circle“ vs. „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);
    }
}

Integrieren Sie dies nun direkt in eine Entität:

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);
    }
}

Keine Quadratwurzeln, immun gegen Sprite-Rotation und blitzschnell.


4. Level 3: Gemischte Formen – Kreis vs. Box („MathHelper.Clamp“)

Was passiert, wenn ein kreisförmiges Raumschiff in Paint Trek durch ein enges Labyrinth rechteckiger Verteidigungsbarrieren navigiert oder wenn eine runde Kugel in Blocked: Pixel Panzer einen quadratischen Block trifft?

Wir brauchen eine Kreis-gegen-Rechteck-Kollision.

Der Spannalgorithmus

Die Strategie besteht darin, den Punkt auf dem Rechteck zu finden, der dem Kreismittelpunkt am nächsten liegt, und dann zu testen, ob der Abstand von diesem nächstgelegenen Punkt zum Mittelpunkt kleiner als der Kreisradius ist.

„MathHelper.Clamp“ von MonoGame macht dies trivial:

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);
    }
}

Sie können Spielerschilde jetzt ohne Zuweisungen und mit hoher Genauigkeit gegen rechteckige Steine ​​testen!


5. Ebene 4: Kontinuierliche Kollisionserkennung (CCD) und geschwenkte Strahlen

Haben Sie in Ihrem Spiel schon einmal ein Hochgeschwindigkeits-Scharfschützengeschoss oder einen Railgun-Laser abgefeuert, nur um zu beobachten, wie die Kugel auf magische Weise ein dünnes feindliches Schiff durchdringt, ohne Schaden anzurichten?

Dieser Fehler wird als Tunneling bezeichnet.

Da diskrete Spiele in Zeitschritten aktualisiert werden (\(\Delta t = 16,6\text{ms}\) bei 60 FPS), bewegt sich ein Objekt mit 1.800 Pixeln pro Sekunde 30 Pixel in einem einzelnen Frame. Wenn die gegnerische Hülle nur 15 Pixel dick ist, befand sich die Kugel in Bild 1 vor dem Feind und in Bild 2 vollständig hinter dem Feind.

Frame 1:  [ Bullet ]  --->       | Enemy Wall |
Frame 2:                          | Enemy Wall |       --->  [ Bullet ]
                               (NO HIT DETECTED!)

Die Lösung: Sweep-Segment vs. Box (Plattenmethode)

Anstatt einen einzelnen Punkt zu testen, testen wir das gesamte Liniensegment, das die Position des Aufzählungszeichens in Frame 1 („ previousPosition“) mit Frame 2 („currentPosition“) verbindet.

Hier ist die Produktions-Raycasting-Plattenschnittmethode aus unserem Begleittitel 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;
    }
}

In Blocked: Pixel Panzer nutzt die kontinuierliche Laserstrahl-Fertigkeit des Spielers genau diesen Strahl, um Reihen absteigender Blöcke zu durchschneiden, ohne eine einzige Kollision zu verpassen.


6. Stufe 5: Pixelgenaue Kollisionserkennung in Produktionsqualität

Jetzt erreichen wir das ultimative Niveau der 2D-Genauigkeit: Pixel-Perfect Collision.

In einem Retro-Panzer-Shooter oder Raumschiff-Luftkampf werden unregelmäßige Formen (Panzerrohre, Flügel, Cockpit-Cockpits) von transparenten Pixeln in der Sprite-Textur umgeben. Wenn eine feindliche Rakete diesen transparenten Raum trifft, merken es die Spieler sofort.

Bei der pixelgenauen Kollision werden die tatsächlichen Alphakanäle (Transparenzkanäle) der überlappenden Texturen überprüft. Wenn sich zwei nicht transparente Pixel an derselben Weltkoordinate überlappen, liegt ein echter physischer Treffer vor.

Der fatale Fehler: „GetData“ in „Update()“.

Viele Tutorials weisen Anfänger dazu an:

// DO NOT DO THIS!
Color[] dataA = new Color[textureA.Width * textureA.Height];
textureA.GetData(dataA); // STALLS GPU, CREATES MASSIVE GC LAG!

Der Aufruf von „Texture2D.GetData()“ während der Laufzeit zwingt die CPU, auf das Leeren der GPU-Pipeline zu warten, wodurch der Texturspeicher über den Bus zurückgezogen wird. Wenn Sie dies für Dutzende von Sprites in jedem Frame tun, wird Ihr Spiel von 60 FPS auf 5 FPS gesteigert und Android-ANRs werden sofort ausgelöst!

Die kampferprobte Lösung von Blocked: Pixel Panzer

In Blocked: Pixel Panzer implementiert unser „Sprite.cs“ eine hochoptimierte „IntersectsPixel“-Routine, die auf 5 entscheidenden Regeln basiert:

  1. Statischer Farbarray-Cache: „GetData“ wird genau einmal aufgerufen, wenn die Textur geladen wird, und in einem „Dictionary<Texture2D, Color[]>“ gespeichert.
  2. AABB Early Exit Guard: Wenn „Bounds.Intersects(other.Bounds)“ falsch ist, steigen wir sofort aus. 99 % der Kontrollen entfallen, bevor ein einzelnes Pixel geprüft wird.
  3. Unterstützung für Texturatlas und „SourceRectangle“: Verarbeitet Sprites, die in Texturatlanten gepackt sind, unter Verwendung von Quellrechteck-Offsets.
  4. Berechnetes Überlappungsfenster: Wir durchlaufen nur das exakte Schnittrechteck zwischen beiden Sprites („Math.Max(a.Top, b.Top)“ usw.).
  5. Alpha-Kurzschluss: Wenn das Pixel von Sprite A transparent ist („A <= 20“), wird Sprite B vollständig übersprungen.
  6. Property Hoisting: Durch den Zugriff auf virtuelle Eigenschaften („Bounds“) in verschachtelten Schleifen werden Tausende von Strukturkopien erstellt. Wir speichern sie in lokalen Stapelvariablen, bevor wir in die Schleife eintreten.

Hier ist der vollständige, produktionsbereite Code:

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;
    }
}

Diese Routine wird auf moderner Android-Hardware und Desktop-Systemen in weniger als 0,1 Millisekunden ausgeführt.


7. Ebene 6: Breitphasiges räumliches Raster und mobiler GC ohne Zuweisung

Stellen Sie sich vor, Ihr Spiel hat 100 Blöcke, 60 Kugeln und 20 Feinde. Wenn Sie jede Entität mit jeder anderen Entität mithilfe verschachtelter „for“-Schleifen testen, führen Sie Folgendes aus:

\(\frac{180 \times 179}{2} = 16.110 \text{ Schecks pro Frame!}\)

Bei 60 FPS sind das fast 1.000.000 Kollisionsprüfungen pro Sekunde.

Um Ihr Spiel zu skalieren, müssen Sie Broad-Phase Spatial Partitioning implementieren.

Das einheitliche räumliche Hash-Gitter

Wir unterteilen den Bildschirm in ein 2D-Raster aus gleichmäßigen Zellen (in Blocked: Pixel Panzer hat jede Zelle 150 \(\times 150\) Pixel). Eine Entität testet nur Kollisionen mit anderen Entitäten, die sich in denselben Gitterzellen befinden.

Das Problem des mobilen Speichers: GC-Thrashing

Wenn Ihr „SpatialGrid“ in jedem Frame „new List()“ oder „new List()“ erstellt, weisen Sie jede Minute Megabyte Müll auf dem verwalteten Heap zu.

Auf der Mono-Laufzeitumgebung von Android löst dies häufige Garbage Collections der Generation 0 aus, was zu Folgendem führt: „Mono-Laufzeit: Native Sperrkonflikt (mono_class_is_subclass_)“.

Das Spiel stottert und Google Play markiert Ihr Spiel mit ANR-Warnungen!

Die Lösung: Wiederverwendbare Scratch-Puffer

Hier ist das Zero-Allocation Spatial Hash Grid aus „CollisionManager.cs“ in Blocked: Pixel Panzer:

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);
                            }
                        }
                    }
                }
            }
        }
    }
}

Durch die Wiederverwendung von „_scratchCells“ und „_scratchCheckedEntities“ wird der gesamte Kollisionsschritt mit 0 Byte Heap-Zuweisung pro Frame ausgeführt.


8. Zusammenfassende Vergleichstabelle

Technik Mathematische Kosten Rotationsunterstützung Tunnelbau sicher? Bester Anwendungsfall in MonoGame
AABB (Rectangle.Intersects) Ultraschnell (~4 Ganzzahlvergleiche) Nein Nein Gitterblöcke, Bullet-Breitphase, UI
Kreis (DistanceSquared) Sehr schnell (3 Muls, 0 Quadratmeter) Ja (Invariant) Nein Runde Schiffe, Feuerbälle, Energiekugeln
Kreis vs. Box („Klemme“) Schnell (Local MathHelper.Clamp) Ja Nein Kreisspieler navigiert durch enge Mauern
Swept Ray (Slab CCD) Moderat (Parametrischer Raycast) Ja Ja Schnelle Scharfschützengeschosse, Railguns, Laser
Pixel-Perfekt (IntersectsPixel) Selektiv (eingeschränktes Unterrecht) Ja Nein Unregelmäßige Sprite-Konturen, faire Hitboxen
Räumliches Gitter (Zero-GC) Breitphasig (\(O(N^2) \to O(N)\)) N/A N/A Dichte Wellen, Kugelhöllen, Android 60 FPS

Produktionsvorführungen aus der realen Welt: Arar Games

Diese Kollisionsarchitekturen sind keine theoretischen Experimente – sie sind die eigentliche technische Grundlage für unsere kommerziell veröffentlichten Titel:

  • Blockiert: Pixel Panzer: Unser Retro-Panzer-Survival-Arcade-Spiel bei Google Play und Microsoft Store. Es verfügt über unser komplettes zweistufiges Kollisionssystem: ein räumliches Hash-Gitter ohne Zuordnung, das fallende Blöcke filtert, und eine pixelgenaue Kontaktprüfung für Panzer, Kampfjets, Turmgranaten und ColorWheel-Elementarmunition.
  • Paint Trek: Unser rasanter Weltraum-Shooter mit Rotationskreiskollision, kontinuierlichem Raycasting und Raketenabwehrsystemen mit überstrichenem Volumen.

Fazit und nächste Schritte

MonoGame gibt Ihnen die Möglichkeit, eine Kollisionserkennung zu entwerfen, die perfekt zu den Anforderungen Ihres Spiels passt. Indem Sie teure Pixel-Perfect-Prüfungen hinter schnellen AABB-Tests einschließen, quadratische Distanzen für Kreise verwenden und die Speicherbereinigung mit wiederverwendbaren Scratch-Puffer eliminieren, können Sie sowohl auf Desktop- als auch auf mobilen Plattformen eine konsolenähnliche Leistung von 60/120 FPS liefern.

Schauen Sie sich unsere Spiele in den App Stores an, um diese Kollisionssysteme in Aktion zu sehen, und beginnen Sie noch heute mit der Implementierung dieser Muster in Ihren eigenen MonoGame-Projekten!



SEO-Keywords und Hashtags

Schlüsselwörter: MonoGame 2D-Kollisionserkennung, C#-Spieleentwicklung, Triangle.Intersects MonoGame, Pixel Perfect Collision C#, Spatial Hash Grid MonoGame, Swept Ray Continuous Collision, Indie Game Performance Optimization, Zero Allocation Game Loop, Android MonoGame Optimization, Arar Games, Blocked Pixel Panzer, Paint Trek.

#MonoGame #CSharp #GameDev #IndieDev #GamePhysics #DotNet #2DGameDev #MobileGameDev #PerformanceOptimization #CleanCode #BlockedPixelPanzer #PaintTrek #ArarGames #GameProgramming

2D Collision Detection in MonoGame: From Basic Rectangles to Zero-Allocation Pixel-Perfect Systems

2D Collision Detection in MonoGame: From Basic Rectangles to Zero-Allocation Pixel-Perfect Systems

When building 2D games in visual engines like Unity or Godot, collision detection often feels like a series of inspector checkboxes: you slap on a BoxCollider2D or CircleCollider2D, attach a Rigidbody, and hope the internal physics step doesn't stutter on mobile devices.

In MonoGame and C#, however, you are in total control. There is no hidden physics overhead, no unwanted rotational inertia, and no mysterious garbage collection (GC) spikes stealing your frame budget.

At Arar Games, when we built Blocked: Pixel Panzer and Paint Trek, our arcade game loops needed to process hundreds of high-speed enemy bullets, exploding brick grids, rotating tank turrets, fighter jet flybys, and particle shields at 60 to 120 FPS across both Windows PC and Android devices. A general-purpose physics engine was out of the question—we needed a purpose-built, tiered collision architecture.

In this comprehensive, code-driven guide, we will start from the absolute basics of MonoGame collision (Rectangle.Intersects with real Bullet and Enemy sprites) and build up to advanced circle checks, mixed clamping, anti-tunneling raycasts, production-grade Pixel-Perfect collision, and Zero-Allocation Spatial Grids optimized for mobile GC survival.


1. The Foundation: A Simple MonoGame Sprite Hierarchy

Before detecting collisions, we need clean game entities. In MonoGame, an entity fundamentally possesses a position, a texture, and a bounding rectangle.

Here is the baseline entity architecture used across our games:

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);
    }
}

Now let's create concrete Player, Enemy, and Bullet classes:

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;
    }
}

With these entities in place, let's explore how to detect collisions between them, starting from the simplest method.


2. Level 1: The Simplest Collision – Rectangle.Intersects (AABB)

The most fundamental 2D collision check in MonoGame is the Axis-Aligned Bounding Box (AABB) test. The term "axis-aligned" simply means that the rectangle's edges are completely parallel to the screen's \(X\) and \(Y\) axes (no rotation).

MonoGame provides a fast, built-in method: Rectangle.Intersects(Rectangle value).

How Rectangle.Intersects Works Under the Hood

Underneath the surface, MonoGame executes four integer comparisons:

public bool Intersects(Rectangle value)
{
    return value.Left < this.Right &&
           this.Left < value.Right &&
           value.Top < this.Bottom &&
           this.Top < value.Bottom;
}

If all four conditions are met, the rectangles overlap. If even one condition fails, an empty axis separates them, and no collision is possible.

Real Gameplay Code: Bullet vs. Enemy in Game1.Update

Here is how you check collisions between a list of active bullets and a list of active enemies inside your main MonoGame Update loop:

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);
    }
}

Performance Tip: Notice we iterate backwards (for (int i = list.Count - 1; i >= 0; i--))! If you use foreach and try to call _bullets.Remove(bullet), C# throws an InvalidOperationException: Collection was modified. Iterating backwards allows safe element removal without memory re-indexing issues.

The Arcade Secret: "Fair Hitboxes" via Inflate

In retro games like Blocked: Pixel Panzer, sprite textures often include transparent margins or antenna spikes. If the player's tank explodes because a bullet touched an empty transparent corner of its texture, the player will feel cheated.

To make collision feel responsive and fair, games use a smaller Hitbox inside the sprite using Rectangle.Inflate:

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. Level 2: Circle-to-Circle Collision (Rotational Immunity)

Rectangles work great for static blocks and grid tiles, but they fail when sprites rotate. When a non-square spaceship rotates in Paint Trek, an axis-aligned bounding box must expand to enclose the spinning corners, resulting in frustrating "phantom collisions" in empty air.

For circular asteroids, homing energy orbs, and rotating spacecraft, Bounding Circles are the ideal solution.

The Square Root Trap

Two circles collide when the distance between their centers is less than or equal to the sum of their radii:

\(\text{Distance}(C_A, C_B) \le r_A + r_B\)

In code, calculating Euclidean distance uses Math.Sqrt (or Vector2.Distance). However, calculating square roots in a loop with 200 projectiles burns hundreds of unnecessary CPU cycles!

By comparing the squared distance against the squared radius sum, we completely eliminate the square root:

\(\text{DistanceSquared} \le (r_A + r_B)^2\)

MonoGame Implementation: Circle vs. 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);
    }
}

Now integrate this directly into an entity:

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);
    }
}

Zero square roots, immune to sprite rotation, and lightning-fast.


4. Level 3: Mixed Shapes – Circle vs. Box (MathHelper.Clamp)

What happens when a circular spaceship in Paint Trek navigates through a tight maze of rectangular defense barriers, or when a round bullet hits a square block in Blocked: Pixel Panzer?

We need Circle vs. Rectangle collision.

The Clamping Algorithm

The strategy is to find the point on the rectangle that is closest to the circle's center, and then test whether the distance from that closest point to the center is less than the circle's radius.

MonoGame's MathHelper.Clamp makes this trivial:

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);
    }
}

You can now test player shields against rectangular bricks with zero allocations and high accuracy!


5. Level 4: Continuous Collision Detection (CCD) & Swept Rays

Have you ever fired a hyper-velocity sniper round or a railgun laser in your game, only to watch the bullet magically pass straight through a thin enemy ship without dealing damage?

This bug is known as tunneling.

Because discrete games update in time steps (\(\Delta t = 16.6\text{ms}\) at 60 FPS), an object moving at 1,800 pixels per second travels 30 pixels in a single frame. If the enemy hull is only 15 pixels thick, the bullet was in front of the enemy on Frame 1, and completely behind the enemy on Frame 2.

Frame 1:  [ Bullet ]  --->       | Enemy Wall |
Frame 2:                          | Enemy Wall |       --->  [ Bullet ]
                               (NO HIT DETECTED!)

The Solution: Swept Segment vs. Box (Slab Method)

Instead of testing a single point, we test the entire line segment connecting the bullet's position on Frame 1 (previousPosition) to Frame 2 (currentPosition).

Here is the production raycasting slab-intersection method from our companion title 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;
    }
}

In Blocked: Pixel Panzer, the player's continuous Laser Beam skill uses this exact raycast to slice through rows of descending blocks without missing a single collision.


6. Level 5: Production-Grade Pixel-Perfect Collision Detection

Now we reach the ultimate level of 2D accuracy: Pixel-Perfect Collision.

In a retro tank shooter or spaceship dogfight, irregular shapes (tank barrels, wings, cockpit cockpits) are surrounded by transparent pixels in the sprite texture. When an enemy missile hits that transparent space, players notice immediately.

Pixel-perfect collision inspects the actual alpha (transparency) channels of the overlapping textures. If two non-transparent pixels overlap at the same world coordinate, a true physical hit has occurred.

The Fatal Mistake: GetData inside Update()

Many tutorials instruct beginners to do this:

// DO NOT DO THIS!
Color[] dataA = new Color[textureA.Width * textureA.Height];
textureA.GetData(dataA); // STALLS GPU, CREATES MASSIVE GC LAG!

Calling Texture2D.GetData() during runtime forces the CPU to wait for the GPU pipeline to flush, pulling texture memory back over the bus. Doing this for dozens of sprites every frame will tank your game from 60 FPS to 5 FPS and trigger Android ANRs immediately!

The Battle-Tested Solution from Blocked: Pixel Panzer

In Blocked: Pixel Panzer, our Sprite.cs implements an ultra-optimized IntersectsPixel routine built around 5 crucial rules:

  1. Static Color Array Cache: GetData is called exactly once when the texture loads, and stored in a Dictionary<Texture2D, Color[]>.
  2. AABB Early Exit Guard: If Bounds.Intersects(other.Bounds) is false, we bail out immediately. 99% of checks are eliminated before inspecting a single pixel.
  3. Texture Atlas & SourceRectangle Support: Handles sprites packed into texture atlases using source rectangle offsets.
  4. Calculated Overlap Window: We only loop over the exact intersection rectangle between both sprites (Math.Max(a.Top, b.Top), etc.).
  5. Alpha Short-Circuiting: If Sprite A's pixel is transparent (A <= 20), Sprite B is completely skipped.
  6. Property Hoisting: Accessing virtual properties (Bounds) in nested loops creates thousands of struct copies. We store them in local stack variables before entering the loop.

Here is the complete, production-ready code:

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;
    }
}

This routine executes in under 0.1 milliseconds on modern Android hardware and desktop systems.


7. Level 6: Broad-Phase Spatial Grid & Zero-Allocation Mobile GC

Imagine your game has 100 blocks, 60 bullets, and 20 enemies. If you test every entity against every other entity using nested for loops, you perform:

\(\frac{180 \times 179}{2} = 16,110 \text{ checks per frame!}\)

At 60 FPS, that is nearly 1,000,000 collision checks every second.

To scale your game, you must implement Broad-Phase Spatial Partitioning.

The Uniform Spatial Hash Grid

We divide the screen into a 2D grid of uniform cells (in Blocked: Pixel Panzer, each cell is \(150 \times 150\) pixels). An entity only tests collisions against other entities that reside in the same grid cells.

The Mobile Memory Problem: GC Thrashing

If your SpatialGrid creates new List<Point>() or new List<Block>() every frame, you allocate megabytes of garbage every minute on the managed heap.

On Android's Mono runtime, this triggers frequent Gen-0 Garbage Collections, causing: mono runtime: Native lock contention (mono_class_is_subclass_)

The game stutters and Google Play flags your game with ANR warnings!

The Solution: Reusable Scratch Buffers

Here is the Zero-Allocation Spatial Hash Grid from CollisionManager.cs in Blocked: Pixel Panzer:

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);
                            }
                        }
                    }
                }
            }
        }
    }
}

By reusing _scratchCells and _scratchCheckedEntities, the entire collision step runs with 0 bytes of heap allocation per frame.


8. Summary Comparison Table

Technique Mathematical Cost Rotational Support Tunneling Safe? Best Use Case in MonoGame
AABB (Rectangle.Intersects) Ultra-Fast (~4 integer comparisons) No No Grid blocks, bullet broad-phase, UI
Circle (DistanceSquared) Very Fast (3 muls, 0 sqrt) Yes (Invariant) No Round ships, fireballs, energy orbs
Circle vs Box (Clamp) Fast (Local MathHelper.Clamp) Yes No Circular player navigating tight walls
Swept Ray (Slab CCD) Moderate (Parametric Raycast) Yes Yes Fast sniper bullets, railguns, lasers
Pixel-Perfect (IntersectsPixel) Selective (Restricted Sub-rect) Yes No Irregular sprite contours, fair hitboxes
Spatial Grid (Zero-GC) Broad-phase (\(O(N^2) \to O(N)\)) N/A N/A Dense waves, bullet hells, Android 60 FPS

Real-World Production Showcases: Arar Games

These collision architectures are not theoretical experiments—they are the real engineering foundation powering our commercially released titles:

  • Blocked: Pixel Panzer: Our retro tank survival arcade game on Google Play and Microsoft Store. It features our complete two-tier collision system: a zero-allocation spatial hash grid filtering falling blocks, and pixel-perfect contact checking for tanks, fighter jets, turret shells, and ColorWheel elemental ammunition.
  • Paint Trek: Our fast-paced space shooter featuring rotational circle collision, continuous raycasting, and swept-volume missile defense systems.

Conclusion & Next Steps

MonoGame gives you the power to design collision detection that perfectly matches the needs of your game. By gating expensive Pixel-Perfect checks behind fast AABB tests, utilizing Squared Distances for circles, and eliminating garbage collection with Reusable Scratch Buffers, you can deliver console-smooth 60/120 FPS performance on both desktop and mobile platforms.

Check out our games on the app stores to see these collision systems in action, and start implementing these patterns in your own MonoGame projects today!



SEO Keywords & Hashtags

Keywords: MonoGame 2D collision detection, C# game development, Rectangle.Intersects MonoGame, pixel perfect collision C#, spatial hash grid MonoGame, swept ray continuous collision, indie game performance optimization, zero allocation game loop, Android MonoGame optimization, Arar Games, Blocked Pixel Panzer, Paint Trek.

#MonoGame #CSharp #GameDev #IndieDev #GamePhysics #DotNet #2DGameDev #MobileGameDev #PerformanceOptimization #CleanCode #BlockedPixelPanzer #PaintTrek #ArarGames #GameProgramming

MonoGame’de 2D Çarpışma Tespiti: Basit Dikdörtgenlerden Sıfır-GC Piksel-Hassasiyetine

MonoGame’de 2D Çarpışma Tespiti: Basit Dikdörtgenlerden Sıfır-GC Piksel-Hassasiyetine

Unity veya Godot gibi görsel oyun motorlarında 2D oyun geliştirirken çarpışma tespiti (collision detection) genellikle arayüzdeki birkaç onay kutusundan ibarettir: Nesneye bir BoxCollider2D veya CircleCollider2D eklersiniz, bir Rigidbody iliştirirsiniz ve arka plandaki fizik motorunun mobil cihazlarda takılma yapmamasını umarsınız.

Oysa MonoGame ve C# dünyasında kontrolün tamamı sizin elinizdedir. Gizli bir fizik motoru yükü, istenmeyen dönme sürtünmesi ve kare bütçenizi çalan gizemli çöp toplayıcı (Garbage Collection - GC) takılmalarıyla uğraşmazsınız.

Arar Games olarak Blocked: Pixel Panzer ve Paint Trek oyunlarımızı geliştirirken, arcade oyun döngümüzün hem Windows PC'de hem de Android mobil cihazlarda saniyede 60 ila 120 FPS hızında yüzlerce yüksek hızlı düşman mermisini, patlayan tuğla ızgaralarını, dönen tank taretlerini, savaş uçaklarını ve koruyucu enerji kalkanlarını işlemesi gerekiyordu. Genel amaçlı ağır fizik kütüphaneleri bu hız için hantal kalıyordu; bu yüzden amaca özel katmanlı bir çarpışma mimarisi inşa ettik.

Bu rehberde, lafı uzatmadan doğrudan çalışan kodlarla ilerleyeceğiz: En basit MonoGame çarpışmasından (Rectangle.Intersects ile somut Bullet ve Enemy sprite nesneleri) başlayacak; daire kontrolleri, karma şekil kırpmaları, mermilerin duvardan geçmesini önleyen raycast çözümleri, üretim seviyesinde Pixel-Perfect (Piksel-Hassas) çarpışma ve Android ANR kilitlenmelerini bitiren Sıfır-Tahsisli Uzamsal Izgara (Spatial Hash Grid) mimarisine kadar adım adım kodlayacağız.


1. Temel Yapı: Temiz Bir MonoGame Sprite Hiyerarşisi

Çarpışma tespiti yapabilmek için önce ekranda hareket eden temiz varlıklara ihtiyacımız var. MonoGame'de bir nesne temel olarak bir konuma (Vector2), bir dokuya (Texture2D) ve bir sınırlayıcı dikdörtgene (Rectangle) sahiptir.

İşte oyunlarımızda temel aldığımız taban sınıfı:

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);
    }
}

Şimdi bu tabandan türeyen somut Player, Enemy ve Bullet sınıflarımızı oluşturalım:

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;
    }
}

Bu temel sınıflarımız hazır olduğuna göre, aralarındaki çarpışmaları en basitten en karmaşığa doğru test etmeye başlayalım.


2. Kademe 1: En Basit Çarpışma – Rectangle.Intersects (AABB)

MonoGame’de en temel ve en hızlı 2D çarpışma testi Axis-Aligned Bounding Box (AABB) yöntemidir. "Eksenle hizalı" ifadesi, dikdörtgenin kenarlarının ekranın Kartezyen \(X\) ve \(Y\) eksenlerine her zaman paralel olduğu (hiçbir rotasyon içermediği) anlamına gelir.

MonoGame bunun için son derece hızlı bir yerleşik metoda sahiptir: Rectangle.Intersects(Rectangle value).

Rectangle.Intersects Arka Planda Nasıl Çalışır?

MonoGame kaynak kodunda bu metot tam olarak dört tamsayı karşılaştırması yapar:

public bool Intersects(Rectangle value)
{
    return value.Left < this.Right &&
           this.Left < value.Right &&
           value.Top < this.Bottom &&
           this.Top < value.Bottom;
}

Dört koşul da sağlanıyorsa iki dikdörtgen kesişiyordur. Tek bir koşul bile bozulursa dikdörtgenler arasında boş bir eksen vardır ve çarpışma imkansızdır.

Gerçek Oyun Döngüsü: Game1.Update İçinde Mermi ve Düşman Kontrolü

Aktif mermiler ve düşmanlar arasındaki çarpışmayı ana Update döngüsünde şu şekilde kontrol ederiz:

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);
    }
}

Performans İpucu: Listeleri geriye doğru (for (int i = list.Count - 1; i >= 0; i--)) taradığımıza dikkat edin! foreach döngüsü içinde _bullets.Remove(bullet) çağırmaya çalışırsanız C# derhal InvalidOperationException: Collection was modified hatası fırlatır. Sondan başa doğru döngü kurmak, liste elemanlarını bellek indeksi kayması olmadan güvenle silmenizi sağlar.

Arcade Sırrı: Inflate ile "Adil Hitbox" Tasarımı

Blocked: Pixel Panzer gibi retro arcade oyunlarında sprite dokularının kenarlarında genellikle boş saydam pikseller veya anten çıkıntıları bulunur. Eğer oyuncunun tankı merminin o boş saydam piksele değmesiyle patlarsa oyuncu haksızlığa uğradığını hisseder.

Çarpışmayı oyuncuya karşı adil ve pürüzsüz hissettirmek için Rectangle.Inflate kullanarak hitbox'ı sprite dokusundan birkaç piksel içeri çekeriz:

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. Kademe 2: Daire-Daire Çarpışması (Dönme Bağışıklığı)

Dikdörtgenler sabit bloklar için harikadır; ancak sprite'lar dönmeye başladığında sınırlayıcı dikdörtgen köşeleri kapsamak için devasa boyutlara genişler. Bu da boş havada mermi çarpmış gibi algılanan "hayalet temaslara" (phantom collisions) neden olur.

Paint Trek'teki dönen uzay gemileri, dairesel asteroitler ve küresel enerji kalkanları için Sınırlayıcı Daireler (Bounding Circles) kusursuz çözümdür.

Karekök Tuzağı

İki daire, merkezleri arasındaki mesafe yarıçaplarının toplamından küçük veya eşitse çarpışır:

\(\text{Distance}(C_A, C_B) \le r_A + r_B\)

Kod yazarken iki nokta arasındaki mesafeyi bulmak için Math.Sqrt ya da Vector2.Distance kullanılır. Ancak ekranda 200 mermi varken her karede yüzlerce kez karekök hesaplamak işlemciyi fuzuli yere yorar!

Eşitsizliğin her iki tarafının karesini alarak karekök alma maliyetinden tamamen kurtuluruz:

\(\text{DistanceSquared} \le (r_A + r_B)^2\)

MonoGame Uygulaması: Daire ve Daire Kesişimi

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);
    }
}

Bunu bir oyun nesnesine entegre etmek son derece kolaydır:

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);
    }
}

Sıfır karekök işlemi, sprite rotasyonundan tamamen bağımsız ve inanılmaz derecede hızlı.


4. Kademe 3: Karma Şekiller – Daire ve Dikdörtgen (MathHelper.Clamp)

Paint Trek'te dairesel bir uzay gemisi dar koridorlardaki dikdörtgen savunma duvarları arasından geçerken veya Blocked: Pixel Panzer'de yuvarlak bir bomba kare bir bloğa çarptığında ne yapacağız?

Bize Daire vs. Dikdörtgen çarpışması gerekir.

Kırpma (Clamping) Algoritması

Temel mantık; dikdörtgenin yüzeyinde dairenin merkezine en yakın olan noktayı bulmak ve o nokta ile daire merkezi arasındaki karesel mesafenin daire yarıçapının karesinden küçük olup olmadığını kontrol etmektir.

MonoGame'in yerleşik MathHelper.Clamp metodu bunu iki satıra indirir:

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);
    }
}

Böylece tek bir bellek tahsisi yapmadan karmaşık geometriyi çözmüş olursunuz.


5. Kademe 4: Sürekli Çarpışma Tespiti (CCD) ve Işın Kırpma (Raycasting)

Oyununuzda saniyede binlerce piksel hızla uçan bir keskin nişancı mermisi ya da lazer attığınızda, merminin ince bir düşmanın içinden hiçbir hasar vermeden geçip gittiğini gördünüz mü?

Bu hataya oyun geliştirmede Tünelleme (Tunneling) denir.

Oyunlar zaman adımlarıyla güncellendiği için (\(16.6\text{ms}\)), saniyede 1.800 piksel hızla giden bir mermi tek bir karede 30 piksel zıplar. Önündeki düşman gövdesi 15 piksel kalınlığındaysa; mermi 1. Karede düşmanın önündedir, 2. Karede ise arkasına geçmiştir. Ayrık kontroller sırasında düşmanla hiçbir karede kesişmemiştir!

1. Kare:  [ Mermi ]  --->       | Duvar / Düşman |
2. Kare:                        | Duvar / Düşman |       --->  [ Mermi ]
                               (ÇARPIŞMA KAÇIRILDI!)

Çözüm: Süpürülmüş Işın ve Kutu Kesişimi (Slab Yöntemi)

Tek bir nokta yerine merminin önceki karedeki konumu ile bu karedeki konumu arasına çekilen doğru parçasını (ışın) hedef kutunun paralel düzlemleriyle kesiştiririz:

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 oyunundaki Lazer Işını Becerisi tam olarak bu algoritmayı kullanarak yukarıdan inen blok dizisini anında keser ve tünelleme hatası yaşamaz.


6. Kademe 5: Üretim Standardında Pixel-Perfect (Piksel-Hassas) Çarpışma

Şimdi 2D hassasiyetin zirvesine geliyoruz: Pixel-Perfect Çarpışma.

Düzensiz açılara sahip bir tank namlusu veya kanatlı bir uzay gemisi düşünün. Dikdörtgenler dokunun köşelerindeki şeffaf alanları da içine alır. Gerçek bir isabet olup olmadığını anlamak için örtüşen piksellerin alfa (saydamlık) değerlerini taramamız gerekir.

Ölümcül Hata: Update() İçinde GetData Çağırmak

İnternetteki pek çok başlangıç rehberi şu feci hatayı yapar:

// KESİNLİKLE YAPMAYIN!
Color[] dataA = new Color[textureA.Width * textureA.Height];
textureA.GetData(dataA); // GPU HATTINI KİLİTLER, GC KASILMALARI YARATIR!

Oyun döngüsü içinde Texture2D.GetData() çağırmak CPU'yu GPU hattını beklemeye zorlar. Bunu her karede onlarca nesne için yapmak FPS'inizi 60'tan 5'e düşürür ve Android'de doğrudan çökmelere neden olur!

Blocked: Pixel Panzer Üretim Çözümü

Blocked: Pixel Panzer oyunumuzun Sprite.cs dosyasındaki IntersectsPixel metodu beş katı optimizasyon kuralına göre çalışır:

  1. Statik Renk Dizisi Önbelleği: GetData doku yüklendiğinde yalnızca bir kez çağrılır ve Dictionary<Texture2D, Color[]> içinde saklanır.
  2. AABB Erken Çıkış Muhafızı: Eğer Bounds.Intersects(other.Bounds) yanlışsa tek bir piksel bile taranmadan anında çıkılır (kontrollerin %99'u burada elenir).
  3. SpriteSheet / SourceRectangle Desteği: Tek bir büyük sprite atlasında yer alan alt-dokuların ofsetlerini kusursuz eşler.
  4. Sınırlandırılmış Kesişim Penceresi: Tüm doku yerine yalnızca iki dikdörtgenin kesiştiği dar pencere taranır (Math.Max(a.Top, b.Top) vb.).
  5. Alfa Erken Çıkışı (Early Bailout): Eğer Sprite A'nın pikseli şeffafsa (A <= 20), Sprite B'nin pikseli hiç taranmaz.
  6. Özellik (Property) Kaldırma: Bounds gibi struct döndüren özellikleri iç döngüde çağırmak binlerce gereksiz kopyalama yaratır; bunlar döngü başında yerel değişkenlere alınır.

İşte tam çalışan üretim kodu:

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;
    }
}

Bu metot, en yoğun sahnelerde bile Android telefonlarda ve Windows cihazlarında milisaniyenin onda biri sürede kusursuz çalışır.


7. Kademe 6: Uzamsal Izgara (Spatial Grid) ve Sıfır-Tahsisli Mobil GC Mimarisi

Sahnede 100 blok, 60 mermi ve 20 düşman olduğunda iç içe for döngüleriyle her nesneyi birbiriyle karşılaştırırsanız:

\(\frac{180 \times 179}{2} = \text{Kare başına } 16.110 \text{ kontrol!}\)

60 FPS hızında bu, saniyede yaklaşık 1.000.000 çarpışma kontrolü demektir.

Bunu çözmek için ekranı sanal ızgara hücrelerine böleriz (Blocked: Pixel Panzer'de her hücre \(150\times 150\) pikseldir). Bir nesne yalnızca aynı ızgara hücresinde bulunan diğer nesnelerle test edilir.

Android ve Mobil Bellek Kâbusu: GC Thrashing

Eğer her karede new List<Point>() veya new List<Sprite>() oluşturursanız, yönetilen belleğe dakikada megabaytlarca çöp yığarsınız.

Android Mono çalışma zamanında bu durum sürekli çöp toplayıcıyı (GC) tetikler ve şu meşhur kilitlenmeye yol açar: mono runtime: Native lock contention (mono_class_is_subclass_)

Oyun takılır, kare düşer ve Google Play konsolunda ANR (Uygulama Yanıt Vermiyor) uyarıları patlar!

Çözüm: Sıfır-Tahsisli Tamponlar (Scratch Buffers)

İşte Blocked: Pixel Panzer'in CollisionManager.cs dosyasında uyguladığımız Sıfır-Tahsisli Uzamsal Izgara:

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 ve _scratchCheckedEntities listeleri tekrar tekrar temizlenip kullanılarak çarpışma adımı kare başına tam 0 bayt bellek tahsisiyle tamamlanır.


8. Özet Karşılaştırma Tablosu

Teknik Matematiksel Maliyet Rotasyon Desteği Tünelleme Koruması MonoGame'de En Uygun Kullanım
AABB (Rectangle.Intersects) Ultra-Hızlı (~4 tamsayı kontrolü) Yok Yok Izgara blokları, mermi erken kontrolü, UI
Daire (DistanceSquared) Çok Hızlı (3 çarpma, 0 karekök) Var (Bağışık) Yok Yuvarlak gemiler, ateş topları, kalkanlar
Daire vs Kutu (Clamp) Hızlı (Yerel MathHelper.Clamp) Var Yok Duvarlar arasında gezen yuvarlak oyuncu
Süpürülmüş Işın (CCD) Orta (Parametrik Doğru Parçası) Var Var Hızlı roketler, kesintisiz lazerler
Pixel-Perfect (IntersectsPixel) Seçici (Yalnızca Kesişen Alan) Var Yok Girintili sprite hatları, adil temaslar
Uzamsal Izgara (Sıfır-GC) Geniş Faz (\(O(N^2) \to O(N)\)) İlgisiz İlgisiz Yoğun dalgalar, Android'de 60 FPS akıcılık

Gerçek Oyunlarımızdan Referanslar: Arar Games

Bu sistemler sadece teorik anlatımlar değildir; mağazalarda yer alan yapımlarımızın kalbidir:

  • Blocked: Pixel Panzer: İki kademeli çarpışma mimarimizin en eksiksiz örneğidir. ColorWheel mühimmatı, dönen oyuncu taretleri, patlayan blok formasyonları ve lazer ışınları; sıfır-tahsisli spatial grid sayesinde Google Play ve Windows'ta 60'tan fazla dilde 60 FPS akıcılıkla çalışır.
  • Paint Trek: Rotasyonel daire-daire çarpışmaları ve süpürülmüş ışın algoritmalarıyla yüksek tempolu retro uzay savaşlarını ve mermi cehennemi kaçış dinamiklerini yönetir.

Sonuç

MonoGame, gereksiz motor yüklerinden arınmış yalın yapısıyla amaca özel yüksek performanslı çarpışma sistemleri kurmanız için mükemmel bir ortam sunar. Ağır Pixel-Perfect testlerini hızlı AABB filtrelerinin arkasına gizleyerek, Daire matematiğinde kareköklerden kaçınarak ve Sıfır-Tahsisli Uzamsal Izgaralar ile bellek temizleyicisini rahatlatarak her cihazda akıcı ve pürüzsüz oyunlar inşa edebilirsiniz.

Siz MonoGame projelerinizde hangi çarpışma zorluklarıyla karşılaştınız? Mağaza bağlantılarından oyunlarımızı inceleyebilir ve deneyimlerinizi bizimle paylaşabilirsiniz!


Mağaza Bağlantıları ve Kaynaklar


SEO Anahtar Kelimeleri ve Etiketler

Anahtar Kelimeler: MonoGame 2D çarpışma tespiti, C# oyun geliştirme, Rectangle.Intersects MonoGame, pixel perfect collision C#, spatial hash grid MonoGame, sürekli çarpışma tespiti MonoGame, bağımsız oyun performans optimizasyonu, sıfır tahsisli oyun döngüsü, Android MonoGame optimizasyonu, Arar Games, Blocked Pixel Panzer, Paint Trek.

#MonoGame #CSharp #GameDev #IndieDev #GamePhysics #DotNet #2DGameDev #MobileGameDev #PerformansOptimizasyonu #CleanCode #BlockedPixelPanzer #PaintTrek #ArarGames #OyunGeliştirme

23 Eylül 2026 Çarşamba

O software morreu? Respondemos com o Paradoxo de Jevons

O software morreu? Respondemos com o Paradoxo de Jevons

A cada poucas décadas, o setor de tecnologia passa por uma onda coletiva de pânico existencial.

Nos anos 1950, críticos diziam que a linguagem assembly desapareceria e os programadores perderiam a utilidade com compiladores de alto nível como Fortran. Nos anos 1990, ferramentas 4GL, ambientes RAD e camadas visuais de banco de dados geraram boatos de que a "engenharia de software estava com os dias contados". Nos anos 2000, o outsourcing no exterior supostamente aniquilaria todas as carreiras locais.

Hoje, a narrativa apocalíptica retorna com força total: "A inteligência artificial generativa escreve código agora. A engenharia de prompt substituirá os desenvolvedores. O software morreu."

Comentaristas preveem escritórios vazios, diplomas de computação inúteis e um futuro automatizado onde a programação simplesmente desaparece.

Eles ignoram uma lei fundamental da economia e da ambição humana descoberta há mais de 160 anos: O Paradoxo de Jevons.

O software não está morrendo. Ele está prestes a viver a maior explosão de demanda da história humana.


1. A lição de 1865: O que é o Paradoxo de Jevons?

Em 1865, o economista inglês William Stanley Jevons observou um fenômeno intrigante no auge da Revolução Industrial.

James Watt havia aprimorado radicalmente a máquina a vapor, diminuindo expressivamente a quantidade de carvão necessária para gerar uma unidade de trabalho mecânico. O consenso da época previa que, como as máquinas consumiam muito menos carvão, a demanda nacional britânica iria despencar.

Aconteceu exatamente o contrário.

graph TD
    A["Avanço Tecnológico (Motor de Watt / IA Generativa)"] --> B["Custo e Esforço por Unidade Despencam"]
    B --> C["Viabilidade Econômica Multiplicada por 100"]
    C --> D["Explosão de Novas Indústrias, Ideias e Casos de Uso"]
    D --> E["Consumo Total do Recurso Dispara (Paradoxo de Jevons)"]

Como a força a vapor se tornou barata, eficiente e acessível, indústrias que antes jamais teriam condições de utilizá-la — tecelagens, siderúrgicas, ferrovias a vapor, navios transoceânicos — a adotaram em massa. O consumo de carvão não encolheu: ele explodiu exponencialmente.

A Lei Central do Paradoxo de Jevons:
Quando o progresso tecnológico eleva a eficiência com que um recurso é utilizado, a demanda elástica faz com que o consumo total desse recurso cresça enormemente em vez de diminuir.

No século XXI, o código de software é o novo carvão. E a IA é o motor de James Watt.


2. A realidade individual: Noites sem dormir e a fome de criar

Quem decreta o fim da programação supõe ingenuamente que a ambição e a criatividade têm um "teto fixo". Imaginam um desenvolvedor dizendo:
"Meu assistente de IA concluiu minha tarefa diária às 11h da manhã. Vou fechar o notebook e não fazer nada pelo resto da vida."

Isso já aconteceu com algum criador de verdade? Nunca.

Pense no seu próprio cotidiano no último ano. Quando você incorporou copilotos inteligentes e agentes de código ao seu fluxo, o que realmente aconteceu?

Seu desejo de construir diminuiu? Você trabalhou menos?

Muito pelo contrário: ao ver sua capacidade se multiplicar, uma paixão criativa tão intensa foi despertada que você passou noites sem dormir construindo sem parar.

flowchart LR
    Atrito["Alto Atrito & Avanço Lento
(Velho Paradigma)"] -.-> Cansaco["Exaustão & Ideias Abandonadas"]
    IA["Velocidade Potencializada pela IA
(Novo Paradigma)"] --> Fluxo["Retorno Imediato & Estado de Flow"]
    Fluxo --> Ambicao["Ambição Sem Precedentes
(Noites em claro criando novos sistemas)"]

Quando o atrito entre uma ideia na mente e um executável funcionando na tela cai para quase zero, isso não gera preguiça; gera uma euforia criativa avassaladora.

De repente, aquela mecânica de jogo que levaria meses fica pronta às 2h da manhã. Você olha para o relógio, são 4h30 da madrugada e, em vez de dormir, pensa:
"Se fiz isso em duas horas, por que não criar também um sistema de clima dinâmico? Por que não traduzir o jogo para 64 idiomas? Por que não erguer uma infraestrutura multiplayer?"

Quando as ferramentas expandem a capacidade humana, a ambição humana se expande para consumir essa capacidade. Os desenvolvedores não vão para casa; eles elevam a régua do que uma única pessoa é capaz de inventar.


3. A realidade corporativa: O backlog infinito que nunca morre

Agora olhe pelo ponto de vista da diretoria e da liderança corporativa.

Comentaristas cínicos dizem que os executivos demitirão 80% do time de engenharia e deixarão poucos funcionários apenas "digitando prompts".

Quem diz isso nunca pisou numa reunião de roadmap ou de planejamento orçamentário.

Toda empresa do planeta possui um backlog infinito de iniciativas de TI paradas. Em quadros do Jira e planilhas acumulam-se projetos marcados como "Adiado: Falta de capacidade da engenharia": automações internas, portais de clientes, aplicativos móveis, pipelines de dados e auditorias de segurança.

sequenceDiagram
    participant Gestao as Liderança / Diretoria
    participant Time as Time de Engenharia
    participant IA as Ferramentas com IA

    Note over Gestao,Time: Cenário Antigo: 10 Projetos Desejados, Orçamento para 2
    Gestao->>Time: "Podemos construir essas 10 ferramentas internas?"
    Time-->>Gestao: "Só temos braço para 2 neste ano."

    Note over Gestao,Time: A Realidade de Jevons com IA
    Time->>IA: Multiplica a entrega em 5x
    Time->>Gestao: "Entregamos esses 2 projetos em 2 meses!"
    Gestao->>Time: "Incrível! Desengavetem os outros 8 do backlog e adicionem mais 15 integrações!"

Quando a gestão percebe que seu time de 5 engenheiros produz cinco vezes mais rápido com IA, o que a diretoria faz?

  1. Demite 4 desenvolvedores e mantém o ritmo baixo de entregas?
  2. Ou tira da gaveta os 40 projetos pendentes para atropelar a concorrência?

No mercado competitivo, a resposta é sempre a opção 2.

A gestão não vai demitir o time; ela vai exigir uma produção muito maior. Vai acelerar os ciclos de lançamento e modernizar sistemas inteiros. O ritmo não vai diminuir; vai se intensificar.

Os desenvolvedores não ficarão ociosos: mal terão tempo de tirar os olhos da tela, orquestrando agentes autônomos, arquitetando sistemas distribuídos complexos e entregando software em velocidades antes inimagináveis.


4. O software não está morrendo; está mudando de estado físico

O mito do "fim do software" confunde digitar sintaxe com fazer verdadeira engenharia de software.

  • Digitar código repetitivo não é engenharia de software.
  • Decorar detalhes de sintaxe não é engenharia de software.
  • Fazer endpoints CRUD básicos repetidamente não é engenharia de software.

Engenharia de software é pensamento sistêmico, arquitetura, modelagem de domínio, gerenciamento de estado, tolerância a falhas, redução de latência e tradução de necessidades humanas em lógica determinística de máquina.

graph LR
    subgraph "Deslocamento do Valor"
        Sintaxe["Digitação de Sintaxe Básica
(Comoditizada pela IA)"]
        Arquitetura["Arquitetura de Sistemas & Domínio
(Valor multiplicado por 100)"]
        Seguranca["Casos Limite, Validação & Segurança
(Julgamento humano indispensável)"]
    end

Quando os compiladores tornaram o assembly manual dispensável, o assembly "morreu", mas a indústria de software cresceu mil vezes. Quando C# e Python automatizaram a alocação de memória, o manuseio direto de ponteiros "morreu", mas milhões de novos desenvolvedores ingressaram no setor.

A IA é apenas a próxima camada de abstração. O engenheiro humano deixa de ser quem assenta tijolos e passa a ser o arquiteto-chefe e mestre de obras.


5. O veredito: O renascimento do construtor incansável

O software não morreu. O mundo nunca teve tanta sede de software de excelência.

Do celular no bolso aos veículos autônomos, aparelhos médicos e fluxos corporativos, tudo necessita de código mais inteligente, robusto e elegante.

À medida que o custo marginal de produzir software caminha para zero, o volume total consumido pelo mundo caminhará para o infinito.

A todos os desenvolvedores que criam até tarde da noite em frente às suas telas:

  • Não tenham medo das ferramentas.
  • Não chorem pela sintaxe de ontem.
  • Usem esse fogo criativo.

Os profissionais que abraçarem essa transformação — que pensarem grande, desenharem arquiteturas profundas e nunca pararem de construir — não serão substituídos. Eles construirão o amanhã numa velocidade sem precedentes na história.


Conheça os Jogos da Arar Games

Veja a nossa engenharia e visão de design em ação no PC e no celular:

🎮 Blocked: Pixel Panzer

Um shooter de tanques retrô 2D dinâmico, com mecânica de munição ColorWheel, confrontos intensos contra chefes, 9 habilidades de combate aprimoráveis e suporte completo para mais de 63 idiomas.

🚀 Paint Trek

Um frenético jogo de tiro espacial 2D combinando os reflexos do arcade clássico com a performance do .NET 9, empacotamento MSIX completo e design de fases artesanal.


#monogame #blockedpixelpanzer #firebase #painttrek #arargames #jevonssparadox #ai #softwareengineering #dotnet #csharp #indiedev #gamedev #mayhemco