16.1 Entity Component System (ECS)

เฟส 16 · Engine Programming (C++ ลึก, ไม่บังคับ) · เวลาเรียน: 25–40 h

สถาปัตยกรรม data-oriented — entity เป็น ID, component เป็น array ข้อมูลต่อเนื่อง, system เป็นตัวแปลงข้อมูลนั้น

ศัตรูทุกตัวในเกมต้องเคลื่อนที่ได้ โดนตีแล้วเลือดลด และอาจจะโจมตีได้ด้วย วิธีธรรมชาติที่สุดในการออกแบบสิ่งนี้ใน C++ หรือ C# คือใช้ class hierarchy (ลำดับชั้นของคลาส): มี class Enemy เป็นฐาน แล้วมี subclass ที่เพิ่มพฤติกรรมเข้าไป วิธีนี้ใช้ได้ดีจนกระทั่งดีไซเนอร์ขอศัตรูตัวหนึ่งที่บินได้ด้วยและว่ายน้ำได้ด้วย บทนี้เริ่มจากอธิบายว่าทำไมคำของ่าย ๆ แบบนั้นถึงทำลาย inheritance hierarchy ที่ลึกลงไปหลายชั้น จากนั้นพาไปดู composition ในฐานะทางแก้แรก แสดงให้เห็นว่าระบบ component ของ Unity เอง (GameObject + MonoBehaviour) ยังมีจุดอ่อนตรงไหนเมื่อขยายสเกลใหญ่ ๆ และจบด้วยคำตอบแบบ data-oriented ที่สตูดิโอจริง ๆ ใช้กัน: Entity Component System (ECS) คุณจะได้เขียน ECS เล็ก ๆ ที่ใช้งานได้จริงด้วยตัวเองใน C++ ก่อนที่จะไปดูเวอร์ชันโปรดักชันของ Unity คือ DOTS / Entities

รูปแบบเหมือนเดิมทุกบท: โค้ดเล็ก ๆ ที่รันได้จริง แสดง output จริงหรือ trace ที่คำนวณด้วยมือ แล้วอธิบายง่าย ๆ ว่าเกิดอะไรขึ้น บทนี้พึ่งพาความรู้เรื่อง memory layout จากบทที่ 3 อย่างมาก — cache line, Array-of-Structs กับ Struct-of-Arrays — ดังนั้นควรทบทวนบทนั้นให้สดใหม่ในหัวก่อนอ่านบทนี้

1. ปัญหา: Inheritance Hierarchy ที่ลึกเกินไป

เริ่มจากการออกแบบตามธรรมชาติแบบแรกสำหรับศัตรูในเกม มี base class เก็บข้อมูลที่ศัตรูทุกตัวต้องมี แล้ว subclass แต่ละตัวเพิ่มความสามารถในการเคลื่อนที่แบบเฉพาะเข้าไป:

#include <cstdio>

class Enemy {
public:
    virtual void takeDamage(int amount) { health -= amount; }
    virtual ~Enemy() = default;

protected:
    float x = 0.0f, y = 0.0f, z = 0.0f;
    int   health = 100;
};

class FlyingEnemy : public Enemy {
public:
    void fly(float dt) { y += flySpeed * dt; }
protected:
    float flySpeed = 5.0f;
};

class SwimmingEnemy : public Enemy {
public:
    void swim(float dt) { y -= swimSpeed * dt; }
protected:
    float swimSpeed = 3.0f;
};

นี่คือ worked trace ไม่ใช่โปรแกรมที่มี output พิมพ์ออกมา ตอนนี้ดูสมเหตุสมผลดี: ค้างคาวเป็น FlyingEnemy ปลาเป็น SwimmingEnemy แล้วอยู่ ๆ ดีไซเนอร์ก็ขอศัตรูแบบแมลงปอที่บินอยู่เหนือน้ำแล้วดำลงไปว่ายน้ำไล่ตามผู้เล่น มันต้องมีความสามารถทั้งสองอย่าง วิธีที่ตรงไปตรงมาที่สุดใน C++ คือ multiple inheritance:

class FlyingSwimmingEnemy : public FlyingEnemy, public SwimmingEnemy {
public:
    void update(float dt) {
        fly(dt);
        swim(dt);
    }
};

โค้ดนี้ compile ผ่าน แต่มันสร้างปัญหาคลาสสิกที่เรียกว่า diamond problem: FlyingSwimmingEnemy สืบทอดมาจากสอง class ที่ต่างก็สืบทอดมาจาก Enemy อีกที ดังนั้น — ถ้าไม่เติมคำว่า virtual เข้าไปในทั้งสองบรรทัดของการสืบทอด — object ที่ได้จะมีข้อมูลของ Enemy อยู่ สอง ชุดแยกกัน (มี x สองตัว, y สองตัว, health สองตัว) การเรียก takeDamage() จะลดค่าแค่ชุดใดชุดหนึ่งในสองชุดเท่านั้น ทำให้ศัตรูตัวนี้แอบเก็บ health เก่าไว้บางส่วนตลอดไปโดยไม่มีใครรู้ นี่ไม่ใช่ syntax error — มัน compile ผ่านและรันได้ปกติ แต่บั๊กจะโผล่มาในรูปแบบ "ศัตรูตัวนี้ต้องตีสองเท่าถึงจะตาย" ซึ่งมักถูกเจอตอน playtest

Enemy (x, y, z, health) / | FlyingEnemy SwimmingEnemy (+ flySpeed) (+ swimSpeed) | | +----------+-----------+ | FlyingSwimmingEnemy Without "virtual" inheritance, FlyingSwimmingEnemy contains TWO separate Enemy sub-objects - two x, two y, two health - one reached through the FlyingEnemy path, one through SwimmingEnemy. That is the "diamond problem": the diamond shape in the class graph above.

C++ ให้คุณแก้ปัญหาข้อมูลซ้ำได้ด้วย virtual inheritance (class FlyingEnemy : public virtual Enemy) ซึ่งบังคับให้ทั้งสองเส้นทางใช้ Enemy sub-object ตัวเดียวร่วมกัน วิธีนี้กำจัดบั๊กข้อมูลซ้ำไปได้ แต่ไม่ได้แก้ปัญหาที่ลึกกว่านั้น: hierarchy นี้ไม่ scale เพิ่ม BurrowingEnemy กับ WalkingEnemy เข้าไป แล้วดีไซเนอร์ก็ขอศัตรูที่ขุดดินได้ด้วยบินได้ด้วย ศัตรูที่เดินได้ด้วยว่ายน้ำได้ด้วย ศัตรูที่บินได้ ว่ายน้ำได้ ขุดดินได้ทั้งสามอย่าง... ทุกชุดค่าผสมใหม่ต้องเขียน class ใหม่ด้วยมือ หรือไม่ก็ต้องแก้ diamond ของ multiple inheritance อีกชุดหนึ่ง ถ้ามีความสามารถอิสระ N อย่าง จำนวนชุดค่าผสมที่เป็นไปได้จะเพิ่มขึ้นเข้าใกล้ 2^N — การระเบิดของจำนวนชุดค่าผสม (combinatorial explosion) ที่ class hierarchy ไม่เคยถูกออกแบบมาให้รองรับ

ข้อผิดพลาดที่พบบ่อย หยิบ multiple inheritance มาใช้ทุกครั้งที่ object ต้องการความสามารถสองอย่างที่ไม่เกี่ยวข้องกัน มัน compile ผ่าน เลยรู้สึกว่าโอเค แต่ทุกชุดค่าผสมใหม่จะเพิ่ม class ที่เขียนด้วยมือ (หรือ diamond อีกอัน) ให้ต้องมาแก้ปม ทางแก้ที่แท้จริงไม่ใช่ hierarchy ที่ฉลาดขึ้น แต่คือการไม่ใช้ inheritance กับปัญหานี้เลย ซึ่งเป็นเนื้อหาของหัวข้อถัดไป

2. Composition Over Inheritance: ทางแก้

ทางแก้นี้มีชื่อเรียกว่า composition over inheritance แทนที่จะถามว่า "ศัตรูตัวนี้เป็นประเภทอะไร" (ความสัมพันธ์แบบ is-a ซึ่งเป็นสิ่งที่ inheritance จำลองไว้) ให้ถามว่า "ศัตรูตัวนี้มีความสามารถอะไรบ้าง" (ความสัมพันธ์แบบ has-a) แทน ให้แต่ละความสามารถมี struct หรือ class เล็ก ๆ ของตัวเองที่เป็นอิสระ แล้วให้ enemy ถือ instance ของอันไหนก็ได้ที่ต้องการ — เป็นแค่ member data ธรรมดา ไม่ใช่ base class

#include <cstdio>

struct FlyMovement {
    float speed = 5.0f;
    void update(float dt, float& y) { y += speed * dt; }
};

struct SwimMovement {
    float speed = 3.0f;
    void update(float dt, float& y) { y -= speed * dt; }
};

class Enemy {
public:
    void update(float dt) {
        if (flyMovement)  flyMovement->update(dt, y);
        if (swimMovement) swimMovement->update(dt, y);
    }

    float x = 0.0f, y = 0.0f, z = 0.0f;
    int   health = 100;

    FlyMovement*  flyMovement  = nullptr;   // nullptr = "does not have this ability"
    SwimMovement* swimMovement = nullptr;
};

int main() {
    FlyMovement fly;
    SwimMovement swim;

    Enemy dragonfly;
    dragonfly.flyMovement  = &fly;     // has both abilities - no new class needed
    dragonfly.swimMovement = &swim;

    Enemy bat;
    bat.flyMovement = &fly;            // has only one ability

    dragonfly.update(1.0f);
    bat.update(1.0f);

    printf("dragonfly.y = %.1f\n", dragonfly.y);
    printf("bat.y = %.1f\n", bat.y);
}

Output:

dragonfly.y = 2.0
bat.y = 5.0

ค่า y ของแมลงปอเปลี่ยนไป +5.0 - 3.0 = 2.0 — ความสามารถทั้งสองทำงานจริง บวกซ้อนกันไป โดยไม่มี diamond และไม่มี health ซ้ำ มี Enemy class อยู่แค่ class เดียวเท่านั้น ศัตรูที่บินได้-ว่ายน้ำได้-ขุดดินได้ก็แค่เป็น Enemy ที่ตั้งค่า pointer สามตัว แทนที่จะต้องเขียน class ใหม่เฉพาะสำหรับชุดค่าผสมนั้น นี่คือไอเดียเดียวกับหลักการ "has-a" ที่สอนกันในพื้นฐาน OOP เพียงแต่เอามาใช้อย่างตั้งใจเพื่อแก้ปัญหา combinatorial explosion จากหัวข้อที่ 1: หยิบความสามารถจากชั้นวางมาติดเข้าไป แทนที่จะเขียน class ใหม่สำหรับทุกชุดค่าผสม

เคล็ดลับ นี่ไม่ใช่ไอเดียเฉพาะ C++ เท่านั้น ภาษาไหนก็ตามที่มี object สามารถทำ composition ได้: ถือ reference หรือ pointer ไปยัง helper object เล็ก ๆ แทนการสืบทอดจากมัน ระบบ component ทั้งหมดของ Unity ที่จะพูดถึงต่อไป ก็คือไอเดียนี้เอง ที่ถูกฝังไว้ในตัว engine เลย

3. Unity เป็น Component-Based อยู่แล้ว: GameObject + MonoBehaviour

Unity ไม่ได้บังคับให้คุณสร้าง composition ด้วยมือเอง — มันคือแก่นการออกแบบของตัว engine เลย GameObject เปล่า ๆ ตัวหนึ่งแทบจะไม่มีอะไรอยู่ในตัว: มันแค่มีชื่อ, Transform (position, rotation, scale) และ list ของ component ที่แนบอยู่ ทุกความสามารถ — การ render mesh, เล่นเสียง, ชนกับโลก หรือรันโค้ด gameplay ของคุณเอง — ล้วนเป็น component แยกกันที่แนบเข้าไปใน list นั้น โค้ด gameplay ของคุณเองก็คือ subclass ของ MonoBehaviour ซึ่งตัวมันเองก็เป็นแค่ component อีกชนิดหนึ่ง

using UnityEngine;

public class FlyMovement : MonoBehaviour {
    public float speed = 5f;

    void Update() {
        transform.position += Vector3.up * speed * Time.deltaTime;
    }
}

public class SwimMovement : MonoBehaviour {
    public float speed = 3f;

    void Update() {
        transform.position += Vector3.down * speed * Time.deltaTime;
    }
}

การจะสร้างศัตรูแมลงปอ คุณไม่ต้องเขียน class ใหม่เลย: แค่ลากทั้งสอง script ไปแปะบน GameObject ตัวเดียวกันใน Inspector (หรือเรียก gameObject.AddComponent<FlyMovement>() กับ AddComponent<SwimMovement>() จากโค้ดก็ได้) Unity จะเรียก Update() บนทุก MonoBehaviour ที่แนบอยู่ในทุกเฟรม ดังนั้นทั้งสอง script จะรันแยกจากกันอิสระบน object เดียวกัน ค้างคาวก็แค่ได้ script เดียวที่มันต้องการ นี่คือไอเดีย composition จากหัวข้อที่ 2 เป๊ะ ๆ — GameObject ทำหน้าที่เป็น "ชั้นวาง" ของ pointer เพียงแต่ editor ของ Unity ให้ดีไซเนอร์แนบ component ได้ด้วยสายตา โดยไม่ต้องเขียนโค้ดเลยสักบรรทัด

GameObject "Dragonfly" +-------------------------------------+ | Transform (position, rotation) | | MeshRenderer (how it looks) | | FlyMovement (your script) | | SwimMovement (your script) | +-------------------------------------+ GameObject "Bat" +-------------------------------------+ | Transform (position, rotation) | | MeshRenderer (how it looks) | | FlyMovement (your script) | +-------------------------------------+

Component คุยกันเองได้ผ่าน GetComponent<T>() ซึ่งจะค้นหาใน list ของ component ของ GameObject เพื่อหาตัวที่เป็น type T:

using UnityEngine;

public class Health : MonoBehaviour {
    public int hp = 100;
}

public class DamageOnTouch : MonoBehaviour {
    public int amount = 10;

    void OnCollisionEnter(Collision other) {
        Health h = other.gameObject.GetComponent<Health>();
        if (h != null) {
            h.hp -= amount;
        }
    }
}

โค้ดนี้ไม่มี output พิมพ์ออกมาด้วยตัวมันเอง — มันถูกกระตุ้นโดยการชนกันทางฟิสิกส์ (physics collision) สังเกตรูปแบบตรงนี้: ไม่มี inheritance chain ระหว่าง Health กับ DamageOnTouch เลย GameObject ตัวไหนก็ตามที่บังเอิญมี Health component จะโดนตีเลือดได้; ตัวที่ไม่มีก็แค่ได้ null กลับมาแล้วไม่มีอะไรเกิดขึ้น นี่คือระบบ component ของ Unity ที่แก้ปัญหาเดียวกับที่หัวข้อ 1 พูดถึงได้พอดี และเป็นเหตุผลว่าทำไมโปรเจกต์ Unity จริง ๆ แทบไม่เคยสร้าง class hierarchy ที่ลึกของ MonoBehaviour เลย — engine ให้ composition มาฟรี ๆ อยู่แล้ว

4. จุดที่ GameObject + MonoBehaviour ยังมีข้อจำกัด

สำหรับศัตรูหลักสิบหรือหลักร้อยตัว GameObject + MonoBehaviour ทำงานได้ดีเยี่ยม และไม่มีอะไรในบทนี้ที่จะค้านวิธีนี้ ปัญหาจะเริ่มเมื่อสเกลใหญ่มาก ๆ — object ง่าย ๆ หลักหมื่นตัวที่ต้อง update ทุกเฟรม: กระสุนในเกมยิงแบบ bullet-hell, boid แต่ละตัวในฝูง, หน่วยรบในเกม RTS ขนาดใหญ่ ต้นทุนสองอย่างที่มองไม่เห็นตอนสเกลเล็ก จะเริ่มมามีอิทธิพล:

What the CPU actually has to chase through memory, updating 6 enemies' positions one MonoBehaviour.Update() at a time: RAM: [Enemy3]...[Enemy0]......[Enemy5]..[Enemy1]........[Enemy4]..[Enemy2] Update() call order: 0, 1, 2, 3, 4, 5 Memory visit order: scattered - a cache miss almost every single call, because each object landed wherever the heap allocator/GC put it, not next to the object that gets updated right before or after it.

ย้อนกลับไปที่บทที่ 3.1 และ 3.2: CPU จะดึงข้อมูลมาทีละ cache line ขนาด 64 byte เต็ม ๆ และมันจะเร็วก็ต่อเมื่อสิ่งถัดไปที่คุณแตะถูกดึงมาพร้อมกับ cache line ก่อนหน้าอยู่แล้ว — นั่นคือ locality MonoBehaviour หมื่นตัวที่กระจัดกระจายอยู่ทั่ว managed heap ถือว่าใกล้เคียงกับ layout ที่แย่ที่สุดสำหรับ locality: แทบทุกการเรียก Update() จะเป็น cache miss และ CPU ต้องหยุดรอ RAM สิ่งนี้ไม่ใช่บั๊กของ Unity — มันคือต้นทุนโดยตรงของสิ่งที่ทำให้ GameObject/MonoBehaviour สะดวกขนาดนี้: object อิสระที่เพิ่มและลบได้อย่างอิสระ แต่ละตัวครบในตัวเอง ความสะดวกแบบนี้กับ layout ที่ packed แบบเป็นมิตรกับ cache ดึงกันไปคนละทาง และเมื่อ object มีหลักหมื่นตัว layout ก็เริ่มเป็นฝ่ายชนะข้อถกเถียงนี้

เคล็ดลับ นี่ไม่ใช่เหตุผลให้เลี่ยง MonoBehaviour — แต่เป็นเหตุผลให้รู้ว่าจำนวน object มากแค่ไหนถึงจะเริ่มมีผล หัวข้อ 11 จะย้อนกลับมาพูดเรื่องนี้พร้อมคำแนะนำที่จับต้องได้

5. แนวคิดที่ต่างออกไป: Entity, Component, System

Entity Component System (ECS) โยนแนวคิดเรื่อง object ทิ้งไปเลย แล้วถามคำถามแยกกันสามข้อแทนที่จะถามข้อเดียว แต่ละคำถามมีคำตอบของตัวเองที่แคบอย่างตั้งใจ:

เทียบกับ GameObject + MonoBehaviour: ตรงนั้น component มัดรวมทั้ง data ของตัวเองและ logic ของ Update() เข้าด้วยกัน แล้วอยู่เป็น heap object ของตัวเอง แต่ใน ECS data (component) กับ logic (system) ถูกแยกออกจากกันอย่างสิ้นเชิง และ entity ก็เป็นแค่ตัวเลขที่บอกว่า "ค่า component ชุดนี้เป็นของกันและกันนะ" โลกของเกมใน ECS หน้าตาเหมือนตาราง:

Entity | Position | Velocity | Health -------+----------------+----------------+-------- 0 | (1, 0, 0) | (0, 1, 0) | 100 1 | (5, 2, 0) | (0, -1, 0) | -- 2 | (0, 0, 0) | -- | 50 3 | (2, 2, 2) | (1, 1, 0) | 30 Entity 0: has Position, Velocity, Health Entity 1: has Position, Velocity (no Health - maybe a static hazard) Entity 2: has Position, Health (no Velocity - it does not move) Entity 3: has Position, Velocity, Health

movement system คือ function ที่สนใจแค่ entity ในคอลัมน์ "Position" กับ "Velocity" — มันจะแตะ entity 0, 1, และ 3 แล้วข้าม entity 2 ไปเลย โดยไม่รู้และไม่สนด้วยซ้ำว่า entity 2 มีค่า Health อยู่ ส่วน damage system สนใจแค่คอลัมน์ "Health" — entity 0, 2, 3 ไม่มี class ของ entity ไหนต้องเปลี่ยนเพื่อรองรับชุดค่าผสมใหม่เลย คุณแค่แนบหรือถอด component ออกจาก entity แล้ว system ไหนก็ตามที่ตรงกับชุด component ใหม่ของ entity นั้นก็จะเริ่มหรือหยุดแตะมันโดยอัตโนมัติ นี่คือ composition ที่ถูกดึงไปจนสุดทาง: ไม่ใช่แค่ "has-a" แทน "is-a" แต่ data กับ logic ถูกแยกออกจากกันเต็มที่ และ — อย่างที่หัวข้อถัดไปจะแสดงให้เห็น — ถูกจัดเรียงใน memory เพื่อความเร็วโดยเฉพาะ

6. Memory Layout: ทำไม Packed Component Array ถึงเร็ว

ตารางในหัวข้อ 5 ไม่ใช่แค่ diagram สำหรับสอนเฉย ๆ — มันใกล้เคียงกับวิธีที่ ECS เก็บข้อมูลจริง ๆ แทนที่จะกระจาย entity object ทั้งตัวไปทั่ว heap แบบที่ GameObject ทำ ECS จะเก็บ array ที่ packed แน่นหนึ่งอันต่อ component type หนึ่งชนิด นี่คือ layout แบบ Struct-of-Arrays (SoA) จากบทที่ 3.2 เป๊ะ ๆ เพียงแต่เอามาใช้กับโลกทั้งเกม แทนที่จะใช้กับแค่ particle system อย่างเดียว

GameObject-style (AoS, scattered on the heap): heap: [Enemy0 { x,y,z, vx,vy,vz, hp }] ... [Enemy1 {...}] ... [Enemy2 {...}] scattered wherever the allocator/GC put each object - not contiguous ECS-style (SoA, one packed array per component): Position: [p0][p1][p2][p3][p4][p5] ... <- all Positions, contiguous Velocity: [v0][v1][v2][v3][v4][v5] ... <- all Velocities, contiguous Health: [h0] [h2] [h5] ... <- only entities that HAVE Health

movement system ที่อ่านแค่ Position กับ Velocity จะไหลผ่าน array เล็ก ๆ ที่ packed เต็มสองอันติดกัน — ทุก byte ที่โหลดมาจาก RAM ถูกใช้งานจริงทั้งหมด มันไม่แตะ array ของ Health เลยแม้แต่น้อย และไม่เสีย cache line ไปกับการลาก field ที่ไม่เกี่ยวข้องเข้ามาแบบที่ struct Enemy แบบ AoS จะทำ ย้อนกลับไปดูตัวเลขจริงจากบทที่ 3.2: struct ที่มี 7 field แต่ loop ต้องการแค่ 1 field, AoS จะดึงข้อมูลมาเกินความจำเป็นประมาณ 7 เท่า packed component array ของ ECS ก็ได้กำไรแบบเดียวกันนี้ แต่ใช้กับ entity ทั้งโลกของเกม แทนที่จะเป็นแค่ struct particle เดียว — และนี่ก็เป็นเหตุผลว่าทำไม movement system ใน ECS ถึงใช้คำสั่ง SIMD บน array ของ Position ได้โดยตรง เหมือนกับที่บทที่ 3.2 แสดงให้เห็นว่า SIMD ต้องการ float ที่ contiguous เพื่อโหลดทีเดียวจบ

เคล็ดลับ นี่คือเหตุผลใหญ่ที่สุดข้อเดียวที่สตูดิโอหยิบ ECS มาใช้เมื่อสเกลใหญ่: มันไม่ใช่ไอเดียใหม่เลย มันคือบทเรียนเรื่อง cache-locality จากบทที่ 3 ที่ถูกเอามาใช้อย่างตั้งใจกับ object ทั้งเกม แทนที่จะปล่อยให้เป็นเรื่องของดวงแบบที่ heap object กระจัดกระจายเป็นอยู่

7. สร้าง ECS เล็ก ๆ ใน C++: Entity กับ Component Storage

ถึงเวลาสร้าง ECS เล็ก ๆ ที่ใช้งานได้จริงกันแล้ว entity ก็แค่ตัวเลขตัวหนึ่ง:

#include <cstdint>

using Entity = std::uint32_t;

Component คือ struct ธรรมดาที่ไม่มี method:

struct Position { float x, y, z; };
struct Velocity { float x, y, z; };

ส่วนที่น่าสนใจคือเรื่อง storage: ComponentArray<T> ที่เก็บทุกค่าของ type T ไว้ใน std::vector ที่ packed แน่นตัวเดียว แล้วจำแยกต่างหากว่า entity ไหนเป็นเจ้าของ slot ไหน:

#include <vector>
#include <unordered_map>

template <typename T>
class ComponentArray {
public:
    void add(Entity e, T component) {
        std::size_t index = data.size();
        entityToIndex[e] = index;
        indexToEntity.push_back(e);
        data.push_back(component);
    }

    bool has(Entity e) const {
        return entityToIndex.find(e) != entityToIndex.end();
    }

    T& get(Entity e) {
        return data[entityToIndex[e]];
    }

    std::vector<T>& all() { return data; }
    std::vector<Entity>& entities() { return indexToEntity; }

private:
    std::vector<T>                          data;           // tightly packed values
    std::vector<Entity>                     indexToEntity;  // slot index -> owning entity
    std::unordered_map<Entity, std::size_t> entityToIndex;  // entity -> slot index
};

data คือ packed array จากหัวข้อ 6 — ไม่มีอะไรนอกจากค่า Position หรือ Velocity เรียงติดกัน ไม่มีช่องว่าง entityToIndex กับ indexToEntity คือ bookkeeping ที่ให้คุณไปมาระหว่าง "entity ไหน" กับ "slot ไหน" ได้ โดยที่ bookkeeping นี้ไม่เคยไปแตะ packed data เองเลย World เล็ก ๆ ตัวหนึ่งจะผูกทุกอย่างเข้าด้วยกัน:

struct World {
    Entity nextEntity = 0;

    ComponentArray<Position> positions;
    ComponentArray<Velocity> velocities;

    Entity createEntity() { return nextEntity++; }
};

ตอนนี้มาสร้าง world เล็ก ๆ แล้วพิสูจน์ว่า array นั้น packed จริง ๆ:

#include <cstdio>

int main() {
    World world;

    Entity e0 = world.createEntity();
    world.positions.add(e0, {0.0f, 0.0f, 0.0f});
    world.velocities.add(e0, {1.0f, 0.0f, 0.0f});

    Entity e1 = world.createEntity();
    world.positions.add(e1, {5.0f, 0.0f, 0.0f});
    world.velocities.add(e1, {0.0f, 1.0f, 0.0f});

    Entity e2 = world.createEntity();          // e2 has no Velocity - it does not move
    world.positions.add(e2, {9.0f, 0.0f, 0.0f});

    Position* p0 = &world.positions.get(e0);
    Position* p1 = &world.positions.get(e1);

    printf("sizeof(Position) = %zu\n", sizeof(Position));
    printf("p1 - p0 = %td bytes\n", (char*)p1 - (char*)p0);
    printf("positions stored: %zu\n", world.positions.all().size());
    printf("velocities stored: %zu\n", world.velocities.all().size());
}

Output:

sizeof(Position) = 12
p1 - p0 = 12 bytes
positions stored: 3
velocities stored: 2

p1 - p0 = 12 bytes ตรงกับ sizeof(Position) เป๊ะ — เป็นหลักฐานว่า position ของ e0 กับ e1 อยู่ติดกันใน memory จริง ๆ โดยไม่มีอะไรคั่นกลาง positions เก็บค่าไว้ 3 ค่า (ทุก entity ทั้งสามตัวมีค่านี้) ในขณะที่ velocities เก็บแค่ 2 ค่า (e2 ไม่มี) — component แต่ละชนิดเก็บ packed array ของตัวเองแยกอิสระ ตรงกับตาราง SoA จากหัวข้อ 6 เป๊ะ ๆ เพียงแต่คราวนี้มีโค้ดที่รันได้จริงรองรับอยู่เบื้องหลัง

8. สร้าง ECS เล็ก ๆ ใน C++: การ Query กับ Movement System

system คือ function ที่หาทุก entity ที่ตรงกับชุด component ที่กำหนด (เรียกว่า query) แล้วอัปเดตมัน query แบบง่ายที่สุด: เลือก array ที่สั้นที่สุดในบรรดา array ที่เกี่ยวข้อง วนผ่านมัน แล้วเช็ค array อื่น ๆ:

void movementSystem(World& world, float dt) {
    for (Entity e : world.velocities.entities()) {
        if (world.positions.has(e)) {
            Position& pos = world.positions.get(e);
            Velocity& vel = world.velocities.get(e);
            pos.x += vel.x * dt;
            pos.y += vel.y * dt;
            pos.z += vel.z * dt;
        }
    }
}

รันมันกับ world จากหัวข้อ 7:

int main() {
    World world;

    Entity e0 = world.createEntity();
    world.positions.add(e0, {0.0f, 0.0f, 0.0f});
    world.velocities.add(e0, {1.0f, 0.0f, 0.0f});

    Entity e1 = world.createEntity();
    world.positions.add(e1, {5.0f, 0.0f, 0.0f});
    world.velocities.add(e1, {0.0f, 1.0f, 0.0f});

    Entity e2 = world.createEntity();
    world.positions.add(e2, {9.0f, 0.0f, 0.0f});   // no Velocity - untouched below

    movementSystem(world, 1.0f);
    movementSystem(world, 1.0f);

    for (Entity e : world.positions.entities()) {
        Position& p = world.positions.get(e);
        printf("entity %u position = (%.1f, %.1f, %.1f)\n", e, p.x, p.y, p.z);
    }
}

Output:

entity 0 position = (2.0, 0.0, 0.0)
entity 1 position = (5.0, 2.0, 0.0)
entity 2 position = (9.0, 0.0, 0.0)

Entity 0 เคลื่อนที่ด้วย (1,0,0) สองครั้ง มาจบที่ x = 2.0 Entity 1 เคลื่อนที่ด้วย (0,1,0) สองครั้ง มาจบที่ y = 2.0 ส่วน Entity 2 ไม่ขยับเลยแม้แต่นิดเดียว — movementSystem วนผ่าน velocities.entities() และ entity 2 ไม่เคยถูกเพิ่มเข้า velocities เลย ดังนั้น system นี้เลยไม่มีทางเห็นมันเลย ไม่มี if ตรงไหนเลยที่ถามว่า "entity นี้เป็นประเภทที่เคลื่อนที่ได้หรือเปล่า" — entity จะเคลื่อนที่ได้หรือไม่ถูกตัดสินจากการมี Velocity component หรือไม่เท่านั้น เช็คแค่ครั้งเดียว ไม่ได้ถูกฝังไว้ใน class hierarchy

บรรทัด if (world.positions.has(e)) มีต้นทุนจริง: hash-map lookup ต่อ entity หนึ่งตัว ซ้อนทับกับการวน array ไปด้วย ECS engine ระดับโปรดักชันจะเลี่ยงสิ่งนี้ด้วย signature — bitmask เล็ก ๆ ต่อ entity หนึ่งตัว ที่ bit ตำแหน่ง k จะถูกตั้งค่าถ้า entity นั้นมี component type k การเช็คว่า "entity นี้มีทั้ง Position และ Velocity หรือเปล่า" จะกลายเป็นแค่ bitwise AND ระหว่าง bitmask สองตัว แทนที่จะเป็น hash lookup และการหา entity ที่ตรงกันทั้งหมดก็กลายเป็นการ intersection ที่เร็ว แทนที่จะเช็คทีละ entity ECS เล็ก ๆ ในบทนี้ข้ามส่วนนี้ไปเพื่อให้โค้ดสั้นพอที่จะอ่านจบในรวดเดียว

9. Archetype กับ Chunk: ECS ระดับโปรดักชันจัดกลุ่ม Entity อย่างไร

ComponentArray<T> จากหัวข้อ 7 คือ array หนึ่งอันต่อ component type หนึ่งชนิด ใช้ร่วมกันโดยทุก entity ที่มีมัน ไม่ว่า entity เหล่านั้นจะมีอะไรอื่นอีกก็ตาม ECS engine ระดับโปรดักชัน (รวมถึง Unity DOTS) ไปไกลกว่านั้นอีกขั้นด้วยไอเดียที่เรียกว่า archetype: ชุด component type ที่ entity หนึ่งถืออยู่แบบเป๊ะ ๆ entity ทุกตัวที่มีแค่ {Position, Velocity} เท่านั้นจะอยู่ archetype เดียวกัน; entity ทุกตัวที่มีแค่ {Position, Velocity, Health} จะอยู่คนละ archetype แม้จะมี component type ร่วมกันสองตัวก็ตาม

entity ทุกตัวที่อยู่ archetype เดียวกันจะถูกเก็บรวมกันในบล็อกหน่วยความจำที่ contiguous บล็อกเดียวเรียกว่า chunk (มักมีขนาดคงที่เช่น 16 KB) และภายใน chunk หนึ่งอัน component แต่ละชนิดก็ยังมี sub-array ที่ packed ของตัวเอง — เป็น SoA อยู่ภายใน chunk การวนผ่าน "entity ทุกตัวที่มี Position กับ Velocity" ไม่ได้หมายถึงการเดินผ่าน array มหึมาอันเดียวแล้วเช็คข้าม entity ทีละตัวอีกต่อไป แต่หมายถึงการไปเยี่ยม chunk แค่หยิบมือเดียวที่ archetype ของมันมีทั้งสองอย่าง แล้วไหลผ่านมันไปตรง ๆ โดยไม่มีการแตกกิ่งเช็คทีละ entity เลย

Archetype A = { Position, Velocity } chunk (packed, SoA): Position: [p0][p1][p2][p3][p4] ... Velocity: [v0][v1][v2][v3][v4] ... Archetype B = { Position, Velocity, Health } chunk (packed, SoA): Position: [p0][p1][p2] ... Velocity: [v0][v1][v2] ... Health: [h0][h1][h2] ... A system querying for {Position, Velocity} visits BOTH chunks (both archetypes have that pair) and ignores any chunk that lacks either. A system querying for {Health} only visits chunk B.

เมื่อ entity ได้รับหรือเสีย component ระหว่างที่เกมกำลังรัน (เช่น Health component ถูกเพิ่มเข้าไปใน entity ที่เดิมมีแค่ {Position, Velocity}) archetype ของมันจะเปลี่ยน ทำให้ engine ต้องคัดลอกค่า component ของมันออกจาก chunk เก่าไปยัง chunk ของ archetype ใหม่ — เป็นต้นทุนจริงที่เกิดขึ้น ซึ่งเป็นเหตุผลหนึ่งที่โค้ด ECS ระดับโปรดักชันมักจะกำหนดชุด component เต็มของ entity ไว้ตั้งแต่แรกครั้งเดียว แทนที่จะเพิ่มลบ component ทุกเฟรม ไอเดียเรื่อง chunk นี้คือเวอร์ชันระดับโปรดักชันของ packed array จากหัวข้อ 7: หลักการเดียวกัน — จัดเก็บข้อมูลตาม type แล้ววนผ่านโดยไม่แตกกิ่ง — จัดระเบียบให้กลุ่ม entity ที่ตรงกันทั้งกลุ่มถูกกวาดผ่านได้ในรอบเดียวที่ contiguous แทนที่จะเป็นหลาย ๆ รอบเล็ก ๆ

10. Unity DOTS / Entities: เวอร์ชันระดับโปรดักชัน

ECS ระดับโปรดักชันของ Unity เรียกว่า DOTS (Data-Oriented Technology Stack) สร้างขึ้นรอบ package ที่ชื่อ Entities มันคือทุกอย่างจากหัวข้อ 5-9 รวมกัน บวกกับ engine จริง, compiler จริง, และ scheduler จริงอยู่เบื้องหลัง Component เป็น struct ธรรมดาที่ implement IComponentData — ไม่มี method เหมือนกฎในหัวข้อ 5 เป๊ะ ๆ:

using Unity.Entities;
using Unity.Mathematics;

public struct Position : IComponentData { public float3 Value; }
public struct Velocity : IComponentData { public float3 Value; }

system คือ struct ที่ implement ISystem โดย OnUpdate ของมันเทียบเท่ากับ function movementSystem จากหัวข้อ 8 — เพียงแต่ query ถูกเขียนแบบ declarative และ engine เป็นคนวนผ่านให้เอง:

using Unity.Entities;
using Unity.Burst;

[BurstCompile]
public partial struct MovementSystem : ISystem {
    [BurstCompile]
    public void OnUpdate(ref SystemState state) {
        float dt = SystemAPI.Time.DeltaTime;

        foreach (var (pos, vel) in
                 SystemAPI.Query<RefRW<Position>, RefRO<Velocity>>()) {
            pos.ValueRW.Value += vel.ValueRO.Value * dt;
        }
    }
}

SystemAPI.Query<RefRW<Position>, RefRO<Velocity>>() คือการเขียนตรง ๆ ว่า "ขอทุก entity ที่มีทั้ง Position และ Velocity" — เป็น query เดียวกับที่หัวข้อ 8 เขียนด้วยมือเป็น hash-map check เพียงแต่ DOTS หามันเจอด้วยการกระโดดไปตรง chunk ที่ตรงกันจากหัวข้อ 9 เลย โดยไม่มีการแตกกิ่งเช็คทีละ entity RefRW แปลว่า "เข้าถึง component นี้แบบอ่าน-เขียนได้" RefRO แปลว่า "อ่านได้อย่างเดียว" — engine ใช้ข้อมูลนี้เพื่อรันหลาย system พร้อมกันบนหลาย CPU core อย่างปลอดภัยผ่าน Job System ของ Unity เพราะมันพิสูจน์ได้ล่วงหน้าว่า system ไหนแค่อ่าน component (รันขนานกันได้อย่างปลอดภัย) กับ system ไหนที่เขียนลงไปด้วย

[BurstCompile] บอกให้ Burst compiler ของ Unity คอมไพล์ method นี้ลงไปเป็น native machine code ที่ optimize แน่นมาก — รวมถึงคำสั่ง SIMD ที่ทำงานกับค่า Position ของหลาย entity พร้อมกันทีเดียว เป็นไอเดีย SIMD เดียวกับบทที่ 3.2 เพียงแต่คราวนี้ถูกสร้างขึ้นให้อัตโนมัติ เพราะข้อมูลถูกจัดวางเป็น packed array อยู่ภายในแต่ละ chunk อยู่แล้ว นี่คือประเด็นทั้งหมดของบทนี้ในประโยคเดียว: DOTS ไม่ใช่ไอเดียที่ต่างไปจาก ECS เล็ก ๆ ใน C++ จากหัวข้อ 7-9 เลย — มันคือไอเดีย entity-component-system อันเดียวกัน เพียงแต่มี job scheduler กับ compiler เฉพาะทางที่สร้างขึ้นมาเพื่อใช้ประโยชน์จาก layout ที่เป็นมิตรกับ cache ที่ไอเดียนี้สร้างขึ้นมา

11. เมื่อไหร่ ECS ถึงคุ้มค่า เทียบกับ MonoBehaviour ธรรมดา

ECS ไม่ใช่การอัปเกรดสากลที่ใช้ได้กับทุกกรณี — มันคือเครื่องมือเฉพาะทางที่มี learning curve จริง ๆ และมี debugging workflow ที่ต่างออกไปอย่างแท้จริง (ไม่มี Inspector ให้ลากวาง ไม่ได้ step เข้าไปดู method ของ object ที่คุ้นเคยใน debugger แบบเดิม) การหยิบมันมาใช้เป็นค่าเริ่มต้น สำหรับเกมที่มีศัตรูแค่ห้าสิบตัว มีแต่จะเพิ่มความซับซ้อนโดยไม่ได้อะไรกลับมาเลย

กฎเชิงปฏิบัติสะท้อนบทเรียนเรื่อง allocator จากบทที่ 3: profile ก่อน เข้าใจก่อนว่ามิลลิวินาทีจริง ๆ หายไปไหน แล้วค่อยหยิบเครื่องมือเฉพาะทางที่เข้ากับ pattern ที่วัดได้มาใช้ — อย่าหยิบ ECS มาใช้เพราะ "มันเร็วกว่า" โดยไม่เช็คก่อนว่าจำนวน object ในเกมจริงของคุณใกล้เคียงจุดที่ความเร็วนั้นจะมีความหมายหรือเปล่า

12. คำศัพท์

13. แบบฝึกหัด

แบบฝึกหัด 1 นี่คือจุดเริ่มต้นแบบ single-inheritance ไร้เดียงสา หน้าตาเหมือนหัวข้อ 1 ตอนนี้ดีไซเนอร์อยากได้ AmphibiousEnemy ที่ walk() ได้ด้วยและ swim() ได้ด้วย จงอธิบายด้วยคำพูดของตัวเองว่าจะเกิดอะไรผิดพลาดถ้าคุณเขียน class AmphibiousEnemy : public WalkingEnemy, public SwimmingEnemy โดยไม่มี virtual inheritance แล้วเขียนใหม่ทั้งหมดด้วย composition แทน (ไม่มี multiple inheritance เลย)
class Enemy {
protected:
    float x = 0.0f, y = 0.0f;
    int   health = 100;
};

class WalkingEnemy : public Enemy {
public:
    void walk(float dt) { x += walkSpeed * dt; }
protected:
    float walkSpeed = 2.0f;
};

class SwimmingEnemy : public Enemy {
public:
    void swim(float dt) { y -= swimSpeed * dt; }
protected:
    float swimSpeed = 3.0f;
};
ดูเฉลย

ถ้าไม่มี virtual inheritance, AmphibiousEnemy : public WalkingEnemy, public SwimmingEnemy จะจบลงด้วย Enemy sub-object แยกกันสองชุด — ชุดหนึ่งเข้าถึงผ่านเส้นทาง WalkingEnemy อีกชุดผ่านเส้นทาง SwimmingEnemy — เลยมี x, y, และ health อย่างละสองชุด walk() จะอัปเดต x ในชุดที่อยู่ในเส้นทาง WalkingEnemy; อะไรก็ตามที่อ่าน x ผ่านเส้นทาง SwimmingEnemy (หรือผ่าน Enemy* ธรรมดาที่บังเอิญชี้ไปที่ sub-object ผิดตัว) จะเห็นค่าเก่าที่ไม่เคยถูกอัปเดตเลย นี่คือ diamond problem จากหัวข้อ 1 ที่มาเกิดขึ้นอีกครั้งในตัวอย่างใหม่

Composition หลบปัญหานี้ได้ทั้งหมด — มี Enemy class เดียว ความสามารถการเคลื่อนที่ถูกถือไว้เป็น pointer แทนที่จะเป็น base class:

#include <cstdio>

struct WalkMovement {
    float speed = 2.0f;
    void update(float dt, float& x) { x += speed * dt; }
};

struct SwimMovement {
    float speed = 3.0f;
    void update(float dt, float& y) { y -= speed * dt; }
};

class Enemy {
public:
    void update(float dt) {
        if (walkMovement) walkMovement->update(dt, x);
        if (swimMovement) swimMovement->update(dt, y);
    }

    float x = 0.0f, y = 0.0f;
    int   health = 100;

    WalkMovement* walkMovement = nullptr;
    SwimMovement* swimMovement = nullptr;
};

int main() {
    WalkMovement walk;
    SwimMovement swim;

    Enemy amphibious;
    amphibious.walkMovement = &walk;
    amphibious.swimMovement = &swim;
    amphibious.update(1.0f);

    printf("amphibious x=%.1f y=%.1f\n", amphibious.x, amphibious.y);
}

Output:

amphibious x=2.0 y=-3.0

มี Enemy class อยู่แค่ class เดียว มี health แค่ตัวเดียว และไม่มี diamond เลย — AmphibiousEnemy ไม่จำเป็นต้องมีอยู่เป็น class ของตัวเองเลยด้วยซ้ำ

แบบฝึกหัด 2 ComponentArray<T> จากหัวข้อ 7 ยังไม่มีวิธีลบ component ของ entity ออก จงเพิ่ม method remove(Entity e) มันต้องทำให้ data ยังคง packed แน่นโดยไม่มีช่องว่างเหลืออยู่ — ใช้เทคนิค "swap-and-pop": ย้าย element ตัวสุดท้ายเข้าไปแทนที่ slot ที่ถูกลบ แล้วลดขนาด array ลงหนึ่งช่อง จากนั้นแก้ index map ทั้งสองตัวให้ตรงกัน
// Starting point: same ComponentArray<T> as section 7
// (data, indexToEntity, entityToIndex).
// TODO: add   void remove(Entity e);
// It must not leave a gap in "data".
ดูเฉลย
template <typename T>
class ComponentArray {
public:
    void add(Entity e, T component) {
        std::size_t index = data.size();
        entityToIndex[e] = index;
        indexToEntity.push_back(e);
        data.push_back(component);
    }

    void remove(Entity e) {
        std::size_t removedIndex = entityToIndex[e];
        std::size_t lastIndex    = data.size() - 1;
        Entity      lastEntity   = indexToEntity[lastIndex];

        data[removedIndex]          = data[lastIndex];          // swap
        indexToEntity[removedIndex] = lastEntity;

        data.pop_back();                                        // and pop
        indexToEntity.pop_back();

        entityToIndex[lastEntity] = removedIndex;                // fix the moved entity
        entityToIndex.erase(e);                                  // remove the deleted one
    }

    bool has(Entity e) const {
        return entityToIndex.find(e) != entityToIndex.end();
    }

    T& get(Entity e) { return data[entityToIndex[e]]; }
    std::vector<T>& all() { return data; }
    std::vector<Entity>& entities() { return indexToEntity; }

private:
    std::vector<T>                          data;
    std::vector<Entity>                     indexToEntity;
    std::unordered_map<Entity, std::size_t> entityToIndex;
};

int main() {
    ComponentArray<Position> positions;
    positions.add(0, {1.0f, 0.0f, 0.0f});
    positions.add(1, {2.0f, 0.0f, 0.0f});
    positions.add(2, {3.0f, 0.0f, 0.0f});

    positions.remove(0);   // removes entity 0, entity 2's data moves into slot 0

    printf("count = %zu\n", positions.all().size());
    printf("slot 0 now belongs to entity %u, x=%.1f\n",
           positions.entities()[0], positions.all()[0].x);
    printf("entity 0 still present: %s\n", positions.has(0) ? "yes" : "no");
}

Output:

count = 2
slot 0 now belongs to entity 2, x=3.0
entity 0 still present: no

Position ของ entity 2 (element ตัวสุดท้าย) ถูกคัดลอกเข้าไปที่ slot 0 ซึ่งเดิมเป็นของ entity 0 แล้ว array ก็หดลงหนึ่งช่อง — data ยังคง packed เต็มโดยไม่มีช่องว่างเลย แลกมาด้วยการที่ entity 2 ตอนนี้อยู่คนละ index จากเดิม โค้ดตัวไหนก็ตามที่แคช raw index ของ array นี้ไว้ข้ามการเรียก remove() จะพังทันที การไปผ่าน get(Entity) ซึ่งจะ lookup index ปัจจุบันใหม่ทุกครั้ง คือสิ่งที่ทำให้วิธีนี้ปลอดภัย

แบบฝึกหัด 3 จงเพิ่ม Health component และ ComponentArray<Health> healths เข้าไปใน World เขียน damageSystem(World& world, Entity target, int amount) ที่ลด amount ออกจาก health ของ target เฉพาะตอนที่มันมี Health เท่านั้น และถ้า health ลดลงเหลือ 0 หรือต่ำกว่า ให้ลบ component Position, Velocity, และ Health ของ entity นั้นออกทั้งหมด (ใช้ remove() จากแบบฝึกหัด 2) เพื่อไม่ให้ system ไหนแตะมันได้อีกเลย จงอธิบายเป็นหนึ่งประโยคว่าทำไม system นี้ถึงไม่จำเป็นต้องดู array ของ Position หรือ Velocity เลย
struct Health { int hp; };

// TODO: add ComponentArray<Health> healths; to World
// TODO: void damageSystem(World& world, Entity target, int amount);
ดูเฉลย
struct Health { int hp; };

struct World {
    Entity nextEntity = 0;

    ComponentArray<Position> positions;
    ComponentArray<Velocity> velocities;
    ComponentArray<Health>   healths;

    Entity createEntity() { return nextEntity++; }
};

void damageSystem(World& world, Entity target, int amount) {
    if (!world.healths.has(target)) {
        return;                                    // no Health - nothing to damage
    }

    Health& h = world.healths.get(target);
    h.hp -= amount;

    if (h.hp <= 0) {
        if (world.positions.has(target))  world.positions.remove(target);
        if (world.velocities.has(target)) world.velocities.remove(target);
        world.healths.remove(target);
    }
}

int main() {
    World world;

    Entity e0 = world.createEntity();
    world.positions.add(e0, {0.0f, 0.0f, 0.0f});
    world.velocities.add(e0, {1.0f, 0.0f, 0.0f});
    world.healths.add(e0, {30});

    damageSystem(world, e0, 20);
    printf("after first hit,  alive: %s, hp: %d\n",
           world.healths.has(e0) ? "yes" : "no",
           world.healths.has(e0) ? world.healths.get(e0).hp : 0);

    damageSystem(world, e0, 20);
    printf("after second hit, alive: %s\n", world.healths.has(e0) ? "yes" : "no");
    printf("positions remaining: %zu\n", world.positions.all().size());
}

Output:

after first hit,  alive: yes, hp: 10
after second hit, alive: no
positions remaining: 0

damageSystem แตะแค่ world.healths เท่านั้น (และเรียก remove บนตัวอื่น ๆ ตอนที่ entity ตาย) เพราะ health เป็นค่าเดียวที่ query ของ system นี้ต้องอ่านหรือเขียน — มันไม่เคยไหลผ่าน positions หรือ velocities เลยแม้แต่น้อย ซึ่งตรงกับผลตอบแทนจากหัวข้อ 6 เป๊ะ ๆ: system จ่ายต้นทุนให้กับแค่ component array ที่มัน query จริง ๆ เท่านั้น ไม่เคยจ่ายให้ array ที่บังเอิญเป็นของ entity เดียวกันแต่ไม่เกี่ยวข้องกับ logic ส่วนนี้เลย

← กลับไปหน้ารวมบท