เราสมมติว่าคุณรู้ C++ มาตรฐานอยู่แล้ว ทั้ง class, pointer, stack กับ heap, template, standard library บทเรียนนี้จะพูดถึงว่าอะไรเปลี่ยนไปบ้างเมื่อเราเขียน C++ ใน Unreal Engine คำตอบสั้น ๆ คือ ภาษาไม่ได้เปลี่ยน แต่มี framework ก้อนใหญ่มาครอบอยู่ด้านบน แล้ว framework นี้คุยกับโค้ดของเราผ่าน macro ต่าง ๆ บทเรียนนี้จะพาไปดู macro พวกนั้น, type ของ Unreal เอง, memory model ของมัน, และวิธีที่โค้ด C++ เล็ก ๆ ชิ้นหนึ่งจะกลายเป็นสิ่งที่ควบคุมได้จาก Blueprint (ภาษา visual scripting ของ Unreal)
ทุกอย่างที่คุณรู้เรื่อง 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++ เพิ่มให้เบื้องหลังด้วย ส่วนนี้แหละที่เป็นของใหม่ และเป็นหัวข้อหลักของบทเรียนนี้
ลองเทียบ 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
UCLASS() บวกกับ GENERATED_BODY() ทำหน้าที่คล้าย ๆ กับตอนที่ Unity รู้จัก class ว่าเป็น MonoBehaviour แล้วโชว์ field ต่าง ๆ ใน Inspector ให้ ต่างกันตรงที่ Unreal ต้องการให้เราบอกมันตรง ๆ ผ่าน macro เพราะ C++ ธรรมดาไม่มี reflection ในตัวแบบที่ C# มี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 บรรทัดสุดท้ายของไฟล์เสมอ
เราจะไม่มีวันเปิดหรือแก้ไฟล์ .generated.h เอง มันถูก generate ใหม่ทุกครั้งที่ build ถ้าลืม include มัน หรือลืม GENERATED_BODY(), การ build จะพังพร้อม compiler error ที่ชี้ไปยังโค้ดที่ generate หายไป — นี่มักเป็น error แรกที่มือใหม่เจอ ตอนนี้คุณรู้แล้วว่าทำไมมันถึงเกิดขึ้น
#include "MyActor.generated.h" ไว้ที่ไหนก็ได้นอกจาก include บรรทัดสุดท้ายจริง ๆ ของ header UHT คาดหวังให้มันอยู่ท้ายสุด ถ้าวางไว้ก่อนหน้า (หรือลืมใส่) จะทำให้ compile ไม่ผ่านMacro ทั้งสามตัวนี้บอก UHT ว่า "type นี้ควรมีอยู่ในระบบ reflection ของ Unreal" แต่ละตัวใช้กับ type คนละแบบ
UCLASS() — class ที่ inherit (ตรง ๆ หรือทางอ้อม) มาจาก UObject class ส่วนใหญ่ที่เราเขียนสำหรับ gameplay จะเป็น UCLASS type เช่น actor, component, game mode, player controllerUSTRUCT() — struct เก็บข้อมูลธรรมดา ๆ ที่ไม่ได้ derive มาจาก UObject ใช้สำหรับรวมกลุ่มค่าเล็ก ๆ ที่เกี่ยวข้องกัน เหมือน struct ที่คุณรู้จักจาก C++ อยู่แล้ว แต่ถูก reflect ด้วยUENUM() — enum ที่เราอยากให้ค่าของมันมองเห็นได้จาก editor และ Blueprint มักเขียนเป็น enum class ที่ใช้ uint8 เป็น base เพื่อประหยัดพื้นที่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 เองคาดหวังให้เป็นแบบนี้
A — class ที่ derive มาจาก AActor (สิ่งของที่วางใน world ได้ spawn ได้)U — class ที่ derive มาจาก UObject แต่ไม่ได้มาจาก AActor (เช่น UActorComponent หรือ gameplay object ธรรมดา ๆ)F — struct ธรรมดาที่ไม่ได้ derive มาจาก UObject เลย (FVector, FString และ struct ของเราเองอย่าง FLootEntry)E — enumทุก UCLASS และ USTRUCT ต้องขึ้นต้นด้วย GENERATED_BODY() เสมอ — macro ตัวนี้คือจุดที่ UHT เติมโครงสร้างภายใน (constructor, ตัวช่วย serialization, การลงทะเบียน reflection) ที่ทำให้ macro ตัวอื่นทำงานได้ ถ้าไม่มีมัน UPROPERTY หรือ UFUNCTION ที่อยู่ข้างล่างจะไม่ทำงานอะไรเลย
BlueprintType บน USTRUCT หรือ UENUM แปลว่า "Blueprint สร้างตัวแปร type นี้ได้" ถ้าไม่ใส่ struct หรือ enum นั้นก็ยัง compile ผ่านใน C++ ได้ปกติ แต่ Blueprint graph จะใช้มันตรง ๆ ไม่ได้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 ที่ใช้บ่อย
EditAnywhere — แก้ไขได้ใน editor ทั้งบน class default และบน instance แต่ละตัวที่วางลงใน levelEditDefaultsOnly — แก้ไขได้เฉพาะบน class default เท่านั้น (ใน Blueprint editor ของ Blueprint child) แก้แยกต่อ instance ใน level ไม่ได้VisibleAnywhere — โชว์ใน editor แต่เป็นสีเทา แก้จาก editor ไม่ได้ (ยัง set จาก C++ ได้)BlueprintReadWrite — Blueprint graph อ่านและเขียนค่านี้ได้ตอน runtimeBlueprintReadOnly — Blueprint graph อ่านค่าได้ แต่ set ค่าไม่ได้Category = "Name" — จัดกลุ่ม field ไว้ใต้หัวข้อใน Details panel ของ editor เป็นแค่เรื่องจัดระเบียบfield ที่ไม่มี UPROPERTY() เลยจะมองไม่เห็นจากทุกระบบเหล่านั้น ไม่มีใน editor ไม่มีใน Blueprint และ — ข้อนี้สำคัญมาก — ไม่ถูก garbage collector track ถ้ามันเป็น pointer ไปยัง UObject หัวข้อ 8 จะย้อนกลับมาอธิบายว่าทำไมข้อสุดท้ายนี้ทำให้เกมพังได้
[SerializeField] ของ Unity, UPROPERTY(EditAnywhere) ทำหน้าที่คล้ายกัน คือเปิด field ที่รู้สึกเหมือน private ให้ editor เห็น โดยไม่ต้องทำให้มันเป็น public field ของ C++ ที่ class อื่นเข้าถึงได้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();
BlueprintCallable — โผล่ใน Blueprint เป็น node ที่มี execution pin (ขั้นตอนแบบ "ทำสิ่งนี้ แล้วไปต่อ") ใช้กับฟังก์ชันที่ "ทำ" อะไรบางอย่าง (ยิงอาวุธ, ใส่ damage)BlueprintPure — โผล่ใน Blueprint เป็น node ที่ไม่มี execution pin มีแค่ data output ใช้กับฟังก์ชันที่แค่ "คำนวณ" แล้ว "คืนค่า" กลับมาโดยไม่มี side effect (เหมือน GetHealthPercent ด้านบน)BlueprintImplementableEvent — เราประกาศฟังก์ชันใน C++ แต่ไม่เขียน body ให้มันเลย Blueprint เป็นคนเขียน implementation ทั้งหมดเอง C++ เรียก OnWeaponFired() ตามปกติ แล้วอะไรก็ตามที่ designer ต่อไว้ใน Event Graph จะรันมีตัวเลือกที่ 4 ซึ่งเกี่ยวข้องกันควรรู้ไว้ คือ BlueprintNativeEvent ซึ่งคล้าย BlueprintImplementableEvent แต่ C++ มี default body ให้ด้วย (เขียนเป็น OnWeaponFired_Implementation()) ซึ่ง Blueprint จะ override หรือไม่ก็ได้ บทเรียนนี้จะไม่ใช้มัน แต่คุณจะเจอมันในโค้ดของคนอื่นแน่นอน
BlueprintPure ทั้งที่จริง ๆ มันเปลี่ยน game state (เช่นเพิ่มค่า counter) Blueprint อาจ evaluate pure node ศูนย์ครั้ง หนึ่งครั้ง หรือหลายครั้งก็ได้ ขึ้นอยู่กับว่ามี node อื่นต้องการ output ของมันกี่ตัว — side effect ในฟังก์ชันที่ "pure" ทำให้เกิด bug ที่งงและไม่คงเส้นคงวา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 ใช้ทำอะไร
FString — string ที่แก้ไขได้ ขนาดยืดหยุ่นได้ ประมาณ std::string string literal ห่อด้วย TEXT("...") เพื่อให้มันใช้ character type ที่ถูกต้องในทุกแพลตฟอร์มFVector — float สามตัว (X, Y, Z) เป็น 3D vector/point type ของ Unreal มี operator สำหรับบวก ลบ dot product, ความยาว ฯลฯ เขียนไว้ให้แล้วTArray<T> — dynamic array ประมาณ std::vector<T> memory ต่อเนื่องกัน โตขึ้นเมื่อเรา Add() index ด้วย [] ได้ ดูขนาดด้วย .Num() แทนที่จะเป็น .size()TMap<K, V> — hash map ประมาณ std::unordered_map<K, V> เก็บคู่ key-value, .Add(Key, Value), ค้นหาด้วย .Find(Key)เครื่องหมาย * ก่อน Name ใน UE_LOG คือการแปลง FString ให้เป็น raw character pointer ซึ่งเป็นสิ่งที่ format specifier %s ต้องการ — FString overload dereference operator * ไว้เพื่อจุดประสงค์นี้โดยเฉพาะ
T แปลว่า "template container" (TArray, TMap, TObjectPtr, หัวข้อ 9) คำนำหน้า F แปลว่า "plain struct" (FString, FVector) และ type ตัวเลขเป็นแบบ fixed-width (int32, uint8) แทนที่จะเป็น int ธรรมดา เพื่อให้ขนาดของมันไม่ขึ้นอยู่กับแพลตฟอร์มหรือ compilerนี่เป็นคำถามที่สมเหตุสมผลถ้าคุณรู้จัก standard library ดีอยู่แล้ว — ทำไมต้องเรียนชื่อ container ใหม่สำหรับสิ่งที่ดูเหมือนจะทำงานเหมือนกัน มีเหตุผลจริง ๆ 4 ข้อ
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 ด้วย
หัวข้อ 8 จะอธิบายเรื่องนี้ละเอียดกว่านี้ แต่สรุปสั้น ๆ คือ garbage collector ต้องเดินสำรวจทุก container ที่เป็น UPROPERTY เพื่อหา pointer ของ UObject ข้างในนั้น TArray กับ TMap ถูกสร้างมาให้รองรับการเดินสำรวจแบบนี้ ส่วน std::vector ไม่ได้เป็นส่วนหนึ่งของระบบนี้เลย
Unreal ออกเกมบน PC, console, มือถือ แต่ละแบบมีข้อจำกัด memory ต่างกัน container ทั้งหมดของมัน allocate ผ่านระบบ memory ของ Unreal เอง (FMemory) ซึ่ง memory profiler ของ engine และ allocator เฉพาะแต่ละแพลตฟอร์มเสียบเข้ามาใช้ได้ ถ้าใช้เส้นทาง allocation แยกที่ไม่เกี่ยวข้องกัน (std::vector ดิบ ๆ) ก็จะข้ามเครื่องมือพวกนั้นไปหมด
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++ ภายในล้วน ๆ ที่ไม่ข้ามเส้นนั้นไป
ย้อนกลับไปที่บทเรียน 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 ด้วย อะไรก็ตามที่มันเดินไปถึงระหว่างสำรวจจะรอด อะไรที่มันไปไม่ถึงจะถูกทำลาย
นี่คืออันตรายของ 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;
};
UPROPERTY() เปล่า ๆ ไม่มี specifier เลย เหมือนตัวอย่างข้างบน ใช้ได้สมบูรณ์แบบ ไม่จำเป็นต้องมี EditAnywhere หรือ BlueprintReadWrite เพื่อให้ GC track มัน มือใหม่บางคนข้าม UPROPERTY() ไปเลยกับ pointer ภายในเพราะคิดว่า "editor ไม่ต้องเห็นตัวนี้หรอก" ซึ่งทำให้ GC tracking หายไปพร้อมกับทุกอย่างอื่นด้วยUnreal สมัยใหม่ (UE5) ชอบใช้ TObjectPtr<T> มากกว่า T* ธรรมดาสำหรับ UPROPERTY ตัวไหนก็ตามที่ชี้ไปยัง UObject เราใช้มันไปแล้วในหัวข้อก่อนหน้าโดยยังไม่ได้อธิบายเต็ม ๆ มาดูกัน
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI")
TObjectPtr<AActor> TargetActor;
ในโค้ดทั่วไป TObjectPtr<AActor> ทำตัวเหมือน AActor* ธรรมดา คือ dereference ด้วย ->, เทียบกับ nullptr ได้ และส่งต่อไปมาแบบเดียวกัน สิ่งที่มันเพิ่มขึ้นมาคือ
AActor* เป็น TObjectPtr<AActor> แทบไม่ต้องแก้โค้ดที่ใช้ field นั้นเลยต้องเข้าใจให้ชัดว่า TObjectPtr ไม่ใช่อะไร มันไม่ใช่ reference-counted smart pointer แบบ std::shared_ptr มันไม่ได้ตัดสินใจว่า object จะตายเมื่อไหร่ Garbage Collector ยังเป็นเจ้าของการตัดสินใจนั้นทั้งหมดเหมือนเดิม เหมือนกับ raw UPROPERTY pointer — TObjectPtr แค่ทำให้เครื่องมือของ Unreal เห็นและตรวจสอบ reference นี้ได้ง่ายขึ้นเท่านั้น
มี type ที่เกี่ยวข้องกันอีก 2 ตัวที่ควรรู้จักชื่อไว้ แม้จะไม่ได้ใช้ในตัวอย่างของบทเรียนนี้
TWeakObjectPtr<T> — reference ที่ไม่ได้เป็นเจ้าของ ไม่ทำให้ object มีชีวิตอยู่ต่อ และจะกลายเป็น null อัตโนมัติถ้า GC ทำลาย object นั้น มีประโยชน์เมื่อเราอยากอ้างถึงบางอย่างโดยไม่ส่งผลต่อว่ามันจะรอดหรือไม่TSharedPtr<T> — smart pointer แบบ reference-counted จริง ๆ คล้าย std::shared_ptr แต่ใช้ได้เฉพาะกับ C++ object ธรรมดาที่ไม่ได้ derive มาจาก UObject ห้ามใช้กับ UObject เด็ดขาด เพราะ GC กับระบบ ref-counting จะแย่งกันเป็นเจ้าของ object นั้นUObject เป็น member ของ class? ใช้ UPROPERTY() TObjectPtr<T> C++ object ธรรมดาที่ไม่ใช่ UObject แต่ต้องการ shared ownership? ใช้ TSharedPtr<T> ไม่ต้องมี UPROPERTY ไม่เกี่ยวกับ GC เลยที่ผ่านมาเราเปิด 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();
};
Blueprintable — C++ class นี้เป็น parent ของ Blueprint class ใหม่ได้ ใน editor: Content Browser -> Add -> Blueprint Class -> หา AInteractableDoor เป็น parentBlueprintType — class นี้ใช้เป็น variable type ใน Blueprint graph ได้ (เช่น ช่องตัวแปรที่เก็บ "reference ไปยัง Interactable Door")ส่วนใหญ่ 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++ เลย
project ของ Unreal ไม่ได้เป็น source file กองใหญ่ก้อนเดียวที่ compile พร้อมกันทีเดียว มันถูกแบ่งเป็น module — หน่วยที่ compile แยกกัน แต่ละหน่วยมีชื่อของตัวเอง มี folder ของตัวเองอยู่ใต้ Source/ และมี list ของ dependency ของตัวเอง โค้ดเกมหลักของเราคือ module หนึ่ง แต่ละ plugin คือ module เพิ่มอีกหนึ่งหรือหลายตัว ตัว engine เองก็ build มาจาก module หลายสิบตัว (Core, CoreUObject, Engine และอื่น ๆ)
ทุก 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[] { });
}
}
Core — type พื้นฐานที่สุดของ Unreal FString, TArray, math type, ระบบ memoryCoreUObject — ระบบ UObject เอง reflection, garbage collection, ทุกอย่างที่บทเรียนนี้พูดถึงEngine — class สำหรับ gameplay AActor, APawn, UWorld, component และอื่น ๆInputCore — คำจำกัดความของ input key และ axisPublicDependencyModuleNames — dependency ที่มองเห็นได้จาก module อื่นที่มาพึ่งพา module ของเราด้วยPrivateDependencyModuleNames — dependency ที่ใช้แค่ภายใน module ของเรา ไม่ส่งต่อไปให้ใครอีกตรงนี้เองที่ 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 — เป็นเบาะแสที่ดีในการแยกความผิดพลาดสองแบบนี้ออกจากกัน
.Build.cs compiler error ที่ได้จะไม่เหมือน macro หายไปเลย มันมักเป็น linker error เกี่ยวกับ symbol ที่ unresolved เพราะ header ถูกเจอ แต่โค้ดที่ compile แล้วของ module นั้นไม่เคยถูก link เข้ามาหัวข้อนี้เอาทุกอย่างมารวมกัน 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;
}
ไล่ดูทีละส่วน เชื่อมกับหัวข้อก่อนหน้า
PrimaryActorTick.bCanEverTick = true ใน constructor เปิด Tick ให้ทำงาน ค่า default คือปิดไว้เพื่อ performance — actor ทุกตัวที่เปิด Tick กินเวลาไปนิดหน่อยทุก ๆ เฟรม Unreal เลยให้เรา opt-in เอง (ถ้าคุณรู้จัก MonoBehaviour.Update() ของ Unity: นี่คือแนวคิดเดียวกับ Tick(float DeltaTime) ต่างกันตรงที่ Unity เรียก Update ให้อัตโนมัติ ส่วน Unreal ต้องเปิด switch นี้เอง)BeginPlay() คือ hook "เริ่มเล่น" ของ Unreal ถูกเรียกครั้งเดียว — เหมือน Start() ของ Unity มันเก็บตำแหน่งเริ่มต้นของ actor ไว้เป็นจุดศูนย์กลางการโคจรTick(float DeltaTime) รันทุกเฟรม DeltaTime คือเวลาเป็นวินาทีนับตั้งแต่เฟรมที่แล้ว แนวคิดเดียวกับการเคลื่อนที่แบบไม่ขึ้นกับเฟรมที่เคยเรียนในบทเรียนคณิตศาสตร์/engine ก่อนหน้านี้OrbitRadius กับ OrbitSpeed เป็น EditAnywhere, BlueprintReadWrite ทำให้ designer ปรับค่าต่อ instance ใน level ได้จาก Details panel หรืออ่าน/เปลี่ยนมันตอน runtime จาก Blueprint graph ได้ โดยไม่ต้อง recompile C++ReverseDirection() เป็น BlueprintCallable ทำให้ต่อเข้ากับ input event แบบ "On Button Pressed" ได้เลยทั้งหมดใน Blueprintตัวอย่างการทำงาน (ไม่มี console output ตรงนี้ เพราะเป็นพฤติกรรมทางภาพ นี่คือสิ่งที่เราจะเห็นใน editor)
OrbitRadius กับ OrbitSpeed ถูกเปิดออกมา เราสร้าง Blueprint child ต่างกัน 5 แบบจาก C++ class ตัวเดียวนี้ได้ เช่นโคจรกว้างช้า ๆ, โคจรแคบเร็ว ๆ โดยไม่ต้องเขียน C++ หรือ Blueprint logic เพิ่มเลย แค่เปลี่ยนค่าใน Details panel เท่านั้นถอยออกมามองภาพรวม macro ทุกตัวในบทเรียนนี้มีอยู่เพื่อสร้างสิ่งเดียว คือเส้นแบ่งที่ควบคุมได้ระหว่าง C++ ที่เราเขียนกับ Blueprint graph ที่ designer สร้างต่อยอดขึ้นมา C++ เป็นคนตัดสินว่าอะไรข้ามเส้นนั้นได้ (ผ่าน specifier ของ UPROPERTY/UFUNCTION) ส่วน Blueprint ทำงานได้แค่กับสิ่งที่ C++ เลือกเปิดออกมาให้เท่านั้น
ทุกอย่างที่อยู่ฝั่ง C++ ของเส้นนั้นต้อง "แลกมา" ด้วย macro ไม่มีอะไรข้ามไปเองอัตโนมัติ นี่เป็นการออกแบบโดยตั้งใจ — มันทำให้เส้นแบ่งเล็กและชัดเจน engineer มองดู header file แล้วรู้ได้ทันทีว่า designer แตะอะไรได้บ้าง
GENERATED_BODY() เป็นบรรทัดแรกในตัว UCLASS/USTRUCT — ทำให้เจอ compiler error กองใหญ่ที่งง ๆ ชี้ไปที่ไฟล์ .generated.h#include "ClassName.generated.h" ให้เป็น include บรรทัดสุดท้ายของ headernew/delete กับ UObject/AActor แทนที่จะใช้ NewObject/SpawnActor และ Destroy() — ข้ามระบบ object ของ engine ไปทั้งหมดUObject โดยไม่มี UPROPERTY() — Garbage Collector มองไม่เห็น อาจถูกทำลายทั้งที่เรายังถือ pointer ที่ "ดูเหมือนใช้ได้" แต่จริง ๆ dangling อยู่PrimaryActorTick.bCanEverTick = true แล้วงงว่าทำไม Tick() ไม่เคยรันPublicDependencyModuleNames / PrivateDependencyModuleNames ใน .Build.cs — ทำให้เกิด linker error ไม่ใช่ reflection errorข้อผิดพลาดทุกข้อนี้มีรูปแบบเดียวกันอยู่ข้างใต้ คือมีส่วนหนึ่งของ framework ของ Unreal (GC, Blueprint, editor, linker) ต้องรู้เรื่องบางอย่าง แล้ว macro หรือ dependency ที่ขาดหายไปทำให้มันไม่รู้ เมื่อเจอ Unreal error แปลก ๆ คำถามแรกที่ควรถามคือ "ระบบไหนในนี้ที่ไม่รู้จักโค้ดของเรา แล้วทำไม"
UObject) ให้เข้าระบบ reflection ของ UnrealUObject) ให้ถูก reflectUCLASS/USTRUCT UHT เติมโครงสร้างภายในให้เบื้องหลังClassName.generated.h ก่อนที่ compiler ตัวจริงจะรันUFUNCTION ที่ควบคุมว่าฟังก์ชันจะโผล่และทำงานยังไงใน BlueprintUObject ทำลาย object ที่ไม่ reachable ผ่าน UPROPERTY pointer จาก root set อีกต่อไปUPROPERTY ที่ reference ไปยัง UObject ทำตัวเหมือน raw pointer แต่เพิ่ม tracking และ debug checkUObject ที่กลายเป็น null อัตโนมัติเมื่อ object ถูก GC ทำลาย.Build.csUCLASS ที่ควบคุมว่า class ใช้เป็น Blueprint parent ได้ไหม / ใช้เป็น Blueprint variable type ได้ไหมAActor) สำหรับสิ่งของที่วางและกำหนดตำแหน่งใน level ได้Update() ของ Unity ปิดไว้เป็น default ต่อ actorUSTRUCT ชื่อ FQuestReward ที่ Blueprint ใช้เป็น variable type ได้ ให้มัน 2 field คือ ItemName (เป็น FString) และ Amount (เป็น int32) ทั้งคู่ต้องแก้ไขได้ใน editor และอ่าน/เขียนได้จาก BlueprintUSTRUCT(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 ของมันอัตโนมัติ
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();
}
};
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 และจะไม่ถูกทำลายไปโดยที่เรายังใช้มันอยู่
UFUNCTION ที่ทำเครื่องหมาย BlueprintCallable ซึ่งรับ TArray<int32> (แบบ const reference) แล้วคืนผลรวมของสมาชิกทั้งหมดเป็น int32 จากนั้นอธิบายสัก 1-2 ประโยคว่าทำไมฟังก์ชันนี้ถึงใช้ TArray<int32> แทน std::vector<int32> ในเมื่อมันต้องเรียกได้จาก BlueprintUFUNCTION(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 เข้ามาในฟังก์ชันนี้ได้ตรง ๆ