27 Eylül 2026 Pazar

การตรวจจับการชนกันแบบ 2 มิติใน MonoGame: จากสี่เหลี่ยมพื้นฐานไปจนถึงระบบที่สมบูรณ์แบบของพิกเซลที่มีการจัดสรรเป็นศูนย์




การตรวจจับการชนกันของ 2D ใน MonoGame: จากสี่เหลี่ยมพื้นฐานไปจนถึงระบบที่สมบูรณ์แบบของพิกเซลที่มีการจัดสรรเป็นศูนย์

เมื่อสร้างเกม 2 มิติด้วยเอ็นจิ้นภาพ เช่น Unity หรือ Godot การตรวจจับการชนมักจะรู้สึกเหมือนเป็นชุดของช่องตรวจสอบ: คุณตบ BoxCollider2D หรือ CircleCollider2D แนบ Rigidbody และหวังว่าขั้นตอนทางฟิสิกส์ภายในจะไม่กระตุกบนอุปกรณ์มือถือ

อย่างไรก็ตาม ใน MonoGame และ C# คุณเป็นผู้ควบคุมทั้งหมด ไม่มีค่าใช้จ่ายด้านฟิสิกส์ที่ซ่อนอยู่ ไม่มีความเฉื่อยในการหมุนที่ไม่ต้องการ และไม่มีการเก็บขยะลึกลับ (GC) ที่เพิ่มขึ้นอย่างรวดเร็วเพื่อขโมยงบประมาณเฟรมของคุณ

ที่ Arar Games เมื่อเราสร้าง Blocked: Pixel Panzer และ Paint Trek เกมอาร์เคดของเราจำเป็นต้องประมวลผลกระสุนศัตรูความเร็วสูงหลายร้อยนัด ตะแกรงอิฐที่ระเบิด ป้อมรถถังที่หมุนได้ เครื่องบินขับไล่ไอพ่นบินผ่าน และเกราะป้องกันอนุภาคที่ 60 ถึง 120 FPS ทั้งบนพีซี Windows และอุปกรณ์ Android เครื่องยนต์ฟิสิกส์อเนกประสงค์ไม่เป็นปัญหา เราต้องการสถาปัตยกรรมการชนกันแบบเป็นชั้นที่สร้างขึ้นตามจุดประสงค์

ในคู่มือที่ครอบคลุมและขับเคลื่อนด้วยโค้ดนี้ เราจะเริ่มต้นจากพื้นฐานที่แท้จริงของการชนกันของ 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)

การตรวจสอบการชนกันแบบ 2 มิติขั้นพื้นฐานที่สุดใน MonoGame คือการทดสอบ Axis-Aligned Bounding Box (AABB) คำว่า "จัดแนวแกน" หมายความง่ายๆ ว่าขอบของสี่เหลี่ยมผืนผ้าขนานกับแกน \(X\) และ \(Y\) ของหน้าจอ (ไม่มีการหมุน)

MonoGame มีวิธีการในตัวที่รวดเร็ว: Rectangle.Intersects(Rectangle value)

วิธีการทำงานของ สี่เหลี่ยมตัดกัน ภายใต้ประทุน

ภายใต้พื้นผิว MonoGame ดำเนินการเปรียบเทียบจำนวนเต็มสี่ค่า:

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

หากตรงตามเงื่อนไขทั้งสี่ สี่เหลี่ยมจะทับซ้อนกัน หากเงื่อนไขใดเงื่อนไขหนึ่งล้มเหลว แกนว่างก็จะแยกออกจากกัน และจะไม่มีการชนกัน

รหัสการเล่นเกมจริง: Bullet กับ Enemy ใน Game1.Update

ต่อไปนี้เป็นวิธีตรวจสอบการชนกันระหว่างรายการกระสุนที่ทำงานอยู่และรายชื่อศัตรูที่ทำงานอยู่ในลูป MonoGame Update หลักของคุณ:

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: Collection was modified การทำซ้ำแบบย้อนกลับช่วยให้สามารถลบองค์ประกอบได้อย่างปลอดภัยโดยไม่มีปัญหาในการจัดทำดัชนีหน่วยความจำใหม่

ความลับของอาร์เคด: "Hitboxes ที่ยุติธรรม" ผ่าน Inflate

ในเกมย้อนยุคเช่น Blocked: Pixel Panzer พื้นผิวสไปรท์มักจะมีขอบโปร่งใสหรือหนามแหลมของเสาอากาศ หากรถถังของผู้เล่นระเบิดเพราะกระสุนสัมผัสกับมุมโปร่งใสของพื้นผิว ผู้เล่นจะรู้สึกว่าถูกโกง

เพื่อให้การชนรู้สึกตอบสนองและยุติธรรม เกมจะใช้ Hitbox ที่เล็กกว่าภายในสไปรต์โดยใช้ 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. ระดับ 2: การชนแบบวงกลมต่อวงกลม (ภูมิคุ้มกันแบบหมุน)

สี่เหลี่ยมใช้งานได้ดีกับบล็อกคงที่และไทล์กริด แต่จะล้มเหลวเมื่อสไปรท์หมุน เมื่อยานอวกาศที่ไม่ใช่สี่เหลี่ยมจัตุรัสหมุนใน Paint Trek กล่องขอบเขตที่จัดแนวแกนจะต้องขยายเพื่อปิดมุมที่หมุนอยู่ ส่งผลให้เกิด "การชนกันของภาพลวงตา" ที่น่าหงุดหงิดในอากาศว่างเปล่า

สำหรับดาวเคราะห์น้อยทรงกลม ลูกกลมพลังงานกลับบ้าน และยานอวกาศที่หมุนได้ Bounding Circles ถือเป็นทางออกที่ดี

กับดักรากที่สอง

วงกลมสองวงชนกันเมื่อระยะห่างระหว่างจุดศูนย์กลางน้อยกว่าหรือเท่ากับผลรวมของรัศมี:

\(\ข้อความ{ระยะทาง}(C_A, C_B) \le r_A + r_B\)

ในโค้ด การคำนวณระยะทางแบบยุคลิดจะใช้ Math.Sqrt (หรือ Vector2.Distance) อย่างไรก็ตาม การคำนวณรากที่สองในลูปด้วยกระสุน 200 นัด จะเผาผลาญรอบ CPU ที่ไม่จำเป็นนับร้อย!

เมื่อเปรียบเทียบ ระยะทางกำลังสอง กับ ผลรวมรัศมีกำลังสอง เราจะกำจัดรากที่สองได้อย่างสมบูรณ์:

\(\ข้อความ{DistanceSquared} \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

เราต้องการการชนกันของ วงกลมกับสี่เหลี่ยมผืนผ้า

อัลกอริธึมการหนีบ

กลยุทธ์คือการหาจุดบนสี่เหลี่ยมที่อยู่ใกล้กับศูนย์กลางของวงกลมมากที่สุด แล้วทดสอบว่าระยะห่างจากจุดที่ใกล้ที่สุดไปยังศูนย์กลางนั้นน้อยกว่ารัศมีของวงกลมหรือไม่

MathHelper.Clamp ของ MonoGame ทำให้สิ่งนี้เป็นเรื่องเล็กน้อย:

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!)

วิธีแก้ปัญหา: Swept Segment vs. Box (วิธี Slab)

แทนที่จะทดสอบจุดเดียว เราจะทดสอบส่วนของเส้นทั้งหมดที่เชื่อมต่อตำแหน่งของสัญลักษณ์แสดงหัวข้อย่อยในเฟรม 1 (previousPosition) กับเฟรม 2 (currentPosition)

ต่อไปนี้เป็นวิธีการผลิต raycasting slab-intersection จากชื่อร่วมของเรา 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 ทักษะ Laser Beam ต่อเนื่องของผู้เล่นจะใช้การฉายรังสีที่แม่นยำนี้เพื่อฟันผ่านแถวของบล็อกจากมากไปน้อยโดยไม่พลาดการชนกันแม้แต่ครั้งเดียว


6. ระดับ 5: การตรวจจับการชนที่สมบูรณ์แบบของพิกเซลระดับการผลิต

ตอนนี้เรามาถึงระดับสูงสุดของความแม่นยำ 2D แล้ว: Pixel-Perfect Collision

ในเกมยิงรถถังย้อนยุคหรือการต่อสู้อุตลุดในยานอวกาศ รูปร่างที่ไม่ปกติ (ถังถัง ปีก ห้องนักบิน) จะถูกล้อมรอบด้วยพิกเซลโปร่งใสในพื้นผิวสไปรท์ เมื่อขีปนาวุธของศัตรูกระทบพื้นที่โปร่งใสนั้น ผู้เล่นจะสังเกตเห็นได้ทันที

การชนกันของพิกเซลที่สมบูรณ์แบบจะตรวจสอบช่องอัลฟา (ความโปร่งใส) ที่แท้จริงของพื้นผิวที่ทับซ้อนกัน หากพิกเซลที่ไม่โปร่งใสสองพิกเซลซ้อนทับกันที่พิกัดโลกเดียวกัน จะเกิดการปะทะทางกายภาพอย่างแท้จริง

ข้อผิดพลาดร้ายแรง: GetData ภายใน Update()

บทเรียนหลายบทแนะนำให้ผู้เริ่มต้นทำสิ่งนี้:

// 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 และทริกเกอร์ ANR ของ Android ทันที!

โซลูชันที่ผ่านการทดสอบการต่อสู้จาก Blocked: Pixel Panzer

ใน Blocked: Pixel Panzer นั้น Sprite.cs ของเราใช้รูทีน IntersectsPixel ที่ได้รับการปรับให้เหมาะสมเป็นพิเศษ ซึ่งสร้างขึ้นตามกฎสำคัญ 5 ข้อ:

  1. แคชอาร์เรย์สีแบบคงที่: GetData จะถูกเรียกหนึ่งครั้งทุกประการเมื่อพื้นผิวโหลด และจัดเก็บไว้ใน Dictionary<Texture2D, Color[]>
  2. AABB Early Exit Guard: หาก Bounds.Intersects(other.Bounds) เป็นเท็จ เราจะประกันตัวออกทันที 99% ของการตรวจสอบจะหมดไปก่อนที่จะตรวจสอบพิกเซลเดียว
  3. รองรับ Atlas พื้นผิวและ SourceRectangle: จัดการสไปรท์ที่อัดแน่นอยู่ใน Atlas พื้นผิวโดยใช้การชดเชยสี่เหลี่ยมของแหล่งที่มา
  4. หน้าต่างที่ทับซ้อนกันจากการคำนวณ: เราจะวนซ้ำเฉพาะสี่เหลี่ยมผืนผ้าที่ตัดกันระหว่างสไปรต์ทั้งสองเท่านั้น (Math.Max(a.Top, b.Top) ฯลฯ)
  5. การลัดวงจรของอัลฟ่า: หากพิกเซลของสไปรท์ A มีความโปร่งใส (A <= 20) สไปรท์ B จะถูกข้ามไปโดยสิ้นเชิง
  6. การยกคุณสมบัติ: การเข้าถึงคุณสมบัติเสมือน (ขอบเขต) ในลูปที่ซ้อนกันจะสร้างสำเนาโครงสร้างนับพันรายการ เราจัดเก็บไว้ในตัวแปรสแต็กในเครื่องก่อนที่จะเข้าสู่ลูป

นี่คือโค้ดที่สมบูรณ์และพร้อมสำหรับการผลิต:

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

รูทีนนี้ดำเนินการภายในเวลาไม่ถึง 0.1 มิลลิวินาทีบนฮาร์ดแวร์และระบบเดสก์ท็อป Android สมัยใหม่


7 ระดับ 6: ตารางเชิงพื้นที่แบบกว้างเฟสและ GC มือถือแบบไม่มีการจัดสรร

ลองนึกภาพเกมของคุณมีบล็อก 100 บล็อก กระสุน 60 นัด และศัตรู 20 ตัว หากคุณทดสอบทุกเอนทิตีกับเอนทิตีอื่น ๆ โดยใช้ลูป for ที่ซ้อนกัน คุณจะดำเนินการ:

\(\frac{180 \times 179}{2} = 16,110 \text{ ตรวจสอบต่อเฟรม!}\)

ที่ 60 FPS นั่นเท่ากับ การตรวจสอบการชนกันเกือบ 1,000,000 ครั้งทุกๆ วินาที

หากต้องการปรับขนาดเกม คุณต้องใช้ การแบ่งพาร์ติชันเชิงพื้นที่แบบกว้าง

ตารางแฮชเชิงพื้นที่ที่สม่ำเสมอ

เราแบ่งหน้าจอออกเป็นตาราง 2 มิติของเซลล์ที่เหมือนกัน (ใน ถูกบล็อก: Pixel Panzer แต่ละเซลล์มีราคา 150 ดอลลาร์ \คูณ 150 ดอลลาร์พิกเซล) เอนทิตีจะทดสอบการชนกับเอนทิตีอื่น ๆ ที่อยู่ในเซลล์กริดเดียวกันเท่านั้น

ปัญหาหน่วยความจำมือถือ: GC Thrashing

หาก SpatialGrid ของคุณสร้าง new List<Point>() หรือ new List<Block>() ทุกเฟรม คุณจะจัดสรรขยะเมกะไบต์ทุกๆ นาทีบนฮีปที่ได้รับการจัดการ

บนรันไทม์แบบโมโนของ Android สิ่งนี้จะทริกเกอร์คอลเลกชันขยะ Gen-0 บ่อยครั้ง ทำให้เกิด: รันไทม์โมโน: การโต้แย้งแบบเนทิฟล็อค (mono_class_is_subclass_)

เกมกระตุกและ Google Play ตั้งค่าสถานะเกมของคุณด้วยคำเตือน ANR!

วิธีแก้ปัญหา: บัฟเฟอร์รอยขีดข่วนแบบใช้ซ้ำได้

นี่คือ Zero-Allocation Spatial Hash Grid จาก CollisionManager.cs ใน 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);
                            }
                        }
                    }
                }
            }
        }
    }
}

เมื่อใช้ _scratchCells และ _scratchCheckedEntities ซ้ำ ขั้นตอนการชนกันทั้งหมดจะทำงานด้วย การจัดสรรฮีป 0 ไบต์ต่อเฟรม


8 ตารางเปรียบเทียบสรุป

เทคนิค ต้นทุนทางคณิตศาสตร์ สนับสนุนการหมุนเวียน การขุดอุโมงค์ปลอดภัยไหม? กรณีการใช้งานที่ดีที่สุดใน MonoGame
AABB (สี่เหลี่ยม ตัดกัน) เร็วมาก (เปรียบเทียบจำนวนเต็ม ~ 4) ไม่ ไม่ บล็อกกริด, หัวข้อย่อยแบบกว้าง, UI
วงกลม (DistanceSquared) เร็วมาก (3 หน่วย, 0 sqrt) ใช่ (ไม่เปลี่ยนแปลง) ไม่ เรือทรงกลม ลูกไฟ ลูกกลมพลังงาน
วงกลมกับกล่อง (แคลมป์) รวดเร็ว (MathHelper.Clamp ในเครื่อง) ใช่ ไม่ ผู้เล่นวงกลมนำทางกำแพงแน่น
Swept Ray (Slab CCD) ปานกลาง (Parametric Raycast) ใช่ ใช่ กระสุนสไนเปอร์เร็ว, ปืนเรลกัน, เลเซอร์
พิกเซลสมบูรณ์แบบ (IntersectsPixel) Selective (Restricted Sub-rect) ใช่ ไม่ รูปทรงสไปรท์ไม่สม่ำเสมอ ฮิตบ็อกซ์ที่ยุติธรรม
ตารางเชิงพื้นที่ (Zero-GC) เฟสกว้าง (\(O(N^2) \to O(N)\)) ไม่มี ไม่มี คลื่นหนาแน่น กระสุนนรก Android 60 FPS

นิทรรศการการผลิตในโลกแห่งความเป็นจริง: Arar Games

สถาปัตยกรรมการชนกันเหล่านี้ไม่ใช่การทดลองทางทฤษฎี แต่เป็นรากฐานทางวิศวกรรมที่แท้จริงที่ขับเคลื่อนเกมที่เผยแพร่ในเชิงพาณิชย์ของเรา:

  • ถูกบล็อก: Pixel Panzer: เกมอาร์เคดเอาชีวิตรอดรถถังย้อนยุคของเราบน Google Play และ Microsoft Store มันมีระบบการชนสองชั้นที่สมบูรณ์ของเรา: ตารางแฮชเชิงพื้นที่ที่จัดสรรเป็นศูนย์เพื่อกรองบล็อกที่ตกลงมา และการตรวจสอบการสัมผัสที่สมบูรณ์แบบด้วยพิกเซลสำหรับรถถัง เครื่องบินรบ กระสุนป้อมปืน และกระสุนองค์ประกอบ ColorWheel
  • Paint Trek: เกมยิงอวกาศที่ดำเนินไปอย่างรวดเร็วของเรามีการชนกันเป็นวงกลม การฉายรังสีอย่างต่อเนื่อง และระบบป้องกันขีปนาวุธกวาดล้าง

บทสรุปและขั้นตอนต่อไป

MonoGame มอบพลังในการออกแบบการตรวจจับการชนที่ตรงกับความต้องการของเกมของคุณอย่างสมบูรณ์แบบ ด้วยการตรวจสอบ Pixel-Perfect ที่มีราคาแพงหลังการทดสอบ AABB ที่รวดเร็ว โดยใช้ Squared Distances สำหรับวงกลม และกำจัดการรวบรวมขยะด้วย Reusable Scratch Buffers คุณสามารถส่งมอบประสิทธิภาพ 60/120 FPS ที่ราบรื่นของคอนโซลทั้งบนแพลตฟอร์มเดสก์ท็อปและมือถือ

ลองเข้าไปดูเกมของเราใน App Store เพื่อดูการทำงานของระบบการชนกัน และเริ่มปรับใช้รูปแบบเหล่านี้ในโปรเจ็กต์ MonoGame ของคุณเองวันนี้!


ลิงค์ร้านค้าและทรัพยากร


คำหลักและแฮชแท็ก SEO

คำสำคัญ: การตรวจจับการชนกันของ MonoGame 2D, การพัฒนาเกม C#, สี่เหลี่ยมผืนผ้า ตัดกัน MonoGame, การชนกันของพิกเซลที่สมบูรณ์แบบ C#, ตารางแฮชเชิงพื้นที่ MonoGame, การชนกันของรังสีแบบกวาดต่อเนื่อง, การเพิ่มประสิทธิภาพเกมอินดี้, ลูปเกมการจัดสรรเป็นศูนย์, การเพิ่มประสิทธิภาพ Android MonoGame, เกม Arar, Pixel Panzer ที่ถูกบล็อก, Paint Trek

#MonoGame #CSharp #GameDev #IndieDev #GamePhysics #DotNet #2DGameDev #MobileGameDev #เพิ่มประสิทธิภาพการทำงาน #CleanCode #BlockedPixelPanzer #PaintTrek #ArarGames #GameProgramming




Hiç yorum yok:

Yorum Gönder