ศัตรูทุกตัวในเกมต้องเคลื่อนที่ได้ โดนตีแล้วเลือดลด และอาจจะโจมตีได้ด้วย วิธีธรรมชาติที่สุดในการออกแบบสิ่งนี้ใน 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 — ดังนั้นควรทบทวนบทนั้นให้สดใหม่ในหัวก่อนอ่านบทนี้
เริ่มจากการออกแบบตามธรรมชาติแบบแรกสำหรับศัตรูในเกม มี 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
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 ไม่เคยถูกออกแบบมาให้รองรับ
ทางแก้นี้มีชื่อเรียกว่า 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 ใหม่สำหรับทุกชุดค่าผสม
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 ได้ด้วยสายตา โดยไม่ต้องเขียนโค้ดเลยสักบรรทัด
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 มาฟรี ๆ อยู่แล้ว
สำหรับศัตรูหลักสิบหรือหลักร้อยตัว GameObject + MonoBehaviour ทำงานได้ดีเยี่ยม และไม่มีอะไรในบทนี้ที่จะค้านวิธีนี้ ปัญหาจะเริ่มเมื่อสเกลใหญ่มาก ๆ — object ง่าย ๆ หลักหมื่นตัวที่ต้อง update ทุกเฟรม: กระสุนในเกมยิงแบบ bullet-hell, boid แต่ละตัวในฝูง, หน่วยรบในเกม RTS ขนาดใหญ่ ต้นทุนสองอย่างที่มองไม่เห็นตอนสเกลเล็ก จะเริ่มมามีอิทธิพล:
ย้อนกลับไปที่บทที่ 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 จะย้อนกลับมาพูดเรื่องนี้พร้อมคำแนะนำที่จับต้องได้Entity Component System (ECS) โยนแนวคิดเรื่อง object ทิ้งไปเลย แล้วถามคำถามแยกกันสามข้อแทนที่จะถามข้อเดียว แต่ละคำถามมีคำตอบของตัวเองที่แคบอย่างตั้งใจ:
Position ก็แค่ { x, y, z } ไม่มีอะไรมากไปกว่านั้น — มันไม่มี function update() ของตัวเองเทียบกับ GameObject + MonoBehaviour: ตรงนั้น component มัดรวมทั้ง data ของตัวเองและ logic ของ Update() เข้าด้วยกัน แล้วอยู่เป็น heap object ของตัวเอง แต่ใน ECS data (component) กับ logic (system) ถูกแยกออกจากกันอย่างสิ้นเชิง และ entity ก็เป็นแค่ตัวเลขที่บอกว่า "ค่า component ชุดนี้เป็นของกันและกันนะ" โลกของเกมใน ECS หน้าตาเหมือนตาราง:
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 เพื่อความเร็วโดยเฉพาะ
ตารางในหัวข้อ 5 ไม่ใช่แค่ diagram สำหรับสอนเฉย ๆ — มันใกล้เคียงกับวิธีที่ ECS เก็บข้อมูลจริง ๆ แทนที่จะกระจาย entity object ทั้งตัวไปทั่ว heap แบบที่ GameObject ทำ ECS จะเก็บ array ที่ packed แน่นหนึ่งอันต่อ component type หนึ่งชนิด นี่คือ layout แบบ Struct-of-Arrays (SoA) จากบทที่ 3.2 เป๊ะ ๆ เพียงแต่เอามาใช้กับโลกทั้งเกม แทนที่จะใช้กับแค่ particle system อย่างเดียว
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 เล็ก ๆ ที่ใช้งานได้จริงกันแล้ว 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 เป๊ะ ๆ เพียงแต่คราวนี้มีโค้ดที่รันได้จริงรองรับอยู่เบื้องหลัง
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 เล็ก ๆ ในบทนี้ข้ามส่วนนี้ไปเพื่อให้โค้ดสั้นพอที่จะอ่านจบในรวดเดียว
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 เลย
เมื่อ entity ได้รับหรือเสีย component ระหว่างที่เกมกำลังรัน (เช่น Health component ถูกเพิ่มเข้าไปใน entity ที่เดิมมีแค่ {Position, Velocity}) archetype ของมันจะเปลี่ยน ทำให้ engine ต้องคัดลอกค่า component ของมันออกจาก chunk เก่าไปยัง chunk ของ archetype ใหม่ — เป็นต้นทุนจริงที่เกิดขึ้น ซึ่งเป็นเหตุผลหนึ่งที่โค้ด ECS ระดับโปรดักชันมักจะกำหนดชุด component เต็มของ entity ไว้ตั้งแต่แรกครั้งเดียว แทนที่จะเพิ่มลบ component ทุกเฟรม ไอเดียเรื่อง chunk นี้คือเวอร์ชันระดับโปรดักชันของ packed array จากหัวข้อ 7: หลักการเดียวกัน — จัดเก็บข้อมูลตาม type แล้ววนผ่านโดยไม่แตกกิ่ง — จัดระเบียบให้กลุ่ม entity ที่ตรงกันทั้งกลุ่มถูกกวาดผ่านได้ในรอบเดียวที่ contiguous แทนที่จะเป็นหลาย ๆ รอบเล็ก ๆ
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 ที่ไอเดียนี้สร้างขึ้นมา
ECS ไม่ใช่การอัปเกรดสากลที่ใช้ได้กับทุกกรณี — มันคือเครื่องมือเฉพาะทางที่มี learning curve จริง ๆ และมี debugging workflow ที่ต่างออกไปอย่างแท้จริง (ไม่มี Inspector ให้ลากวาง ไม่ได้ step เข้าไปดู method ของ object ที่คุ้นเคยใน debugger แบบเดิม) การหยิบมันมาใช้เป็นค่าเริ่มต้น สำหรับเกมที่มีศัตรูแค่ห้าสิบตัว มีแต่จะเพิ่มความซับซ้อนโดยไม่ได้อะไรกลับมาเลย
Update() หรือการหยุดชะงักของ garbage collector จากจำนวน object ที่มหาศาลคือ bottleneck จริง ๆ: กระสุนหลักหมื่นในเกม bullet-hell, ฝูงชนขนาดใหญ่, จำนวนหน่วยรบใน RTS ที่เยอะมาก, การจำลองแบบ particle ที่มี gameplay logic จริงติดอยู่ (ไม่ใช่แค่ particle ที่เป็นภาพเฉย ๆ ซึ่งระบบ particle ของ engine จัดการได้อยู่แล้วโดยไม่ต้องใช้ ECS)MonoBehaviour แล้วลงไปใช้ ECS แค่กับ system หนึ่งหรือสองระบบที่ต้องการ object ราคาถูกที่เหมือนกันหลักหมื่นตัวที่ต้อง update ทุกเฟรมจริง ๆ — เช่น crowd simulation ที่อยู่เบื้องหลังตัวละครหลักที่มีชื่อและ script เฉพาะตัวตามปกติกฎเชิงปฏิบัติสะท้อนบทเรียนเรื่อง allocator จากบทที่ 3: profile ก่อน เข้าใจก่อนว่ามิลลิวินาทีจริง ๆ หายไปไหน แล้วค่อยหยิบเครื่องมือเฉพาะทางที่เข้ากับ pattern ที่วัดได้มาใช้ — อย่าหยิบ ECS มาใช้เพราะ "มันเร็วกว่า" โดยไม่เช็คก่อนว่าจำนวน object ในเกมจริงของคุณใกล้เคียงจุดที่ความเร็วนั้นจะมีความหมายหรือเปล่า
virtualTransform, และ list ของ component ที่แนบอยู่GameObject ในฐานะ component หนึ่งตัว มีทั้ง data และ logic ของตัวเอง (เช่น Update())Position { x, y, z })EntitiesAmphibiousEnemy ที่ 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 ของตัวเองเลยด้วยซ้ำ
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 ปัจจุบันใหม่ทุกครั้ง คือสิ่งที่ทำให้วิธีนี้ปลอดภัย
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 ส่วนนี้เลย