16.4 Rendering Engine และ RHI

เฟส 16 · Engine Programming (C++ ลึก, ไม่บังคับ) · เวลาเรียน: 100–150 h

สร้าง renderer และ render-hardware-interface ที่ครอบ Vulkan และ DirectX 12 จัดด้วย render graph

บทที่ 7.7 เปิดฝาให้ดู graphics API แบบตรง ๆ คุณเรียก function ของ OpenGL เองด้วยมือ และเห็นรูปร่างแบบ explicit ระดับ low-level ของ Vulkan กับ DirectX 12 ข้างใน แต่เอนจินเกมจริง ๆ ไม่ปล่อยให้โค้ด gameplay ทำแบบนั้น ระหว่างโค้ดที่โปรแกรมเมอร์ gameplay หรือ rendering เขียน กับ call ดิบ ๆ ของ Vulkan/DX12/Metal จากบทก่อนหน้า มี layer อีกชั้นคั่นอยู่ สร้างขึ้นครั้งเดียวแล้วทุกอย่างข้างบนใช้ร่วมกัน นั่นคือ RHI (Render Hardware Interface) บทนี้จะพูดถึง layer นั้น และทุกอย่างที่เอนจินทำรอบ ๆ มันในทุก ๆ เฟรม ตั้งแต่ตัดสินใจว่าอะไรคุ้มค่าจะวาดบ้าง ตัดสินใจลำดับการวาด เชื่อมขั้นตอน rendering เข้าด้วยกันแบบอัตโนมัติแทนที่จะทำมือ และจัดการความจริงที่ว่า CPU กับ GPU ไม่เคยทำงานเฟรมเดียวกันพร้อมกันจริง ๆ เลย

1. RHI อยู่ตรงไหน: เอนจินเดียว สาม Graphics API

สตูดิโอที่ออกเกมบน PC, PlayStation, Xbox และมือถือ จะเขียน renderer แยกกันสามสี่ตัวจากศูนย์ไม่ได้ แต่ละแพลตฟอร์มพูด graphics API ข้างในไม่เหมือนกัน (Vulkan บน Linux/Android/Windows บางส่วน, DirectX 12 บน Windows/Xbox, Metal บน macOS/iOS) แต่ logic การ render ของเอนจิน — จะวาดอะไร ลำดับไหน ด้วยข้อมูลอะไร — เหมือนกันเป๊ะไม่ว่าจะแพลตฟอร์มไหน RHI คือ layer C++ บาง ๆ ที่ทำให้เป็นไปได้ คือ interface เล็ก ๆ ตัวเดียวที่นิ่งไม่เปลี่ยน ให้ทั้งเอนจินเขียนโค้ดอ้างอิงมันตัวเดียว แล้วมี implementation ของ interface เดียวกันนั้นแยกต่างหากต่อ graphics API หนึ่งตัวอยู่ข้างใต้

Engine / gameplay code | v IRHIDevice / IRHICommandList (ONE C++ interface, same on every platform) | +---------------+---------------+ | | | v v v RHIDevice_Vulkan RHIDevice_D3D12 RHIDevice_Metal | | | v v v vkCreateBuffer CreateCommitted newBufferWithLength vkCmdDrawIndexed ...->DrawIndexedInstanced ...drawIndexedPrimitives | | | v v v Vulkan DirectX 12 Metal driver driver driver | | | v v v GPU hardware

ไฟล์ backend ทุกตัว (RHIDevice_Vulkan.cpp, RHIDevice_D3D12.cpp, RHIDevice_Metal.cpp) implement function ชุดเดียวกันเป๊ะ แค่เรียก API ข้างใต้คนละตัว พอเอนจินเรียก device->CreateBuffer(...) backend ของ Vulkan จะแปลงมันเป็น vkCreateBuffer บวกกับการ allocate memory, backend ของ DirectX 12 แปลงเป็น CreateCommittedResource ส่วน backend ของ Metal แปลงเป็น newBufferWithLength โค้ดฝั่งเอนจินที่เรียก CreateBuffer ไม่เปลี่ยนเลยข้ามแพลตฟอร์ม — เปลี่ยนแค่ว่าไฟล์ .cpp ไหนถูกคอมไพล์เข้าไปใน executable สุดท้าย

นี่คือแนวคิดเดียวกับที่คุณรู้จักมาแล้วจากภาษาอื่น RHI คือ abstract interface (ชุด function signature ที่ไม่มี implementation) ที่มี concrete implementation หลายตัว (ตัวละ backend) มา satisfy interface นั้น ถ้าบทก่อน ๆ เคยให้คุณเห็น C++ base class ที่มี pure virtual function กับ derived class หลายตัวที่ override มัน แพทเทิร์นนั้นแหละคือสิ่งที่ RHI จริง ๆ ถูกสร้างขึ้นมาจาก Section 3 จะเขียนมันออกมาให้ดู

Tip "RHI" เป็นคำที่ Unreal Engine ใช้ในซอร์สโค้ดสาธารณะ เอนจินตัวอื่นเรียกแนวคิดเดียวกันนี้ว่า "graphics device abstraction", "GAL" (Graphics Abstraction Layer) หรือแค่ "renderer backend interface" ชื่อเปลี่ยนไปตามที่ แต่หน้าที่ — interface เดียว implementation ต่อ API หนึ่งตัว — เหมือนกันทุกที่ที่คุณจะเจอมัน

2. ทำไมโค้ด Gameplay ไม่เรียก Vulkan ตรง ๆ เด็ดขาด

ไม่มีอะไรห้ามโปรแกรมเมอร์ gameplay #include <vulkan/vulkan.h> ในไฟล์ player-controller แล้วเรียก vkCmdDrawIndexed ตรง ๆ ได้ แต่เอนจินจริงจังทุกตัวป้องกันเรื่องนี้อย่างตั้งใจ โดยทำให้ RHI เป็น rendering surface เดียวที่โค้ดนอก renderer ได้รับอนุญาตให้มองเห็น

Gameplay code (spawn enemy, play VFX, request "draw this mesh") | v Engine rendering systems (gather + cull, build draw items, SORT, frame graph, submit) <-- THIS CHAPTER | v IRHIDevice / IRHICommandList <-- the ONLY line gameplay | code is allowed to see v Backend (RHIDevice_Vulkan / _D3D12 / _Metal) <-- vendor-specific, | swapped per platform v GPU driver --> GPU hardware

มีเหตุผลที่จับต้องได้สี่ข้อรองรับเส้นแบ่งนี้

Common mistake มองว่า RHI เป็นแค่ "wrapper บาง ๆ ที่ฉันเลี่ยงได้เวลาต้องการอะไรที่ interface ยังไม่รองรับ" ทางแก้เกือบทุกครั้งคือเพิ่มความสามารถนั้นเข้าไปใน RHI อย่างถูกต้อง (function ใหม่, flag ใหม่บน descriptor ที่มีอยู่) แทนที่จะข้ามมันไป ทุกครั้งที่โค้ดเลี่ยง RHI portability กับการรับประกันระดับเฟรมข้างบนจะพังแบบเงียบ ๆ บ่อยครั้งโผล่มาเฉพาะบนแพลตฟอร์มเดียวที่ไม่มีใครเทสต์อาทิตย์นั้น

3. ออกแบบ RHI: Handle, Descriptor, และ Command List

RHI ถูกสร้างจากชิ้นส่วนสามแบบ คือ handle (ค่าเล็ก ๆ แบบ opaque ที่ใช้แทนออบเจกต์บน GPU แทนที่จะเป็น pointer ดิบเข้าไปในหน่วยความจำของ driver), descriptor (struct ธรรมดาที่อธิบายว่าจะสร้างอะไร) และ interface แบบ abstract ของ device กับ command list นี่คือตัวอย่างเล็ก ๆ แต่ครบ


// A handle is just an ID and a generation counter -- NOT a pointer.
// Gameplay code never sees a VkBuffer or an ID3D12Resource, only this.
struct RHIBufferHandle   { uint32_t id = 0; uint32_t generation = 0; };
struct RHITextureHandle  { uint32_t id = 0; uint32_t generation = 0; };
struct RHIPipelineHandle { uint32_t id = 0; uint32_t generation = 0; };

struct RHIBufferDesc
{
    size_t sizeBytes;
    bool   isVertexBuffer;
    bool   isIndexBuffer;
    bool   cpuWritable;   // true for buffers the CPU updates every frame
};

struct RHITextureDesc
{
    uint32_t width, height;
    uint32_t format;       // e.g. RHI_FORMAT_RGBA8, RHI_FORMAT_D32_FLOAT
    bool     isRenderTarget;
};

struct RHIPipelineDesc
{
    const char* vertexShaderPath;
    const char* fragmentShaderPath;
    bool        depthTestEnabled;
    bool        blendEnabled;      // Chapter 7.7 Section 10's "PSO", baked once
};

Descriptor พวกนี้เป็น plain data ล้วน ๆ ไม่มี type ของ Vulkan หรือ DirectX โผล่มาที่ไหนเลย ทีนี้มาดู interface ที่สร้างและทำลาย resource จาก descriptor พวกนั้น


// ONE header, shared by every platform. A .cpp file per backend
// (RHIDevice_Vulkan.cpp, RHIDevice_D3D12.cpp, RHIDevice_Metal.cpp)
// implements every pure virtual function below.
class IRHIDevice
{
public:
    virtual ~IRHIDevice() = default;

    virtual RHIBufferHandle   CreateBuffer(const RHIBufferDesc& desc, const void* initialData) = 0;
    virtual RHITextureHandle  CreateTexture(const RHITextureDesc& desc) = 0;
    virtual RHIPipelineHandle CreatePipeline(const RHIPipelineDesc& desc) = 0;

    virtual void UpdateBuffer(RHIBufferHandle handle, const void* data, size_t sizeBytes) = 0;

    virtual void DestroyBuffer(RHIBufferHandle handle) = 0;
    virtual void DestroyTexture(RHITextureHandle handle) = 0;

    virtual class IRHICommandList* CreateCommandList() = 0;
    virtual void SubmitCommandLists(IRHICommandList** lists, int count) = 0;
};

และ interface สำหรับ record งานบน GPU — คู่กันกับ vkCmd* call และ method ของ ID3D12GraphicsCommandList จากบท 7.7 ที่ถูกรวมเป็นรูปร่างเดียว


class IRHICommandList
{
public:
    virtual ~IRHICommandList() = default;

    virtual void BeginRecording() = 0;
    virtual void EndRecording() = 0;

    virtual void SetPipeline(RHIPipelineHandle pipeline) = 0;
    virtual void BindVertexBuffer(RHIBufferHandle buffer, uint32_t slot) = 0;
    virtual void BindIndexBuffer(RHIBufferHandle buffer) = 0;
    virtual void SetConstants(const void* data, size_t sizeBytes, uint32_t bindSlot) = 0;
    virtual void DrawIndexed(uint32_t indexCount, uint32_t firstIndex, uint32_t vertexOffset) = 0;
};

เทียบ cmd->DrawIndexed(indexCount, 0, 0) กับ call ดิบ ๆ จากบท 7.7 มันคือแนวคิดเดียวกับ vkCmdDrawIndexed และ ID3D12GraphicsCommandList::DrawIndexedInstanced แค่ตัด parameter เฉพาะ vendor กับ state เฉพาะ vendor ออก เหลือแค่สิ่งที่ทุก backend ต้องการจริง ๆ แต่ละ backend มี override ของ DrawIndexed ที่ทำงานจริงข้างใน


// Inside RHICommandList_Vulkan.cpp (sketch)
void RHICommandList_Vulkan::DrawIndexed(uint32_t indexCount, uint32_t firstIndex, uint32_t vertexOffset)
{
    vkCmdDrawIndexed(m_vkCommandBuffer, indexCount, 1, firstIndex, vertexOffset, 0);
}

// Inside RHICommandList_D3D12.cpp (sketch)
void RHICommandList_D3D12::DrawIndexed(uint32_t indexCount, uint32_t firstIndex, uint32_t vertexOffset)
{
    m_d3dCommandList->DrawIndexedInstanced(indexCount, 1, firstIndex, vertexOffset, 0);
}

ไม่มีโค้ดชั้นบนของสองไฟล์นี้ต้องรู้เลยว่า DrawIndexed กลายเป็น vkCmdDrawIndexed บนแพลตฟอร์มหนึ่งและ DrawIndexedInstanced บนอีกแพลตฟอร์ม การออกแบบ handle สำคัญพอ ๆ กับ virtual function เพราะ RHIBufferHandle เป็น ID ธรรมดา ไม่ใช่ pointer ตาราง internal ของ device จึงย้าย สร้างใหม่ หรือทำให้ออบเจกต์ GPU จริงข้างหลังมัน invalid ได้อย่างปลอดภัย (เช่น หลัง device หลุดแล้วถูกสร้างใหม่) โดยไม่ต้องให้ทุกจุดที่ถือ handle นั้นต้องเปลี่ยนตาม ฟีลด์ generation ทำให้ device ตรวจจับและปฏิเสธ handle ที่เก่าแล้วได้ — handle ที่ resource ข้างหลังถูกทำลายไปแล้วและช่อง ID ถูกเอาไปใช้ซ้ำกับอย่างอื่น — แทนที่จะอ่านขยะแบบเงียบ ๆ

4. Render Pipeline ทั้งหมดตั้งแต่ต้นจนจบ

พอมี RHI แล้ว งานของ renderer ในแต่ละเฟรมคือแปลง "scene ปัจจุบัน" ให้กลายเป็นลำดับของ RHI call เรื่องนี้เกิดขึ้นเป็นสี่ขั้นตอน เรียงลำดับตายตัวแบบนี้

Scene (every object that exists, e.g. 50,000 in a large level) | v [1] GATHER + CULL -- frustum test (Section 5), optionally occlusion test | (visible objects, e.g. 1,200) v [2] BUILD DRAW ITEMS -- one DrawItem per (mesh, material, transform) | (draw item list, e.g. 1,800 -- some objects use more than one material) v [3] SORT -- opaque front-to-back, transparent back-to-front (Sections 6-7) | (same list, reordered) v [4] SUBMIT -- walk the sorted list, emit RHI commands (Section 8) | v IRHICommandList --> GPU

ห้า section ถัดไปแต่ละตัวจะสร้างกล่องหนึ่งกล่องของ diagram นี้ในโค้ดจริง พอถึงท้าย Section 8 คุณจะมี render loop ที่ทำงานได้จริง (แม้จะย่อลง) ครบวงจร Section 9 จะโชว์ว่าเอนจินจริง ๆ ห่อขั้นตอนที่ 4 ด้วยอะไรที่ยืดหยุ่นกว่า function เขียนมือตัวเดียวยังไง

5. Gather, Cull, และสร้าง Draw List

Culling คือการตัดสินใจ ก่อนจะเสียเวลา GPU ไปเลยสักนิด ว่าออบเจกต์ไหนไม่คุ้มค่าที่จะพยายามวาดด้วยซ้ำ Frustum culling (แบบที่ถูกที่สุดและพบบ่อยที่สุด) เทส bounding box ของแต่ละออบเจกต์กับระนาบทั้งหกของ frustum มุมมองกล้อง — ปริมาตรทรงพีระมิดที่กล้องมองเห็นได้จริง ๆ — แล้วทิ้งทุกอย่างที่อยู่นอกมันทั้งหมด


struct AABB   { Vec3 min, max; };               // axis-aligned bounding box
struct Plane  { Vec3 normal; float distance; };  // normal . point + distance >= 0 means "in front"
struct Frustum{ Plane planes[6]; };              // left, right, top, bottom, near, far

bool IsAABBOutsidePlane(const AABB& box, const Plane& plane)
{
    // the box corner furthest along the plane's normal -- if even THAT
    // corner is behind the plane, the whole box must be behind it too
    Vec3 positive;
    positive.x = (plane.normal.x >= 0.0f) ? box.max.x : box.min.x;
    positive.y = (plane.normal.y >= 0.0f) ? box.max.y : box.min.y;
    positive.z = (plane.normal.z >= 0.0f) ? box.max.z : box.min.z;

    return Dot(plane.normal, positive) + plane.distance < 0.0f;
}

bool IsVisible(const AABB& box, const Frustum& frustum)
{
    for (int i = 0; i < 6; ++i)
    {
        if (IsAABBOutsidePlane(box, frustum.planes[i]))
            return false;   // fully outside at least one plane -- cull it
    }
    return true;   // not fully outside any plane -- keep it
}

ไล่ trace ดู: ลองสมมติ frustum แบบง่าย ๆ ที่มีแค่ near plane ที่ z = 1 กับ far plane ที่ z = 100 (ไม่สนใจระนาบด้านข้างทั้งสี่สำหรับ trace นี้) กับออบเจกต์สามตัวที่อยู่ z = 0.5, z = 10 และ z = 150 ออบเจกต์ที่ z = 0.5 จะ fail IsAABBOutsidePlane กับ near plane (มันใกล้กว่าที่กล้องมองเห็นได้) แล้วถูก cull ออบเจกต์ที่ z = 150 จะ fail กับ far plane แล้วถูก cull เหลือแค่ออบเจกต์ที่ z = 10 ที่ผ่านทุก plane test แล้วรอดมาได้

Frustum culling เป็นตัวกรองแรกที่ถูก ไม่ใช่ตัวกรองเดียว กำแพงที่อยู่ตรงหน้ากล้องเลยก็ยังบังห้องข้างหลังมันได้ทั้งห้อง ทั้งที่ห้องนั้นอยู่ในกรอบ frustum เทคนิคที่ไปไกลกว่านั้นและแพงกว่า เรียกว่า occlusion culling (เทสออบเจกต์กับสิ่งที่วาดไปแล้ว โดยใช้ hardware occlusion query หรือ software depth pyramid) จะกำจัดออบเจกต์แบบนั้นออก โค้ดในบทนี้ยึดที่ frustum culling เพื่อให้ตัวอย่างจับต้องได้ง่าย — รูปร่างของ pipeline เหมือนกันไม่ว่าจะแบบไหน แค่มี filter step เพิ่มขึ้นมา

พอลิสต์ที่มองเห็นได้เกิดขึ้นแล้ว ออบเจกต์ที่รอดแต่ละตัวจะกลายเป็น draw item หนึ่งตัวหรือมากกว่า — หน่วยจริงที่ pipeline ที่เหลือจะเรียงลำดับและ submit


struct Renderable
{
    AABB           worldBounds;
    MeshHandle     mesh;       // vertex buffer + index buffer + index count
    MaterialHandle material;   // shader + parameters + a compiled RHIPipelineHandle
    Mat4           worldTransform;
};

struct DrawItem
{
    RHIPipelineHandle pipeline;        // from material->pipeline (Section 13 explains why)
    RHIBufferHandle   vertexBuffer;
    RHIBufferHandle   indexBuffer;
    uint32_t          indexCount;
    Mat4              worldTransform;
    float             viewDepth;       // distance from camera, filled in here
    uint32_t          meshIndex;       // a stable id, used as a sort tiebreaker
    uint64_t          sortKey = 0;     // filled in by Section 7
};

std::vector<DrawItem> BuildDrawItems(const std::vector<Renderable*>& visible, const Vec3& cameraPos)
{
    std::vector<DrawItem> items;
    items.reserve(visible.size());

    for (Renderable* r : visible)
    {
        DrawItem item;
        item.pipeline       = r->material->pipeline;
        item.vertexBuffer   = r->mesh->vertexBuffer;
        item.indexBuffer    = r->mesh->indexBuffer;
        item.indexCount     = r->mesh->indexCount;
        item.worldTransform = r->worldTransform;
        item.viewDepth      = Length(r->worldTransform.GetPosition() - cameraPos);
        item.meshIndex      = r->mesh.id;
        items.push_back(item);
    }
    return items;
}

สังเกตว่า DrawItem ตั้งใจไม่มีอะไรบางอย่าง มันไม่รู้เลยว่าตัวเองเป็น opaque หรือ transparent และไม่รู้ว่าสุดท้ายจะถูกวาดลำดับไหน นั่นแหละคือสิ่งที่สอง section ถัดไปจะเพิ่มเข้ามา — ประเด็นทั้งหมดของการแยก "รวบรวมว่ามีอะไรอยู่บ้าง" ออกจาก "ตัดสินใจลำดับ" ก็คือการเรียงลำดับสามารถมองทั้งลิสต์พร้อมกันได้ แทนที่แต่ละออบเจกต์จะตัดสินชะตากรรมตัวเองแบบโดดเดี่ยว

6. Sort: Front-to-Back สำหรับ Opaque, Back-to-Front สำหรับ Transparent

ออบเจกต์ opaque กับ transparent ต้องการลำดับการวาดตรงข้ามกัน ด้วยเหตุผลที่ต่างกันโดยสิ้นเชิงสองข้อ

Camera far o -----------------------------------------------------------> OPAQUE, drawn FRONT-TO-BACK (near first): [A: depth 2] [B: depth 5] [C: depth 8] draw order: A, then B, then C why: early-z can reject B's and C's hidden pixels using the depth A already wrote, WITHOUT ever running B's or C's (possibly expensive) fragment shader TRANSPARENT, drawn BACK-TO-FRONT (far first): [D: depth 4] [E: depth 9] draw order: E, then D why: blending D's color OVER E's already-blended result is what "D is in front of E" is supposed to look like; blending in the OTHER order gives a different, wrong color -- worked below

สำหรับออบเจกต์ opaque เหตุผลคือ early-z (early depth test — GPU เช็ค depth ของ fragment กับ depth buffer ก่อน รัน fragment shader แล้วทิ้ง fragment ทันทีถ้ามีอะไรวาดไปแล้วใกล้กว่า) early-z จะคุ้มก็ต่อเมื่อมีอะไรใกล้กว่าถูกวาดลงใน depth buffer ไปแล้วตอนที่ออบเจกต์ไกลกว่ามาถึง ซึ่งนั่นคือสิ่งที่ลำดับ front-to-back รับประกันไว้เป๊ะ วาดออบเจกต์ใกล้ก่อน แล้วทุกพิกเซลของออบเจกต์ไกลที่มันบังจริง ๆ จะถูก reject ก่อนที่ fragment shader ของมัน (ที่อาจแพง คำนวณ texture sample, แสง) จะรันเลยด้วยซ้ำ งานที่หลีกเลี่ยงได้แบบนี้เรียกว่า overdraw ที่ประหยัดไป (การชี่ต pixel ที่สุดท้ายมองไม่เห็น เพราะถูกทับด้วยอย่างอื่น) ถ้าวาด back-to-front แทน early-z จะช่วยอะไรไม่ได้เลย — ทุกพิกเซลไกลถูกชี่ตเต็ม ๆ ก่อน แล้วก็โดนทับโดยออบเจกต์ใกล้อยู่ดี เสีย GPU time ไปเปล่า ๆ พอดีกับที่ลำดับ front-to-back จะช่วยหลีกเลี่ยงได้

สำหรับออบเจกต์ transparent depth test ทำให้ปัญหาเรื่องลำดับหายไปไม่ได้ เพราะ alpha blending (รวมสีของ fragment ใหม่กับสีที่มีอยู่แล้วใน framebuffer ถ่วงน้ำหนักด้วย opacity แทนที่จะแทนที่เต็ม ๆ) ไม่ commutative — blend A ทับ B ให้ผลลัพธ์ต่างจาก blend B ทับ A ตัวอย่างเลขง่าย ๆ จะทำให้เห็นภาพชัด ลองพื้นหลังสีดำ ออบเจกต์สีแดงไกลที่ opacity 50% กับออบเจกต์สีน้ำเงินใกล้ที่ opacity 50% โดยใช้สูตร blend มาตรฐาน result = src * srcAlpha + dst * (1 - srcAlpha)


Correct order -- back-to-front (draw red first, then blue over it):
  after red:   result = (1,0,0)*0.5 + (0,0,0)*0.5 = (0.50, 0.00, 0.00)
  after blue:  result = (0,0,1)*0.5 + (0.50,0,0)*0.5 = (0.25, 0.00, 0.50)
  final color: (0.25, 0.00, 0.50)  -- a purple leaning blue, blue is on top (correct)

Wrong order -- front-to-back (draw blue first, then red over it):
  after blue:  result = (0,0,1)*0.5 + (0,0,0)*0.5 = (0.00, 0.00, 0.50)
  after red:   result = (1,0,0)*0.5 + (0.00,0,0.50)*0.5 = (0.50, 0.00, 0.25)
  final color: (0.50, 0.00, 0.25)  -- a purple leaning red, even though
                                       blue was supposed to be in front

ออบเจกต์เดียวกันสองตัว opacity เท่ากัน ทุกอย่างเหมือนกันหมดยกเว้นลำดับการวาด แต่สีสุดท้ายต่างกัน และมีแค่แบบเดียวที่ถูก นี่คือเหตุผลที่ออบเจกต์ transparent ต้องถูกวาดแบบ back-to-front มันไม่ใช่การ optimize performance แบบที่ opaque front-to-back เป็น แต่เป็นลำดับเดียวที่ให้ผลลัพธ์ถูกต้องทางสายตาได้เลย

Common mistake เรียงลำดับ draw list ทั้งลิสต์ด้วยกฎ depth เดียวกันหมด ลืมไปว่าออบเจกต์ opaque กับ transparent ต้องการกฎตรงข้ามกัน ทางแก้ ซึ่งอยู่ใน section ถัดไป คือเรียงลำดับ opaque กับ transparent เป็นสองกลุ่มแยกกันภายใน sort key รวมตัวเดียว — opaque ทั้งกลุ่มถูกวาดก่อน transparent เสมอ (เพื่อให้ transparent blend กับพื้นหลัง opaque ที่วาดเสร็จสมบูรณ์แล้วได้ถูกต้อง) เรียง front-to-back ในกลุ่ม opaque และ back-to-front ในกลุ่ม transparent

7. Sort Key: อัดลำดับความสำคัญลงในตัวเลขเดียว

Sort key คือตัวเลขตัวเดียวที่คำนวณต่อ draw item หนึ่งตัว ให้การเรียงลำดับจากน้อยไปมากธรรมดา ๆ ด้วยตัวเลขนั้นตัวเดียว ให้ลำดับการวาดที่ renderer ต้องการเป๊ะ การอัดความสำคัญหลายอย่าง (opaque มาก่อน transparent, แล้วลำดับ depth, แล้วลดการเปลี่ยน state) ลงใน integer 64 บิตตัวเดียว โดยความสำคัญมากสุดอยู่ที่บิตสูงสุด ทำให้เรียงลำดับทั้งหมดเป็นแค่การ sort ตัวเลขธรรมดาเร็ว ๆ ตัวเดียว — ไม่ต้องเขียน comparator เองตอนวาด


constexpr uint64_t kTranslucentBit = 1ull << 63;

uint32_t QuantizeDepth(float viewDepth, float nearZ, float farZ)
{
    float t = std::clamp((viewDepth - nearZ) / (farZ - nearZ), 0.0f, 1.0f);
    return static_cast<uint32_t>(t * 0xFFFFFFFFu);   // pack depth into 32 bits
}

uint64_t MakeSortKey(const DrawItem& item, bool isTransparent, float nearZ, float farZ)
{
    uint32_t depthBits = QuantizeDepth(item.viewDepth, nearZ, farZ);

    if (isTransparent)
    {
        // back-to-front: the FARTHEST object must sort FIRST, so invert
        // the quantized depth -- a large raw depth becomes a SMALL key.
        depthBits = 0xFFFFFFFFu - depthBits;
    }
    // else: opaque, front-to-back -- a small raw depth already sorts
    // first with no inversion needed.

    uint64_t key = 0;
    key |= isTransparent ? kTranslucentBit : 0;          // bit 63: opaque bucket always first
    key |= (uint64_t)depthBits << 24;                    // bits 55-24: depth priority
    key |= (uint64_t)(item.pipeline.id & 0xFFFF) << 8;    // bits 23-8: groups shared pipelines
    key |= (uint64_t)(item.meshIndex & 0xFF);             // bits 7-0: stable final tiebreaker
    return key;
}

ไล่อ่าน bit layout จากสำคัญสุดไปน้อยสุดตามลำดับความสำคัญ บิต 63 ทำให้ opaque item ทุกตัวอยู่ก่อน transparent item ทุกตัว ไม่ว่าอย่างอื่นจะเป็นยังไงก็ตาม — นี่คือสิ่งที่รับประกันว่า transparent object จะ blend กับพื้นหลัง opaque ที่วาดครบแล้วเสมอ บิต 55-24 พาลำดับ depth จาก Section 6 มา ซึ่งกลับด้านไว้แล้วสำหรับ transparency เพื่อให้ ascending sort ตัวเดียวจัดการทั้งสองกรณีได้ถูกต้อง บิต 23-8 จัดกลุ่ม item ที่ใช้ pipeline เดียวกันให้อยู่ติดกัน Section 8 จะโชว์ว่าทำไมสิ่งนี้สำคัญตอน submit บิตล่างสุด 8 บิตมีไว้แค่เพื่อให้ item สองตัวที่ key เหมือนกันจนถึงตรงนั้น (หายาก แต่เกิดได้) ยังเรียงลำดับแบบตายตัวและทำซ้ำได้แทนที่จะไม่แน่นอน

ไล่ trace ดู: ใช้สเกล depth แบบง่าย 0-1000 แทนช่วง 32 บิตเต็มเพื่อให้อ่านง่าย item สี่ตัว — A (opaque, depth 3), B (opaque, depth 7), C (transparent, depth 4), D (transparent, depth 9) — จะได้ key แบบนี้ (บิต pipeline กับ mesh ปล่อยเป็น 0 ไว้เพื่อให้ตัวเลขอ่านง่าย)


A: opaque,      depth 3  ->  bucket 0, depthBits =    3  ->  key ~ 0x000000003_00_00
B: opaque,      depth 7  ->  bucket 0, depthBits =    7  ->  key ~ 0x000000007_00_00
C: transparent, depth 4  ->  bucket 1, depthBits = 1000-4 = 996  ->  key ~ 0x8...3E4_00_00
D: transparent, depth 9  ->  bucket 1, depthBits = 1000-9 = 991  ->  key ~ 0x8...3DF_00_00

Ascending sort by key gives:  A, B, D, C
  -- opaque group first, near-to-far (A depth 3, then B depth 7): correct
  -- transparent group second, far-to-near (D depth 9, then C depth 4): correct
Tip เอนจินที่ออกเกมจริงหลายตัวเอา pipeline ID ไว้เหนือ depth สำหรับออบเจกต์ opaque ด้วยซ้ำ — เรียงลำดับหลักเพื่อลดการเปลี่ยน pipeline/state ที่แพง แล้วค่อยเรียงตาม depth เป็นรองภายในกลุ่ม pipeline เดียวกัน แบบนี้แลก early-z efficiency บางส่วนไปกับการเปลี่ยน state ของ GPU ที่น้อยลง ซึ่งบ่อยครั้งเป็นการแลกที่คุ้มกว่าในฉากที่มี material เยอะ ๆ ไม่ซ้ำกัน บทนี้ให้ depth เป็น key หลักของ opaque เพราะมันสอนแนวคิด early-z ได้ชัดกว่า ส่วนการ profile ฉากจริงต่างหากที่จะบอกว่าการแลกแบบไหนชนะจริง ๆ

8. Submit: Draw Item กลายเป็น RHI Call

Submit คือขั้นตอนสุดท้าย เดินลิสต์ที่เรียงแล้วตามลำดับ แล้วสำหรับแต่ละ item บอก RHI ให้ bind สิ่งที่เปลี่ยนไปตั้งแต่ item ก่อนหน้า แล้ววาดมัน เพราะ sort key จาก Section 7 จัดกลุ่ม item ที่ pipeline เดียวกันไว้ติดกันแล้ว item ติดกันส่วนใหญ่ในลิสต์ที่เรียงแล้วจึงไม่ต้องเปลี่ยน pipeline จริง ๆ state cache เล็ก ๆ จะข้าม call ที่ซ้ำซ้อนพวกนี้ไป


void SubmitDrawList(IRHICommandList* cmd, const std::vector<DrawItem>& sortedItems)
{
    RHIPipelineHandle currentPipeline{};
    RHIBufferHandle   currentVB{};
    RHIBufferHandle   currentIB{};

    for (const DrawItem& item : sortedItems)
    {
        if (item.pipeline.id != currentPipeline.id)
        {
            cmd->SetPipeline(item.pipeline);
            currentPipeline = item.pipeline;
        }
        if (item.vertexBuffer.id != currentVB.id)
        {
            cmd->BindVertexBuffer(item.vertexBuffer, 0);
            currentVB = item.vertexBuffer;
        }
        if (item.indexBuffer.id != currentIB.id)
        {
            cmd->BindIndexBuffer(item.indexBuffer);
            currentIB = item.indexBuffer;
        }

        cmd->SetConstants(&item.worldTransform, sizeof(item.worldTransform), 0);
        cmd->DrawIndexed(item.indexCount, 0, 0);
    }
}

รวมกันแล้ว Section 5 ถึง 8 คือ pipeline ที่ครบวงจร GatherVisible กรอง scene ลงด้วย frustum culling, BuildDrawItems แปลงผู้รอดชีวิตให้เป็นลิสต์แบน ๆ, MakeSortKey บวกกับ std::sort เอาลิสต์นั้นไปเรียงเป็นลำดับเดียวที่ทั้งถูกต้อง (opaque ก่อน transparent, transparent แบบ back-to-front) และเร็ว (opaque front-to-back, pipeline จัดกลุ่มกัน) แล้ว SubmitDrawList เดินผลลัพธ์รอบเดียว ยิง RHI call เท่าที่จำเป็นพอดี ไม่มากไม่น้อย


std::vector<Renderable*> visible = GatherVisible(allObjects, cameraFrustum);
std::vector<DrawItem>    items   = BuildDrawItems(visible, cameraPos);

for (DrawItem& item : items)
    item.sortKey = MakeSortKey(item, IsTransparent(item), nearZ, farZ);

std::sort(items.begin(), items.end(),
          [](const DrawItem& a, const DrawItem& b) { return a.sortKey < b.sortKey; });

SubmitDrawList(cmd, items);

9. Render Graph / Frame Graph: Pass ประกาศ Dependency ของตัวเอง

SubmitDrawList จาก Section 8 วาดลงใน framebuffer ที่ bind อยู่ตอนนั้น — โอเคสำหรับ pass เดียว แต่เฟรมจริง ๆ มีหลาย pass เช่น depth prepass, opaque geometry pass, lighting pass, bloom pass, tonemap pass แต่ละตัวอ่าน texture ที่ pass ก่อนหน้าเขียนไว้ บทที่ 7.7 Section 12 เคยให้คุณเขียน barrier ระหว่าง pass สองตัวแบบนั้นด้วยมือ render graph (เรียกอีกชื่อว่า frame graph) เอาส่วนที่ต้องทำมือนั้นออกไป แต่ละ pass ประกาศไว้ล่วงหน้าว่าอ่าน resource ไหนบ้างและเขียนอะไรบ้าง แล้วขั้นตอน build จะคำนวณลำดับที่ถูกต้องและใส่ barrier ทุกตัวให้อัตโนมัติ


class FrameGraph
{
public:
    RGTextureHandle CreateTexture(const char* name, const RHITextureDesc& desc);

    // setup() ONLY calls Read()/Write() on an RGBuilder -- it never touches
    // the RHI. execute() is the only place that records real RHI commands,
    // and it only runs later, after Compile() has ordered every pass.
    template <typename SetupFn, typename ExecuteFn>
    void AddPass(const char* name, SetupFn setup, ExecuteFn execute);

    void Compile();                    // order passes, allocate/alias resources, insert barriers
    void Execute(IRHICommandList* cmd); // run every pass's execute() in the computed order
};

ประกาศเฟรมสี่ pass เล็ก ๆ ด้วย API นี้


RGTextureHandle depthTex   = graph.CreateTexture("Depth",      depthDesc);
RGTextureHandle gbufferA   = graph.CreateTexture("GBufferA",   gbufferDesc);
RGTextureHandle sceneColor = graph.CreateTexture("SceneColor", colorDesc);
RGTextureHandle backbuffer = graph.GetBackbuffer();

graph.AddPass("DepthPrepass",
    [&](RGBuilder& b) { b.Write(depthTex); },
    [=](IRHICommandList* cmd) { SubmitDrawList(cmd, opaqueDepthOnlyItems); });

graph.AddPass("GBufferPass",
    [&](RGBuilder& b) { b.Write(gbufferA); b.Write(depthTex); },
    [=](IRHICommandList* cmd) { SubmitDrawList(cmd, opaqueItems); });

graph.AddPass("LightingPass",
    [&](RGBuilder& b) { b.Read(gbufferA); b.Read(depthTex); b.Write(sceneColor); },
    [=](IRHICommandList* cmd) { DrawFullscreenLightingShader(cmd, gbufferA, depthTex); });

graph.AddPass("TonemapPass",
    [&](RGBuilder& b) { b.Read(sceneColor); b.Write(backbuffer); },
    [=](IRHICommandList* cmd) { DrawFullscreenTonemapShader(cmd, sceneColor); });

graph.Compile();
graph.Execute(cmd);
DepthPrepass --Write--> [Depth] | Read v GBufferPass --Write--> [GBufferA] (also Writes [Depth] again) | | Read Read v v LightingPass --------> --Write--> [SceneColor] | Read v TonemapPass --Read [SceneColor]--> --Write--> [Backbuffer] Compile() walks these Read/Write edges, finds the only legal order (DepthPrepass, GBufferPass, LightingPass, TonemapPass), and inserts a barrier before every Read that transitions its resource from "being written" to "safe to read" -- the same barrier Chapter 7.7 Section 12 had you write out by hand, generated automatically here.

มีสองอย่างเกิดขึ้นข้างใน Compile() ที่โค้ด rendering เขียนมือต้องทำให้ถูกเองแทน อย่างแรกคือ ลำดับ เพราะ LightingPass ประกาศ Read(gbufferA) และ GBufferPass ประกาศ Write(gbufferA) graph จึงรู้ว่า GBufferPass ต้องรันก่อน — เหตุผลแบบเดียวกับ topological sort บน dependency graph ที่บทโครงสร้างข้อมูลก่อน ๆ เคยคลุมไว้แล้วในบริบทอื่น อย่างที่สองคือ อายุของ resource กับการ alias graph รู้เป๊ะว่า texture แบบ transient แต่ละตัวมีชีวิตอยู่ในช่วงเฟรมไหน (texture ที่ใช้แค่ใน pass เดียวแล้วไม่ถูกอ่านอีกเลยหลังจากนั้น ไม่ต้องมี barrier เลยด้วยซ้ำ และยังแชร์หน่วยความจำ GPU จริงกับ texture ที่ไม่เกี่ยวข้องกันเลยที่ใช้ทีหลังในเฟรมเดียวกันได้ ทันทีที่ graph ยืนยันว่าช่วงชีวิตของทั้งสองไม่ทับกัน) — สิ่งนี้เรียกว่า resource aliasing และมันลดพื้นที่หน่วยความจำ GPU ของเฟรมได้จริงจัง โดยที่โค้ดเขียน pass ไม่ต้องคิดเรื่อง memory เลยสักนิด

Tip การแยก function setup กับ execute ของแต่ละ pass เป็นเรื่องตั้งใจและสำคัญมาก setup รันครั้งเดียวต่อเฟรม แค่ประกาศ dependency เท่านั้น ต้องถูกและไม่มี side effect execute รันทีหลัง — บางทีรันบน thread คนละตัวไปเลย (Section 10) — และเป็นที่เดียวที่ได้รับอนุญาตให้แตะ RHI การผสมสองอย่างนี้เข้าด้วยกัน (record draw call จริงข้างใน lambda ของ `setup`) จะทำลายความสามารถของ graph ในการจัดลำดับใหม่หรือขนาน pass เพราะมันไม่สามารถเชื่อได้อีกต่อไปว่า `setup` ไม่มี side effect

10. Command Buffer และการ Record บนหลาย Thread

บทที่ 7.7 Section 11 เคยโชว์ Vulkan แบ่ง recording ข้ามสี่ thread แต่ละตัวสร้าง command buffer ของตัวเอง แล้ว submit พร้อมกัน renderer เอาแนวคิดเดียวกันเป๊ะมาใช้กับ sorted draw list จาก Section 8 เพราะลิสต์อยู่ในลำดับสุดท้ายที่ถูกต้องแล้วก่อนที่จะเริ่ม record เลยด้วยซ้ำ มันเลยตัดเป็นชิ้นต่อเนื่องกันได้ง่าย ๆ record ทีละชิ้นต่อ thread แล้วเอา command list ที่ได้กลับมา submit ตามลำดับเดิม


void RecordChunk(const std::vector<DrawItem>& items, size_t begin, size_t end, IRHICommandList* out)
{
    out->BeginRecording();
    std::vector<DrawItem> chunk(items.begin() + begin, items.begin() + end);
    SubmitDrawList(out, chunk);
    out->EndRecording();
}

void RecordFrameParallel(IRHIDevice* device, const std::vector<DrawItem>& sortedItems, int threadCount)
{
    std::vector<IRHICommandList*> lists(threadCount);
    std::vector<std::thread>      workers;

    size_t chunkSize = (sortedItems.size() + threadCount - 1) / threadCount;
    for (int t = 0; t < threadCount; ++t)
    {
        lists[t] = device->CreateCommandList();
        size_t begin = (size_t)t * chunkSize;
        size_t end   = std::min(begin + chunkSize, sortedItems.size());
        workers.emplace_back(RecordChunk, std::cref(sortedItems), begin, end, lists[t]);
    }
    for (auto& w : workers) w.join();

    device->SubmitCommandLists(lists.data(), (int)lists.size());   // executed in this array order
}
Sorted draw list (2000 items, already in final draw order) | +-- split into 4 contiguous, order-preserving chunks --+ | | | | v v v v Thread 1 Thread 2 Thread 3 Thread 4 records records records records items items items items 0-499 500-999 1000-1499 1500-1999 into cmdList0 into cmdList1 into cmdList2 into cmdList3 | | | | +------------+-------+-------+---------------+ v device->SubmitCommandLists({cmdList0, cmdList1, cmdList2, cmdList3}) -- executed in this array order, so the final draw order on screen is identical to the single-threaded version

State cache ข้างใน SubmitDrawList จาก Section 8 ตอนนี้ reset ใหม่ทุกครั้งที่เริ่ม chunk (currentPipeline ของแต่ละ thread เริ่มว่างใหม่) เลยมี call เปลี่ยน state ซ้ำซ้อนโผล่มานิดหน่อยตรงรอยต่อ chunk ที่ single-threaded pass เคยข้ามได้ ต้นทุนเล็กน้อยและมีขอบเขตแบบนี้ปกติเล็กกว่ามากเมื่อเทียบกับสิ่งที่ CPU สี่คอร์ record พร้อมกันประหยัดได้ เทียบกับคอร์เดียว record ทุกอย่างเรียงลำดับ เอนจินจริง ๆ ใช้ job system (สร้างจาก thread pool แบบเดียวกับที่บทก่อน ๆ เคยคลุมไว้) แทน std::thread ดิบ ๆ ต่อ chunk แต่รูปร่าง — แบ่ง, record แยกกัน, submit รวมตามลำดับ — เหมือนกันเป๊ะ

11. CPU/GPU Frame Pipeline: Double และ Triple Buffering

พอเฟรมถูก submit แล้ว GPU ต้องใช้เวลาจริง ๆ ในการรันมันจริง ๆ — บ่อยครั้งใช้เวลามากกว่าที่ CPU ต้องใช้ record เฟรมถัดไปด้วยซ้ำ ถ้า CPU รอให้ GPU ทำเฟรม N เสร็จสมบูรณ์ก่อนถึงจะเริ่ม record เฟรม N+1 ได้ CPU กับ GPU จะผลัดกันว่าง แล้วเวลารวมต่อเฟรมก็จะเท่ากับเวลา CPU บวกเวลา GPU เรียงต่อกัน แทนที่จะเป็นแบบนั้น เอนจินปล่อยให้ CPU เริ่ม record เฟรม N+1 ได้ทันที ในขณะที่ GPU ยังรันเฟรม N อยู่

frame 0 frame 1 frame 2 frame 3 CPU records: [ F0 ]------[ F1 ]------[ F2 ]------[ F3 ]------> | | | | v v v v GPU executes: (idle)--->[ F0 ]------[ F1 ]------[ F2 ]----> | | | v v v displayed on screen: F0 F1 F2 At the instant CPU starts recording F3, GPU is still finishing F2 -- the GPU is running "a frame behind" the CPU. The image the player sees at that instant was built from input read one or two frames earlier, not from this instant -- a small amount of added latency, traded for keeping both CPU and GPU busy at the same time.

การทำแบบนี้ให้ปลอดภัยต้องการให้ CPU ไม่มีวันเขียนทับ resource ที่ GPU อาจจะยังอ่านอยู่จากเฟรมก่อนหน้า ทางแก้คือเก็บสำเนาจริงของทุก resource ต่อเฟรมไว้มากกว่าหนึ่งชุด — double buffering เก็บ 2 ชุด, triple buffering เก็บ 3 ชุด — แล้วให้ CPU วนใช้แต่ละชุด เขียนลงชุดที่ GPU ไม่ได้ใช้อยู่ตอนนั้นเสมอ


constexpr int kFramesInFlight = 2;   // double buffering; use 3 for triple buffering

RHIBufferHandle  perFrameConstants[kFramesInFlight];
IRHICommandList* perFrameCmdLists[kFramesInFlight];
RHIFenceHandle   perFrameFence[kFramesInFlight];

void RenderFrame(IRHIDevice* device, uint64_t frameNumber)
{
    int slot = (int)(frameNumber % kFramesInFlight);

    // Wait until the GPU finished whatever THIS slot held kFramesInFlight
    // frames ago -- only then is it safe to overwrite it for this frame.
    device->WaitForFence(perFrameFence[slot]);

    device->UpdateBuffer(perFrameConstants[slot], /* this frame's camera/light data */ nullptr, 0);

    IRHICommandList* cmd = perFrameCmdLists[slot];
    // ... record this frame's draw list into cmd, as in Sections 8-10 ...

    device->SubmitCommandLists(&cmd, 1);   // signals perFrameFence[slot] on completion
}

ด้วย kFramesInFlight = 2 CPU จะนำหน้า GPU ได้เต็มที่หนึ่งเฟรมก่อนที่ WaitForFence จะบล็อกมันจริง ๆ ส่วนแบบ 3 นำหน้าได้สองเฟรม จำนวนเฟรม in flight ที่มากขึ้นแปลว่ามีที่หย่อนมากขึ้นเวลาเฟรมหนึ่งเฟรมใช้เวลา GPU นานกว่าปกตินิดหน่อย (อาการกระตุกที่เห็นได้ลดลง) แลกกับหน่วยความจำต่อเฟรมที่ซ้ำกันมากขึ้นและ input latency ที่มากขึ้นนิดหน่อย เพราะเฟรมที่อยู่บนจอตอนนี้ถูก record ไปนานกว่านั้นในอดีต นี่คือกลไกเดียวกันเป๊ะเบื้องหลังตัวเลือก "double buffering" / "triple buffering" ที่เกมบน PC หลายเกมโชว์ในเมนู graphics settings — การตั้งค่าที่ผู้เล่นเห็นกับ array ที่ index ด้วย fence ข้างบนคือแนวคิดเดียวกัน

Common mistake อัปเดต perFrameConstants[slot] (หรือ record ลงใน perFrameCmdLists[slot]) โดยไม่รอ perFrameFence[slot] ก่อน ถ้า GPU ยังอ่านข้อมูลของ slot นั้นจากเมื่อ kFramesInFlight เฟรมก่อนไม่เสร็จจริง ๆ การเขียนใหม่ของ CPU จะแข่งกับการอ่านที่ GPU ยังทำค้างอยู่ — เป็นบั๊ก data race แบบเดียวกับที่บทที่ 7.7 Section 12 อธิบายไว้สำหรับ barrier ที่หายไป แค่เกิดระหว่าง CPU กับ GPU แทนที่จะเป็นระหว่าง GPU pass สองตัว อาการที่เห็นมักเป็น geometry กระพริบใช้ transform หรือข้อมูลสีของเฟรมผิด โผล่มาแล้วก็หายไปขึ้นกับ timing

12. ระบบ Shader และ Material: Permutation

Material คือ shader บวกกับชุดค่า parameter เฉพาะ (texture ไหน สีไหน ปุ่มตัวเลขไหน) material หลายตัวใช้ shader source เดียวกันแต่ต้องการโค้ดที่คอมไพล์แล้วต่างกันนิดหน่อย — ออบเจกต์หนึ่งเป็น skinned (ขยับด้วยโครงกระดูก) อีกตัวไม่ใช่ ตัวหนึ่งต้อง sample shadow อีกตัวไม่ต้อง แทนที่จะเขียนไฟล์ shader แยกทุก combination ด้วยมือ เอนจินเขียน shader source เดียวที่มี feature toggle ตอน compile time แล้วให้ build system สร้างทุก combination ที่ต้องการ — แต่ละ combination เรียกว่า permutation (เรียกอีกชื่อว่า shader variant)


// One shader SOURCE file, several features toggled by #define at compile time.
#if defined(FEATURE_SKINNED)
    layout(location = 4) in ivec4 aBoneIndices;
    layout(location = 5) in vec4  aBoneWeights;
#endif

#if defined(FEATURE_SHADOWS)
    uniform sampler2D uShadowMap;
#endif

void main()
{
    vec3 worldPos = ComputeWorldPosition();

#if defined(FEATURE_SKINNED)
    worldPos = ApplySkinning(worldPos, aBoneIndices, aBoneWeights);
#endif

    float shadow = 1.0;
#if defined(FEATURE_SHADOWS)
    shadow = SampleShadow(uShadowMap, worldPos);
#endif

    // ... use worldPos and shadow to compute the final color ...
}

Material แต่ละตัวเลือก combination ของ feature flag แล้ว combination นั้นก็กลายเป็น permutation key — integer เล็ก ๆ ที่ระบุ variant ที่คอมไพล์แล้วตัวหนึ่งแบบไม่ซ้ำใคร


uint32_t ComputePermutationKey(bool skinned, bool shadows, bool instanced, bool fog)
{
    uint32_t key = 0;
    key |= skinned   ? (1u << 0) : 0;
    key |= shadows   ? (1u << 1) : 0;
    key |= instanced ? (1u << 2) : 0;
    key |= fog       ? (1u << 3) : 0;
    return key;   // 4 independent toggles -> up to 2^4 = 16 distinct compiled variants
}

RHIPipelineHandle GetOrCreatePipeline(Material* material, uint32_t permutationKey)
{
    auto it = material->pipelineCache.find(permutationKey);
    if (it != material->pipelineCache.end())
        return it->second;

    RHIPipelineHandle pipeline = CompilePipelineForPermutation(material, permutationKey);
    material->pipelineCache[permutationKey] = pipeline;
    return pipeline;
}

toggle boolean อิสระสี่ตัวก็สร้าง permutation ได้ถึง 16 แบบแล้ว shader จริง ๆ มักมี toggle เป็นสิบตัวขึ้นไป จำนวน permutation ทางทฤษฎีเลยระเบิดไปถึงหลักพันได้ ทั้งที่ฉากไหนฉากหนึ่งใช้จริงแค่เศษเสี้ยวเล็ก ๆ ของมัน บทที่ 7.7 Section 10 เคยบอกไว้แล้วว่าการสร้าง pipeline หนึ่งตัวอาจใช้เวลาเป็นมิลลิวินาที เพราะ driver validate มันเต็มที่ตอนสร้าง เอาต้นทุนนั้นคูณด้วย "ฉากนี้ต้องการ permutation ที่ไม่ซ้ำกันกี่แบบ" แล้วอาการ "shader compilation stutter" ที่มีชื่อเสียงบน PC ก็คือต้นทุนนี้แหละที่โผล่มากลางเกม ครั้งแรกที่ permutation ที่ไม่มีใคร pre-compile ไว้ตอนโหลดถูกขอใช้

Tip ทางแก้ที่เอนจินใช้จริงคือ shader variant collection (หรือ "pipeline cache warm-up") ขั้นตอนหนึ่ง ปกติตอน build time หรือครั้งแรกที่เปิดเกม ที่ไล่หาทุก permutation ที่ material ใดก็ตามในคอนเทนต์ที่ออกจริงเข้าถึงได้ แล้วคอมไพล์ทั้งหมดล่วงหน้า ไม่ว่าจะตอน loading screen หรือ background thread เพื่อให้ GetOrCreatePipeline ข้างบนเจอ cache hit เกือบตลอดตอนเล่นเกมจริง แทนที่จะคอมไพล์แบบ on demand

13. อ่านซอร์ส Renderer ของเอนจินโดยไม่จมน้ำ

Renderer ของเอนจินที่ออกจริงอาจมีโค้ดเป็นหมื่นบรรทัดกระจายอยู่หลายสิบไฟล์ เปิดอ่านจากบนลงล่างไม่ใช่วิธีที่ใครใช้เรียนรู้มันจริง ๆ — section ข้างบนทั้งหมดให้แผนที่ไว้เดินตาม แทนที่จะอ่านตามลำดับไฟล์

SceneRenderer::Render() <-- 1. start here: this IS the | table of contents +-- Gather + Cull <-- 2. matches Section 5 +-- Build draw items + Sort <-- 2. matches Sections 6-8 +-- FrameGraph::AddPass(...) <-- 3. matches Section 9 +-- FrameGraph::Execute() <-- 4. calls into IRHICommandList | v RHI/ (a folder or namespace) <-- 5. the line: portable code IRHIDevice.h ABOVE, vendor backend IRHICommandList.h code BELOW Vulkan/ RHIDevice_Vulkan.cpp <-- 6. pick the ONE backend you D3D12/ RHIDevice_D3D12.cpp actually run, skip the Metal/ RHIDevice_Metal.cpp rest while still learning

กลยุทธ์จับต้องได้ เรียงลำดับ

14. Glossary

15. Exercises

Exercise 1 — หาบั๊กใน Sort Key Function MakeSortKey ข้างล่างคอมไพล์และรันได้ ออบเจกต์ opaque วาดถูกต้อง แต่ตรงไหนก็ตามที่ออบเจกต์ transparent สองตัวซ้อนกัน สีที่ blend ออกมาดูผิด — บางทีออบเจกต์ที่ควรอยู่ข้างหน้ากลับดู blend เหมือนอยู่ข้างหลัง ใช้ Section 6 กับ 7 หาขั้นตอนที่หายไป แล้วอธิบายด้วยคำพูดของคุณเองว่าทำไมมันถึงให้ผลลัพธ์ผิดทางสายตาโดยไม่ crash หรือมี error ใด ๆ เลย

uint64_t MakeSortKey(const DrawItem& item, bool isTransparent, float nearZ, float farZ)
{
    uint32_t depthBits = QuantizeDepth(item.viewDepth, nearZ, farZ);

    uint64_t key = 0;
    key |= isTransparent ? kTranslucentBit : 0;
    key |= (uint64_t)depthBits << 24;
    key |= (uint64_t)(item.pipeline.id & 0xFFFF) << 8;
    key |= (uint64_t)(item.meshIndex & 0xFF);
    return key;
}
Show answer

ขั้นตอนที่หายไปคือการกลับด้าน depthBits สำหรับ item ที่เป็น transparent ตามที่เขียนไว้ depthBits ถูกใช้ตรง ๆ ตามที่ QuantizeDepth คำนวณออกมา — เล็กสำหรับออบเจกต์ใกล้ ใหญ่สำหรับออบเจกต์ไกล — สำหรับ item ทั้งสองแบบ ทั้ง opaque และ transparent นั่นแปลว่า ascending sort จะจัดออบเจกต์ transparent ให้เรียงแบบ front-to-back (ใกล้สุดก่อน) กฎเดียวกันเป๊ะกับออบเจกต์ opaque แทนที่จะเป็นลำดับ back-to-front ที่ transparency ต้องการจริง ๆ เลขที่ไล่ trace ไว้ใน Section 6 โชว์แล้วว่าทำไมมันถึงสำคัญ — blend ออบเจกต์ใกล้ก่อนแล้วออบเจกต์ไกลทีหลัง ให้สีสุดท้ายต่างจาก และผิด เมื่อเทียบกับ blend ไกลแล้วค่อยใกล้ ทั้งที่ depth test เองไม่บ่นอะไรเลย — ไม่มี crash ไม่มี validation error เพราะการเรียงลิสต์ผิดลำดับไม่ใช่การละเมิดกฎของ Vulkan/DirectX แค่เป็นคำตอบที่ผิดของโจทย์คณิตศาสตร์ที่ API ไม่มีทางเช็คให้คุณได้


uint64_t MakeSortKey(const DrawItem& item, bool isTransparent, float nearZ, float farZ)
{
    uint32_t depthBits = QuantizeDepth(item.viewDepth, nearZ, farZ);

    if (isTransparent)
    {
        depthBits = 0xFFFFFFFFu - depthBits;   // added: invert for back-to-front
    }

    uint64_t key = 0;
    key |= isTransparent ? kTranslucentBit : 0;
    key |= (uint64_t)depthBits << 24;
    key |= (uint64_t)(item.pipeline.id & 0xFFFF) << 8;
    key |= (uint64_t)(item.meshIndex & 0xFF);
    return key;
}
Exercise 2 — เพิ่ม Bloom Pass เข้าไปใน Frame Graph Frame graph ของ Section 9 รัน LightingPass (เขียน sceneColor) ตรงเข้า TonemapPass (อ่าน sceneColor, เขียน backbuffer) เลย ใช้ API และ diagram ของ Section 9 เป็นแนวทาง เพิ่ม BloomPass ใหม่ระหว่างสองตัวนั้นที่อ่าน sceneColor และเขียน texture ใหม่ชื่อ bloomTexture แล้วแก้ TonemapPass ให้อ่าน bloomTexture แทนที่จะอ่าน sceneColor ตรง ๆ เขียนการประกาศ pass ที่แก้แล้วให้ครบ
Show answer

RGTextureHandle sceneColor   = graph.CreateTexture("SceneColor",   colorDesc);
RGTextureHandle bloomTexture = graph.CreateTexture("BloomTexture", colorDesc);
RGTextureHandle backbuffer   = graph.GetBackbuffer();

graph.AddPass("LightingPass",
    [&](RGBuilder& b) { b.Read(gbufferA); b.Read(depthTex); b.Write(sceneColor); },
    [=](IRHICommandList* cmd) { DrawFullscreenLightingShader(cmd, gbufferA, depthTex); });

graph.AddPass("BloomPass",
    [&](RGBuilder& b) { b.Read(sceneColor); b.Write(bloomTexture); },
    [=](IRHICommandList* cmd) { DrawFullscreenBloomShader(cmd, sceneColor); });

graph.AddPass("TonemapPass",
    [&](RGBuilder& b) { b.Read(bloomTexture); b.Write(backbuffer); },
    [=](IRHICommandList* cmd) { DrawFullscreenTonemapShader(cmd, bloomTexture); });

เพราะ BloomPass ประกาศ Read(sceneColor) graph จึงรู้แล้วว่ามันต้องรันหลัง LightingPass (ที่เขียน sceneColor) และก่อน TonemapPass (ที่ตอนนี้อ่าน bloomTexture ซึ่งเขียนโดย BloomPass เท่านั้น) — ไม่มีอะไรเกี่ยวกับลำดับ pass ต้องพูดออกมาตรง ๆ ที่ไหนเลย มันตกออกมาเองทั้งหมดจากการอ่าน/เขียนที่ประกาศไว้ ตรงตามที่ Section 9 อธิบาย Compile() ยังรู้ด้วยว่าต้องใส่ barrier ให้ sceneColor อ่านได้ก่อน BloomPass จะรัน และ barrier ที่สองให้ bloomTexture อ่านได้ก่อน TonemapPass จะรัน โดยไม่มี execute ของ pass ไหนต้องเขียน barrier เองด้วยมือสักตัวเดียว

Exercise 3 — วางแผน Threading กับ Buffering Renderer ตัวหนึ่งตอนนี้ record sorted draw list ทั้งหมด (ประมาณ 4,000 item) บน CPU thread เดียว และใช้แค่ kFramesInFlight = 1 (ไม่มี double buffering เลย — CPU รอ GPU ทำเฟรมเสร็จเต็ม ๆ ก่อนถึงจะเริ่มเฟรมถัดไปทุกครั้ง) การ profile พบว่า CPU ใช้เวลาส่วนใหญ่ไปกับการรอเฉย ๆ และการ record อย่างเดียวก็ใช้เวลานานพอที่จะจำกัด frame rate ได้ชัดเจน (a) คุณจะแบ่ง recording ข้ามสี่ thread ยังไง และอะไรต้องเป็นจริงอยู่แล้วเกี่ยวกับ draw list ก่อนที่คุณจะแบ่งมัน เพื่อให้ลำดับการวาดบนจอสุดท้ายยังถูกต้อง (b) คุณจะเปลี่ยน kFramesInFlight เป็นค่าอะไร ต้องทำสำเนา resource ต่อเฟรมอะไรบ้างเป็นผลจากนั้น และจะเกิดบั๊กแบบไหนเจาะจงถ้าคุณเปลี่ยนค่านั้นแต่ลืมเพิ่ม WaitForFence ที่ควรมี
Show answer

(a) การแบ่ง recording: ใช้แนวทางของ Section 10 ตรง ๆ — draw list ต้องถูกเรียงลำดับให้ครบแล้ว (Section 6-7) ก่อนที่จะถูกแบ่ง เพื่อให้การตัดมันเป็น 4 ชิ้นต่อเนื่องกัน แล้ว record แต่ละชิ้นบน thread ของตัวเองลงใน IRHICommandList ของตัวเอง ยังคงลำดับการวาดเหมือนกันเป๊ะกับเวอร์ชัน single-threaded ทันทีที่ SubmitCommandLists รัน command list ทั้งสี่ที่ได้กลับตามลำดับ array การแบ่งลิสต์ที่ยังไม่เรียงลำดับ หรือแบ่งหลังเรียงลำดับแล้วแต่ submit command list ที่ได้ผิดลำดับ ทั้งสองแบบจะทำลายการรับประกันลำดับที่ Section 6-7 พึ่งพาอยู่ — ออบเจกต์ transparent อาจถูกวาดก่อนออบเจกต์ opaque ข้างหลังมันจะเสร็จ หรือออบเจกต์ transparent สองตัวอาจ blend ผิดลำดับข้ามรอยต่อ chunk ได้

(b) Buffering: เปลี่ยน kFramesInFlight เป็น 2 (หรือ 3 เพื่อเผื่อที่หย่อนเพิ่มเวลาเฟรมพุ่งกะทันหัน แลกกับ memory และ latency ที่มากขึ้น ตามที่ Section 11 พูดไว้) ทุก resource ที่ CPU เขียนและ GPU อ่านระหว่างเฟรมต้องมีสำเนาหนึ่งชุดต่อ slot ของ frame-in-flight คือ per-frame constant buffer (camera matrix, ข้อมูลแสง), per-frame command list (หรือ command allocator ที่มัน record มาจาก) และ per-frame fence ที่ใช้รู้ว่า slot นั้นปลอดภัยที่จะใช้ซ้ำเมื่อไหร่ ถ้า kFramesInFlight ถูกยกเป็น 2 แต่ไม่ได้เพิ่ม WaitForFence(perFrameFence[slot]) ที่ควรมี (หรือดันไปรอ fence ของ slot ผิดตัวโดยไม่ตั้งใจ) CPU จะเริ่มเขียนทับ perFrameConstants[slot] สำหรับเฟรมใหม่ ในขณะที่ GPU อาจยังวาดค้างอยู่ อ่าน buffer ตัวเดียวกันนั้นเพื่อเฟรมก่อนที่ใช้ slot เดียวกัน อาการที่เห็นคือความเพี้ยนแบบไม่แน่นอน ขึ้นกับ timing — ออบเจกต์กระพริบใช้ transform หรือข้อมูลแสงผิดไปหนึ่งหรือสองเฟรม แย่ลงเมื่อ GPU โหลดหนัก — ซึ่งก็คือ data race ระหว่าง CPU/GPU แบบเดียวกับที่กล่องเตือนของ Section 11 อธิบายไว้ แค่ถูกกระตุ้นด้วยการเปลี่ยน threading ใหม่นี้แทนที่จะเป็นความผิดพลาดที่เขียนมือ

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