ทุกเกมต้องโหลดของจากดิสก์ — texture, audio clip, ข้อมูลไอเทม, ไฟล์เซฟทั้งไฟล์ — แล้วแปลงมันให้เป็น object จริง ๆ ที่โค้ดเราใช้งานได้ ก่อนที่สุดท้ายจะปล่อยมันทิ้งไปอีกครั้ง จนถึงตอนนี้ในหลักสูตรนี้ เกือบทุกอย่างที่เราสร้างมาอยู่ในหน่วยความจำล้วน ๆ ตลอดเวลาที่โปรแกรมรัน ไม่ว่าจะเป็นสถานะฟิสิกส์ ท่าแอนิเมชัน หรือข้อความเครือข่าย บทนี้ครอบคลุมสามงานที่เกี่ยวโยงกันแน่นแฟ้น ซึ่งอยู่ใต้แทบทุกอย่างในเอนจิ้น resource system เป็นตัวตัดสินว่าไฟล์หนึ่งจะถูกอ่านจากดิสก์จริง ๆ เมื่อไหร่ และถูกทิ้งไปอีกครั้งเมื่อไหร่ serialization แปลง object ให้เป็น byte หรือ text (แล้วแปลงกลับ) เพื่อให้เซฟ โหลด หรือส่งไปที่อื่นได้ reflection คือกลไกที่ทำให้เครื่องมือของเอนจิ้นเอง — หน้าต่าง Inspector, ตัว serializer เอง — ทำงานกับ class ไหนก็ได้ที่เราเขียน โดยที่คนเขียนเครื่องมือไม่เคยเห็นโค้ดของเรามาก่อนเลย Unity พึ่งพาทั้งสามอย่างนี้ตลอดเวลา บทนี้เลยจบด้วยส่วนที่เป็น Unity โดยตรง — ScriptableObject, [SerializeField], และไฟล์ .meta — ซึ่งเป็นสิ่งที่มือใหม่เจอก่อนและมักเข้าใจผิดมากที่สุด
การอ่านไฟล์จากดิสก์ช้ากว่าแทบทุกอย่างที่โค้ดเราทำ — ช้ากว่าการเรียกฟังก์ชัน ช้ากว่าการวน loop ผ่าน array ในหน่วยความจำ ถ้าโค้ด UI กับโค้ดโหลดเลเวลของเราต่างก็ต้องการ texture ก้อนหินตัวเดียวกัน แล้วทั้งคู่ก็แค่เรียก "โหลดไฟล์นี้" ทุกครั้งที่ต้องการมัน เราก็ต้องจ่ายค่าอ่านดิสก์ที่ช้านั้นถึงสองครั้งสำหรับ byte ชุดเดียวกันเป๊ะ ๆ แล้วสุดท้ายก็ได้ภาพเดียวกันสองชุดแยกกันนอนอยู่ในหน่วยความจำ
resource system (เรียกอีกอย่างว่า asset system) แก้ปัญหานี้ด้วยตาราง lookup: ครั้งแรกที่มีใครขอ path หนึ่ง มันจะอ่านไฟล์จริง ๆ แล้วจำผลลัพธ์ไว้ภายใต้ path นั้น ทุกครั้งหลังจากนั้นที่มีการขอ path เดิม มันก็แค่ส่ง object ที่มีอยู่แล้วกลับไป
using System;
using System.Collections.Generic;
class ResourceCache<T> where T : class
{
Dictionary<string, T> cache = new Dictionary<string, T>();
public T Load(string path, Func<string, T> loader)
{
if (cache.TryGetValue(path, out T existing))
{
Console.WriteLine($"[cache hit] {path}");
return existing;
}
Console.WriteLine($"[cache miss] {path} -- reading from disk");
T loaded = loader(path);
cache[path] = loaded;
return loaded;
}
}
class Texture
{
public string Path;
public Texture(string path) { Path = path; }
}
class Program
{
static void Main()
{
var cache = new ResourceCache<Texture>();
Texture uiIcon = cache.Load("art/rock.png", p => new Texture(p));
Texture levelRock = cache.Load("art/rock.png", p => new Texture(p));
Console.WriteLine(ReferenceEquals(uiIcon, levelRock) ? "same object" : "different objects");
}
}
output จริงที่คาดว่าจะได้:
[cache miss] art/rock.png -- reading from disk
[cache hit] art/rock.png
same object
ฟังก์ชัน loader (ตัวที่อ่าน byte จากดิสก์จริง ๆ แล้วสร้าง Texture) รันแค่ครั้งเดียว ตอนเรียกครั้งแรกเท่านั้น การเรียกครั้งที่สองเจอว่า "art/rock.png" อยู่ใน dictionary แล้ว เลย return object ตัวเดิมเป๊ะ ๆ กลับไป ReferenceEquals ยืนยันเรื่องนี้ — ตัวแปรทั้งสองตัวชี้ไปที่ Texture ตัวเดียวกันในหน่วยความจำ ไม่ใช่สองชุดแยกกัน
ทุกเอนจิ้นที่เราจะได้ทำงานด้วยมีอะไรแบบนี้เวอร์ชันหนึ่งเสมอ: asset loading ของ Unity, asset registry ของ Unreal, หรือ texture/mesh manager ของเอนจิ้นที่เขียนเอง ชื่อเรียกเปลี่ยนไป แต่แนวคิด (ตารางจาก key ที่คงที่ ไปยัง object ที่สร้างเสร็จแล้ว) ไม่เปลี่ยน
cache จากข้อ 1 มีปัญหาจริงจังอยู่หนึ่งอย่าง: มันไม่เคยลืมอะไรเลย ทุก texture ที่เคยโหลดมาจะอยู่ในหน่วยความจำไปตลอดอายุโปรแกรม ไม่ว่าจะยังมีใครต้องการมันอยู่หรือไม่ ในเกมจริง เลเวลถูกโหลดออกแล้วโหลดกลับเข้ามาใหม่ตลอด ผู้เล่นเปิดปิด inventory ที่เต็มไปด้วยไอคอนตลอดเวลา การเก็บทุกอย่างไว้ตลอดไปแบบนี้สุดท้ายก็หน่วยความจำหมดแน่นอน
ทางแก้คือ reference counting: แทนที่จะจำแค่ "โหลดแล้วหรือยัง" cache จะจำว่า ตอนนี้มีกี่อย่างที่ต้องการมันอยู่ ทุกคนที่ยืมไปใช้จะเรียก Acquire เพื่อขอ object (ซึ่งจะเพิ่ม count) แล้วเรียก Release ตอนใช้เสร็จ (ซึ่งจะลด count) เมื่อ count ลงมาเป็นศูนย์ แปลว่าไม่มีใครต้องการมันแล้ว เลยปลอดภัยที่จะปล่อยหน่วยความจำจริง ๆ
using System;
using System.Collections.Generic;
class ResourceCache<T> where T : class
{
Dictionary<string, T> cache = new Dictionary<string, T>();
Dictionary<string, int> refCount = new Dictionary<string, int>();
public T Acquire(string path, Func<string, T> loader)
{
if (cache.TryGetValue(path, out T existing))
{
refCount[path]++;
Console.WriteLine($"[acquire] {path} refs={refCount[path]} (reused)");
return existing;
}
T loaded = loader(path); // actually reads the file from disk
cache[path] = loaded;
refCount[path] = 1;
Console.WriteLine($"[acquire] {path} refs=1 (loaded from disk)");
return loaded;
}
public void Release(string path)
{
if (!refCount.ContainsKey(path)) return;
refCount[path]--;
Console.WriteLine($"[release] {path} refs={refCount[path]}");
if (refCount[path] <= 0)
{
cache.Remove(path);
refCount.Remove(path);
Console.WriteLine($"[unload] {path} no users left, memory freed");
}
}
}
class Program
{
static void Main()
{
var textures = new ResourceCache<Texture>();
textures.Acquire("art/rock.png", p => new Texture(p)); // UI system needs it
textures.Acquire("art/rock.png", p => new Texture(p)); // Level system needs it too
textures.Release("art/rock.png"); // UI system is done
textures.Release("art/rock.png"); // Level system is done -- now it unloads
}
}
[acquire] art/rock.png refs=1 (loaded from disk)
[acquire] art/rock.png refs=2 (reused)
[release] art/rock.png refs=1
[release] art/rock.png refs=0
[unload] art/rock.png no users left, memory freed
malloc/free จากบท C หรือ new/delete ใน C++ แค่เปลี่ยนจากครั้งเดียวจบเป็นแบบนับจำนวน: ทุก Acquire ต้องคู่กับ Release พอดีหนึ่งครั้ง ลืม Release แล้ว count จะไม่มีวันถึงศูนย์ — memory leak จริง ๆ แค่ซ่อนอยู่หลัง resource cache แทนที่จะเป็น raw pointer เรียก Release เกินจำนวนไปหนึ่งครั้ง count ก็ติดลบได้ ทำให้สิ่งที่ส่วนอื่นของเกมยังคิดว่าตัวเองเป็นเจ้าของอยู่ถูก unload ไปทั้งที่ยังใช้อยู่มันเป็นเรื่องน่าลองที่จะระบุ asset ด้วย file path ของมัน — "Art/Player/hero.png" เป็น key ที่คนอ่านเข้าใจได้ดีเลย ปัญหาจะโผล่มาทันทีที่มีใครเปลี่ยนชื่อโฟลเดอร์หรือย้ายไฟล์ ซึ่งเกิดขึ้นตลอดเวลาในทีมจริง ๆ: artist จัดโฟลเดอร์ Art ใหม่ designer เปลี่ยนชื่อ hero.png เป็น hero_v2.png asset อื่น ๆ ทุกตัวที่เก็บ path string นั้นไว้ตรง ๆ เป็น reference — prefab, scene, ไฟล์เซฟ — ตอนนี้ก็ชี้ไปที่ไฟล์ที่ไม่มีอยู่ตรงนั้นแล้ว นั่นคือ broken reference และในทีมที่มี asset เป็นพัน ๆ ตัว การเปลี่ยนชื่ออะไรก็ตามกลายเป็นเรื่องน่ากลัว
ทางแก้ที่เอนจิ้นจริงจังทุกตัวใช้คือ GUID (Globally Unique Identifier) — ID ที่คงที่ สุ่มมาแบบที่ถือว่าไม่ซ้ำใคร กำหนดให้ asset แค่ครั้งเดียวตอนที่มันถูก import เข้ามาครั้งแรก asset อื่น ๆ จะเก็บ reference ไปที่ GUID ไม่ใช่ path ดิบ ๆ มีตาราง lookup แยกต่างหาก ที่เอนจิ้นดูแลไว้ คอยแมป GUID แต่ละตัวไปยัง path ปัจจุบัน ของ asset นั้น การเปลี่ยนชื่อหรือย้ายไฟล์แค่อัปเดตแถวเดียวในตารางนั้น GUID reference ทุกที่ในโปรเจกต์ก็ยัง resolve ถูกต้องต่อไป โดยไม่มีใครต้องไปตามหาแก้เอง
using System;
using System.Collections.Generic;
class AssetDatabase
{
// GUID -> current file path -- this table IS the .meta file system, simplified
Dictionary<Guid, string> guidToPath = new Dictionary<Guid, string>();
public Guid Register(string path)
{
Guid id = Guid.NewGuid(); // assigned ONCE, when the asset is first imported
guidToPath[id] = path;
return id;
}
public void Rename(Guid id, string newPath)
{
guidToPath[id] = newPath; // GUID stays the same, only the path column changes
}
public string Resolve(Guid id) => guidToPath[id];
}
class Program
{
static void Main()
{
var db = new AssetDatabase();
Guid heroTextureId = db.Register("Art/Player/hero.png");
// a prefab "remembers" the asset by storing this GUID, not the path
Guid prefabReference = heroTextureId;
Console.WriteLine("Before rename: " + db.Resolve(prefabReference));
db.Rename(heroTextureId, "Art/Characters/hero_v2.png");
Console.WriteLine("After rename: " + db.Resolve(prefabReference));
}
}
Before rename: Art/Player/hero.png
After rename: Art/Characters/hero_v2.png
prefabReference ไม่เคยเปลี่ยนเลย — มันเป็น GUID ตัวเดิมตลอด การ lookup ผ่าน db.Resolve ให้ path ปัจจุบันที่ถูกต้องทั้งก่อนและหลังเปลี่ยนชื่อ เพราะการเปลี่ยนชื่อแตะแค่คอลัมน์ path ของตาราง ไม่ได้แตะ GUID เอง
hero.png ไม่แตะ GUID ของมันเลย — มีแต่การลบไฟล์ (หรือทำ metadata หายไป ซึ่งพูดถึงในข้อ 10) เท่านั้นที่จะทำให้ GUID เปลี่ยนSerialization คือกระบวนการแปลง object ที่อยู่ในหน่วยความจำ ให้กลายเป็นชุดของ byte หรือ character ที่เขียนลงไฟล์ ส่งผ่านเครือข่าย หรือเก็บลงฐานข้อมูลได้ Deserialization คือกระบวนการย้อนกลับ: อ่าน byte หรือ character เหล่านั้นกลับมา แล้วสร้าง object ที่เทียบเท่ากันขึ้นมาใหม่
using System;
using System.Text.Json;
class PlayerSave
{
public string Name { get; set; }
public int Level { get; set; }
public float[] Position { get; set; }
}
class Program
{
static void Main()
{
var save = new PlayerSave { Name = "Aran", Level = 7, Position = new float[] { 12.5f, 0f, -3.2f } };
string json = JsonSerializer.Serialize(save);
Console.WriteLine(json);
PlayerSave loaded = JsonSerializer.Deserialize<PlayerSave>(json);
Console.WriteLine($"{loaded.Name} is level {loaded.Level}");
}
}
{"Name":"Aran","Level":7,"Position":[12.5,0,-3.2]}
Aran is level 7
Serialize จะไล่ผ่าน property ของ save ทุกตัว แล้วเขียนค่าของแต่ละตัวออกมาเป็นรูปแบบ text ของ JSON Deserialize จะ parse text นั้นแล้วสร้าง PlayerSave object ใหม่เอี่ยมที่มีค่าเหมือนกัน — เป็น object คนละตัวในหน่วยความจำจาก save ตัวเดิม แต่ถือข้อมูลที่เทียบเท่ากัน
บรรทัดสุดท้ายนั้นสำคัญกว่าที่มันดูนะ ข้อมูลที่ serialize แล้วจะมี raw pointer อยู่ไม่ได้เลย — memory address จากเนื้อหาเรื่อง pointer ในบทที่ 2 address จะมีความหมายก็ต่อเมื่ออยู่ในโปรแกรมที่กำลังรันและเป็นเจ้าของหน่วยความจำนั้นเท่านั้น เซฟมันลงดิสก์แล้วโหลดกลับในโปรเซสใหม่ (หรือคนละเครื่องไปเลย) ตัวเลขนั้นก็เป็นขยะ หรือแย่กว่านั้นคือชี้ไปที่หน่วยความจำอื่นที่ไม่เกี่ยวข้องกันที่บังเอิญมีอยู่ตรงนั้นตอนนี้ นี่คือเหตุผลที่ระบบ GUID ในข้อ 3 มีอยู่: แทนที่จะเซฟ raw address ไปที่ "hero texture" serializer จะเซฟ ID ที่คงที่ (GUID, path, หรือ index) แล้วไปตามหา object จริงกลับผ่าน resource cache หลังโหลดเสร็จ แบบเดียวกับที่ AssetDatabase.Resolve ทำ
ข้อมูลที่ serialize แล้วแบ่งเป็นสองตระกูลใหญ่ ๆ Text format (JSON, YAML, XML) เก็บข้อมูลเป็น character ที่คนอ่านออก — เปิดไฟล์ด้วย text editor ตัวไหนก็อ่านได้เลย Binary format เก็บข้อมูลเป็น byte ดิบ ๆ ที่จัดวางให้ตรงกับที่ค่าต่าง ๆ อยู่ในหน่วยความจำ หรือ encoding แบบกระชับแบบอื่น — โดยทั่วไปต้องมีโปรแกรม (หรือเอกสารที่ถูกต้อง) ถึงจะแปลความหมายมันได้
using System;
using System.IO;
using System.Text.Json;
struct SaveDataBinary
{
public int Level { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
}
class Program
{
static byte[] WriteBinary(SaveDataBinary data)
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(data.Level); // 4 bytes, no field name stored anywhere
writer.Write(data.X); // 4 bytes
writer.Write(data.Y); // 4 bytes
writer.Write(data.Z); // 4 bytes
return stream.ToArray();
}
static SaveDataBinary ReadBinary(byte[] bytes)
{
using var stream = new MemoryStream(bytes);
using var reader = new BinaryReader(stream);
// fields must be read back in the EXACT same order they were written
return new SaveDataBinary
{
Level = reader.ReadInt32(),
X = reader.ReadSingle(),
Y = reader.ReadSingle(),
Z = reader.ReadSingle()
};
}
static void Main()
{
var data = new SaveDataBinary { Level = 7, X = 12.5f, Y = 0f, Z = -3.2f };
byte[] binary = WriteBinary(data);
string json = JsonSerializer.Serialize(data);
Console.WriteLine($"binary size: {binary.Length} bytes");
Console.WriteLine($"json size: {json.Length} chars -- \"{json}\"");
SaveDataBinary back = ReadBinary(binary);
Console.WriteLine($"read back: Level={back.Level} pos=({back.X},{back.Y},{back.Z})");
}
}
binary size: 16 bytes
json size: 35 chars -- "{"Level":7,"X":12.5,"Y":0,"Z":-3.2}"
read back: Level=7 pos=(12.5,0,-3.2)
สิบหก byte สำหรับฟอร์ม binary (สี่ field, field ละสี่ byte ไม่มีชื่อเก็บไว้ที่ไหนเลย — ตัวอ่านรู้แค่ลำดับ) สามสิบห้า character สำหรับฟอร์ม JSON เพราะมันสะกดคำว่า "Level", "X", "Y", "Z" เป็น text ตรง ๆ ทุกครั้งไป บวกเครื่องหมายวรรคตอนอีก สังเกตว่า ReadBinary ไม่รู้เลยด้วยซ้ำว่า "Level" หรือ "X" คืออะไร — มันแค่เชื่อว่าอะไรก็ตามที่เขียน byte พวกนี้ เขียนมาตามลำดับเดียวกับที่มันกำลังจะอ่าน ความเปราะบางแบบนี้คือราคาจริงของความเร็วและขนาดของ binary และมันคือสิ่งที่ข้อ 6 ต้องแก้ไขต่อไป
นี่คือสถานการณ์ที่ทำให้เกมที่ shipped ไปแล้วพังมากกว่าเรื่องอื่นแทบทุกเรื่องในหัวข้อนี้เลย: patch 1.0 ออกไปพร้อมกับ save format หนึ่ง patch 1.1 เพิ่ม field ใหม่หนึ่งตัวเข้าไปใน format นั้น ผู้เล่นทุกคนที่เซฟเกมไว้ตอน 1.0 ตอนนี้มีไฟล์บนดิสก์ที่ไม่มี field นั้นอยู่เลย — และเกมต้องโหลดไฟล์นั้นให้ถูกต้องตลอดไป เพราะเราย้อนเวลาไปเซฟไฟล์เก่าของทุกคนใหม่ไม่ได้
สมมติว่า v1.0 shipped SaveData ที่มี Name กับ Level patch 1.1 เพิ่ม Stamina เข้ามา ถ้าเกมแค่เรียก JsonSerializer.Deserialize<SaveData>(oldJson) กับไฟล์ยุค 1.0 ตัว JSON deserializer จะไม่ crash — มันแค่เงียบ ๆ ปล่อย field ที่ขาดหายไปจากไฟล์ให้เป็นค่า default ของ type นั้น สำหรับ int ค่า default คือ 0 ผู้เล่นที่กลับมาเล่นทุกคนเลยมี Stamina = 0 แบบเงียบ ๆ ทันทีที่โหลด ไม่ว่าค่านั้นจะสมเหตุสมผลหรือไม่ — ในกรณีนี้มันแปลว่าผู้เล่นเดิมทุกคนตอนนี้หมดแรงถาวร โดยไม่มี error ไม่มี crash และไม่มีสาเหตุที่ชัดเจนใน bug report เลย นี่คือบัคที่พบบ่อยที่สุดในหัวข้อนี้ทั้งหมด: ไม่ใช่ crash แต่เป็น default ผิด ๆ แบบเงียบ ๆ ที่ดูเหมือนบัค gameplay จนกว่าจะมีใครนึกขึ้นได้ว่า save format เปลี่ยนไปแล้ว
ทางแก้คือเก็บเลข Version ไว้ในตัวข้อมูลเซฟเองเลย แล้วตอนโหลดก็เช็คมัน: ถ้า version ของไฟล์เก่ากว่า version ปัจจุบันของเกม ให้รันฟังก์ชันเล็ก ๆ ที่เติมค่าที่ถูกต้องให้กับสิ่งที่เปลี่ยนไป จากนั้นค่อย mark ข้อมูลว่าเป็น version ปัจจุบัน ก่อนที่อย่างอื่นจะแตะมันต่อ
using System;
using System.Text.Json;
class SaveData
{
public int Version { get; set; }
public string PlayerName { get; set; }
public int Level { get; set; }
public int Stamina { get; set; } // added in version 2 -- default should NOT be 0
}
class SaveLoader
{
const int CurrentVersion = 2;
public static SaveData Load(string json)
{
SaveData data = JsonSerializer.Deserialize<SaveData>(json);
if (data.Version < 2)
data = MigrateV1ToV2(data);
// future patches add more steps here, e.g.:
// if (data.Version < 3) data = MigrateV2ToV3(data);
data.Version = CurrentVersion;
return data;
}
static SaveData MigrateV1ToV2(SaveData data)
{
Console.WriteLine("migrating save from v1 to v2...");
data.Stamina = 100; // v1 saves had no Stamina -- give a sensible starting value
return data;
}
}
class Program
{
static void Main()
{
string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Aran\",\"Level\":7}";
SaveData loaded = SaveLoader.Load(oldSaveFromDisk);
Console.WriteLine($"{loaded.PlayerName}: level {loaded.Level}, stamina {loaded.Stamina}, version {loaded.Version}");
}
}
migrating save from v1 to v2...
Aran: level 7, stamina 100, version 2
จุดสำคัญคือ MigrateV1ToV2 เลือกค่า 100 โดยตั้งใจ — เป็นค่าที่มีคนตัดสินใจแล้วว่าสมเหตุสมผลสำหรับผู้เล่นที่กลับมา — แทนที่จะปล่อยให้รับค่า default อะไรก็ตามที่ deserializer เลือกให้แบบเงียบ ๆ พอ save format โตขึ้นเรื่อย ๆ ข้ามหลาย patch field ใหม่แต่ละตัวก็จะมี migration step ของตัวเอง เชื่อมต่อกันเป็นลำดับ ทำให้ไฟล์เซฟจากสามปีก่อนและสิบ patch ที่แล้ว ยังคงเดินผ่านขั้นตอนได้ถูกต้อง ทีละขั้น จนไปถึงสิ่งที่ version ปัจจุบันคาดหวัง
Reflection คือความสามารถของโปรแกรมที่จะมองดู type ของตัวเอง — field, method, และ attribute — ระหว่างที่มันกำลังรันอยู่ แทนที่จะให้ทุกอย่างถูกกำหนดตายตัวและเรียกชื่อไว้ตอน compile time โค้ดปกติต้องรู้ชื่อ field ก่อนถึงจะใช้มันได้: stats.Health จะ compile ผ่านก็ต่อเมื่อ Health เป็น field จริงบน type ของ stats โค้ดที่ใช้ reflection กลับถาม type เองในฐานะข้อมูล ว่า "มี field อะไรบ้าง" แล้วก็อ่านหรือเขียนตัวไหนก็ได้ผ่านชื่อของมัน ที่ค้นพบตอน runtime
using System;
using System.Reflection;
class EnemyStats
{
public string Name = "Goblin";
public int Health = 30;
public float MoveSpeed = 3.5f;
}
class Program
{
static void Main()
{
EnemyStats stats = new EnemyStats();
Type type = stats.GetType(); // ask the OBJECT what type it is, at runtime
Console.WriteLine($"Type name: {type.Name}");
foreach (FieldInfo field in type.GetFields())
{
object value = field.GetValue(stats); // read a field WITHOUT knowing its name at compile time
Console.WriteLine($" {field.Name} ({field.FieldType.Name}) = {value}");
}
}
}
Type name: EnemyStats
Name (String) = Goblin
Health (Int32) = 30
MoveSpeed (Single) = 3.5
type.GetFields() คืนลิสต์ของ FieldInfo object — ตัวละหนึ่ง field บน EnemyStats — ที่ค้นพบจากการมองดู class เอง ไม่ใช่จากที่ใครไปเขียนคำว่า "Name", "Health", "MoveSpeed" เป็น literal string ไว้ที่ไหนเลย แล้ว field.GetValue(stats) ก็อ่านค่าจริงของ field นั้นออกมาจาก object stats ตัวนั้นโดยเฉพาะ ไม่มีอะไรใน Program ที่ต้องรู้รูปร่างของ EnemyStats ล่วงหน้าเลย — loop นี้ทำงานเหมือนเดิมเป๊ะ ๆ กับ class ไหนก็ได้ทั้งนั้น
loop จากข้อ 7 — "เอา field ทุกตัวมา อ่านชื่อกับค่าของมัน" — ไม่ใช่แค่เรื่องน่ารู้เฉย ๆ มันคือกลไกเดียวที่อยู่เบื้องหลังสามอย่างที่ทุกเอนจิ้นต้องมี ซึ่งถ้าไม่มี reflection ก็ต้องเขียนโค้ดมือให้กับทุก ๆ class ที่เราสร้างขึ้นมา:
int, checkbox ให้ทุก bool, กล่อง text ให้ทุก string สำหรับ class ไหนก็ได้ ทำงานโดยการ reflect ผ่าน field ของ class นั้นแล้วเลือก widget ตาม Type ของแต่ละ field — ไม่ใช่จากที่มีใครเขียนโค้ด UI เฉพาะให้กับทุก class ในเกมJsonSerializer.Serialize จากข้อ 4 ก็ทำแบบนี้อยู่ข้างในเป๊ะ ๆFieldInfo.SetValueแต่ถ้ามี reflection อย่างเดียว มันจะโชว์ ทุก field เลย รวมถึงตัวที่ควรเป็น internal ล้วน ๆ ด้วย เอนจิ้นแก้ปัญหานี้ด้วย attribute — ข้อมูล metadata ชิ้นเล็ก ๆ ที่แปะไว้กับโค้ด ซึ่งโค้ดอื่นค้นพบได้ผ่าน reflection [SerializeField] ของ Unity เอง (ข้อ 10) ก็คือ attribute แบบนี้เป๊ะ ๆ นี่คือเวอร์ชันเล็ก ๆ ของไอเดียเดียวกัน:
using System;
using System.Reflection;
[AttributeUsage(AttributeTargets.Field)]
class ShowInInspectorAttribute : Attribute { }
class EnemySpawner
{
public int MaxEnemies = 10; // public -- shown automatically
[ShowInInspector]
private float spawnRadius = 5.0f; // private, but explicitly marked -- shown too
private string internalCacheKey = "x1"; // private, not marked -- hidden
}
class MiniInspector
{
public static void Draw(object target)
{
Type type = target.GetType();
Console.WriteLine($"--- Inspector: {type.Name} ---");
foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
Console.WriteLine($"{field.Name} = {field.GetValue(target)}");
foreach (FieldInfo field in type.GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
if (field.GetCustomAttribute<ShowInInspectorAttribute>() != null)
Console.WriteLine($"{field.Name} = {field.GetValue(target)} (private, marked)");
}
}
class Program
{
static void Main() => MiniInspector.Draw(new EnemySpawner());
}
--- Inspector: EnemySpawner ---
MaxEnemies = 10
spawnRadius = 5 (private, marked)
internalCacheKey ไม่โผล่มาเลย — มันเป็น private และไม่มี attribute [ShowInInspector] ติดอยู่ ทั้งสอง loop เลยข้ามมันไป กฎเล็ก ๆ นี้ "field ที่เป็น public บวก field ที่เป็น private ที่ mark attribute ไว้ชัดเจน" ไม่ใช่เรื่องบังเอิญเลย มันคือกฎเดียวกันเป๊ะ ๆ ที่ Inspector และ serializer จริงของ Unity ใช้ ซึ่งข้อ 10 จะพูดถึงโดยตรง
MonoBehaviour อยู่บน GameObject ใน scene หนึ่ง — มันต้องมี scene อยู่ถึงจะมีตัวมันได้ ข้อมูลเกมเยอะมากที่ไม่ได้เป็นของ scene ไหนโดยเฉพาะ: ชื่อกับสถิติของไอเทม สถิติพื้นฐานของศัตรูแต่ละชนิด การตั้งค่าเกมแบบ global ScriptableObject ของ Unity เป็น base class สำหรับข้อมูลที่อยู่เป็น asset file ของตัวเองบนดิสก์ ไม่ขึ้นกับ scene ไหนเลย ให้ scene และ object กี่ตัวก็ได้ reference มันได้
using UnityEngine;
[CreateAssetMenu(fileName = "NewItem", menuName = "Game/Item Definition")]
public class ItemDefinition : ScriptableObject
{
public string ItemName;
public int MaxStack = 99;
public Sprite Icon;
}
[CreateAssetMenu] เพิ่มเมนู "Create > Game > Item Definition" เข้าไปใน editor menu ของ Unity ทำให้ designer สร้างไฟล์ .asset ใหม่ ๆ — Sword.asset, HealthPotion.asset — ได้ด้วยมือล้วน ๆ ไม่ต้องเขียนโค้ดเลย แต่ละไฟล์คือชุดค่าที่เซฟแยกกันของ field ใน class นี้
using UnityEngine;
public class ItemPickup : MonoBehaviour
{
public ItemDefinition Item; // drag an ItemDefinition asset here in the Inspector
void OnTriggerEnter(Collider other)
{
Debug.Log($"Picked up {Item.ItemName} (max stack {Item.MaxStack})");
}
}
ItemPickup object หลาย ๆ ตัวที่กระจายอยู่คนละ scene สามารถชี้ไปที่ Sword.asset ไฟล์เดียวกันเป๊ะ ๆ ได้ทั้งหมด เรื่องนี้เชื่อมกลับไปที่ข้อ 1 ถึง 3 โดยตรง: Unity โหลด Sword.asset แค่ครั้งเดียว ทุก reference แชร์ object ที่โหลดแล้วตัวนั้นตัวเดียว (พูดง่าย ๆ คือ resource cache จากข้อ 1 กับ 2 ที่ฝังอยู่ในตัวเอนจิ้นนั่นเอง) และแต่ละ reference ก็ถูก track ด้วย GUID (ข้อ 3) ทำให้การเปลี่ยนชื่อ Sword.asset เป็น IronSword.asset ไม่ทำให้ ItemPickup ตัวไหนที่ reference มันพังเลยสักตัว
serializer ของ Unity เอง ข้างในก็คือ loop reflect-over-fields จากข้อ 8 นั่นแหละ แค่มีกฎเฉพาะหนึ่งข้อว่า field ไหนถึงจะนับ field หนึ่งจะถูก serialize (เซฟลงไฟล์ scene หรือ asset และแสดงใน Inspector แบบ default) ก็ต่อเมื่อทุกข้อต่อไปนี้เป็นจริง:
public หรือ เป็น private/protected ที่ mark ด้วย [SerializeField];static, ไม่ใช่ const, และไม่ใช่ readonly;Unity.Object (เช่น ItemDefinition หรือ Sprite), enum, array/List<T> ของ type ที่ serialize ได้ หรือ class/struct ธรรมดาที่ mark ด้วย [System.Serializable]using UnityEngine;
using System.Collections.Generic;
public class EnemyStatsComponent : MonoBehaviour
{
public int Health = 30; // public -- serialized, shown
[SerializeField]
private float moveSpeed = 3.5f; // private + [SerializeField] -- serialized, shown
private int internalFrameCounter; // private, no attribute -- NOT serialized, NOT shown
public static int EnemyCount; // static -- NEVER serialized
public int Damage { get; set; } = 5; // a PROPERTY -- NOT serialized, NOT shown
public Dictionary<string, int> Resistances; // Dictionary -- NOT serialized (no built-in support)
}
สองบรรทัดในนี้ทำให้มือใหม่เกือบทุกคนพลาดอย่างน้อยหนึ่งครั้ง Damage มองจากข้างนอกเหมือน field เป๊ะ ๆ — enemy.Damage = 10; compile ผ่านและทำงานได้ปกติ — แต่มันเป็น property (คู่ของ get/set method ที่ซ่อนอยู่) และ serializer ของ Unity เดินผ่านแค่ field จริง ๆ เท่านั้น ไม่ใช่ property นี่ตรงข้ามกับ System.Text.Json จากข้อ 4 เลย ซึ่งโดย default จะอ่าน property ไม่ใช่ field — สองระบบที่ใช้ reflection เหมือนกัน แต่กฎ "อะไรถึงจะนับ" ต่างกันคนละแบบ Resistances ก็ compile ผ่านเหมือนกัน แต่ Unity ไม่มี serializer built-in ให้กับ Dictionary<TKey,TValue> มันเลยโชว์เป็นค่าว่างเปล่าเงียบ ๆ ใน Inspector แล้วก็ไม่เซฟด้วย ทางแก้ทั่วไปคือใช้ list ของ struct เล็ก ๆ ที่ serialize ได้แทน:
[System.Serializable]
public struct StatResistance
{
public string StatName;
public int Amount;
}
public class EnemyStatsComponent : MonoBehaviour
{
public List<StatResistance> Resistances; // works: a List of a [Serializable] struct
}
[SerializeField], ไม่ใช่ static, ไม่ใช่ const, ไม่ใช่ readonly และเป็น type ที่รองรับ — ไม่ใช่ "field อะไรก็ตามที่ฉันประกาศไว้" property ที่ทำงานเหมือนกันเป๊ะจากภายนอกจะไม่โผล่มา ซึ่งนี่คือช่องว่างระหว่าง "compile ผ่าน" กับ "Unity serialize มันจริง ๆ" ที่ทำให้ developer ที่ย้ายจากสไตล์ C# ทั่วไปมาสู่โมเดลแบบ field ของ Unity สะดุดบ่อย ๆข้อ 3 พูดถึงตาราง lookup ของ GUID โดยยังไม่บอกว่า Unity เก็บมันไว้ที่ไหนจริง ๆ — คำตอบคือใน meta file ทุก asset ในโปรเจกต์ Unity — texture, script, โฟลเดอร์, asset ของ ScriptableObject — จะมีไฟล์คู่กันที่ต่อท้ายชื่อด้วย .meta ถูกสร้างและดูแลโดย Unity เอง
.meta หรือใส่มันไว้ใน .gitignore หรือเปลี่ยนชื่อ/ย้าย asset นอกหน้าต่าง Project ของ Unity (ไปทำใน Finder หรือ File Explorer ตรง ๆ) ทั้งสองแบบทำให้ GUID ถูกสร้างใหม่ และทำให้ reference ทุกตัวที่ชี้ไปที่ asset นั้นพังแบบเงียบ ๆ ทั่วทั้งโปรเจกต์ ต้อง commit ไฟล์ .meta ลง source control เสมอ และเปลี่ยนชื่อ/ย้าย asset จากในตัว Unity เองเสมอตอนที่เกมรันอยู่ใน editor (หรือ development build ที่เปิด asset watching ไว้) การแก้ source asset — retouch texture ในโปรแกรมแก้ภาพแล้วเซฟ หรือปรับตัวเลขบน ScriptableObject ใน Inspector — ควรอัปเดตสำเนาในหน่วยความจำของเกมที่กำลังรันอยู่ โดยไม่ต้อง restart ใหม่ทั้งหมด นี่คือ hot reloading และมันสำคัญล้วน ๆ เพื่อความเร็วในการทำงานซ้ำ (iteration speed): artist อยากเห็น texture เปลี่ยนภายในไม่กี่วินาที ไม่ใช่ต้อง relaunch เกมทั้งเกมแล้ววนกลับไปจุดเดิมใหม่
ไอเดียหลักนี้ทำงานได้ก็เพราะการออกแบบจากข้อ 1 กับ 2: resource cache แจก shared reference ไม่ใช่สำเนาส่วนตัว ให้กับทุกคนที่เรียกใช้ นั่นแปลว่า hot reloading แค่ต้องเขียนทับเนื้อหาของ object ที่ cache ไว้ ตัวเดียว นั้นในที่เดิม — ทุกคนที่ถือ reference นั้นจะเห็นข้อมูลใหม่โดยอัตโนมัติ เพราะพวกเขาไม่เคยถือสำเนาของตัวเองตั้งแต่แรกอยู่แล้ว
using System.Reflection;
partial class ResourceCache<T> where T : class
{
public void Reload(string path, T freshData)
{
if (!cache.TryGetValue(path, out T existing))
return; // nothing cached yet, nothing to reload
CopyInto(existing, freshData); // overwrite the EXISTING object's contents in place
System.Console.WriteLine($"[hot reload] {path} updated -- {refCount[path]} holder(s) see new data instantly");
}
static void CopyInto(object existing, object fresh)
{
// the same reflect-over-fields loop from Section 7 and 8, used to copy generically
foreach (FieldInfo field in existing.GetType().GetFields())
field.SetValue(existing, field.GetValue(fresh));
}
}
CopyInto ไม่ต้องมีโค้ดเฉพาะของแต่ละ type เลย: มัน reflect ผ่าน field ที่ type ของ existing มีอยู่จริง แล้ว copy แต่ละตัวข้ามมาจาก fresh เป็นเทคนิค generic แบบเดียวกับที่ loop print field ในข้อ 7 และ MiniInspector ในข้อ 8 ใช้
System.Type ใน C#) ที่อธิบาย class หนึ่งตัว: ชื่อ, field, method, และ attribute ของมัน[SerializeField]) ที่โค้ดอื่นค้นพบได้ผ่าน reflection ตอน runtimeResourceCache<Texture> (ข้อ 2) ทำงานบางอย่างกับมัน แล้ว release มัน หา path ในฟังก์ชันนี้ที่ไม่เคยเรียก Release เลย อธิบายว่าจริง ๆ แล้วเกิดอะไรขึ้นกับ refcount เพราะเรื่องนี้ แล้วเขียนฟังก์ชันใหม่ให้ทุก path ที่เป็นไปได้ release พอดีหนึ่งครั้ง แม้ DoWork จะ throw exception ก็ตาม
void UseTextureTemporarily(ResourceCache<Texture> cache, string path, bool skipWork)
{
Texture tex = cache.Acquire(path, Loader.LoadTexture);
if (skipWork)
return; // uh oh
DoWork(tex);
cache.Release(path);
}
ตอน skipWork เป็น true ฟังก์ชันจะ return ทันทีหลังจาก Acquire ก่อนที่จะไปถึง cache.Release(path) เลย path การเรียกแบบนี้เพิ่ม refcount แต่ไม่เคยลดมันเลย ทุกครั้งที่เรื่องนี้เกิดขึ้น count จะสูงกว่าจำนวนผู้ใช้งานจริงอยู่หนึ่งเสมอ — asset นั้นไม่มีวันถึงศูนย์แล้ว unload ได้เลย แม้จะผ่านไปนานแล้วที่ไม่มีอะไรในเกมต้องการมันจริง ๆ นี่คือ resource leak ที่มีรูปแบบเดียวกันเป๊ะ ๆ กับการลืมเรียก free() คู่กับ malloc() ในบท C: early return ที่ข้ามโค้ด cleanup ด้านล่างไป
ทางแก้คือ block try/finally ซึ่งรับประกันว่าส่วน finally จะรันในทุก exit path ที่ออกจาก try — ไม่ว่าจะ return ปกติ, early return, หรือ exception ที่ DoWork throw ออกมา:
void UseTextureTemporarily(ResourceCache<Texture> cache, string path, bool skipWork)
{
Texture tex = cache.Acquire(path, Loader.LoadTexture);
try
{
if (skipWork)
return;
DoWork(tex);
}
finally
{
cache.Release(path); // runs no matter which path we took, or even if DoWork throws
}
}
ตอนนี้มี Acquire พอดีหนึ่งครั้งและ Release พอดีหนึ่งครั้งในทุกเส้นทางที่เป็นไปได้ของฟังก์ชัน ซึ่งเป็น invariant ที่ reference-counted cache ต้องพึ่งพา ถึงจะ unload อะไรได้เลย
Stamina) ไปเป็น v2 ด้วยการเพิ่ม Stamina ที่ default เป็น 100 ตอนนี้เกมต้องการ v3: field ใหม่ชื่อ Difficulty (int, 0 = Normal, 1 = Hard) ซึ่งควร default เป็น 1 สำหรับตัวละครที่อยู่ Level 20 ขึ้นไปอยู่แล้วตอนถูก migrate ครั้งแรก และเป็น 0 สำหรับกรณีอื่น เพิ่ม Difficulty ลงใน SaveData เขียน MigrateV2ToV3 อัปเดต SaveLoader.Load ให้รัน migration ทั้งสองขั้นตามลำดับ แล้ว trace ว่าเกิดอะไรขึ้นตอนโหลดไฟล์ v1 ของตัวละคร level 25
string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Rin\",\"Level\":25}";
using System;
using System.Text.Json;
class SaveData
{
public int Version { get; set; }
public string PlayerName { get; set; }
public int Level { get; set; }
public int Stamina { get; set; } // added in version 2
public int Difficulty { get; set; } // added in version 3
}
class SaveLoader
{
const int CurrentVersion = 3;
public static SaveData Load(string json)
{
SaveData data = JsonSerializer.Deserialize<SaveData>(json);
if (data.Version < 2)
data = MigrateV1ToV2(data);
if (data.Version < 3)
data = MigrateV2ToV3(data);
data.Version = CurrentVersion;
return data;
}
static SaveData MigrateV1ToV2(SaveData data)
{
Console.WriteLine("migrating save from v1 to v2...");
data.Stamina = 100;
return data;
}
static SaveData MigrateV2ToV3(SaveData data)
{
Console.WriteLine("migrating save from v2 to v3...");
data.Difficulty = data.Level >= 20 ? 1 : 0; // Hard for already-strong characters
return data;
}
}
class Program
{
static void Main()
{
string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Rin\",\"Level\":25}";
SaveData loaded = SaveLoader.Load(oldSaveFromDisk);
Console.WriteLine($"{loaded.PlayerName}: level {loaded.Level}, stamina {loaded.Stamina}, difficulty {loaded.Difficulty}, version {loaded.Version}");
}
}
migrating save from v1 to v2...
migrating save from v2 to v3...
Rin: level 25, stamina 100, difficulty 1, version 3
field Version ของไฟล์เริ่มที่ 1 เช็ค migration ทั้งสอง ตัวเลยทำงานตามลำดับ: MigrateV1ToV2 รันก่อนแล้วเติม Stamina จากนั้น MigrateV2ToV3 รันกับข้อมูลที่อัปเดตแล้วนั้น แล้วตั้งค่า Difficulty ตาม Level (ซึ่งคือ 25 ดังนั้น Difficulty เลยกลายเป็น 1, Hard) เซฟที่เริ่มจาก v2 อยู่แล้วจะข้ามไปเช็คตัวที่สองเลยตัวเดียว และเซฟที่เป็น v3 อยู่แล้วจะข้ามทั้งสองตัว — แต่ละขั้นจะรันเฉพาะกับ version ที่ต้องการมันจริง ๆ เท่านั้น
type.GetFields()) เขียนฟังก์ชัน string ToKeyValueText(object obj) ที่แปลง field แบบ public ของ object ไหนก็ได้ให้เป็น text คู่ Name=Value บรรทัดละคู่ — เช่น object EnemyStats ที่มี Name="Goblin", Health=30 ควรได้ text Name=Goblin ตามด้วย Health=30 ในบรรทัดถัดไป ไม่ต้องจัดการ nested object หรือ array ก็ได้ จากนั้นอธิบายในหนึ่งหรือสองประโยคว่าทำไมฟังก์ชันนี้ถึงไม่ต้องแก้อะไรเลยถ้ามีใครเพิ่ม field ใหม่เอี่ยมลงใน EnemyStats เดือนหน้า และทำไมข้อเท็จจริงเดียวกันนี้ถึงเป็นจริงกับ Inspector ของ Unity และ JsonSerializer.Serialize ด้วยเหมือนกันusing System;
using System.Reflection;
using System.Text;
static class MiniSerializer
{
public static string ToKeyValueText(object obj)
{
var sb = new StringBuilder();
foreach (FieldInfo field in obj.GetType().GetFields())
sb.Append($"{field.Name}={field.GetValue(obj)}\n");
return sb.ToString();
}
}
class EnemyStats
{
public string Name = "Goblin";
public int Health = 30;
}
class Program
{
static void Main()
{
Console.Write(MiniSerializer.ToKeyValueText(new EnemyStats()));
}
}
Name=Goblin
Health=30
ToKeyValueText ไม่เคยเรียกชื่อ Name หรือ Health ตรง ๆ ในซอร์สโค้ดของมันเองเลย — มันถาม obj.GetType() ว่ามี field อะไรอยู่ตอนนี้บ้างแล้ว loop ผ่านลิสต์นั้น ถ้ามีใครเพิ่ม field MoveSpeed ลงใน EnemyStats เดือนหน้า การเรียก ToKeyValueText ครั้งถัดไปจะมีบรรทัด MoveSpeed=... เพิ่มเข้ามาเองโดยอัตโนมัติ โดยไม่ต้องแก้โค้ดของ MiniSerializer สักบรรทัดเดียว นี่คือเหตุผลเป๊ะ ๆ ว่าทำไม Inspector ของ Unity ถึงแสดง field [SerializeField] ตัวใหม่ทันทีที่มันถูกเพิ่มเข้ามา และทำไม JsonSerializer.Serialize ถึงจัดการ class ไหนก็ได้โดยไม่ต้องมีโค้ดเฉพาะของแต่ละ class: ทั้งสามอย่างนี้คือ loop reflect-over-fields ตัวเดียวกันจากข้อ 7 แค่ใส่เสื้อผ้าคนละชุดเท่านั้นเอง