15.3 Optimize Memory และ Asset

เฟส 15 · Optimization และมือถือ · เวลาเรียน: 25–45 h

คุม memory ให้อยู่ในงบ — texture compression, streaming, object pooling และเลี่ยง garbage-collection spike ใน C#

บททุกบทเรื่อง optimization ที่ผ่านมาพูดถึงความเร็วทั้งนั้น: ลด draw call, ทำ collision check ให้ถูกลง, ลดงาน CPU ต่อ frame บทนี้พูดถึง budget อีกแบบหนึ่ง คือ memory เกมที่รันลื่นๆ ที่ 60 frame ต่อวินาทีก็ยัง crash ได้ทันทีถ้ามันขอ RAM จากเครื่องมากกว่าที่ OS ยอมให้ — ส่วนใหญ่ไม่มี exception ไม่มี stack trace ให้ดูด้วยซ้ำ แค่โดนฆ่าทิ้งเงียบๆ มือถือคือที่ที่โดนหนักที่สุด: มือถือบางเครื่องอาจให้แอปเราใช้ได้แค่ประมาณ 500 MB ถึง 2 GB ก่อนที่ OS จะตัดสินว่าเกมเราทำตัวไม่ดีแล้วสั่งปิดทิ้ง

ย้อนกลับไปที่บท C, memory ก็แค่ byte ที่มีเลขกำกับซึ่งเราตามเองด้วยมือผ่าน malloc กับ free ในเกมจริงที่สร้างด้วย Unity เราไม่ต้องตาม byte ทุกตัวเอง แต่ byte พวกนั้นก็ยังอยู่ตรงนั้น และก็ยังหมดได้เหมือนเดิม บทนี้พูดถึงการรู้ว่ามันไปอยู่ไหนบ้าง — texture, mesh, audio, และ object ของ C# ที่โค้ดเราสร้างขึ้นมา — แล้วก็พูดถึงนิสัยที่ช่วยไม่ให้เซสชันเล่นยาวๆ ค่อยๆ กิน memory จนหมด เราจะโยงกลับไปที่บท garbage collection ของ C# ด้วย: garbage collection ไม่ได้ฟรี วิธีที่ดีที่สุดในการรับมือกับมันคือทำให้มันมีงานให้ทำน้อยลงตั้งแต่แรก

1. Memory ของเกมเราไปอยู่ที่ไหนกันแน่

"Optimize memory" เฉยๆ มันกว้างเกินไปจนลงมือทำอะไรไม่ได้ คำถามแรกที่มีประโยชน์คือ: สำหรับเกม 3D ทั่วไป category ไหนกิน RAM มากที่สุด แล้วเรียงลำดับยังไง สำหรับเกมเกือบทุกเกม ลำดับจะเป็นแบบนี้: texture มาก่อน (ทิ้งห่างขาดลอย) ตามด้วย mesh (โมเดล 3D — ข้อมูล vertex กับ triangle) ตามด้วย audio แล้วก็ code กับ object (managed heap ของ C# บวกกับงานบัญชีภายในของ engine เอง) มีข้อยกเว้นอยู่บ้าง — เกมที่มีข้อมูล terrain โลกเปิดกว้างมากๆ หรือเกมที่สร้างจาก audio เกือบทั้งหมด — แต่สำหรับเกม 3D แบบที่มือใหม่ส่วนใหญ่สร้างกันเป็นเกมแรก texture คือตัวที่กินมากที่สุด

สมมติว่าเรากำลังเล็งเป้าไปที่มือถือ Android ระดับกลาง แล้วหักลบส่วนที่ OS กับแอปพื้นหลังใช้ไปแล้ว เกมเราจะเหลือ memory ให้ใช้จริงๆ ประมาณ 700 MB ก่อนที่จะเริ่มเสี่ยง budget เริ่มต้นที่พอสมเหตุสมผล แบ่งตาม category จะเป็นแบบนี้:

MEMORY BUDGET -- mid-range mobile target, 700 MB total ================================================================ Category Share Budget Bar ---------------------------------------------------------------- Textures 55% 385 MB [###################] Meshes 15% 105 MB [#####] Audio 12% 84 MB [####] Code / objects 10% 70 MB [####] Other (UI, fonts) 8% 56 MB [###] ---------------------------------------------------------------- TOTAL 100% 700 MB ================================================================ Order confirmed: Textures > Meshes > Audio > Code/objects > Other

นี่ไม่ใช่กฎฟิสิกส์อะไร มันแค่จุดเริ่มต้นที่เราเอาไว้วัดเทียบ ส่วนที่เหลือของบทนี้จะไล่ทีละ category: วิธีคำนวณว่าของแต่ละอย่างกินเท่าไหร่จริงๆ แล้วถ้า category ไหนเกิน share ของตัวเองไปควรทำยังไง

Tip ตั้ง budget คร่าวๆ แบบตารางนี้ไว้ตั้งแต่ ก่อน ที่จะมีปัญหา memory ไม่ใช่หลังจากมีปัญหาแล้ว พอ category ไหนเกิน เราจะรู้จากตัวเลข ไม่ใช่จาก crash report สองอาทิตย์ก่อน launch

2. Texture Memory: เลขจริงๆ ที่ต้องคำนวณ

ขนาดไฟล์ของ texture บนดิสก์ (ไฟล์ .png หรือ .jpg ที่ compress แล้ว) แทบไม่บอกอะไรเลยว่ามันจะกิน RAM เท่าไหร่ตอนโหลดขึ้นมาจริงๆ สิ่งที่สำคัญตอน runtime คือขนาดแบบ uncompressed ที่พร้อมให้ GPU ใช้ และมีสูตรง่ายๆ สำหรับคำนวณมัน:

texture memory (bytes) = width * height * bytes_per_pixel * mip_factor

bytes_per_pixel ขึ้นอยู่กับ GPU texture format ที่เราเลือกใน import settings ไม่ใช่ format ของไฟล์ต้นฉบับ mip_factor คือส่วนที่มาจาก mip map (mipmap — สำเนาย่อส่วนของ texture เดียวกันที่ทำไว้ล่วงหน้าหลายขนาด ใช้อัตโนมัติตอนวัตถุอยู่ไกลจากกล้อง เพื่อไม่ให้ GPU ต้อง sample texture ขนาดใหญ่มากสำหรับ pixel บนจอแค่หยิบมือเดียว) mip chain เต็มๆ จะเพิ่ม memory ขึ้นมาอีกประมาณหนึ่งในสามจาก base image เพราะแต่ละ mip level ที่เล็กลงมีขนาดแค่หนึ่งในสี่ของอันก่อนหน้า และอนุกรม 1 + 1/4 + 1/16 + 1/64 + ... ลู่เข้าสู่ 4/3 ดังนั้น mip_factor ≈ 1.33 ตอนเปิด mip หรือ 1.0 ตอนปิด mip

มาลองคำนวณตัวอย่างจริงกัน: texture ขนาด 2048x2048 ความละเอียดแบบที่ใช้กับตัวละครหลักหรือพื้นผิวฉากใหญ่ๆ

UNCOMPRESSED (RGBA32, 4 bytes per pixel) ----------------------------------------- base = 2048 * 2048 * 4 = 16,777,216 bytes = 16.00 MB + mips = 16.00 MB * 4/3 = 21.33 MB vs. ASTC 4x4 (compressed, 1 byte per pixel effective) ----------------------------------------- base = 2048 * 2048 * 1 = 4,194,304 bytes = 4.00 MB + mips = 4.00 MB * 4/3 = 5.33 MB RESULT: same texture, same resolution, same mip chain -- 21.33 MB uncompressed vs 5.33 MB compressed. That is a 4x reduction from changing ONE import setting.

ASTC (Adaptive Scalable Texture Compression) คือ GPU texture compression format ที่ใช้กันใน GPU มือถือรุ่นใหม่ๆ (บน desktop ก็รองรับเหมือนกัน) ต่างจาก .png ที่ CPU ต้อง decompress เป็น buffer เต็มๆ ก่อนที่ GPU จะใช้ได้ ASTC จะอยู่ในสภาพ compress ตลอดใน GPU memory แล้วถูก sample ได้โดยตรงเลย — GPU จะ decode block เล็กๆ แบบ on the fly ด้วย hardware ตอนอ่าน pixel นี่คือเหตุผลที่ "bytes per pixel" ของ ASTC เป็นตัวเลขจริงถาวร ไม่ใช่แค่กลเม็ดลดขนาดไฟล์บนดิสก์

ASTC ให้เราเลือก block size ได้ ซึ่งเป็นการแลกระหว่างคุณภาพกับขนาด block เล็ก (4x4) จะเก็บรายละเอียดต่อ pixel ได้มากกว่า ส่วน block ใหญ่ (8x8) จะอัด pixel เข้าไปในข้อมูล compressed เท่าเดิมได้มากกว่า แลกกับคุณภาพที่ลดลง:

FORMAT COMPARISON -- 2048x2048 texture ========================================================================== Format Bytes/pixel Base size With mip chain (x 4/3) -------------------------------------------------------------------------- RGBA32 (raw) 4.00 16.00 MB 21.33 MB RGB24 (raw) 3.00 12.00 MB 16.00 MB ASTC 4x4 1.00 4.00 MB 5.33 MB ASTC 6x6 0.44 1.78 MB 2.37 MB ASTC 8x8 0.25 1.00 MB 1.33 MB ==========================================================================

เราเช็คขนาด memory จริงตอน runtime ของ texture ได้ตลอดเวลาด้วย Profiler.GetRuntimeMemorySizeLong ซึ่งถาม Unity ตรงๆ แทนที่จะเชื่อการคำนวณด้วยมือ:

using UnityEngine;
using UnityEngine.Profiling;

public class TextureMemoryCheck : MonoBehaviour
{
    public Texture2D texture;

    void Start()
    {
        long bytes = Profiler.GetRuntimeMemorySizeLong(texture);
        float megabytes = bytes / 1024f / 1024f;
        Debug.Log(texture.name + " uses " + megabytes.ToString("F2") + " MB");
    }
}

ถ้า import setting ยังเป็น "None" (RGBA32 แบบ uncompressed) กับตัวอย่าง 2048x2048 ของเรา โค้ดนี้จะ print RockDiffuse uses 21.33 MB ถ้าเปลี่ยน compression ใน Texture Import Settings เป็น ASTC 4x4 แล้วกด Play ใหม่ สคริปต์เดียวกันจะ print RockDiffuse uses 5.33 MB — ตรงกับเลขที่คำนวณด้วยมือด้านบนเป๊ะ เพราะสิ่งที่ Unity ทำภายในก็คือการคำนวณแบบนั้นเลย

Common mistake Import texture ต้นฉบับจากอาร์ตติสต์ที่ความละเอียดเดิมเป๊ะๆ (บ่อยครั้งคือ 4096x4096 หรือใหญ่กว่า) ทั้งที่วัตถุนั้นจะโชว์บนจอเล็กนิดเดียว — ไอคอน UI ที่โชว์แค่ 64 pixel ไม่จำเป็นต้องใช้ texture ต้นฉบับ 2048x2048 เลย เช็ค Max Size ใน import setting เทียบกับขนาดที่ texture โชว์จริงบนจอ ไม่ใช่เทียบกับขนาดไฟล์ต้นฉบับ
Tip ควรใช้ texture ที่เป็นสี่เหลี่ยมจัตุรัสและขนาดเป็น power-of-two (256x256, 512x512, 1024x1024...) compression format บางตัวกับระบบ mip streaming บางระบบต้องการหรืออยากได้ขนาดแบบ power-of-two มากๆ และ texture ที่ไม่ใช่ power-of-two อาจแอบ fallback ไปเป็น format แบบ uncompressed โดยไม่บอกเราเลย

3. Mesh Memory: Vertex Count คูณ Vertex Layout

mesh (กลุ่ม triangle ที่ประกอบกันเป็นโมเดล 3D) กิน memory อยู่ใน buffer สองตัว: vertex buffer (หนึ่ง entry ต่อหนึ่ง vertex เก็บทุกอย่างที่ GPU ต้องรู้เกี่ยวกับจุดนั้น — position, normal, ฯลฯ) กับ index buffer (list ของเลขจำนวนเต็มที่บอกว่า vertex สามตัวไหนประกอบกันเป็น triangle แต่ละอัน เพื่อไม่ให้ vertex ที่ใช้ร่วมกันถูกเก็บซ้ำ)

mesh memory (bytes) = vertexCount * bytesPerVertex + indexCount * bytesPerIndex

bytesPerVertex ไม่ใช่ค่าคงที่ — มันขึ้นอยู่กับว่า vertex แต่ละตัวมี channel (attribute) อะไรติดมาบ้าง vertex layout แบบเต็มๆ ทั่วไปจะหน้าตาแบบนี้:

FULL VERTEX LAYOUT ====================================================== Attribute Type Bytes ------------------------------------------------------ Position float3 (x,y,z) 12 Normal float3 (x,y,z) 12 Tangent float4 16 UV0 (base texture) float2 8 UV1 (lightmap) float2 8 Color 4 x byte 4 ------------------------------------------------------ TOTAL PER VERTEX 60 bytes ======================================================

channel ทุกตัวพวกนี้กิน byte ในทุกๆ vertex ไม่ว่าจะมีอะไรใช้งานมันจริงหรือเปล่า ข้อมูล Tangent จำเป็นก็ต่อเมื่อ shader อ่าน normal map UV1 จำเป็นก็ต่อเมื่อมี baked lightmap Vertex color จำเป็นก็ต่อเมื่อ shader อ่านมันใช้ (เช่นเอาไปผสม texture ของ terrain) ถ้า mesh ไม่ต้องการ channel ไหน การถอดมันออกใน Model Import Settings (ติ๊กออก Normals, Tangents, หรือ Vertex Colors หรือลบ UV set ที่ไม่ได้ใช้) คือ memory ที่ได้มาฟรีๆ — ถอดออกไม่เสียอะไรเลย และภาพที่ render ก็ไม่เปลี่ยนแปลงเลยสักนิด

ลองดู mesh ตัวละครที่มี 20,000 vertex กับประมาณ 30,000 triangle (90,000 index) ด้วย layout เต็ม 60 byte:

FULL LAYOUT (60 bytes/vertex) vertex buffer = 20,000 * 60 = 1,200,000 bytes = 1.14 MB index buffer = 90,000 * 2 (ushort) = 180,000 bytes = 0.17 MB TOTAL = 1.32 MB STRIPPED LAYOUT -- position + normal + UV0 only (32 bytes/vertex) vertex buffer = 20,000 * 32 = 640,000 bytes = 0.61 MB index buffer = 90,000 * 2 (ushort) = 180,000 bytes = 0.17 MB TOTAL = 0.78 MB Removing tangent, UV1, and vertex color cut this one mesh from 1.32 MB to 0.78 MB -- about 41% smaller, for free.

เราเช็คต้นทุน layout จริงของ mesh ได้ด้วยวิธีเดียวกับที่เช็ค texture: ถาม engine ตรงๆ แทนที่จะเดา

using UnityEngine;
using UnityEngine.Rendering;

public class MeshMemoryCheck : MonoBehaviour
{
    public MeshFilter target;

    void Start()
    {
        Mesh mesh = target.sharedMesh;
        int bytesPerVertex = 0;
        foreach (VertexAttributeDescriptor d in mesh.GetVertexAttributes())
        {
            bytesPerVertex += ComponentByteSize(d.format) * d.dimension;
        }

        long vertexBufferBytes = (long)bytesPerVertex * mesh.vertexCount;
        int indexBytes = (mesh.indexFormat == IndexFormat.UInt16) ? 2 : 4;
        long indexBufferBytes = (long)mesh.GetIndexCount(0) * indexBytes;
        float totalMB = (vertexBufferBytes + indexBufferBytes) / 1024f / 1024f;

        Debug.Log(mesh.name + ": " + bytesPerVertex + " bytes/vertex, total " +
                   totalMB.ToString("F2") + " MB");
    }

    int ComponentByteSize(VertexAttributeFormat format)
    {
        switch (format)
        {
            case VertexAttributeFormat.Float32: return 4;
            case VertexAttributeFormat.Float16: return 2;
            case VertexAttributeFormat.UNorm8:  return 1;
            default: return 4;
        }
    }
}

ก่อนถอด channel ออก โค้ดนี้ print CharacterMesh: 60 bytes/vertex, total 1.32 MB หลังจากติ๊กออก Tangents, UV set ที่สอง, กับ Vertex Colors ใน Model Import Settings แล้ว import ใหม่ มันจะ print CharacterMesh: 32 bytes/vertex, total 0.78 MB — ตรงกับที่คำนวณด้วยมือเป๊ะ เพราะมันคือสูตรเดียวกัน

Tip ข้อมูล mesh ถูก share กันต่อ asset ไม่ใช่ต่อ instance ถ้าเรามี enemy ตัวเดียวกัน 200 ตัวในฉาก ทุกตัวจะชี้ไปที่ mesh ตัวเดียวกันใน memory — เราจ่าย 0.78 MB แค่ครั้งเดียว ไม่ใช่ 200 ครั้ง ที่ mesh memory จะบวมจริงๆ คือตอนที่เรามี mesh ที่ต่างกันจริงๆเป็นร้อยๆ ตัว แล้วแต่ละตัวก็มี channel ที่เสียเปล่าติดมาด้วย

4. Audio Memory: Streaming กับ Decompressed

Audio memory ทำงานต่างจาก texture กับ mesh เพราะเราเลือกได้เป็นรายคลิปเลยว่าเสียงจะอยู่ใน RAM เป็นตัวเลขดิบๆ หรือค่อย decode ตอนต้องใช้ Unity มี import setting สำหรับเรื่องนี้เรียกว่า Load Type มีสามตัวเลือก: Decompress On Load (clip ทั้งอันถูก decode เป็น PCM sample ดิบใน RAM ทันทีที่โหลด พร้อมเล่นทันทีแบบไม่มี delay), Compressed In Memory (byte ที่ compress แล้วอยู่ใน RAM แล้วค่อยๆ decode ทีละนิดตอนเล่น — กิน memory น้อยกว่า แลกกับ CPU ต่อ frame ที่มากขึ้นนิดหน่อย) และ Streaming (byte ที่ compress แล้วไม่ถูกโหลดเข้ามาเต็มๆ ด้วยซ้ำ — มันจะถูกอ่านแล้ว decode จากดิสก์เป็นชิ้นเล็กๆ ไปเรื่อยๆ ตอนเล่น ทำให้ RAM ที่ใช้เล็กมากและแทบคงที่ไม่ว่า clip จะยาวแค่ไหน)

ขนาดของ clip ที่ decompress เต็มๆ คำนวณได้ตรงไปตรงมา:

decompressed size (bytes) = duration_seconds * sampleRate * channels * bytesPerSample

มาลองคำนวณ clip สองแบบที่ต่างกันมากๆ ที่ setting ระดับ CD (44100 Hz, stereo, sample 16-bit = 2 byte ต่อ sample):

SHORT SFX -- 2 second explosion sound bytes = 2 * 44100 * 2 * 2 = 352,800 bytes = 0.34 MB decompressed MUSIC TRACK -- 180 second (3 minute) background theme bytes = 180 * 44100 * 2 * 2 = 31,752,000 bytes = 30.28 MB decompressed The SFX is small enough that "Decompress On Load" costs almost nothing and buys instant, glitch-free playback. The music track, fully decompressed, is nearly 100x bigger than the SFX and would eat a huge share of the whole audio budget -- this is exactly why long clips use Streaming instead.
using UnityEngine;

public class AudioMemoryCheck : MonoBehaviour
{
    public AudioClip clip;

    void Start()
    {
        long samples = clip.samples;
        int channels = clip.channels;
        long bytes = samples * channels * 2; // 16-bit PCM = 2 bytes/sample
        float megabytes = bytes / 1024f / 1024f;
        Debug.Log(clip.name + ": " + megabytes.ToString("F2") + " MB decompressed");
    }
}

รันโค้ดนี้กับ clip เสียงระเบิดจะ print explosion: 0.34 MB decompressed รันกับเพลงประกอบจะ print theme_music: 30.28 MB decompressed — ตรงกับที่คำนวณด้วยมือ และแสดงให้เห็นชัดว่าทำไมตัวหนึ่งในนี้ไม่ควร decompress เต็มๆ ลง RAM ทีเดียว

กฎง่ายๆ: เสียงสั้นๆ ที่เล่นซ้ำบ่อยๆ (เสียงฝีเท้า เสียงปืน เสียงคลิก UI) ใช้ Decompress On Load — ต้นทุน RAM น้อย แล้วก็ต้องเริ่มเล่นได้ทันทีโดยไม่มี delay ในการ decode เสียงยาวๆ ที่ไม่ค่อยเล่นซ้ำ (เพลง, ambient loop, บทสนทนายาวๆ) ใช้ Streaming — ต้นทุน RAM จะคงที่ไม่ว่าจะยาวแค่ไหน Compressed In Memory อยู่ตรงกลาง เหมาะกับ clip ความยาวปานกลางที่เล่นเป็นบางครั้งแต่ไม่คุ้มที่จะ stream จากดิสก์

Common mistake ปล่อยเพลงยาว 3 นาทีไว้ที่ setting default "Decompress On Load" แทนที่จะกิน memory คงที่แค่ไม่กี่ร้อย KB แบบ streaming มันจะกิน RAM เกิน 30 MB ตลอดเวลาที่มันอยู่ใน memory — แล้วถ้าเกมมีเพลงหลายเพลงที่ import แบบนี้หมด มันจะรวมกันเป็นก้อนใหญ่ของ audio budget ที่หายไปเงียบๆ โดยไม่ได้ประโยชน์อะไรที่ได้ยินเพิ่มขึ้นเลย

5. Object Pooling: เลิก Allocate แล้วหันมา Reuse

กระสุน, particle, hit-effect, enemy ที่ spawn กับ despawn ตลอดเวลา — อะไรก็ตามที่ถูกสร้างและทำลายในอัตราสูงคือปัญหา memory แม้ว่า instance แต่ละอันจะเล็กก็ตาม การเรียก Instantiate กับ Destroy แต่ละครั้งมีต้นทุนจริง: การสร้างและรื้อ GameObject กับ component ของมันฝั่ง engine บวกกับฝั่ง managed ที่มี heap allocation ที่จะกลายเป็น garbage ในภายหลัง ทำแบบนี้หลายสิบครั้งในเฟรมเดียว — ปืนลูกซองยิงกระสุน 12 นัด, การระเบิดที่ spawn particle 40 อัน — แล้วเราจะได้ allocation spike: งาน memory ที่พุ่งกระจุกอยู่ในเฟรมเดียว จนเห็นเป็นอาการกระตุกชัดๆ

ทางแก้คือ object pooling: แทนที่จะ destroy object ตอนใช้เสร็จ เราแค่ deactivate มันแล้วเอากลับเข้า pool (ที่เก็บ object ที่พร้อม reuse) เพื่อให้คำขอครั้งต่อไป reuse มันแทนที่จะสร้างใหม่ นี่คือ pool แบบ generic เต็มรูปแบบ:

using System.Collections.Generic;
using UnityEngine;

public class ObjectPool<T> where T : Component
{
    private readonly Queue<T> pool = new Queue<T>();
    private readonly T prefab;
    private readonly Transform parent;

    public ObjectPool(T prefab, Transform parent, int prewarmCount)
    {
        this.prefab = prefab;
        this.parent = parent;

        for (int i = 0; i < prewarmCount; i++)
        {
            T obj = Object.Instantiate(prefab, parent);
            obj.gameObject.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public T Get()
    {
        T obj = pool.Count > 0 ? pool.Dequeue() : Object.Instantiate(prefab, parent);
        obj.gameObject.SetActive(true);
        return obj;
    }

    public void Return(T obj)
    {
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
}

Prewarming (สร้าง object ไว้ล่วงหน้าครั้งเดียวใน prewarmCount) จะจ่ายต้นทุน Instantiate ที่แพงตอน loading screen แทนที่จะจ่ายตอนเล่นเกมจริง หลังจากนั้น Get() กับ Return() จะแค่สลับ SetActive กับขยับ queue เท่านั้น — ไม่มีการสร้างฝั่ง engine ไม่มี managed allocation ใหม่เลยตอนที่ระบบนิ่งแล้ว

ใช้งาน Pool

กระสุนต้องรู้วิธีคืนตัวเองกลับเข้า pool ตอนหมดอายุ โดยไม่สร้าง closure ใหม่ทุกครั้งที่ยิง (จะอธิบายว่าทำไมเรื่องนี้สำคัญในหัวข้อ 7):

using UnityEngine;

public class Bullet : MonoBehaviour
{
    private ObjectPool<Bullet> pool;
    private float lifetime;
    private float timer;

    public void Init(ObjectPool<Bullet> ownerPool, float life)
    {
        pool = ownerPool;
        lifetime = life;
    }

    void OnEnable()
    {
        timer = 0f;
    }

    void Update()
    {
        timer += Time.deltaTime;
        if (timer >= lifetime)
        {
            pool.Return(this);
        }
    }
}

public class BulletSpawner : MonoBehaviour
{
    public Bullet bulletPrefab;
    private ObjectPool<Bullet> bulletPool;

    void Awake()
    {
        bulletPool = new ObjectPool<Bullet>(bulletPrefab, transform, 30);
    }

    public void Fire(Vector3 position, Quaternion rotation)
    {
        Bullet bullet = bulletPool.Get();
        bullet.transform.SetPositionAndRotation(position, rotation);
        bullet.Init(bulletPool, 3f);
    }
}
OBJECT POOL ====================================================== POOL (inactive, waiting) ACTIVE (in the scene) +-----+ +-----+ +-----+ +-----+ +-----+ | obj | | obj | | obj | | obj | | obj | +-----+ +-----+ +-----+ +-----+ +-----+ -------- Get() -------> (dequeue, SetActive(true)) <------ Return() ------ (SetActive(false), enqueue) ====================================================== No Instantiate() and no Destroy() happen after prewarming -- objects just move between the two groups.

ความต่างที่เห็นใน Profiler

เปิด Window > Analysis > Profiler เปิด CPU module แล้วดู column GC Alloc ตอนยิงกระสุน ถ้าไม่มี pooling — เรียก Instantiate กับ bullet prefab แล้ว Destroy ตอนหมดอายุ — ทุกนัดที่ยิงจะเห็น GC Alloc ไม่เป็นศูนย์ และถ้ายิงกระสุนหลายนัดในเฟรมเดียวก็จะเห็น spike ชัดๆ ในเฟรมนั้น พอมี pool แล้ว การทดสอบเดียวกันจะเห็น GC Alloc: 0 B สำหรับ Get()/Return() ตอนเล่นเกมนิ่งๆ เพราะไม่มีอะไรแตะ managed heap เลยหลังจาก prewarm เสร็จ

Tip Unity มี generic pool มาให้ในตัวคือ UnityEngine.Pool.ObjectPool<T> ใช้ไอเดียเดียวกันแต่มี hook เพิ่มมาให้ (OnGet, OnRelease, OnDestroy, กำหนด max size ที่จะ destroy ของที่เกินแทนที่จะโตไม่มีที่สิ้นสุด) พอเข้าใจ pool ด้านบนแล้ว ตัวในตัวของ Unity มักจะเป็นตัวเลือกที่ดีกว่าสำหรับ production — แต่การรู้วิธีสร้างเองก็ทำให้เราไม่ติดหล่มตอนที่ requirement ของโปรเจกต์ไม่เข้ากับตัวในตัว
Common mistake ลืม reset state ของ object ใน Get() หรือ Return() กระสุนจาก pool ที่ยังมี velocity เก่าติดอยู่, particle จาก pool ที่ยังมีสีเก่าติดอยู่, หรือ enemy จาก pool ที่ยังมี health เก่าจากการต่อสู้เมื่อสามครั้งก่อน จะทำให้เกิดบั๊กที่ดูเหมือนไม่เกี่ยวกับ pooling เลยแม้แต่น้อย ต้อง reset state ทุกอย่างที่ object ที่ reuse ต้องพึ่งพาเสมอ

6. Garbage Collector ของ Unity: มันคืออะไร แล้วทำไมถึงเจ็บ

ย้อนกลับไปที่บท C#: garbage collector (GC) จะ free heap memory อัตโนมัติทันทีที่ไม่มีอะไร reachable ชี้ไปหามันแล้ว เราเลยไม่ต้องเรียกอะไรแบบ free ของ C เองเลย นั่นสะดวกก็จริง แต่มันไม่ได้ฟรี — GC ต้องทำงานเพื่อหาว่าอะไรยัง reachable อยู่บ้าง แล้วงานนั้นก็กิน CPU time ที่ถูกดึงไปจาก frame budget ของเกมเรา

พูดให้ชัดๆ งานของ GC มีสองเฟส: mark (เริ่มจากชุด root — static field, local variable ที่อยู่บน stack ตอนนั้น ฯลฯ — แล้วเดินตาม reference ทุกเส้น ทำเครื่องหมาย object ทุกตัวที่ยัง reachable อยู่) กับ sweep (เก็บ memory ของทุกอย่างที่ไม่ได้ถูกทำเครื่องหมายคืน) collector รุ่นเก่าที่ไม่ใช่ incremental จะทำแบบนี้เป็น pause ก้อนใหญ่ก้อนเดียว: เกมเราจะหยุดนิ่งสนิทระหว่างที่ GC ทำ mark กับ sweep ทั้ง heap เสร็จ ซึ่งอาจกินเวลาหลาย millisecond — นานพอที่จะทำให้เฟรมหลุดไปเลยหนึ่งเฟรม Unity รุ่นใหม่ๆ ใช้ Incremental Garbage Collector เป็นค่า default ซึ่งกระจายงานเดิมนั้นออกเป็นชิ้นเล็กๆ ข้ามหลายเฟรมแทนที่จะเป็น pause ก้อนเดียว วิธีนี้ทำให้เนียนขึ้น แต่ไม่ได้ทำให้งานนั้นฟรี — มันยังกิน CPU time รวมเท่าเดิม แล้วชิ้นงานแต่ละชิ้นก็ยังมีสิทธิ์ไปตกอยู่บนเฟรมที่ budget ตึงอยู่แล้วได้

NON-INCREMENTAL GC INCREMENTAL GC frame time (ms) frame time (ms) | | | ____ | _ _ _ _ | | | | | | | | | | | | |_______| |______ |___| |_| |_| |_| |__ | one huge stop-the-world | several small slices | pause -- one dropped frame | spread across frames -- | | smoother, same total cost

ไม่ว่าจะแบบไหน ปริมาณงานที่ GC ต้องทำจะแปรผันตามปริมาณ garbage (heap memory ที่ unreachable) ที่เกมเราสร้างขึ้น และสำหรับเฟส mark ก็ยังแปรผันตามจำนวน object ที่ยังมีชีวิตอยู่ที่ต้องไล่ตามด้วย หัวข้อ 7 จะพูดถึง pattern การเขียนโค้ดที่แอบสร้าง garbage มากที่สุดโดยเฉพาะ

7. อะไรที่สร้าง Garbage จริงๆ

garbage ส่วนใหญ่ในโปรเจกต์ Unity จริงๆ ไม่ได้มาจากจุดเดียวที่เห็นชัดๆ — มันมาจาก pattern เล็กๆ ที่มองข้ามง่ายแล้วถูกทำซ้ำทุกเฟรม นี่คือสี่ตัวร้ายที่สุด

String Concatenation ใน Update

ย้อนกลับไปที่บทพื้นฐาน C#: string เป็น immutable — เราเปลี่ยนแปลง string เดิมตรงๆ ไม่ได้เลย ดังนั้นการ concatenate แต่ละครั้งจะสร้าง string object ใหม่เอี่ยมบน heap แล้วทิ้งอันเก่าไป

// BEFORE -- allocates a new string every single frame
void Update()
{
    scoreText.text = "Score: " + score + " / " + maxScore;
}

ใน Profiler บรรทัดนี้จะโชว์ GC Alloc ประมาณ 40-50 byte ในทุกๆ เฟรมที่ label นี้แสดงอยู่ — ที่ 60 FPS นั่นคือ garbage หลาย kilobyte ต่อวินาที ตลอดไปเรื่อยๆ แม้แต่ในเฟรมที่คะแนนไม่ได้เปลี่ยนเลยก็ตาม

// AFTER -- only rebuilds the string when the score actually changes
private readonly StringBuilder scoreLabel = new StringBuilder(32);
private int lastDisplayedScore = -1;

void Update()
{
    if (score != lastDisplayedScore)
    {
        scoreLabel.Clear();
        scoreLabel.Append("Score: ").Append(score).Append(" / ").Append(maxScore);
        scoreText.text = scoreLabel.ToString();
        lastDisplayedScore = score;
    }
}

dirty flag (lastDisplayedScore) ทำให้งานที่แพงเกิดขึ้นแค่ในเฟรมที่คะแนนเปลี่ยนจริงๆ ไม่ใช่ทุกเฟรมที่แค่โชว์มันอยู่ StringBuilder ก็ช่วยไม่ให้เกิด string ทิ้งขว้างตรงกลางสำหรับ + แต่ละตัวแบบที่ concatenation ธรรมดาทำ ยังมี allocation หนึ่งอันจาก ToString() อยู่ — แต่เกิดแค่ตอนค่าเปลี่ยนเท่านั้น ไม่ใช่ 60 ครั้งต่อวินาที

Boxing

Boxing คือสิ่งที่เกิดขึ้นตอนที่ value type (เช่น int หรือ struct) ต้องถูกปฏิบัติเหมือนเป็น object reference — runtime จะห่อ value นั้นด้วยกล่องที่ allocate บน heap เพื่อให้มันถูกจัดการแบบเดียวกับ reference type ได้ เรื่องนี้มองไม่เห็นในโค้ดเลย แต่เห็นชัดมากใน profiler

// BEFORE -- ArrayList stores everything as object, so every int gets boxed
ArrayList scores = new ArrayList();
scores.Add(100); // 100 is boxed into a new heap object right here
// AFTER -- List<int> stores the int inline, no boxing at all
List<int> scores = new List<int>();
scores.Add(100); // no allocation -- the int is stored directly

ปัญหาเดียวกันนี้จะโผล่มาทุกครั้งที่ value type ถูกส่งเข้าไปตรงที่ต้องการ object — เช่น method signature ที่ไม่ใช่ generic:

// BEFORE -- boxes the int on every call
void PrintValue(object value) { Debug.Log(value); }
PrintValue(42);
// AFTER -- generic method, T is resolved to int at compile time, no boxing
void PrintValue<T>(T value) { Debug.Log(value); }
PrintValue(42);

LINQ ใน Hot Path

LINQ (Where, Select, ToList, และเพื่อนๆ) สะดวกก็จริง แต่ operator ส่วนใหญ่ของมัน allocate — iterator object สำหรับ Where, list ใหม่เอี่ยมสำหรับ ToList — ทุกครั้งที่บรรทัดนั้นรัน แบบนี้ไม่มีปัญหาสำหรับโค้ดที่รันแค่ครั้งเดียวตอนกดปุ่มใน menu แต่มันเป็นแหล่ง garbage ต่อเนื่องถ้าอยู่ใน Update

// BEFORE -- allocates an iterator AND a new List<Enemy> every single frame
void Update()
{
    var aliveEnemies = enemies.Where(e => e.IsAlive).ToList();
    foreach (var e in aliveEnemies)
    {
        e.Tick();
    }
}
// AFTER -- a plain loop, zero allocation
void Update()
{
    for (int i = 0; i < enemies.Count; i++)
    {
        if (enemies[i].IsAlive)
        {
            enemies[i].Tick();
        }
    }
}

LINQ ไม่ได้ถูกแบน — ใช้ครั้งเดียวตอนโหลด level ก็ไม่มีปัญหาเลย กฎมันแคบกว่านั้น: อย่าใส่ LINQ chain ที่ allocate ไว้ใน method ที่รันทุกเฟรม

Closure ที่ Capture ตัวแปร

closure คือค่าฟังก์ชัน (lambda หรือ delegate) ที่อ้างถึงตัวแปรจาก method ที่ห่อมันอยู่ เพื่อให้ตัวแปรนั้นยังมีชีวิตอยู่หลังจาก method ภายนอก return ไปแล้ว compiler จะสร้าง object ที่ซ่อนอยู่บน heap ขึ้นมาเก็บมันไว้ — object ที่ซ่อนอยู่นั้นแหละคือสิ่งที่ถูก allocate ทุกครั้งที่ lambda ถูกสร้างขึ้น

// BEFORE -- each call allocates a new hidden object to hold "enemyId"
void SetupButton(Button button, int enemyId)
{
    button.onClick.AddListener(() => KillEnemy(enemyId));
}

spawn ปุ่ม 50 ปุ่มพร้อมกันตอน level โหลด (pattern ที่เจอบ่อยๆ ในหน้าเลือก wave หรือหน้าร้านค้า) บรรทัดนี้บรรทัดเดียวจะสร้าง heap allocation เล็กๆ 50 อัน ทั้งหมดในเฟรมเดียวกัน — allocation spike จากอะไรบางอย่างที่ดูไม่มีพิษภัยเลยในโค้ด

// AFTER -- no capture: the id lives on the button itself, listener never changes
public class EnemyButton : MonoBehaviour
{
    public int EnemyId;

    void Awake()
    {
        GetComponent<Button>().onClick.AddListener(HandleClick);
    }

    void HandleClick()
    {
        KillEnemy(EnemyId);
    }
}

listener method ในเวอร์ชันที่แก้แล้วไม่ capture อะไรเลย — มันอ่าน EnemyId จาก object ตัวเดียวกับที่มันอยู่ ทำให้ delegate ตัวเดิมที่ไม่ capture อะไรถูก reuse ได้เลย แทนที่จะสร้าง closure ใหม่ทุกปุ่ม pattern Init(ownerPool, life) ของกระสุนในหัวข้อ 5 ก็ใช้กลเม็ดเดียวกันนี้โดยตั้งใจ: เก็บข้อมูลไว้เป็น field แล้วอ่านมันกลับมาใน method ที่ไม่ capture อะไรเลย

8. ทำไมการเลี่ยง Garbage ถึงดีกว่าการเก็บมัน

มันน่าคิดไปได้ว่า GC เป็นตาข่ายนิรภัยที่ทำให้ต้นทุนของ allocation ไม่สำคัญอีกต่อไป — ยังไงมันก็เก็บกวาดให้อัตโนมัติอยู่แล้ว แต่เฟส mark ต้องไล่ตาม live object ทุกตัวเพื่อพิสูจน์ว่ามันยัง reachable อยู่ ไม่ใช่แค่ตัวที่เพิ่งสร้างขึ้นมาใหม่ๆ ดังนั้นฉากที่มีจำนวน live object เยอะๆ จะทำให้ GC pass แต่ละครั้งแพงขึ้นเสมอ ไม่ว่าเราจะสร้าง garbage สดใหม่มากแค่ไหนตั้งแต่รอบก่อนก็ตาม การสร้าง garbage น้อยลงช่วยได้สองทาง: GC ต้องรันน้อยลง (heap เต็มช้าลง) และแต่ละรอบที่รันก็ไล่ผ่านขยะอายุสั้นๆ ที่เกิดโดยไม่ตั้งใจน้อยลงด้วย

จำนวนอาการกระตุกจาก GC ที่ดีที่สุดที่เป็นไปได้คือศูนย์ และวิธีเดียวที่จะรับประกันศูนย์ได้คือไม่ allocate ตั้งแต่แรก incremental GC, object pooling, กับโค้ดที่ไม่ allocate ไม่ใช่กลยุทธ์ที่แข่งกันเอง — มันทำงานซ้อนกันได้ pooling กับวิธีแก้ในหัวข้อ 7 ลดปริมาณ garbage ที่มีอยู่ทั้งหมดลง จากนั้น incremental GC ก็จะเหลืองานให้ทำให้เนียนน้อยลง แล้วก็ทำส่วนที่เหลือให้เนียนเท่าที่ทำได้ พูดสั้นๆ คือ: garbage collection ที่เร็วที่สุดคือ collection ที่ไม่ต้องรันเลย

Tip เปิด CPU module ของ Profiler ค้างไว้ให้เห็น column GC Alloc ตลอดเวลาที่เขียนโค้ด gameplay ไม่ใช่แค่ตอนไล่บั๊กเฉพาะจุด การเห็นตัวเลขที่ไม่เป็นศูนย์โผล่ขึ้นมาทันทีที่เพิ่มบรรทัดโค้ดคือวิธีที่ถูกที่สุดในการจับนิสัยแย่ๆ ก่อนที่จะ ship ออกไป

9. Asset Bundles กับ Addressables: Load แล้วต้อง Unload ด้วย

AssetBundle คือกลไกของ Unity สำหรับแพ็ค content (texture, prefab, ทั้ง scene, audio) เป็นไฟล์แยกต่างหากที่โหลดตอน runtime แทนที่จะฝังไว้ใน build เริ่มต้นของเกมเลย วิธีนี้ทำให้การดาวน์โหลดครั้งแรกเล็กลง แล้วก็อัพเดต content ได้โดยไม่ต้องส่งแอปทั้งแอปใหม่ Addressables คือระบบรุ่นใหม่ที่สร้างต่อยอดจาก AssetBundle: แทนที่จะถือ reference ตรงไปยัง asset เราจะอ้างถึงมันด้วย address (string key) แล้วระบบจะหา ดาวน์โหลด และโหลดมันให้เราเอง

จุดที่มือใหม่มักพลาดคือ Addressables เป็นระบบ reference-counted: การโหลด asset จะเพิ่มตัวนับขึ้นหนึ่ง แล้ว asset จะออกจาก memory จริงๆ ก็ต่อเมื่อตัวนับนั้นลดกลับไปเป็นศูนย์ผ่านการเรียก release ที่จับคู่กันเท่านั้น ไม่มีอะไร free ตัวเองอัตโนมัติแค่เพราะเราเลิกใช้มันแล้ว

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Collections;

public class LevelLoader : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> handle;

    public IEnumerator LoadLevel(string address)
    {
        handle = Addressables.LoadAssetAsync<GameObject>(address);
        yield return handle;

        GameObject level = handle.Result;
        Instantiate(level);
    }

    public void UnloadLevel()
    {
        Addressables.Release(handle); // without this call, the level's
                                       // textures, meshes, and audio
                                       // stay resident forever
    }
}

ทุก LoadAssetAsync ต้องมี Release ที่จับคู่กันพอดีหนึ่งครั้งเสมอ — วินัยแบบเดียวกับ malloc กับ free จากบท C เลย ความต่างอยู่ที่หน้าตาของความผิดพลาด: ลืม free ในโปรแกรม C เล็กๆ มักโผล่ให้เห็นเร็ว ลืม Addressables.Release จะค่อยๆ โผล่ช้าๆ เป็น memory leak ที่จะเห็นชัดก็ต่อเมื่อผู้เล่นจริงๆ เล่นเกมเราไปนานๆ เท่านั้น

THE CLASSIC BUG -- LoadLevel called without a matching UnloadLevel ======================================================================== enter Level A -> Load (ref count 1) -> play -> leave (no Release!) enter Level B -> Load (ref count 1) -> play -> leave (no Release!) enter Level C -> Load (ref count 1) -> play -> leave (no Release!) | v memory resident: Level A + Level B + Level C, all at once, even though the player is only looking at Level C right now. After 10 level transitions in one session: 10 levels' worth of textures, meshes, and audio sitting in RAM. Eventually the OS kills the app for using too much memory -- looks like a random crash with no exception in the logs. ========================================================================
Common mistake เรียก LoadAssetAsync ทุกครั้งที่ level เริ่ม แต่เรียก Release แค่ใน exit path เดียว (เช่นปุ่ม "Return to Menu") แล้วลืม path อื่นๆ (การตาย, การเปลี่ยน level ที่ scripted ไว้, event ตอนแอปถูกส่งไปพื้นหลัง) ทุก path ที่ไม่ได้จัดการคือ leak ที่จะโผล่ให้เห็นก็ต่อเมื่อเล่นนานพอที่จะสังเกตเห็นเท่านั้น

10. Memory Fragmentation

Memory fragmentation เกิดขึ้นตอนที่ heap มี free memory รวมเยอะพอ แต่ free memory นั้นกระจายอยู่เป็นช่องเล็กๆ หลายช่องแทนที่จะเป็นก้อนต่อเนื่องก้อนเดียว — ทำให้คำขอ allocate ก้อนใหญ่ก้อนเดียวล้มเหลวได้ แม้ว่าผลรวมของช่องว่างทั้งหมดจะมากพอเหลือเฟือก็ตาม

FRAGMENTED HEAP ========================================================== [ A: 2MB ][free: 0.5MB][ B: 4MB ][free: 0.3MB][ C: 1MB ][free: 0.4MB] Total free space: 0.5 + 0.3 + 0.4 = 1.2 MB A new 1 MB texture needs ONE contiguous 1 MB gap. The largest single gap here is only 0.5 MB -- the allocation FAILS despite 1.2 MB being free overall. ==========================================================

เรื่องนี้สำคัญที่สุดกับ native heap (unmanaged) ของ Unity — memory ที่รองรับ texture, mesh, กับ audio buffer — เพราะ heap นั้นไม่ถูก compact (จัดเรียงใหม่เพื่อรวมช่องว่างเข้าด้วยกัน) แบบที่บางส่วนของ managed heap ของ C# ทำได้ เซสชันเล่นยาวๆ ที่โหลดกับ unload asset ขนาดต่างๆ กันซ้ำไปซ้ำมาเรื่อยๆ อาจทำให้ native heap นั้น fragment ขึ้นเรื่อยๆ ตามเวลา จนสุดท้าย allocation ล้มเหลวได้ แม้ว่าภาพรวม memory จะบอกว่ามี "total free" เหลือเยอะก็ตาม การโหลดกับ unload ในลำดับที่คาดเดาได้และสม่ำเสมอ กับการ unload Addressables content ที่ไม่ได้ใช้ตอนเปลี่ยนฉากใหญ่ๆ (เช่นเปลี่ยน level หรือ scene) แทนที่จะทำทีละนิดกลาง level ทั้งสองอย่างช่วยลดความ fragment ของ heap ตลอดเซสชันยาวๆ ได้

11. ล่า Leak ด้วย Memory Profiler

ตอนที่ memory usage ค่อยๆ ไต่ขึ้นเรื่อยๆ ตามเวลา แล้วสิ่งที่เช็คมาทั้งหมดยังอธิบายไม่ได้ Memory Profiler (Window > Analysis > Memory Profiler เป็น package แยกต่างหาก) จะตอบคำถามตรงๆ ว่า: อะไรถูกโหลดขึ้นมาแล้วไม่เคยถูก release เลย workflow จะมีรูปแบบเดิมเสมอ — snapshot, ทำอะไรบางอย่าง, snapshot อีกครั้ง, แล้วเทียบกัน

SNAPSHOT COMPARE -- what a leak looks like ======================================================== Object type Snapshot A Snapshot B (after 5x) -------------------------------------------------------- Texture2D (level) 12 60 <- leak! Mesh (level) 8 40 <- leak! AudioClip (level) 4 4 <- fine, unloads correctly ======================================================== Textures and meshes scaled with the repeat count (12 x 5 = 60). Audio did not grow, so audio is releasing correctly -- the leak is specific to whatever code path loads level textures and meshes, not a general "nothing gets released" bug.
Tip leak ที่จะเห็นก็ต่อเมื่อทำซ้ำ 5 หรือ 10 ครั้ง ไม่ใช่แค่ครั้งเดียว คือเหตุผลตรงๆ ว่าทำไม "ตอนฉันทดสอบมันทำงานปกติ" ไม่ใช่หลักฐานอะไรเลย ต้อง retest leak ที่สงสัยด้วยการทำซ้ำหลายๆ ครั้งเสมอก่อนจะเชื่อผลลัพธ์ไม่ว่าจะออกมาทางไหน

12. รวมทุกอย่างเป็น Memory Budget

ย้อนกลับไปที่ตาราง budget จากหัวข้อ 1 ทุกเทคนิคในบทนี้แม็ปลงไปที่แถวใดแถวหนึ่งของมัน: texture เล็กลงด้วยการจำกัดความละเอียดกับ ASTC compression (หัวข้อ 2); mesh เล็กลงด้วยการถอด channel ที่ shader ไม่เคยอ่าน (หัวข้อ 3); audio เลือกใช้ streaming หรือ decompression ตามความยาวของ clip (หัวข้อ 4); แถว code/object เล็กลงด้วย pooling (หัวข้อ 5) กับการสร้าง garbage ให้น้อยลงตั้งแต่แรก (หัวข้อ 6-8); และทุกอย่างที่ประหยัดได้จะไม่คงอยู่เลยถ้า Addressables content ไม่เคยถูก release (หัวข้อ 9) หรือ heap fragment ไปตลอดเซสชันยาวๆ (หัวข้อ 10)

workflow ที่รวมทุกอย่างเข้าด้วยกันก็เป็นแบบเดียวกับบท performance เสมอ: วัดก่อน ด้วย Memory Profiler กับตัวเลขจากบทนี้ ก่อนจะเดาว่าควร optimize อะไร texture ที่เป็น ASTC 6x6 ที่ความละเอียดเหมาะสมอยู่แล้วไม่ใช่ปัญหาของเรา ส่วน texture หน้า splash-screen แบบ 4096x4096 uncompressed ที่ถูกปล่อยไว้ที่ import setting default อาจกินไปครึ่งหนึ่งของ budget เราเลยทีเดียว ตาราง budget ไม่ใช่แบบฝึกหัดที่ทำครั้งเดียวจบ — ต้องกลับมาดูใหม่เรื่อยๆ ตอนที่มี content เพิ่มเข้ามา แล้วให้ตัวเลขเป็นตัวบอกว่าควรดู category ไหนต่อไป แทนที่จะเดาเอาเอง

13. Glossary (ศัพท์)

14. Exercises (แบบฝึกหัด)

Exercise 1 texture ขนาด 1024x1024 ถูก import สองแบบ: (a) RGB24 uncompressed (3 byte/pixel ไม่มี alpha) และ (b) ASTC 6x6 (0.44 byte/pixel effective) คำนวณ base size กับขนาดที่รวม mip chain เต็มๆ (x 4/3) ของทั้งสองแบบ เป็นหน่วย MB (b) เล็กกว่า (a) กี่เท่า เมื่อรวม mip แล้ว
Show answer

จำนวน pixel ฐาน: 1024 * 1024 = 1,048,576 pixel

(a) RGB24: base = 1,048,576 * 3 = 3,145,728 bytes = 3.00 MB รวม mip: 3.00 * 4/3 = 4.00 MB

(b) ASTC 6x6: base = 1,048,576 * 0.44 ≈ 461,373 bytes ≈ 0.44 MB รวม mip: 0.44 * 4/3 ≈ 0.59 MB

อัตราส่วนเมื่อรวม mip แล้ว: 4.00 / 0.59 ≈ 6.8x เท่า ASTC 6x6 ใช้ compression block ใหญ่กว่า ASTC 4x4 แลกคุณภาพบางส่วนเพื่อลดขนาดได้มากกว่าเดิมเมื่อเทียบกับ uncompressed

Exercise 2 เพื่อนร่วมทีมเขียน method นี้ ถูกเรียกครั้งเดียวต่อเฟรมจาก Update เพื่อสร้าง debug string แสดงรายชื่อ enemy ที่โดนพิษอยู่ตอนนี้ทั้งหมด:
void Update()
{
    string debugLine = "Poisoned: ";
    var poisoned = enemies.Where(e => e.IsPoisoned).ToList();
    foreach (var e in poisoned)
    {
        debugLine += e.Name + ", ";
    }
    debugText.text = debugLine;
}
หาแหล่งที่มาของ garbage ทั้งหมดใน method นี้ (มีมากกว่าหนึ่งจุด) แล้วเขียนใหม่ให้ไม่ allocate อะไรเลย นอกจากตอนที่ชุด enemy ที่โดนพิษเปลี่ยนไปจากเฟรมก่อนหน้าจริงๆ
Show answer

มีแหล่ง garbage แยกกันสามจุดตรงนี้: (1) enemies.Where(...) allocate iterator ทุกเฟรม (2) .ToList() allocate list ใหม่เอี่ยมทุกเฟรม และ (3) debugLine += ... ข้างในลูป allocate string ใหม่ทุกครั้งที่ concatenate ไม่ใช่แค่ครั้งเดียวต่อเฟรม — ถ้ามี enemy โดนพิษ N ตัว นั่นคือ allocation เพิ่มอีก N ครั้งซ้อนบนสองจุดด้านบน ทุกเฟรม ไม่ว่าจะมีอะไรเปลี่ยนหรือไม่ก็ตาม

private readonly StringBuilder debugLine = new StringBuilder(128);
private int lastPoisonedCount = -1;
private int lastPoisonedHash = 0;

void Update()
{
    int count = 0;
    int hash = 17;
    for (int i = 0; i < enemies.Count; i++)
    {
        if (enemies[i].IsPoisoned)
        {
            count++;
            hash = hash * 31 + enemies[i].GetInstanceID();
        }
    }

    if (count != lastPoisonedCount || hash != lastPoisonedHash)
    {
        debugLine.Clear();
        debugLine.Append("Poisoned: ");
        for (int i = 0; i < enemies.Count; i++)
        {
            if (enemies[i].IsPoisoned)
            {
                debugLine.Append(enemies[i].Name).Append(", ");
            }
        }
        debugText.text = debugLine.ToString();
        lastPoisonedCount = count;
        lastPoisonedHash = hash;
    }
}

ลูปแรกเป็น pass ที่ถูกและไม่ allocate เลย ใช้แค่ตรวจว่าชุด enemy ที่โดนพิษเปลี่ยนไปหรือเปล่า (ใช้ count บวกกับ hash รวมง่ายๆ ของ instance ID) จะรัน pass ที่สองซึ่งแพงกว่าก็ต่อเมื่อมีอะไรเปลี่ยนจริงๆ เท่านั้น โดยใช้ StringBuilder แทนการ += ซ้ำๆ ในเฟรมที่สถานะพิษไม่เปลี่ยน — ซึ่งเป็นเฟรมส่วนใหญ่ท่วมท้น — โค้ดนี้จะไม่ allocate อะไรเลยแม้แต่นิดเดียว

Exercise 3 ต่อยอด ObjectPool<T> จากหัวข้อ 5 ให้มี maximum size: เพิ่ม parameter maxSize เข้าไปใน constructor แล้วแก้ Return ให้พอ pool มี inactive object ครบ maxSize แล้ว object ที่คืนกลับมาเพิ่มจะถูก destroy แทนที่จะ enqueue (เพื่อไม่ให้ pool โตแบบไม่มีขีดจำกัดถ้าจู่ๆ มี object ถูก spawn พร้อมกันเยอะกว่าปกติมาก) เขียน class ที่แก้แล้วออกมา
Show answer
using System.Collections.Generic;
using UnityEngine;

public class ObjectPool<T> where T : Component
{
    private readonly Queue<T> pool = new Queue<T>();
    private readonly T prefab;
    private readonly Transform parent;
    private readonly int maxSize;

    public ObjectPool(T prefab, Transform parent, int prewarmCount, int maxSize)
    {
        this.prefab = prefab;
        this.parent = parent;
        this.maxSize = maxSize;

        for (int i = 0; i < prewarmCount; i++)
        {
            T obj = Object.Instantiate(prefab, parent);
            obj.gameObject.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public T Get()
    {
        T obj = pool.Count > 0 ? pool.Dequeue() : Object.Instantiate(prefab, parent);
        obj.gameObject.SetActive(true);
        return obj;
    }

    public void Return(T obj)
    {
        if (pool.Count >= maxSize)
        {
            Object.Destroy(obj.gameObject); // overflow -- do not keep growing the pool
            return;
        }

        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
}

โค้ดนี้ยังเลี่ยง allocation ได้สำหรับกรณีปกติ (มี object ใช้งานพร้อมกันไม่เกิน maxSize) ซึ่งครอบคลุมเฟรมส่วนใหญ่ท่วมท้น มันจะจ่ายต้นทุน Instantiate/Destroy ก็ต่อเมื่อเกิดกรณีหายากที่การใช้งานพุ่งเกินขนาดที่ pool ถูกตั้งไว้จริงๆ — เป็นการแลกที่สมเหตุสมผล แทนที่จะปล่อยให้เฟรมแปลกๆ หนึ่งเฟรม (เช่น event ฝูง enemy 200 ตัว) ทำให้ขนาดพักตัวของ pool บวมค้างไปตลอดที่เหลือของ level

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