บทที่ 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 ไม่เคยทำงานเฟรมเดียวกันพร้อมกันจริง ๆ เลย
สตูดิโอที่ออกเกมบน 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 หนึ่งตัวอยู่ข้างใต้
ไฟล์ 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 จะเขียนมันออกมาให้ดู
ไม่มีอะไรห้ามโปรแกรมเมอร์ gameplay #include <vulkan/vulkan.h> ในไฟล์ player-controller แล้วเรียก vkCmdDrawIndexed ตรง ๆ ได้ แต่เอนจินจริงจังทุกตัวป้องกันเรื่องนี้อย่างตั้งใจ โดยทำให้ RHI เป็น rendering surface เดียวที่โค้ดนอก renderer ได้รับอนุญาตให้มองเห็น
มีเหตุผลที่จับต้องได้สี่ข้อรองรับเส้นแบ่งนี้
VkCommandBuffer ตัวเดียวที่กำลังถูก record บน thread หนึ่ง ห้ามถูกแตะจาก thread อื่นพร้อมกันเด็ดขาด command list ของ RHI สามารถบังคับหรือซ่อนกฎพวกนี้ไว้ที่จุดเดียวได้ ส่วนโปรแกรมเมอร์ gameplay ที่เรียก Vulkan ดิบ ๆ จาก callback ของ gameplay-script (ซึ่งอาจรันบน thread ไหนก็ได้) คือวิธีที่บั๊กพวกนี้เกิดขึ้นจริงในทางปฏิบัติเป๊ะ ๆIRHIDevice อาจมี function ประมาณ 30 ตัว ส่วน Vulkan API ที่มันห่อไว้มีมากกว่าพันตัว ความผิดพลาดในพื้นผิว 30 function รีวิว เทสต์ และ validate ได้ง่าย ส่วนความผิดพลาดที่เกิดจากการเข้าถึง API พันกว่า function ตรง ๆ จากโค้ดที่คนเขียนไม่ใช่ผู้เชี่ยวชาญกราฟิก คือบั๊กแบบที่ทำให้ GPU crash และเฟรมเพี้ยนซึ่งตามรอยยากมาก ๆ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 ถูกเอาไปใช้ซ้ำกับอย่างอื่น — แทนที่จะอ่านขยะแบบเงียบ ๆ
พอมี RHI แล้ว งานของ renderer ในแต่ละเฟรมคือแปลง "scene ปัจจุบัน" ให้กลายเป็นลำดับของ RHI call เรื่องนี้เกิดขึ้นเป็นสี่ขั้นตอน เรียงลำดับตายตัวแบบนี้
ห้า section ถัดไปแต่ละตัวจะสร้างกล่องหนึ่งกล่องของ diagram นี้ในโค้ดจริง พอถึงท้าย Section 8 คุณจะมี render loop ที่ทำงานได้จริง (แม้จะย่อลง) ครบวงจร Section 9 จะโชว์ว่าเอนจินจริง ๆ ห่อขั้นตอนที่ 4 ด้วยอะไรที่ยืดหยุ่นกว่า function เขียนมือตัวเดียวยังไง
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 ถัดไปจะเพิ่มเข้ามา — ประเด็นทั้งหมดของการแยก "รวบรวมว่ามีอะไรอยู่บ้าง" ออกจาก "ตัดสินใจลำดับ" ก็คือการเรียงลำดับสามารถมองทั้งลิสต์พร้อมกันได้ แทนที่แต่ละออบเจกต์จะตัดสินชะตากรรมตัวเองแบบโดดเดี่ยว
ออบเจกต์ opaque กับ transparent ต้องการลำดับการวาดตรงข้ามกัน ด้วยเหตุผลที่ต่างกันโดยสิ้นเชิงสองข้อ
สำหรับออบเจกต์ 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 เป็น แต่เป็นลำดับเดียวที่ให้ผลลัพธ์ถูกต้องทางสายตาได้เลย
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
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);
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);
มีสองอย่างเกิดขึ้นข้างใน 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 เลยสักนิด
setup กับ execute ของแต่ละ pass เป็นเรื่องตั้งใจและสำคัญมาก setup รันครั้งเดียวต่อเฟรม แค่ประกาศ dependency เท่านั้น ต้องถูกและไม่มี side effect execute รันทีหลัง — บางทีรันบน thread คนละตัวไปเลย (Section 10) — และเป็นที่เดียวที่ได้รับอนุญาตให้แตะ RHI การผสมสองอย่างนี้เข้าด้วยกัน (record draw call จริงข้างใน lambda ของ `setup`) จะทำลายความสามารถของ graph ในการจัดลำดับใหม่หรือขนาน pass เพราะมันไม่สามารถเชื่อได้อีกต่อไปว่า `setup` ไม่มี side effectบทที่ 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
}
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 รวมตามลำดับ — เหมือนกันเป๊ะ
พอเฟรมถูก submit แล้ว GPU ต้องใช้เวลาจริง ๆ ในการรันมันจริง ๆ — บ่อยครั้งใช้เวลามากกว่าที่ CPU ต้องใช้ record เฟรมถัดไปด้วยซ้ำ ถ้า CPU รอให้ GPU ทำเฟรม N เสร็จสมบูรณ์ก่อนถึงจะเริ่ม record เฟรม N+1 ได้ CPU กับ GPU จะผลัดกันว่าง แล้วเวลารวมต่อเฟรมก็จะเท่ากับเวลา CPU บวกเวลา GPU เรียงต่อกัน แทนที่จะเป็นแบบนั้น เอนจินปล่อยให้ CPU เริ่ม record เฟรม N+1 ได้ทันที ในขณะที่ GPU ยังรันเฟรม N อยู่
การทำแบบนี้ให้ปลอดภัยต้องการให้ 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 ข้างบนคือแนวคิดเดียวกัน
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 หรือข้อมูลสีของเฟรมผิด โผล่มาแล้วก็หายไปขึ้นกับ timingMaterial คือ 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 ไว้ตอนโหลดถูกขอใช้
GetOrCreatePipeline ข้างบนเจอ cache hit เกือบตลอดตอนเล่นเกมจริง แทนที่จะคอมไพล์แบบ on demandRenderer ของเอนจินที่ออกจริงอาจมีโค้ดเป็นหมื่นบรรทัดกระจายอยู่หลายสิบไฟล์ เปิดอ่านจากบนลงล่างไม่ใช่วิธีที่ใครใช้เรียนรู้มันจริง ๆ — section ข้างบนทั้งหมดให้แผนที่ไว้เดินตาม แทนที่จะอ่านตามลำดับไฟล์
กลยุทธ์จับต้องได้ เรียงลำดับ
Render, RenderFrame หรือ SceneRenderer::Render แทบไม่มีวันทำงานจริงเองเลย มันแค่เรียกออกไปหา gather/cull, sort และ frame graph เรียงลำดับประมาณนั้น — อ่านแค่ call ระดับบนสุดของมันก็ได้สารบัญของทุกอย่างที่เหลือแล้วRHI, Graphics/RHI หรือคล้าย ๆ กัน ที่มี abstract interface header อยู่ข้างใน ทุกอย่างที่เรียกเข้าไปหามันคือ engine logic แบบพกพาได้ — สิ่งที่บทนี้คลุมไว้ ทุกอย่างที่อยู่ในโฟลเดอร์ backend เฉพาะข้างใต้มันเป็นของเฉพาะ vendor ข้ามได้เต็มที่ตอนที่ยังเรียนรู้รูปร่าง pipeline อยู่DrawItem/RenderItem, SortKey, FrameGraph/RenderGraph, CommandList และ Pipeline/PSO มากพอที่การค้นหาคำพวกนี้จะพาคุณไปถึงโค้ดที่เกี่ยวข้องได้เร็ว แทนที่จะอ่านไฟล์ที่ไม่เกี่ยวข้องแล้วหวังว่าจะเจอไฟล์ที่ถูกโดยบังเอิญsetup ของ frame graph (Section 9) มักสั้นและบอกว่า pass ต้องการอะไรและสร้างอะไร โดยไม่มีรายละเอียดการ render จริงของ pass นั้นเลย อ่านทั้งหมดนั้นก่อนเพื่อเข้าใจรูปร่างรวมของเฟรม แล้วค่อยเจาะลึกเข้าไปใน execute body ของ pass เดียว หลังจากรู้แล้วว่ามันอยู่ตรงไหนในลำดับทั้งหมด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;
}
ขั้นตอนที่หายไปคือการกลับด้าน 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;
}
LightingPass (เขียน sceneColor) ตรงเข้า TonemapPass (อ่าน sceneColor, เขียน backbuffer) เลย ใช้ API และ diagram ของ Section 9 เป็นแนวทาง เพิ่ม BloomPass ใหม่ระหว่างสองตัวนั้นที่อ่าน sceneColor และเขียน texture ใหม่ชื่อ bloomTexture แล้วแก้ TonemapPass ให้อ่าน bloomTexture แทนที่จะอ่าน sceneColor ตรง ๆ เขียนการประกาศ pass ที่แก้แล้วให้ครบ
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 เองด้วยมือสักตัวเดียว
kFramesInFlight = 1 (ไม่มี double buffering เลย — CPU รอ GPU ทำเฟรมเสร็จเต็ม ๆ ก่อนถึงจะเริ่มเฟรมถัดไปทุกครั้ง) การ profile พบว่า CPU ใช้เวลาส่วนใหญ่ไปกับการรอเฉย ๆ และการ record อย่างเดียวก็ใช้เวลานานพอที่จะจำกัด frame rate ได้ชัดเจน (a) คุณจะแบ่ง recording ข้ามสี่ thread ยังไง และอะไรต้องเป็นจริงอยู่แล้วเกี่ยวกับ draw list ก่อนที่คุณจะแบ่งมัน เพื่อให้ลำดับการวาดบนจอสุดท้ายยังถูกต้อง (b) คุณจะเปลี่ยน kFramesInFlight เป็นค่าอะไร ต้องทำสำเนา resource ต่อเฟรมอะไรบ้างเป็นผลจากนั้น และจะเกิดบั๊กแบบไหนเจาะจงถ้าคุณเปลี่ยนค่านั้นแต่ลืมเพิ่ม WaitForFence ที่ควรมี(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 ใหม่นี้แทนที่จะเป็นความผิดพลาดที่เขียนมือ