5.3 C++ ใน Unreal

เฟส 5 · Unreal Engine (ทางเลือก) · เวลาเรียน: 60–100 h

เขียน gameplay และระบบด้วย Unreal C++, macro UPROPERTY / UFUNCTION และ C++ กับ Blueprint ทำงานร่วมกันยังไง

เราสมมติว่าคุณรู้ C++ มาตรฐานอยู่แล้ว ทั้ง class, pointer, stack กับ heap, template, standard library บทเรียนนี้จะพูดถึงว่าอะไรเปลี่ยนไปบ้างเมื่อเราเขียน C++ ใน Unreal Engine คำตอบสั้น ๆ คือ ภาษาไม่ได้เปลี่ยน แต่มี framework ก้อนใหญ่มาครอบอยู่ด้านบน แล้ว framework นี้คุยกับโค้ดของเราผ่าน macro ต่าง ๆ บทเรียนนี้จะพาไปดู macro พวกนั้น, type ของ Unreal เอง, memory model ของมัน, และวิธีที่โค้ด C++ เล็ก ๆ ชิ้นหนึ่งจะกลายเป็นสิ่งที่ควบคุมได้จาก Blueprint (ภาษา visual scripting ของ Unreal)

1. Unreal C++ ก็คือ C++ บวกกับ Framework

ทุกอย่างที่คุณรู้เรื่อง C++ อยู่แล้วยังใช้ได้เหมือนเดิมใน Unreal Engine ทั้ง class, inheritance, template, reference, standard library, new กับ delete ทั้งหมดนี้ compile ผ่านหมด Unreal ไม่ได้คิดภาษาใหม่ขึ้นมา สิ่งที่มันเพิ่มเข้ามาคือ framework ก้อนใหญ่ที่มี base class ต่าง ๆ (UObject, AActor, UActorComponent และอื่น ๆ) บวกกับชุด macro ที่เอา class ของเราไปเสียบเข้ากับระบบต่าง ๆ ของ Unreal เอง เช่น editor, garbage collector, ระบบ save game, network replication และ Blueprint

ลองคิดแบบนี้ C++ มาตรฐานคือตัวภาษา ส่วน Unreal C++ ก็คือภาษาเดียวกันนั้นแหละ แต่ใช้อยู่ภายใน library ก้อนใหญ่ที่มีความคิดเห็นของตัวเองสูงมาก คล้าย ๆ กับตอนที่คุณเรียน C แล้วมาใช้ graphics library ทับอีกที ภาษาไม่เปลี่ยน แต่สิ่งที่ต่างออกไปคือ "library" ของ Unreal ยังมีเครื่องมือ generate โค้ดที่อ่าน class ของเราแล้วเติม C++ เพิ่มให้เบื้องหลังด้วย ส่วนนี้แหละที่เป็นของใหม่ และเป็นหัวข้อหลักของบทเรียนนี้

Standard C++ (language + STL) | v Unreal Engine C++ framework (UObject, UCLASS/UPROPERTY/UFUNCTION macros, TArray/TMap/FString, Garbage Collector, Blueprint) | v Your game code (Actors, Components, Structs, ...)

ลองเทียบ class C++ ธรรมดากับ class Actor ของ Unreal (Actor คือ base class ของ Unreal สำหรับ "สิ่งของที่วางลงใน level ได้"):

// Plain standard C++ - nothing Unreal-specific
class Player
{
public:
    void TakeDamage(float Amount);
private:
    float Health = 100.0f;
};
// Unreal C++ - same idea, plugged into the framework
UCLASS()
class MYGAME_API APlayerCharacter : public AActor
{
    GENERATED_BODY()

public:
    APlayerCharacter();

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Health")
    float Health = 100.0f;

    UFUNCTION(BlueprintCallable, Category = "Health")
    void TakeDamage(float Amount);
};

มีของใหม่โผล่มา 4 อย่าง class inherit จาก AActor แทนที่จะไม่ inherit อะไรเลย มี macro UCLASS() อยู่เหนือ class มี macro GENERATED_BODY() เป็นบรรทัดแรกในตัว class และมี MYGAME_API อยู่หน้าชื่อ class (macro นี้ทำหน้าที่ export class ออกไปให้ module อื่นใช้ได้ — จะพูดเรื่อง module ละเอียดในหัวข้อ 11) ทั้งหมดนี้ไม่ใช่ syntax ใหม่ มันคือ macro ล้วน ๆ และ macro ก็คือการแทนที่ข้อความธรรมดา ๆ ที่คุณรู้จักมาแล้วจากบทเรียน C

Tip ถ้าคุณเคยใช้ Unity ให้คิดว่า UCLASS() บวกกับ GENERATED_BODY() ทำหน้าที่คล้าย ๆ กับตอนที่ Unity รู้จัก class ว่าเป็น MonoBehaviour แล้วโชว์ field ต่าง ๆ ใน Inspector ให้ ต่างกันตรงที่ Unreal ต้องการให้เราบอกมันตรง ๆ ผ่าน macro เพราะ C++ ธรรมดาไม่มี reflection ในตัวแบบที่ C# มี

2. ทบทวน Reflection: Unreal อ่าน Macro ของเราได้ยังไง

Reflection คือความสามารถของโค้ดในการตรวจสอบ type ของตัวเองตอนโปรแกรมรันอยู่ — เช่นถามว่า "class นี้มี field อะไรบ้าง" หรือ "เรียกฟังก์ชันนี้ตามชื่อที่เป็น string" C++ ธรรมดาทำแบบนี้ไม่ได้ ไม่มีทางในตัวภาษาที่จะ list member ของ class หรือเรียกฟังก์ชันตามชื่อได้ตอน runtime

Unreal ต้องการความสามารถนี้ในหลายเรื่องมาก เช่น โชว์ field ของ class ใน Details panel ของ editor, ให้ Blueprint เรียกฟังก์ชันของเราได้, save/load object, replicate data ผ่าน network, และให้ garbage collector หาได้ว่า object ไหนชี้ไปที่ object ไหนบ้าง เนื่องจาก C++ ไม่ได้ให้ความสามารถนี้มาฟรี ๆ Unreal จึงสร้างมันขึ้นมาเอง ด้วยขั้นตอน generate โค้ดที่รันก่อน compiler ตัวจริง

ขั้นตอนนี้เรียกว่า UnrealHeaderTool (UHT) ทุกครั้งที่ build project, UHT จะสแกน header file หา macro ที่เราเขียนไว้ (UCLASS, USTRUCT, UENUM, UPROPERTY, UFUNCTION) แล้ว generate ไฟล์เพิ่มขึ้นมาสำหรับแต่ละ header เป็นโค้ด C++ ธรรมดาที่ลงทะเบียน class, property และฟังก์ชันของเราเข้าไปในระบบ reflection ของ Unreal ไฟล์ที่ generate ออกมาชื่อว่า YourClassName.generated.h และ header ของเราต้อง #include มันด้วย — โดยต้องเป็น include บรรทัดสุดท้ายของไฟล์เสมอ

MyActor.h (contains UCLASS / UPROPERTY / UFUNCTION macros) | | UnrealHeaderTool (UHT) scans the macros v MyActor.generated.h (plain C++, written FOR you, not BY you) | | #include "MyActor.generated.h" (last line of includes) v Normal C++ compiler builds everything together | v Reflection data now exists for AMyActor, so: - the Garbage Collector can find its UPROPERTY pointers - the editor Details panel can show its UPROPERTY fields - Blueprint can call its UFUNCTION functions

เราจะไม่มีวันเปิดหรือแก้ไฟล์ .generated.h เอง มันถูก generate ใหม่ทุกครั้งที่ build ถ้าลืม include มัน หรือลืม GENERATED_BODY(), การ build จะพังพร้อม compiler error ที่ชี้ไปยังโค้ดที่ generate หายไป — นี่มักเป็น error แรกที่มือใหม่เจอ ตอนนี้คุณรู้แล้วว่าทำไมมันถึงเกิดขึ้น

Common mistake วาง #include "MyActor.generated.h" ไว้ที่ไหนก็ได้นอกจาก include บรรทัดสุดท้ายจริง ๆ ของ header UHT คาดหวังให้มันอยู่ท้ายสุด ถ้าวางไว้ก่อนหน้า (หรือลืมใส่) จะทำให้ compile ไม่ผ่าน

3. UCLASS, USTRUCT, UENUM — การทำเครื่องหมาย Type ของเรา

Macro ทั้งสามตัวนี้บอก UHT ว่า "type นี้ควรมีอยู่ในระบบ reflection ของ Unreal" แต่ละตัวใช้กับ type คนละแบบ

UCLASS()
class MYGAME_API AEnemyBase : public AActor
{
    GENERATED_BODY()
};

USTRUCT(BlueprintType)
struct FLootEntry
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FString ItemName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    int32 DropChance = 0;
};

UENUM(BlueprintType)
enum class EEnemyState : uint8
{
    Idle,
    Chasing,
    Attacking,
    Dead
};

สังเกตคำนำหน้าชื่อ — นี่ไม่ใช่แค่การตกแต่ง เครื่องมือของ Unreal เองคาดหวังให้เป็นแบบนี้

ทุก UCLASS และ USTRUCT ต้องขึ้นต้นด้วย GENERATED_BODY() เสมอ — macro ตัวนี้คือจุดที่ UHT เติมโครงสร้างภายใน (constructor, ตัวช่วย serialization, การลงทะเบียน reflection) ที่ทำให้ macro ตัวอื่นทำงานได้ ถ้าไม่มีมัน UPROPERTY หรือ UFUNCTION ที่อยู่ข้างล่างจะไม่ทำงานอะไรเลย

Tip BlueprintType บน USTRUCT หรือ UENUM แปลว่า "Blueprint สร้างตัวแปร type นี้ได้" ถ้าไม่ใส่ struct หรือ enum นั้นก็ยัง compile ผ่านใน C++ ได้ปกติ แต่ Blueprint graph จะใช้มันตรง ๆ ไม่ได้

4. UPROPERTY — เปิด Field ข้อมูลให้ใช้งาน

UPROPERTY() วางไว้เหนือ member variable ของ class หรือ struct เพื่อบอก Unreal ว่า "track field นี้ด้วย" การ track หมายถึง 3 เรื่องแยกกัน และเราเลือกได้ว่าจะเอาเรื่องไหนบ้างผ่าน specifier ในวงเล็บ ว่า Garbage Collector ควร follow field นี้ไหมถ้ามันเป็น pointer, editor ควรโชว์มันไหม, และ Blueprint อ่าน/เขียนมันได้ไหม

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Movement")
float MoveSpeed = 300.0f;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stats")
int32 Score = 0;

UPROPERTY(EditDefaultsOnly, Category = "Combat")
float BaseDamage = 10.0f;

Specifier ที่ใช้บ่อย

field ที่ไม่มี UPROPERTY() เลยจะมองไม่เห็นจากทุกระบบเหล่านั้น ไม่มีใน editor ไม่มีใน Blueprint และ — ข้อนี้สำคัญมาก — ไม่ถูก garbage collector track ถ้ามันเป็น pointer ไปยัง UObject หัวข้อ 8 จะย้อนกลับมาอธิบายว่าทำไมข้อสุดท้ายนี้ทำให้เกมพังได้

Tip ถ้าคุณเคยใช้ [SerializeField] ของ Unity, UPROPERTY(EditAnywhere) ทำหน้าที่คล้ายกัน คือเปิด field ที่รู้สึกเหมือน private ให้ editor เห็น โดยไม่ต้องทำให้มันเป็น public field ของ C++ ที่ class อื่นเข้าถึงได้

5. UFUNCTION — เปิดฟังก์ชันให้ใช้งาน

UFUNCTION() วางไว้เหนือ member function และเหมือน UPROPERTY คือบอก Unreal ให้ลงทะเบียนมันใน reflection เหตุผลที่ใช้บ่อยที่สุดคือให้ Blueprint เรียกฟังก์ชัน C++ ของเราได้

UFUNCTION(BlueprintCallable, Category = "Combat")
void Fire();

UFUNCTION(BlueprintPure, Category = "Health")
float GetHealthPercent() const;

UFUNCTION(BlueprintImplementableEvent, Category = "Combat")
void OnWeaponFired();

มีตัวเลือกที่ 4 ซึ่งเกี่ยวข้องกันควรรู้ไว้ คือ BlueprintNativeEvent ซึ่งคล้าย BlueprintImplementableEvent แต่ C++ มี default body ให้ด้วย (เขียนเป็น OnWeaponFired_Implementation()) ซึ่ง Blueprint จะ override หรือไม่ก็ได้ บทเรียนนี้จะไม่ใช้มัน แต่คุณจะเจอมันในโค้ดของคนอื่นแน่นอน

Common mistake ทำเครื่องหมายฟังก์ชันเป็น BlueprintPure ทั้งที่จริง ๆ มันเปลี่ยน game state (เช่นเพิ่มค่า counter) Blueprint อาจ evaluate pure node ศูนย์ครั้ง หนึ่งครั้ง หรือหลายครั้งก็ได้ ขึ้นอยู่กับว่ามี node อื่นต้องการ output ของมันกี่ตัว — side effect ในฟังก์ชันที่ "pure" ทำให้เกิด bug ที่งงและไม่คงเส้นคงวา

6. Type ของ Unreal เอง: FString, FVector, TArray, TMap

Unreal มี string type, math type และ container type ของตัวเอง แล้วใช้มันทุกที่แทน std::string, std::vector และเพื่อน ๆ ของมัน เราจะใช้ type พวกนี้บ่อยมาก มาดูมันทำงานร่วมกันก่อน แล้วหัวข้อ 7 จะอธิบายว่าทำไมมันถึงมีอยู่

FString Name = TEXT("Slime");
FVector Location(100.0f, 0.0f, 50.0f);

TArray<int32> Scores;
Scores.Add(10);
Scores.Add(20);

TMap<FString, int32> Inventory;
Inventory.Add(TEXT("Potion"), 3);
Inventory.Add(TEXT("Sword"), 1);

UE_LOG(LogTemp, Warning, TEXT("Name: %s, Score count: %d"), *Name, Scores.Num());

Output ที่ควรได้ (ในหน้าต่าง Output Log ของ Unreal):

LogTemp: Warning: Name: Slime, Score count: 2

แต่ละ type ใช้ทำอะไร

เครื่องหมาย * ก่อน Name ใน UE_LOG คือการแปลง FString ให้เป็น raw character pointer ซึ่งเป็นสิ่งที่ format specifier %s ต้องการ — FString overload dereference operator * ไว้เพื่อจุดประสงค์นี้โดยเฉพาะ

Tip รูปแบบการตั้งชื่อสม่ำเสมอทั้ง engine คำนำหน้า T แปลว่า "template container" (TArray, TMap, TObjectPtr, หัวข้อ 9) คำนำหน้า F แปลว่า "plain struct" (FString, FVector) และ type ตัวเลขเป็นแบบ fixed-width (int32, uint8) แทนที่จะเป็น int ธรรมดา เพื่อให้ขนาดของมันไม่ขึ้นอยู่กับแพลตฟอร์มหรือ compiler

7. ทำไม Unreal ไม่ใช้ std::string กับ std::vector ตรง ๆ

นี่เป็นคำถามที่สมเหตุสมผลถ้าคุณรู้จัก standard library ดีอยู่แล้ว — ทำไมต้องเรียนชื่อ container ใหม่สำหรับสิ่งที่ดูเหมือนจะทำงานเหมือนกัน มีเหตุผลจริง ๆ 4 ข้อ

Reflection ต้องเข้าใจ container นั้น

UHT (หัวข้อ 2) generate โค้ด reflection ให้ TArray, TMap และเพื่อน ๆ ได้เพราะ Unreal เขียนทั้ง container และเครื่องมือที่อ่านมันขึ้นมาด้วยกันเป็นระบบเดียว UHT ไม่รู้จัก std::vector เลย มัน generate reflection data ให้ไม่ได้ ซึ่งแปลว่า field ที่เป็น std::vector เป็น UPROPERTY ไม่ได้เลย มันจะไม่โชว์ใน editor ไม่มองเห็นจาก Blueprint และถ้ามันเก็บ UObject* pointer อยู่ ก็จะไม่ถูก garbage collector track ด้วย

std::vector<AActor*> Enemies; // UHT cannot see this -> NOT reflected, NOT visible to GC, NOT visible to Blueprint, NOT saved to disk UPROPERTY() TArray<TObjectPtr<AActor>> Enemies; // UHT DOES see this -> reflected, tracked by the Garbage Collector, visible in the editor and to Blueprint, can be saved

ต้องเชื่อมกับ garbage collector ได้

หัวข้อ 8 จะอธิบายเรื่องนี้ละเอียดกว่านี้ แต่สรุปสั้น ๆ คือ garbage collector ต้องเดินสำรวจทุก container ที่เป็น UPROPERTY เพื่อหา pointer ของ UObject ข้างในนั้น TArray กับ TMap ถูกสร้างมาให้รองรับการเดินสำรวจแบบนี้ ส่วน std::vector ไม่ได้เป็นส่วนหนึ่งของระบบนี้เลย

ระบบ memory เดียวกันทุกแพลตฟอร์ม

Unreal ออกเกมบน PC, console, มือถือ แต่ละแบบมีข้อจำกัด memory ต่างกัน container ทั้งหมดของมัน allocate ผ่านระบบ memory ของ Unreal เอง (FMemory) ซึ่ง memory profiler ของ engine และ allocator เฉพาะแต่ละแพลตฟอร์มเสียบเข้ามาใช้ได้ ถ้าใช้เส้นทาง allocation แยกที่ไม่เกี่ยวข้องกัน (std::vector ดิบ ๆ) ก็จะข้ามเครื่องมือพวกนั้นไปหมด

Blueprint ต้องการ container ที่มัน display ได้

UPROPERTY(EditAnywhere) TArray<int32> โผล่ใน editor เป็น list ที่ขยายได้ เพิ่มแถวได้ด้วยปุ่ม + widget ของ editor ตัวนี้สร้างขึ้นมาโดยอิง reflection data ของ TArray โดยเฉพาะ ไม่มีอะไรแบบนี้สำหรับ std::vector ดิบ ๆ

ทั้งหมดนี้ไม่ได้แปลว่า standard library ถูกห้ามใช้ เรายังใช้ std::vector, std::unique_ptr, algorithm จาก header algorithm มาตรฐาน และอื่น ๆ ในโค้ด C++ ภายในของเราได้ Unreal ไม่ได้ห้าม กฎง่าย ๆ คือ ใช้ type ของ Unreal สำหรับอะไรก็ตามที่แตะ UPROPERTY, Blueprint, editor หรือ UObject ส่วน type ของ standard library ใช้ได้สบาย ๆ สำหรับ C++ ภายในล้วน ๆ ที่ไม่ข้ามเส้นนั้นไป

8. Memory ของ UObject และ Garbage Collector

ย้อนกลับไปที่บทเรียน C: ทุก new ต้องมี delete คู่กัน ไม่งั้น memory leak, ทุก delete ต้องเกิดขึ้นแค่ครั้งเดียว ไม่งั้นจะเจอ double-free หรือ dangling pointer Unreal เอาความรับผิดชอบนี้ออกไปทั้งหมดสำหรับ object กลุ่มหนึ่งโดยเฉพาะ คือทุกอย่างที่ derive มาจาก UObject (ซึ่งรวมทุก AActor ด้วย เพราะ AActor derive มาจาก UObject)

object ที่ derive มาจาก UObject ถูกจัดการโดย Garbage Collector (GC) ของ Unreal เป็นระบบที่คอยเช็คเป็นระยะ ๆ ว่า UObject ตัวไหนยัง reachable อยู่บ้าง แล้วทำลายตัวที่ไม่ reachable โดยอัตโนมัติ เราไม่ต้องเรียก delete กับมัน และ — ตรงนี้สำคัญมาก คนมักเข้าใจผิด — เราก็ไม่สร้างมันด้วย new ธรรมดาด้วย

// WRONG - do not construct UObjects with raw new
AEnemyBase* Enemy = new AEnemyBase();

// RIGHT - Actors are created through the World
AEnemyBase* Enemy = GetWorld()->SpawnActor<AEnemyBase>(
    EnemyClass, SpawnLocation, SpawnRotation);

// RIGHT - non-Actor UObjects are created with NewObject
UInventoryComponent* Inv = NewObject<UInventoryComponent>(this);

SpawnActor กับ NewObject ทั้งคู่ลงทะเบียน object ใหม่เข้ากับระบบ object ของ engine ทำให้ garbage collector รู้ว่ามันมีอยู่ new ธรรมดาข้ามการลงทะเบียนนี้ไป — object อาจจะทำงานได้อยู่พักหนึ่ง แต่ระบบต่าง ๆ ของ Unreal (save, replication, GC) ไม่รู้ว่ามันมีอยู่ แล้วเราก็กลับไปต้องจัดการ lifetime ของมันเองอยู่ดี ซึ่งเสียประเด็นที่ใช้ UObject ไปเลย

GC ตัดสินว่าอะไร "ยัง reachable" ยังไง มันเริ่มจาก root object ชุดหนึ่ง (เช่น level ที่โหลดอยู่ตอนนี้, game instance) แล้วเดินสำรวจออกไปผ่านทุก field ที่เป็น UPROPERTY และเป็น pointer ไปยัง UObject — รวมถึง pointer ที่อยู่ใน TArray หรือ TMap ที่เป็น UPROPERTY ด้วย อะไรก็ตามที่มันเดินไปถึงระหว่างสำรวจจะรอด อะไรที่มันไปไม่ถึงจะถูกทำลาย

Root Set (level, game instance, ...) | | walks UPROPERTY pointers v AEnemySpawner | | UPROPERTY TObjectPtr<AEnemyBase> TrackedTarget v AEnemyBase instance <-- reachable, GC keeps it alive AEnemyBase* RawPointer; // NOT a UPROPERTY -> invisible to the walk above -> if nothing else reflects it, GC destroys the object -> RawPointer is now DANGLING, using it crashes

นี่คืออันตรายของ raw pointer แบบเดียวกับที่คุณเรียนไปแล้วในบทเรียน C (pointer ที่ชี้ไปยัง memory ที่ถูก free ไปแล้ว) แค่ครั้งนี้ถูก trigger ด้วย GC ของ Unreal แทนที่จะเป็น delete ที่เราเรียกเอง วิธีแก้เหมือนเดิมเสมอ member ของ class ตัวไหนก็ตามที่ชี้ไปยัง UObject ต้องเป็น UPROPERTY แม้ว่าเราจะไม่มีแผนแก้มันจาก editor หรืออ่านมันจาก Blueprint เลยก็ตาม

UCLASS()
class MYGAME_API AEnemySpawner : public AActor
{
    GENERATED_BODY()

private:
    // BAD: raw pointer, invisible to the garbage collector
    AEnemyBase* CurrentTarget;

public:
    // GOOD: UPROPERTY pointer, tracked by the garbage collector
    UPROPERTY()
    TObjectPtr<AEnemyBase> TrackedTarget;
};
Common mistake UPROPERTY() เปล่า ๆ ไม่มี specifier เลย เหมือนตัวอย่างข้างบน ใช้ได้สมบูรณ์แบบ ไม่จำเป็นต้องมี EditAnywhere หรือ BlueprintReadWrite เพื่อให้ GC track มัน มือใหม่บางคนข้าม UPROPERTY() ไปเลยกับ pointer ภายในเพราะคิดว่า "editor ไม่ต้องเห็นตัวนี้หรอก" ซึ่งทำให้ GC tracking หายไปพร้อมกับทุกอย่างอื่นด้วย

9. TObjectPtr และการจัดการ Pointer ของ UObject อย่างปลอดภัย

Unreal สมัยใหม่ (UE5) ชอบใช้ TObjectPtr<T> มากกว่า T* ธรรมดาสำหรับ UPROPERTY ตัวไหนก็ตามที่ชี้ไปยัง UObject เราใช้มันไปแล้วในหัวข้อก่อนหน้าโดยยังไม่ได้อธิบายเต็ม ๆ มาดูกัน

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI")
TObjectPtr<AActor> TargetActor;

ในโค้ดทั่วไป TObjectPtr<AActor> ทำตัวเหมือน AActor* ธรรมดา คือ dereference ด้วย ->, เทียบกับ nullptr ได้ และส่งต่อไปมาแบบเดียวกัน สิ่งที่มันเพิ่มขึ้นมาคือ

ต้องเข้าใจให้ชัดว่า TObjectPtr ไม่ใช่อะไร มันไม่ใช่ reference-counted smart pointer แบบ std::shared_ptr มันไม่ได้ตัดสินใจว่า object จะตายเมื่อไหร่ Garbage Collector ยังเป็นเจ้าของการตัดสินใจนั้นทั้งหมดเหมือนเดิม เหมือนกับ raw UPROPERTY pointer — TObjectPtr แค่ทำให้เครื่องมือของ Unreal เห็นและตรวจสอบ reference นี้ได้ง่ายขึ้นเท่านั้น

มี type ที่เกี่ยวข้องกันอีก 2 ตัวที่ควรรู้จักชื่อไว้ แม้จะไม่ได้ใช้ในตัวอย่างของบทเรียนนี้

Tip กฎง่าย ๆ pointer ที่ derive มาจาก UObject เป็น member ของ class? ใช้ UPROPERTY() TObjectPtr<T> C++ object ธรรมดาที่ไม่ใช่ UObject แต่ต้องการ shared ownership? ใช้ TSharedPtr<T> ไม่ต้องมี UPROPERTY ไม่เกี่ยวกับ GC เลย

10. เปิด C++ Class ทั้งตัวให้ Blueprint ใช้

ที่ผ่านมาเราเปิด field และฟังก์ชันทีละตัว เรายังควบคุมได้ด้วยว่า class ทั้งตัวจะใช้เป็น Blueprint parent ได้ไหม หรือใช้เป็น Blueprint variable type ได้ไหม ผ่าน specifier บน UCLASS() เอง

UCLASS(Blueprintable, BlueprintType)
class MYGAME_API AInteractableDoor : public AActor
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Door")
    bool bStartsLocked = false;

    UFUNCTION(BlueprintCallable, Category = "Door")
    void Unlock();
};

ส่วนใหญ่ subclass ของ AActor จะเป็น Blueprintable โดย default อยู่แล้ว เพราะ AActor เองก็เป็นแบบนั้น แต่การเขียนมันไว้ตรง ๆ ก็เป็น practice ที่ดี และจำเป็นสำหรับ UObject บาง type ที่ไม่ได้ Blueprintable โดย default

workflow ปกติหลังจากนี้คือ designer สร้าง Blueprint child (มักตั้งชื่อขึ้นต้นด้วย BP_ เช่น BP_InteractableDoor) ตั้งค่า bStartsLocked เป็น true สำหรับประตูบานหนึ่งใน Details panel แล้วเพิ่ม Event Graph ที่เรียก Unlock() เมื่อผู้เล่นกดปุ่ม — ทั้งหมดนี้โดยไม่ต้องแตะหรือ recompile C++ เลย

C++ (compiled) Blueprint (visual, no recompile) -------------------- -------------------------------- AInteractableDoor ---> BP_InteractableDoor bStartsLocked (editable) bStartsLocked = true (this door) Unlock() (callable) Event Graph: On Button Pressed -> call Unlock() | v BP_InteractableDoor instance placed in the level
Tip การแก้ C++ ต้อง recompile ทั้ง module และบ่อยครั้งต้อง restart editor ด้วย ส่วนการแก้ Blueprint apply ได้ทันทีแค่กด "Compile" ใน Blueprint editor เอง นี่คือเหตุผลที่การปรับจูน gameplay (ค่าตัวเลข, พฤติกรรมเล็ก ๆ น้อย ๆ) มักถูกผลักไปไว้ใน Blueprint ส่วน logic ที่สำคัญต่อ performance หรือเป็นรากฐานยังอยู่ใน C++

11. Build System: Module และ .Build.cs

project ของ Unreal ไม่ได้เป็น source file กองใหญ่ก้อนเดียวที่ compile พร้อมกันทีเดียว มันถูกแบ่งเป็น module — หน่วยที่ compile แยกกัน แต่ละหน่วยมีชื่อของตัวเอง มี folder ของตัวเองอยู่ใต้ Source/ และมี list ของ dependency ของตัวเอง โค้ดเกมหลักของเราคือ module หนึ่ง แต่ละ plugin คือ module เพิ่มอีกหนึ่งหรือหลายตัว ตัว engine เองก็ build มาจาก module หลายสิบตัว (Core, CoreUObject, Engine และอื่น ๆ)

MyGame/ Source/ MyGame/ MyGame.Build.cs MyGame.h MyGame.cpp Enemy/ EnemyBase.h EnemyBase.cpp

ทุก module มีไฟล์ .Build.cs อยู่ตัวเดียวเสมอ ไฟล์นี้ไม่ใช่ C++ — มันคือ C# script เล็ก ๆ ที่รันโดยโปรแกรมแยกต่างหากชื่อ UnrealBuildTool (UBT) ก่อนที่ C++ ของเราจะถูก compile หน้าที่ของมันคือประกาศว่า module ของเราต้องพึ่งพาอะไรบ้าง เพื่อให้ UBT รู้ว่าจะเอา header กับ library ตัวไหนมาให้ใช้งาน

using UnrealBuildTool;

public class MyGame : ModuleRules
{
    public MyGame(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;

        PublicDependencyModuleNames.AddRange(new string[] {
            "Core", "CoreUObject", "Engine", "InputCore"
        });

        PrivateDependencyModuleNames.AddRange(new string[] { });
    }
}

ตรงนี้เองที่ macro MYGAME_API จากหัวข้อ 1 มาจาก UBT generate export macro หนึ่งตัวต่อหนึ่ง module (ตั้งชื่อตาม module ในที่นี้คือ MYGAME_API) แล้วเราใส่มันไว้ที่ class ไหนก็ตามที่อยากให้ module อื่นมองเห็นได้ บน Windows เรื่องนี้สำคัญสำหรับ DLL export ถ้าลืมใส่กับ class ที่ module อื่นต้องการจะเกิด linker error ("unresolved external symbol") ไม่ใช่ reflection error — เป็นเบาะแสที่ดีในการแยกความผิดพลาดสองแบบนี้ออกจากกัน

Common mistake ใช้ class หรือฟังก์ชันจาก module ที่ลืมใส่ไว้ใน .Build.cs compiler error ที่ได้จะไม่เหมือน macro หายไปเลย มันมักเป็น linker error เกี่ยวกับ symbol ที่ unresolved เพราะ header ถูกเจอ แต่โค้ดที่ compile แล้วของ module นั้นไม่เคยถูก link เข้ามา

12. ตัวอย่างเต็ม: Actor ที่ Tick ได้และเปิดให้ Blueprint ใช้

หัวข้อนี้เอาทุกอย่างมารวมกัน Actor เล็ก ๆ ตัวหนึ่งที่โคจรรอบจุดที่มันเริ่มต้น tick ทุกเฟรม มี property ตัวเลข 2 ตัวและฟังก์ชัน 1 ตัวที่เปิดให้ Blueprint ใช้ทั้งหมด

// OrbitingCrystal.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "OrbitingCrystal.generated.h"

UCLASS(Blueprintable, BlueprintType)
class MYGAME_API AOrbitingCrystal : public AActor
{
    GENERATED_BODY()

public:
    AOrbitingCrystal();

protected:
    virtual void BeginPlay() override;

public:
    virtual void Tick(float DeltaTime) override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Orbit")
    float OrbitRadius = 200.0f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Orbit")
    float OrbitSpeed = 90.0f; // degrees per second

    UFUNCTION(BlueprintCallable, Category = "Orbit")
    void ReverseDirection();

private:
    FVector OrbitCenter;
    float CurrentAngleDegrees = 0.0f;
};
// OrbitingCrystal.cpp
#include "OrbitingCrystal.h"

AOrbitingCrystal::AOrbitingCrystal()
{
    PrimaryActorTick.bCanEverTick = true;
}

void AOrbitingCrystal::BeginPlay()
{
    Super::BeginPlay();
    OrbitCenter = GetActorLocation();
}

void AOrbitingCrystal::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    CurrentAngleDegrees += OrbitSpeed * DeltaTime;

    float AngleRadians = FMath::DegreesToRadians(CurrentAngleDegrees);
    FVector Offset(FMath::Cos(AngleRadians) * OrbitRadius,
                    FMath::Sin(AngleRadians) * OrbitRadius,
                    0.0f);

    SetActorLocation(OrbitCenter + Offset);
}

void AOrbitingCrystal::ReverseDirection()
{
    OrbitSpeed = -OrbitSpeed;
}

ไล่ดูทีละส่วน เชื่อมกับหัวข้อก่อนหน้า

ตัวอย่างการทำงาน (ไม่มี console output ตรงนี้ เพราะเป็นพฤติกรรมทางภาพ นี่คือสิ่งที่เราจะเห็นใน editor)

Place BP_OrbitingCrystal (a Blueprint child of AOrbitingCrystal) in the level at position (0, 0, 100). Press Play. t = 0.0s position = (0+200, 0, 100) [angle 0 deg] t = 1.0s position = (0, 0+200, 100) [angle 90 deg] t = 2.0s position = (0-200, 0, 100) [angle 180 deg] t = 3.0s position = (0, 0-200, 100) [angle 270 deg] t = 4.0s position = (0+200, 0, 100) [angle 360 deg, one full loop] Calling ReverseDirection() at any point flips the sign of OrbitSpeed, so the crystal immediately starts circling the other way, still at the same radius.
Tip เพราะ OrbitRadius กับ OrbitSpeed ถูกเปิดออกมา เราสร้าง Blueprint child ต่างกัน 5 แบบจาก C++ class ตัวเดียวนี้ได้ เช่นโคจรกว้างช้า ๆ, โคจรแคบเร็ว ๆ โดยไม่ต้องเขียน C++ หรือ Blueprint logic เพิ่มเลย แค่เปลี่ยนค่าใน Details panel เท่านั้น

13. เส้นแบ่งระหว่าง C++ <-> Blueprint และข้อผิดพลาดที่พบบ่อย

ถอยออกมามองภาพรวม macro ทุกตัวในบทเรียนนี้มีอยู่เพื่อสร้างสิ่งเดียว คือเส้นแบ่งที่ควบคุมได้ระหว่าง C++ ที่เราเขียนกับ Blueprint graph ที่ designer สร้างต่อยอดขึ้นมา C++ เป็นคนตัดสินว่าอะไรข้ามเส้นนั้นได้ (ผ่าน specifier ของ UPROPERTY/UFUNCTION) ส่วน Blueprint ทำงานได้แค่กับสิ่งที่ C++ เลือกเปิดออกมาให้เท่านั้น

C++ side (compiled, engineer-owned) ------------------------------------- AOrbitingCrystal (UCLASS, Blueprintable) OrbitRadius -- UPROPERTY(EditAnywhere, BlueprintReadWrite) OrbitSpeed -- UPROPERTY(EditAnywhere, BlueprintReadWrite) ReverseDirection() -- UFUNCTION(BlueprintCallable) ============ reflection boundary, built by UHT ============= Blueprint side (visual graph, designer-owned) ------------------------------------- BP_OrbitingCrystal (a Blueprint child of AOrbitingCrystal) - Details panel shows OrbitRadius / OrbitSpeed, editable - Event Graph has a "Reverse Direction" node that calls the C++ function directly - can add extra visual-only logic (particles, sound cues) with no C++ changes and no recompile

ทุกอย่างที่อยู่ฝั่ง C++ ของเส้นนั้นต้อง "แลกมา" ด้วย macro ไม่มีอะไรข้ามไปเองอัตโนมัติ นี่เป็นการออกแบบโดยตั้งใจ — มันทำให้เส้นแบ่งเล็กและชัดเจน engineer มองดู header file แล้วรู้ได้ทันทีว่า designer แตะอะไรได้บ้าง

สรุปข้อผิดพลาดที่มือใหม่เจอบ่อยที่สุด

ข้อผิดพลาดทุกข้อนี้มีรูปแบบเดียวกันอยู่ข้างใต้ คือมีส่วนหนึ่งของ framework ของ Unreal (GC, Blueprint, editor, linker) ต้องรู้เรื่องบางอย่าง แล้ว macro หรือ dependency ที่ขาดหายไปทำให้มันไม่รู้ เมื่อเจอ Unreal error แปลก ๆ คำถามแรกที่ควรถามคือ "ระบบไหนในนี้ที่ไม่รู้จักโค้ดของเรา แล้วทำไม"

Glossary

Exercises

Exercise 1 เขียน USTRUCT ชื่อ FQuestReward ที่ Blueprint ใช้เป็น variable type ได้ ให้มัน 2 field คือ ItemName (เป็น FString) และ Amount (เป็น int32) ทั้งคู่ต้องแก้ไขได้ใน editor และอ่าน/เขียนได้จาก Blueprint
Show answer
USTRUCT(BlueprintType)
struct FQuestReward
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Reward")
    FString ItemName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Reward")
    int32 Amount = 0;
};

BlueprintType บน USTRUCT() คือสิ่งที่ทำให้ Blueprint graph ประกาศตัวแปร type FQuestReward ได้ แต่ละ field ต้องมี UPROPERTY(EditAnywhere, BlueprintReadWrite) ของตัวเอง — specifier บน struct ไม่ได้ apply ไปยัง field ของมันอัตโนมัติ

Exercise 2 โค้ดข้างล่างนี้ compile ผ่าน แต่เกม crash แบบสุ่ม ๆ เสมอหลังจาก level รันไปได้สักพักแล้ว — ไม่เคย crash ทันที หา bug แล้วแก้มัน
UCLASS()
class MYGAME_API AQuestGiver : public AActor
{
    GENERATED_BODY()

private:
    UDialogueAsset* CurrentDialogue;

public:
    void StartQuest(UDialogueAsset* Dialogue)
    {
        CurrentDialogue = Dialogue;
    }

    void PlayLine()
    {
        // crashes here, sometime later
        CurrentDialogue->PlayNextLine();
    }
};
Show answer

CurrentDialogue เป็น raw pointer ไปยัง UObject (UDialogueAsset derive มาจาก UObject) โดยไม่มี UPROPERTY() Garbage Collector มองไม่เห็นมัน ดังนั้นถ้าไม่มีอะไรอื่นถือ UPROPERTY reference ไปยัง dialogue asset ตัวเดียวกันนั้น GC จะตัดสินว่ามันไม่ reachable ในที่สุดแล้วทำลายมัน — ทั้งที่ AQuestGiver ยัง "ใช้งาน" มันอยู่ผ่าน raw pointer ที่มองไม่เห็นตัวนี้ การเรียก PlayLine() ครั้งต่อไปจะ dereference pointer ที่ dangling อยู่ บางทีก็ crash ทันที บางทีก็อ่าน memory ขยะไปสักพักก่อน นี่คือเหตุผลที่ crash รู้สึกสุ่มและล่าช้า เหมือน bug แบบ use-after-free ที่เคยเรียนในบทเรียน C เป๊ะ ๆ

UCLASS()
class MYGAME_API AQuestGiver : public AActor
{
    GENERATED_BODY()

private:
    UPROPERTY()
    TObjectPtr<UDialogueAsset> CurrentDialogue;

public:
    void StartQuest(UDialogueAsset* Dialogue)
    {
        CurrentDialogue = Dialogue;
    }

    void PlayLine()
    {
        CurrentDialogue->PlayNextLine();
    }
};

การเพิ่ม UPROPERTY() ทำให้ CurrentDialogue เป็นส่วนหนึ่งของ reflection data ที่ GC เดินสำรวจ ดังนั้นตราบใดที่ AQuestGiver ยังมีชีวิตอยู่และถือ reference นี้ dialogue asset นับว่า reachable และจะไม่ถูกทำลายไปโดยที่เรายังใช้มันอยู่

Exercise 3 เขียน UFUNCTION ที่ทำเครื่องหมาย BlueprintCallable ซึ่งรับ TArray<int32> (แบบ const reference) แล้วคืนผลรวมของสมาชิกทั้งหมดเป็น int32 จากนั้นอธิบายสัก 1-2 ประโยคว่าทำไมฟังก์ชันนี้ถึงใช้ TArray<int32> แทน std::vector<int32> ในเมื่อมันต้องเรียกได้จาก Blueprint
Show answer
UFUNCTION(BlueprintCallable, Category = "Math")
int32 SumValues(const TArray<int32>& Values) const;

int32 AMyScoreKeeper::SumValues(const TArray<int32>& Values) const
{
    int32 Total = 0;
    for (int32 Value : Values)
    {
        Total += Value;
    }
    return Total;
}

std::vector มองไม่เห็นจาก UnrealHeaderTool ดังนั้น parameter ของ UFUNCTION ที่เป็น type std::vector<int32> จะไม่มี reflection data เลย — Blueprint จะไม่มี array pin ให้ต่อเข้ากับมัน และฟังก์ชันนี้อาจจะ compile เป็น UFUNCTION ไม่ผ่านด้วยซ้ำ ส่วน TArray<int32> UHT เข้าใจ ทำให้ Blueprint เห็น array input pin ปกติ และสร้างหรือส่ง array ของ integer เข้ามาในฟังก์ชันนี้ได้ตรง ๆ

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