7.1 พื้นฐาน Rendering Pipeline

เฟส 7 · กราฟิกและ Rendering · เวลาเรียน: 40–60 h

triangle กลายเป็น pixel ยังไง — vertex, rasterization, fragment shading, depth buffer สร้าง software renderer เล็ก ๆ ก่อนเพื่อให้ไม่มีอะไรเป็นเวทมนตร์

บทที่ผ่านมาทั้งหมดปฏิบัติกับหน้าจอเหมือนเป็นของที่ทำงานได้เองอยู่แล้ว คุณแค่ตั้งค่า material เรียก Instantiate() แล้วโมเดล 3D ก็โผล่ขึ้นมา บทนี้จะเปิดกล่องดำนั้นออกมาดู บทที่ 2.1 สร้างคณิตศาสตร์ที่จำเป็นไว้แล้ว — เมทริกซ์ model, view, และ projection ที่แปลงจุด 3D ให้กลายเป็นตำแหน่งบนหน้าจอ บทนี้จะแสดงให้เห็นว่า GPU (Graphics Processing Unit หรือชิปที่วาดเกมของคุณ) เอาคณิตศาสตร์นั้นไปทำอะไรต่อ ทีละสเตจ ตั้งแต่สามเหลี่ยมเดียวที่นอนอยู่ในหน่วยความจำ ไปจนถึงพิกเซลสีที่นอนอยู่ใน framebuffer (บล็อกหน่วยความจำที่เก็บภาพที่เสร็จแล้ว) พอจบบทนี้ คุณจะได้เขียน GPU เวอร์ชันซอฟต์แวร์เล็ก ๆ ของตัวเอง จำลอง core drawing loop ด้วย C++ ธรรมดา เพื่อให้ไม่มีสเตจไหนรู้สึกเหมือนเวทมนตร์อีกต่อไป

1. ภาพรวมใหญ่: จากสามเหลี่ยมสู่พิกเซล

โมเดล 3D ทุกชิ้นในเกม ไม่ว่าจะเป็นตัวละคร ก้อนหิน หรือดาบ จริง ๆ แล้วก็คือกองสามเหลี่ยม (เรียกว่า mesh) สามเหลี่ยมแต่ละรูปถูกกำหนดด้วยจุดมุมสามจุดที่เรียกว่า vertices (เอกพจน์คือ vertex) และแต่ละ vertex จะพกตำแหน่งของตัวเองไว้ บ่อยครั้งก็มีข้อมูลเพิ่มด้วย เช่น สี, texture coordinate, หรือทิศทางที่มันหันหน้าไป (เรียกว่า normal) การเอากองสามเหลี่ยมพวกนี้มาแปลงเป็นภาพที่คุณเห็นบนจอ เรียกว่า rendering และลำดับขั้นตอนตายตัวที่ GPU รันเพื่อทำสิ่งนี้ เรียกว่า rendering pipeline

a mesh (many small triangles) the same mesh, rendered +----+----+----+----+ +-------------------+ | /| /| /| /| | .-. | |/ |/ |/ |/ | | ( o o ) | +----+----+----+----+ | '-' | +-------------------+ just numbers in memory: the same numbers, turned into positions, colors, indices... colored pixels on a screen

บทนี้จะพาไปดูทุกสเตจระหว่างภาพสองภาพนั้น: vertex เข้าไปใน vertex shader ถูกจัดกลุ่มเป็นสามเหลี่ยม ถูกแปลงเป็นพิกเซลผู้สมัคร (candidate pixel) ด้วย rasterization ถูกลงสีด้วย fragment shader แล้วสุดท้ายก็ไปจบที่ framebuffer ที่จอของคุณแสดงผล นี่คือทริปทั้งหมดในบรรทัดเดียว — แต่ละลูกศรจะมีเซคชันของตัวเองในบทนี้ทีหลัง:

vertices --> vertex shader --> primitive assembly --> rasterization --> fragment shader --> framebuffer --> your monitor

เรื่องพวกนี้ไม่ได้เจาะจงแค่ Unity หรือ Unreal ทั้งสองเอนจิน รวมถึงเอนจิน 3D แบบเรียลไทม์ตัวอื่น ๆ ต่างก็นั่งอยู่บน pipeline นี้เป๊ะ ๆ ผ่าน graphics API (DirectX, Vulkan, Metal, หรือ OpenGL) ที่คุยกับไดรเวอร์ของ GPU คุณ พอคุณไปเขียน shader ในคอร์สหลังจากนี้ คุณกำลังเขียนโค้ดที่เสียบเข้าไปตรง ๆ ในสองสเตจจากทั้งหมดนี้ การเข้าใจ pipeline ทั้งก้อนก่อน คือสิ่งที่ทำให้โค้ด shader มีเหตุผล แทนที่จะรู้สึกเหมือนคาถาเวทมนตร์ที่ก็อปมาจากทูโทเรียล

2. โมเดลความคิดของ GPU: คนงานตัวจิ๋วนับพัน

ก่อนจะไปดูแต่ละสเตจของ pipeline ทีละอัน มันช่วยได้ถ้าปักไอเดียหนึ่งไว้ในหัวให้แน่น ๆ ก่อน: GPU ถูกสร้างมาต่างจาก CPU โดยสิ้นเชิง และรูปร่างทั้งหมดของ pipeline จะเข้าใจได้ก็ต่อเมื่อรู้ว่าทำไม

CPU (Central Processing Unit — ชิปที่รันโลจิกเกมปกติ, สคริปต์ C#, และ Update() ของคุณ) มีคอร์ทรงพลังอยู่ไม่กี่ตัว แต่ละคอร์ทำงานซับซ้อนและคาดเดายากได้ เช่น ไล่ pointer, แตกกิ่ง (branch) ไปมามั่ว ๆ, รันโค้ดที่ต่างไปจากคอร์ข้าง ๆ โดยสิ้นเชิง ส่วน GPU ใช้แนวทางตรงข้ามกัน: คอร์เล็ก ๆ ง่าย ๆ นับพันตัว ทุกตัวรันโปรแกรมจิ๋ว ๆ ตัวเดียวกันพร้อมกัน แต่ละตัวทำงานกับข้อมูลคนละก้อน

CPU: a few powerful, independent cores GPU: thousands of small, synchronized cores +--------+ +--------+ +--+--+--+--+--+--+--+--+--+--+--+--+ | core 0 | | core 1 | each core can run |c |c |c |c |c |c |c |c |c |c |c |c | | doing | | doing | totally different +--+--+--+--+--+--+--+--+--+--+--+--+ | task A | | task B | code all running the SAME shader program, +--------+ +--------+ each on a DIFFERENT vertex or pixel

นี่แหละคือเหตุผลที่ pipeline ทำงานแบบที่มันทำ shader (โปรแกรมเล็ก ๆ ที่รันบน GPU) ไม่ได้ถูกเขียนแบบ "วนลูปทุก vertex" — แต่ถูกเขียนแบบ "นี่คือสิ่งที่ต้องทำสำหรับ vertex เดียว" แล้ว GPU ก็เอาโปรแกรมจิ๋วตัวเดียวกันนี้ไปรันกับ vertex นับพันพร้อมกัน ไอเดียเดียวกันนี้จะเกิดซ้ำกับพิกเซลในสเตจหลัง ๆ ของ pipeline คุณไม่ต้องเขียนลูปเองเลย ฮาร์ดแวร์ คือ ตัวลูปนั้นเอง

นี่คือโมเดลความคิดในรูปแบบ C++ ธรรมดา จำลองบนคอร์ CPU ตัวเดียวว่า GPU ทำอะไรบ้างข้ามคอร์นับพันตัวพร้อมกัน ลูปข้างล่างนี้ไม่ใช่สิ่งที่คุณจะเขียนสำหรับ GPU จริง ๆ — แต่มันคือสิ่งที่เกิดขึ้นจริงในเชิงแนวคิดเป๊ะ ๆ แค่ทำทีละตัวแทนที่จะทำพร้อมกันทั้งหมด:

#include <iostream>
#include <vector>

struct Vertex { float x, y, z; };

// This is the "shader": a tiny program written for ONE vertex.
Vertex simpleShader(Vertex in)
{
    // A GPU runs this exact function, unmodified, once per vertex --
    // never a loop written by you.
    return { in.x * 2.0f, in.y * 2.0f, in.z * 2.0f };
}

int main()
{
    std::vector<Vertex> vertices = { {1,1,1}, {2,0,1}, {0,3,1}, {4,4,1} };

    // A real GPU would run simpleShader() on all 4 of these AT THE SAME
    // TIME, on 4 different cores. A CPU has to loop, one at a time:
    for (Vertex& v : vertices)
    {
        v = simpleShader(v);
    }

    for (Vertex& v : vertices)
    {
        std::cout << "(" << v.x << ", " << v.y << ", " << v.z << ")\n";
    }
}

Output:

(2, 2, 2)
(4, 0, 2)
(0, 6, 2)
(8, 8, 2)

บน CPU ลูป for นี้จะรัน vertex ทีละตัวเรียงกันไป แต่บน GPU จริง ๆ ที่มี shader core สัก 2000 ตัว ทั้ง 4 ตัวนี้ (และอีกนับพันตัวจาก mesh จริง) จะรัน simpleShader พร้อมกันหมด เสร็จในเวลาที่ใกล้เคียงกับการทำแค่ตัวเดียว นี่คือเหตุผลที่ GPU สามารถดัน vertex เป็นล้าน ๆ ตัวและพิกเซลนับสิบล้านตัวได้ในทุก ๆ เฟรม ไม่ใช่เพราะแต่ละคอร์เร็ว แต่เพราะมันมีเยอะมากจนทำงานพร้อมกันได้

Tip คำสั่ง if มีต้นทุนแพงกว่าบน GPU เทียบกับบน CPU คอร์ที่อยู่ใกล้กันจะถูกจัดกลุ่มและถูกบังคับให้เดินพร้อมกันเป๊ะ ๆ (เรียกว่า warp หรือ wavefront แล้วแต่ค่ายฮาร์ดแวร์) ถ้าคอร์บางตัวในกลุ่มเข้า branch if ส่วนตัวอื่นเข้า else ฮาร์ดแวร์จะรันทั้งสอง branch ให้ทั้งกลุ่ม แล้วทิ้งครึ่งที่ผิดของแต่ละคอร์ทิ้งไป — เรียกว่า divergence shader ที่เต็มไปด้วย branch อาจช้ากว่า shader ที่หลีกเลี่ยงมันมาก แม้จะดูเหมือนทำงาน "เยอะกว่า" โดยรวมก็ตาม

ทำไมเรื่องนี้ถึงสำคัญกับคุณ: คุณจะไม่ได้เขียน scheduler ของ GPU เอง แต่ทุกการออกแบบที่เหลือในบทนี้ — การรัน shader จิ๋วตัวเดียวกันต่อ vertex แล้วก็ต่อพิกเซลอีกที โดยไม่มี state ใด ๆ ใช้ร่วมกันระหว่างการเรียกแต่ละครั้ง — มีอยู่ก็เพราะรูปร่างของฮาร์ดแวร์แบบนี้ จำภาพนี้ไว้ในหัวตลอดบทที่เหลือ: โปรแกรมเล็ก ๆ ตัวเดียว ถูกรันซ้ำจำนวนมหาศาล พร้อมกัน บนข้อมูลที่เป็นอิสระจากกัน

3. มองภาพรวม Pipeline ทั้งก้อน

ทีนี้มาดูภาพเต็ม ทีละสเตจ บางสเตจเป็น programmable (คุณเขียนโค้ดเองว่าจะรันอะไรตรงนั้น — คือ shader) และบางสเตจเป็น fixed-function (ฮาร์ดแวร์ของ GPU ทำงานตายตัวที่คุณเขียนใหม่ไม่ได้ ปรับได้แค่ตั้งค่าบางอย่าง)

[1] Vertex buffer -- raw vertex data sitting in memory | v [2] VERTEX SHADER -- PROGRAMMABLE. runs once per vertex. | transforms local position to clip space (section 5) v [3] Primitive assembly -- FIXED-FUNCTION. groups vertices into triangles, | clips them, may cull back-facing ones (section 6) v [4] RASTERIZATION -- FIXED-FUNCTION. finds which pixels each | triangle covers (section 7) v [5] FRAGMENT SHADER -- PROGRAMMABLE. runs once per covered pixel. | computes a color (section 8) v [6] Depth test -- FIXED-FUNCTION. keeps only the nearest | color per pixel, using the z-buffer (section 9) v [7] Framebuffer -- the finished image, shown on your monitor (section 11)

มีสองสเตจที่เป็น programmable: vertex shader กับ fragment shader (บาง API เรียกว่า pixel shader — ไอเดียเดียวกัน แค่ชื่อต่างกัน) นี่คือสองจุดที่คุณ ในฐานะโปรแกรมเมอร์ เขียนโค้ดที่ GPU รันจริง ๆ ส่วนสเตจอื่น ๆ ทั้งหมด — การรวม vertex เป็นสามเหลี่ยม, การหาว่าสามเหลี่ยมคลุมพิกเซลไหนบ้าง, การเทสต์ความลึก — ทำโดยวงจรฮาร์ดแวร์ตายตัวที่ถูกขัดเกลามาหลายสิบปีในการออกแบบ GPU ซึ่งคุณแค่ตั้งค่า ไม่ได้เขียนใหม่

Tip ใน Unity's Shader Graph หรือไฟล์ shader แบบ HLSL/GLSL ดิบ ๆ สองฟังก์ชันที่คุณเขียนจะแม็ปตรงเข้ากับไดอะแกรมนี้เป๊ะ: ฟังก์ชัน "vertex" คือสเตจ [2] และฟังก์ชัน "fragment" (หรือ "pixel") คือสเตจ [5] ส่วนที่เหลือในไดอะแกรมนี้ทั้งหมด คือ graphics API เบื้องหลังของ Unity กับไดรเวอร์ GPU ของคุณ ที่ทำงานให้อัตโนมัติ

ส่วนที่เหลือของบทนี้จะเดินตามไดอะแกรมจากบนลงล่าง ทีละสเตจ พร้อมเขียนโค้ด C++ เล็ก ๆ สำหรับแต่ละอันให้คุณเห็นชัด ๆ ว่าเกิดอะไรขึ้นจริง ๆ แทนที่จะเชื่อกล่องดำเฉย ๆ

4. ทบทวน Coordinate Space: Model, World, View, Clip

บทที่ 2.1 สร้างเมทริกซ์ที่บททั้งบทนี้รันอยู่บนนั้น ก่อนจะแตะ vertex shader เอง นี่คือการทบทวนแบบเร็ว ๆ ของ coordinate space ที่ vertex หนึ่งจะเดินทางผ่าน เพราะงานทั้งหมดของ vertex shader ก็คือการย้ายจุดหนึ่งผ่านห่วงโซ่นี้เป๊ะ ๆ:

local space (model space) -- the mesh's own tiny coordinate system, | centered on the object itself | MODEL matrix (object's own position/rotation/scale) v world space -- one shared coordinate system every | object in the level agrees on | VIEW matrix (re-measure relative to the camera) v view space (camera space) -- camera sits at the origin, looking | down a fixed axis | PROJECTION matrix (sets up perspective) v clip space -- a 4-value (x, y, z, w) space, ready for the GPU's clipping and divide step

ถ้าชื่อทั้งสี่นี้ชื่อไหนฟังดูไม่คุ้น ให้ย้อนกลับไปอ่านเซคชัน 9 ของบทที่ 2.1 ก่อนอ่านต่อ — บทนี้สมมติว่าคุณรู้อยู่แล้วว่าเมทริกซ์ model, view, และ projection ถูกสร้างและคูณเข้าด้วยกันยังไง บทนี้จะรับช่วงต่อจากตรงที่บทนั้นจบไว้พอดี: คณิตศาสตร์นี้รันตรงไหนจริง ๆ ข้างใน GPU pipeline? คำตอบคือ: รันอยู่ข้างใน vertex shader ทั้งหมด ซึ่งเป็นเซคชันถัดไป

Common mistake การทำคณิตศาสตร์ gameplay (เช็คระยะทาง, "ผู้เล่นอยู่ใกล้วัตถุนี้ไหม") ผิด space — เช่น เอาตำแหน่ง vertex แบบ local-space ดิบ ๆ ของ mesh ไปเทียบกับตำแหน่งผู้เล่นแบบ world-space local space มีจุดศูนย์กลางอยู่ที่ตัววัตถุเอง มันไม่รู้เลยว่าวัตถุนั้นถูกวางไว้ตรงไหนในเลเวล ต้องแปลงเป็น world space ก่อนเสมอ (คูณด้วยเมทริกซ์ model) ก่อนจะเทียบตำแหน่งระหว่างวัตถุคนละชิ้น นี่เป็นบั๊กฝั่ง gameplay ไม่ใช่บั๊กฝั่ง rendering แต่มันคือความสับสนเรื่อง coordinate-space แบบเดียวกันเป๊ะกับที่ vertex shader มีไว้เพื่อแก้ให้ถูกต้องอัตโนมัติทุกเฟรม

5. Vertex Shader: จาก Local Space สู่พิกเซลบนจอ

vertex shader คือสเตจ programmable ตัวแรก สัญญา (contract) ของมันเข้มงวดและง่ายมาก: ตำแหน่ง vertex หนึ่งตัว (พร้อมข้อมูลอื่น ๆ ของมัน) เข้าไป ตำแหน่งใน clip-space หนึ่งตัว (พร้อมข้อมูลอื่น ๆ ของมัน) ออกมา มันมองไม่เห็น vertex ตัวอื่นเลย เพิ่มหรือลบ vertex ไม่ได้ และยังไม่รู้อะไรเกี่ยวกับพิกเซลเลย สัญญาที่แคบขนาดนี้แหละคือสิ่งที่ทำให้ GPU รันมันกับ vertex นับพันตัวพร้อมกันได้ ตามที่พูดไว้ในเซคชัน 2

คูณด้วยเมทริกซ์ MVP รวม

ในเอนจินจริง เมทริกซ์ model, view, และ projection จากบทที่ 2.1 มักจะถูกคูณรวมกันครั้งเดียวเป็นเมทริกซ์ 4x4 ตัวเดียวเรียกว่า MVP matrix ก่อนที่ vertex shader จะรันเสียอีก — โค้ดฝั่งเอนจินบน CPU ทำการคูณนี้ครั้งเดียวต่อวัตถุต่อเฟรม เพราะถ้าทำซ้ำต่อ vertex นับพันครั้งจะเสียแรงเปล่า ๆ งานทั้งหมดของ vertex shader เลยเหลือแค่การคูณเมทริกซ์กับเวกเตอร์ครั้งเดียว:

#include <iostream>

struct Vec3 { float x, y, z; };
struct Vec4 { float x, y, z, w; };
struct Mat4 { float m[4][4]; };

Vec4 mul(const Mat4& M, Vec4 v)
{
    Vec4 r;
    r.x = M.m[0][0]*v.x + M.m[0][1]*v.y + M.m[0][2]*v.z + M.m[0][3]*v.w;
    r.y = M.m[1][0]*v.x + M.m[1][1]*v.y + M.m[1][2]*v.z + M.m[1][3]*v.w;
    r.z = M.m[2][0]*v.x + M.m[2][1]*v.y + M.m[2][2]*v.z + M.m[2][3]*v.w;
    r.w = M.m[3][0]*v.x + M.m[3][1]*v.y + M.m[3][2]*v.z + M.m[3][3]*v.w;
    return r;
}

// THE VERTEX SHADER: one local-space vertex in, one clip-space vertex out.
Vec4 vertexShader(Vec3 localPos, const Mat4& mvp)
{
    Vec4 v = { localPos.x, localPos.y, localPos.z, 1.0f }; // w=1: this is a POINT (ch. 2.1)
    return mul(mvp, v);
}

int main()
{
    // A stand-in MVP matrix, already combining model, view, and projection
    // (built the way chapter 2.1 sections 8-9 showed). Its bottom row is
    // the part a real perspective projection adds: it copies depth into w.
    Mat4 mvp = { {
        {1, 0, 0, 0},
        {0, 1, 0, 0},
        {0, 0, 1, 0},
        {0, 0, 1, 0}
    } };

    Vec3 localVertex = { 2, 3, 5 };
    Vec4 clip = vertexShader(localVertex, mvp);

    std::cout << "clip = (" << clip.x << ", " << clip.y << ", "
               << clip.z << ", " << clip.w << ")\n";
}

Output:

clip = (2, 3, 5, 5)

เพราะแถวล่างสุดของ MVP ตัวอย่างนี้คือ {0, 0, 1, 0} ค่า w ที่ออกมาเลยเท่ากับ z ที่ใส่เข้าไป — เป็นตัวแทนแบบง่าย ๆ ของสิ่งที่เมทริกซ์ perspective projection จริง ๆ ทำ: มันทำให้ w พกข้อมูลความลึกไว้ ตรงตามที่บทที่ 2.1 เซคชัน 10 อธิบายไว้เป๊ะ ส่วน x กับ y ผ่านมาโดยไม่เปลี่ยนแปลงในตัวอย่างนี้ ก็เพราะส่วนที่เหลือของเมทริกซ์ตัวอย่างนี้เป็น identity เฉย ๆ เมทริกซ์ MVP จริง ๆ จะหมุน, สเกล, และเลื่อนตำแหน่งมันด้วย

จาก Clip Space สู่หน้าจอ: Perspective Divide กับ Viewport

ผลลัพธ์ของ vertex shader คือ clip space ซึ่งยังไม่ใช่พิกเซลบนจอ ยังมีอีกสองขั้นตอนที่เกิดขึ้นอัตโนมัติ นอกเหนือจาก shader ใด ๆ ที่คุณเขียน ก่อนที่ rasterization จะเอาผลลัพธ์ไปใช้ได้ — แต่การไล่ตามมันด้วยมือจะทำให้ห่วงโซ่ทั้งหมดเข้าใจกระจ่างขึ้น:

struct ScreenPos { float x, y; };

// Step A: perspective divide -- turns clip space into NDC (Normalized
// Device Coordinates, roughly -1..1 on each axis). The GPU does this
// automatically; it is NOT something you write inside a shader.
Vec3 perspectiveDivide(Vec4 clip)
{
    return { clip.x / clip.w, clip.y / clip.w, clip.z / clip.w };
}

// Step B: viewport transform -- stretches NDC's -1..1 square onto the
// actual pixel grid of the render target.
ScreenPos viewportTransform(Vec3 ndc, float screenW, float screenH)
{
    ScreenPos s;
    s.x = (ndc.x * 0.5f + 0.5f) * screenW;
    s.y = (1.0f - (ndc.y * 0.5f + 0.5f)) * screenH; // Y flipped: rows grow downward
    return s;
}

int main()
{
    Vec4 clip = { 2, 3, 5, 5 }; // from the vertex shader example above
    Vec3 ndc = perspectiveDivide(clip);
    ScreenPos screen = viewportTransform(ndc, 800.0f, 600.0f);

    std::cout << "ndc    = (" << ndc.x << ", " << ndc.y << ")\n";
    std::cout << "screen = (" << screen.x << ", " << screen.y << ")\n";
}

Output:

ndc    = (0.4, 0.6)
screen = (560, 120)

ไล่ดู: เอา (2, 3, 5) หารด้วย w = 5 ได้ NDC (0.4, 0.6) — อยู่ในกรอบที่มองเห็นได้ -1..1 แปลว่า vertex นี้อยู่บนจอ ขั้นตอน viewport จากนั้นจะยืดกรอบนั้นออกไปทาบบนภาพขนาด 800x600: 0.4 แม็ปไปที่ x = 560 (ขวาของจุดกึ่งกลาง) และ 0.6 แม็ปไปที่ y = 120 (กลับด้าน ตกลงมาใกล้ขอบบน) ห่วงโซ่นี้เป๊ะ ๆ — คูณ MVP, หารด้วย w, ยืดเป็นพิกเซล — คือสิ่งที่ vertex ทุกตัวในเกมคุณต้องผ่าน ทุกเฟรม รันพร้อมกันข้ามคอร์ GPU นับพันตัว

Common mistake ลืมไปว่า w ใน clip-space ไม่ได้เท่ากับ 1 เสมอไป ถ้าคุณเคยอ่านค่าตำแหน่ง clip-space กลับมาด้วยมือ (เช่น ใน custom shader effect) แล้วหารแค่ x กับ y ด้วย w โดยลืม z ไป จะได้ค่าความลึกที่พังไป — และถ้าข้ามการหารไปเลยทั้งหมด จะได้พิกัดที่สเกลผิดจนไม่ใช่พิกเซลเลยด้วยซ้ำ

6. Primitive Assembly: Vertex กลายเป็นสามเหลี่ยม

หลังจาก vertex shader รันกับทุก vertex เสร็จแล้ว GPU จะมีลิสต์ใหญ่ ๆ ของจุดเดี่ยว ๆ อยู่ในมือ — มันยังไม่รู้เลยว่าจุดสามจุดไหนประกอบกันเป็นสามเหลี่ยม Primitive assembly คือสเตจ fixed-function ที่จัดกลุ่มมันใหม่ โดยใช้ index buffer (ลิสต์ของเลขจำนวนเต็มที่บอกว่า vertex ไหน โดยตำแหน่งใน vertex buffer เป็นของสามเหลี่ยมรูปไหน) ที่ถูกตั้งไว้ฝั่ง CPU ก่อนหน้า draw call

#include <iostream>
#include <vector>

struct Triangle { int a, b, c; };

int main()
{
    // 4 vertices forming a quad (a square), already run through the
    // vertex shader. Reusing vertex 0 and 2 for both triangles avoids
    // sending duplicate vertex data through the vertex shader twice.
    std::vector<int> indices = { 0, 1, 2,   0, 2, 3 };

    std::vector<Triangle> triangles;
    for (size_t i = 0; i < indices.size(); i += 3)
    {
        triangles.push_back( { indices[i], indices[i+1], indices[i+2] } );
    }

    for (const Triangle& t : triangles)
    {
        std::cout << "triangle: (" << t.a << ", " << t.b << ", " << t.c << ")\n";
    }
}

Output:

triangle: (0, 1, 2)
triangle: (0, 2, 3)
vertex 3 *-----------* vertex 2 | .'| | tri B .' | | .' | | .' | | .' tri A | |.' | vertex 0 *-----------* vertex 1 index buffer: 0,1,2, 0,2,3 triangle A = vertices (0, 1, 2) triangle B = vertices (0, 2, 3)

มีอีกสองเรื่องที่เกิดขึ้นรอบ ๆ สเตจนี้ ทั้งคู่เป็น fixed-function: clipping ตัดส่วนของสามเหลี่ยมที่ยื่นออกไปนอกปริมาตร clip-space ที่มองเห็นได้ (สามเหลี่ยมที่ครึ่งหนึ่งอยู่นอกจอจะถูกตัด ไม่ใช่ทิ้งทั้งรูป) และ backface culling สามารถทิ้งสามเหลี่ยมที่หันหน้าออกจากกล้องทั้งรูปได้เลย โดยดูจาก winding order ของมัน (สาม vertex ของมันเรียงตามเข็มนาฬิกาหรือทวนเข็มนาฬิกาบนจอ) การ cull ช่วยประหยัด GPU จากการ rasterize หรือลงสีพิกเซลที่คุณไม่มีทางเห็นอยู่แล้ว เช่น ด้านในหัวของตัวละคร

Common mistake อิมพอร์ตหรือสร้าง mesh ที่มี winding order ไม่สม่ำเสมอ ถ้า vertex ของสามเหลี่ยมถูกเรียงลำดับการหมุนผิด backface culling จะทิ้งมันไปทั้งที่มันหันหน้าเข้าหากล้องอยู่ อาการคลาสสิกคือรูโหว่ใน mesh หรือทั้งโมเดลที่อิมพอร์ตมาเรนเดอร์ไม่ขึ้นเลย ซึ่งจะหายไปทันทีที่คุณพลิกกล้องไปมองมันจาก "ด้านใน" เอนจินส่วนใหญ่ให้คุณปิด culling ชั่วคราวเป็นขั้นตอน debug เพื่อยืนยันว่านี่คือสาเหตุได้

7. Rasterization กับ Barycentric Coordinates: พิกเซลไหนบ้าง และเท่าไหร่ของแต่ละ Vertex

Rasterization คือจุดที่สามเหลี่ยม ซึ่งเป็นรูปทรงทางคณิตศาสตร์ที่ประกอบจากจุดสามจุดกับขอบเส้นตรง ถูกแปลงเป็นพิกเซลจริง ๆ มันตอบคำถามเดียวซ้ำไปซ้ำมา สำหรับทุกพิกเซลที่อยู่ใกล้สามเหลี่ยม: จุดศูนย์กลางของพิกเซลนี้อยู่ในสามเหลี่ยมไหม?

a triangle overlaid on a pixel grid (small example): . . . . . . . . X X . . . X X X X . . X X X X . . . X X . . . . . . . . X = pixel center falls inside the triangle -> rasterizer keeps it . = pixel center falls outside -> rasterizer skips it entirely

Edge Function: การเทสต์ว่าอยู่ข้างในไหม

การเทสต์แบบคลาสสิกใช้ edge function สำหรับขอบที่วิ่งจากจุด a ไปจุด b และจุดผู้สมัคร p เครื่องหมาย (sign) ของ edge function จะบอกว่า p อยู่ฝั่งไหนของเส้น รันมันกับทั้งสามขอบของสามเหลี่ยม ถ้า p ตกอยู่ฝั่งเดียวกันของทั้งสามขอบ แปลว่ามันอยู่ข้างใน

#include <iostream>

struct Vec2 { float x, y; };

float edgeFunction(Vec2 a, Vec2 b, Vec2 p)
{
    return (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
}

bool insideTriangle(Vec2 a, Vec2 b, Vec2 c, Vec2 p)
{
    float w0 = edgeFunction(b, c, p);
    float w1 = edgeFunction(c, a, p);
    float w2 = edgeFunction(a, b, p);

    // Inside if p is on the same side of all three edges -- all
    // non-negative, or all non-positive (handles either winding order).
    return (w0 >= 0 && w1 >= 0 && w2 >= 0) ||
           (w0 <= 0 && w1 <= 0 && w2 <= 0);
}

int main()
{
    Vec2 A = {2, 1}, B = {14, 2}, C = {7, 8};

    for (int y = 0; y < 10; y++)
    {
        for (int x = 0; x < 17; x++)
        {
            Vec2 pixelCenter = { x + 0.5f, y + 0.5f }; // sample the pixel's MIDDLE
            std::cout << (insideTriangle(A, B, C, pixelCenter) ? '#' : '.');
        }
        std::cout << "\n";
    }
}

Output:

.................
..######.........
...##########....
....########.....
....#######......
.....#####.......
......###........
.......#.........
.................
.................

ทุก # คือพิกเซลที่ rasterizer ตัดสินใจว่าเป็นของสามเหลี่ยมรูปนี้ ลูปซ้อนกันบนพื้นที่ bounding นี้ คือรูปร่างเป๊ะ ๆ ของสิ่งที่ฮาร์ดแวร์ rasterization แบบ fixed-function ทำ แค่ถูกสร้างลงในซิลิคอนแทนที่จะเป็นลูป CPU และถูกเทสต์กับพิกเซลจำนวนมากพร้อมกันแทนที่จะทำทีละตัว

Tip การสุ่มตัวอย่างที่จุดศูนย์กลางพิกเซลเป๊ะ ๆ (x + 0.5, y + 0.5) ไม่ใช่มุมของมัน เป็นทางเลือกที่จงใจและเป็นมาตรฐาน — มันทำให้ขอบสามเหลี่ยมดูสม่ำเสมอไม่ว่าจะวิ่งไปทิศไหน GPU บางตัวสุ่มตัวอย่างหลายจุดต่อพิกเซลแทนที่จะเอาแค่จุดศูนย์กลาง เรียกว่า MSAA (Multisample Anti-Aliasing) แล้วผสมผลลัพธ์เข้าด้วยกันเพื่อทำให้ขอบสามเหลี่ยมที่หยักดูนุ่มขึ้น

Barycentric Coordinates: ตัวเลขชุดเดิม แค่ normalize

ค่า edge function ทั้งสามตัวที่ใช้ข้างบนนั้นไม่ได้ถูกทิ้งไปใน rasterizer จริง ๆ — เมื่อหารด้วยพื้นที่รวมของสามเหลี่ยม มันจะกลายเป็น barycentric coordinates: น้ำหนักสามค่า (wA, wB, wC) หนึ่งค่าต่อหนึ่ง vertex ซึ่งรวมกันได้ 1 เสมอสำหรับจุดใด ๆ ที่อยู่ในสามเหลี่ยม มันตอบคำถามที่สองซึ่งสำคัญพอ ๆ กัน: ไม่ใช่แค่พิกเซลนี้อยู่ข้างในไหม แต่มันใกล้กับแต่ละมุมทั้งสามแค่ไหน?

triangle A, B, C with point P inside: A /| / | / | / P | / | B-----C (drawn as a right triangle here just for clean ASCII art; the real formula works for ANY triangle shape) P's barycentric weights are the areas of the three sub-triangles (P,B,C), (P,C,A), (P,A,B), each divided by the area of the whole triangle (A,B,C): wA = area(P,B,C) / area(A,B,C) wB = area(P,C,A) / area(A,B,C) wC = area(P,A,B) / area(A,B,C)

น้ำหนักพวกนี้แหละคือสิ่งที่ทำให้ข้อมูลแบนราบต่อ vertex ของสามเหลี่ยม (สี, texture coordinate, normal) กลายเป็น gradient ที่เนียนทั่วผิวหน้าของมัน: เอาค่าของแต่ละ vertex คูณด้วยน้ำหนักของมัน แล้วบวกกัน เรียกกระบวนการนี้ว่า interpolation และมันรันอยู่กับแทบทุกอย่างที่ fragment shader แตะต้อง

struct Color { float r, g, b; };

struct Barycentric { float wA, wB, wC; };

Barycentric computeBarycentric(Vec2 a, Vec2 b, Vec2 c, Vec2 p)
{
    float area = edgeFunction(a, b, c);
    Barycentric bc;
    bc.wA = edgeFunction(b, c, p) / area;
    bc.wB = edgeFunction(c, a, p) / area;
    bc.wC = edgeFunction(a, b, p) / area;
    return bc;
}

Color interpolateColor(Barycentric bc, Color colA, Color colB, Color colC)
{
    return {
        bc.wA * colA.r + bc.wB * colB.r + bc.wC * colC.r,
        bc.wA * colA.g + bc.wB * colB.g + bc.wC * colC.g,
        bc.wA * colA.b + bc.wB * colB.b + bc.wC * colC.b
    };
}

int main()
{
    Vec2 A = {2, 1}, B = {14, 2}, C = {7, 8};
    Color colA = {255, 0, 0};   // vertex A is pure red
    Color colB = {0, 255, 0};   // vertex B is pure green
    Color colC = {0, 0, 255};   // vertex C is pure blue

    Vec2 p = { 7.5f, 4.5f }; // a pixel center inside the triangle
    Barycentric bc = computeBarycentric(A, B, C, p);
    Color result = interpolateColor(bc, colA, colB, colC);

    std::cout << "weights = (" << bc.wA << ", " << bc.wB << ", " << bc.wC << ")\n";
    std::cout << "color   = (" << result.r << ", " << result.g << ", " << result.b << ")\n";
}

Output:

weights = (0.272152, 0.265823, 0.462025)
color   = (69.4, 67.8, 117.8)

พิกเซล (7.5, 4.5) อยู่ใกล้ vertex C (สีน้ำเงิน) มากที่สุด ห่างจาก A (สีแดง) กับ B (สีเขียว) ออกมาหน่อย — และสีที่ผสมออกมา ซึ่งเป็นสีน้ำเงินเป็นหลักผสมแดงกับเขียวนิดหน่อย ก็สะท้อนตรงนั้นเป๊ะ การผสมแบบถ่วงน้ำหนักนี้เองคือวิธีที่แสงที่ดูเนียน, gradient สีที่ดูเนียน, และ texture coordinate ที่ดูเนียนของ mesh หนึ่ง ๆ ล้วนมาจากข้อมูลที่จริง ๆ แล้วมีอยู่แค่สามมุมเท่านั้น

8. Fragment Shader: คำนวณสีของแต่ละพิกเซล

เมื่อ rasterization ตัดสินใจแล้วว่าพิกเซลหนึ่งถูกสามเหลี่ยมคลุมอยู่ และคำนวณน้ำหนัก barycentric ของมันแล้ว GPU จะรันสเตจ programmable ตัวที่สอง: fragment shader (เรียกอีกชื่อว่า pixel shader) เหมือนกับ vertex shader สัญญาของมันแคบมาก: fragment หนึ่งตัว (พิกเซลผู้สมัครที่มีข้อมูลถูก interpolate ไว้แล้ว) เข้าไป สีสุดท้ายหนึ่งค่าออกมา มันมองไม่เห็นพิกเซลข้างเคียง หรือย้อนกลับไปหา vertex อื่นได้เลย — ทุกอย่างที่มันต้องใช้ต้องถูก interpolate มาให้เรียบร้อยแล้ว

float dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }

struct Color { float r, g, b; };

// THE FRAGMENT SHADER: one interpolated surface normal in, one color out.
// (dot product recap: chapter 2.1, section 4)
Color fragmentShader(Vec3 interpolatedNormal, Vec3 lightDir, Color baseColor)
{
    float brightness = dot(interpolatedNormal, lightDir);
    if (brightness < 0.0f) brightness = 0.0f; // facing away from the light: no negative light

    return { baseColor.r * brightness, baseColor.g * brightness, baseColor.b * brightness };
}

int main()
{
    Vec3 normal = {0, 1, 0};      // this pixel's surface faces straight up
    Vec3 lightDir = {0, 1, 0};    // light is coming from straight above
    Color baseColor = {200, 150, 100};

    Color shaded = fragmentShader(normal, lightDir, baseColor);
    std::cout << "shaded color = (" << shaded.r << ", " << shaded.g << ", " << shaded.b << ")\n";
}

Output:

shaded color = (200, 150, 100)

พื้นผิวหันเข้าหาแสงตรง ๆ ดังนั้น dot(normal, lightDir) = 1.0 และสีพื้นฐาน (base color) ก็ผ่านมาโดยไม่เปลี่ยนแปลง ลองเอียง normal ของพื้นผิวให้ห่างจากแสง brightness จะลดลงเข้าใกล้ 0 ทำให้พิกเซลมืดลง — ไอเดียเดียวกับที่ dot product ใช้วัดความสอดคล้อง (alignment) จากบทที่ 2.1 ตอนนี้มาตัดสินว่าพิกเซลหนึ่งดูสว่างแค่ไหน

vertex shader runs: once per VERTEX (a mesh might have 2,000 vertices) fragment shader runs: once per PIXEL covered on screen (could be 200,000+ pixels for one large character) the fragment shader usually runs FAR more times per frame than the vertex shader -- which is why expensive math belongs in the vertex shader whenever the result can be interpolated smoothly instead of recomputed per pixel

fragment shader ยังสามารถ sample texture ได้ด้วย (ภาพที่ห่อหุ้มไว้บนพื้นผิว โดยใช้ texture coordinate ที่ interpolate มาจากเซคชัน 7) แทนที่ หรือผสมกับสีพื้นฐานแบบแบน ๆ ก็ได้ — หัวข้อนั้นเต็ม ๆ จะเป็นบทในอนาคตของตัวมันเอง แต่เซคชัน 10 ของบทนี้จะอธิบายรายละเอียดสำคัญอันหนึ่งเรื่องการ interpolate texture coordinate พวกนั้นให้ถูกต้อง

Common mistake ทำงานต่อพิกเซล (per-pixel) ทั้งที่มันควรทำแค่ครั้งเดียวต่อ vertex เท่านั้น — เช่น คำนวณค่าใหม่ตั้งแต่ต้นข้างใน fragment shader ทั้งที่คำนวณครั้งเดียวใน vertex shader แล้ว interpolate แบบเนียน ๆ ก็พอแล้ว จากความต่างของจำนวนครั้งในไดอะแกรมข้างบน การย้ายงาน "ขึ้นไป" จาก fragment shader สู่ vertex shader คือหนึ่งในวิธีแก้ปัญหา performance ที่พบบ่อยที่สุดในงานกราฟิกเกมจริง ๆ

9. Depth Buffer (Z-Buffer): แก้ปัญหา "อะไรอยู่ข้างหน้า"

สามเหลี่ยมถูก rasterize และลงสีตามลำดับใดลำดับหนึ่ง แต่ลำดับนั้นไม่เกี่ยวอะไรเลยกับว่าอันไหนอยู่ใกล้กล้องที่สุดจริง ๆ ถ้าไม่มีตัวช่วยเพิ่มเติม สามเหลี่ยมของกำแพงที่ถูก rasterize หลังสามเหลี่ยมของตัวละคร จะทับตัวละครบนจอไปเฉย ๆ ทั้งที่กำแพงอยู่ข้างหลังมัน การเรียงสามเหลี่ยมทุกรูปตามระยะทางก่อนวาด (painter's algorithm เหมือนจิตรกรที่วาดจากหลังมาหน้า) ฟังดูเหมือนจะแก้ปัญหาได้ แต่มันพังทันทีที่สามเหลี่ยมสองรูปซ้อนทับหรือแทงทะลุกัน — ไม่มีลำดับที่ถูกต้องเพียงหนึ่งเดียวสำหรับสามเหลี่ยมสองรูปที่ตัดกัน

ทางแก้จริง ๆ ทำงานต่อ พิกเซล ไม่ใช่ต่อสามเหลี่ยม: นอกจากสีของ framebuffer แล้ว GPU ยังเก็บ buffer ตัวที่สองขนาดเท่ากันไว้ด้วย เรียกว่า depth buffer (หรือ z-buffer) เก็บความลึกของ fragment ที่ใกล้ที่สุดที่เคยเขียนลงพิกเซลนั้นไว้ ทุก fragment ใหม่จะถูกเทียบความลึกกับค่านั้นก่อนที่สีของมันจะได้รับอนุญาตให้ทับอะไรก็ตาม — การเปรียบเทียบนี้คือ depth test สเตจ fixed-function ที่อยู่ต่อจาก fragment shader ในไดอะแกรมของเซคชัน 3

one pixel, two triangles both cover it: camera o -----------------> screen pixel ^ near triangle | depth = 0.3 (this one wins) | far triangle | depth = 0.8 (hidden behind it) depth test: 0.3 < 0.8, so the NEAR triangle's color is the one actually kept in the framebuffer for this pixel
#include <iostream>
#include <vector>
#include <string>

struct Vec2 { float x, y; };

float edgeFunction(Vec2 a, Vec2 b, Vec2 p)
{
    return (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
}

bool insideTriangle(Vec2 a, Vec2 b, Vec2 c, Vec2 p)
{
    float w0 = edgeFunction(b, c, p);
    float w1 = edgeFunction(c, a, p);
    float w2 = edgeFunction(a, b, p);
    return (w0 >= 0 && w1 >= 0 && w2 >= 0) || (w0 <= 0 && w1 <= 0 && w2 <= 0);
}

struct Triangle { Vec2 a, b, c; float depth; char symbol; };

void drawTriangle(const Triangle& tri, std::vector<std::vector<float>>& depthBuf,
                   std::vector<std::string>& canvas)
{
    for (size_t y = 0; y < canvas.size(); y++)
    {
        for (size_t x = 0; x < canvas[y].size(); x++)
        {
            Vec2 p = { x + 0.5f, y + 0.5f };
            if (!insideTriangle(tri.a, tri.b, tri.c, p)) continue;

            // THE DEPTH TEST: only write if this fragment is closer
            // (smaller depth) than whatever is already at this pixel.
            if (tri.depth < depthBuf[y][x])
            {
                depthBuf[y][x] = tri.depth;
                canvas[y][x] = tri.symbol;
            }
        }
    }
}

int main()
{
    const int W = 18, H = 11;
    std::vector<std::vector<float>> depthBuf(H, std::vector<float>(W, 1e9f));
    std::vector<std::string> canvas(H, std::string(W, '.'));

    Triangle farTri  = { {2,1}, {15,1}, {8,9}, 0.8f, '1' }; // farther from camera
    Triangle nearTri = { {6,3}, {12,3}, {9,7}, 0.3f, '2' }; // closer to camera

    // Drawn far-then-near on purpose -- watch the depth test fix the order.
    drawTriangle(farTri, depthBuf, canvas);
    drawTriangle(nearTri, depthBuf, canvas);

    for (const std::string& row : canvas) std::cout << row << "\n";
}

Output:

..................
..1111111111111...
...11111111111....
....112222221.....
.....1122221......
.....111221.......
......1111........
.......11.........
..................
..................
..................

สามเหลี่ยม 2 (ความลึก 0.3 ใกล้กว่า) ทะลุผ่านสามเหลี่ยม 1 (ความลึก 0.8 ไกลกว่า) ได้ถูกต้องตรงจุดที่มันซ้อนทับกัน ทั้งที่โค้ดนี้วาด 1 ก่อนแล้วค่อยวาด 2 ทีหลัง ลองสลับลำดับการเรียก drawTriangle ทั้งสองแล้วรันใหม่ดู: ภาพที่ออกมาจะเหมือนกันทุกไบต์ เพราะ depth test ไม่ใช่ลำดับการวาด เป็นคนตัดสินผู้ชนะที่ทุกพิกเซล นี่แหละคือสิ่งที่ทำให้ z-buffer ทรงพลังมาก: สามเหลี่ยมทั้งฉากสามารถถูกส่งเข้ามาในลำดับไหนก็ได้ — ปกติก็จะเป็นลำดับที่ส่งไปให้ GPU ได้เร็วที่สุด — แล้วภาพก็จะยังออกมาถูกต้องอยู่ดี

Common mistake พื้นผิวสองอันที่มีความลึกใกล้เคียงกันมาก ๆ (เดคัลที่แปะติดกำแพง, โพลิกอนสองรูปที่ซ้อนทับกันบนระนาบเดียวกัน) จะกระพริบสลับกันว่าใครชนะ depth test ในแต่ละเฟรม หรือแม้แต่แต่ละพิกเซล เพราะความคลาดเคลื่อนเล็ก ๆ จากการปัดเศษ floating-point อาการนี้เรียกว่า z-fighting วิธีแก้ปกติคือขยับพื้นผิวหนึ่งให้เข้าใกล้กล้องนิดหน่อย (เรียกว่า depth bias) หรือไม่ก็แค่ไม่สร้างเรขาคณิตที่อยู่บนระนาบเดียวกันเป๊ะ ๆ ตั้งแต่แรก

10. Perspective-Correct Interpolation: ทำไม Interpolation แบบไร้เดียงสาถึงทำให้ Texture บิดเบี้ยว

เซคชัน 7 interpolate สีโดยใช้น้ำหนัก barycentric ที่คำนวณใน screen space — กริดพิกเซล 2D แบนราบ วิธีนี้ใช้ได้ดีเป๊ะสำหรับสามเหลี่ยมที่หันหน้าเข้าหากล้องตรง ๆ เกือบพอดี แต่สามเหลี่ยมส่วนใหญ่ในฉาก 3D จะเอียงลาดออกไปไกล ๆ และ interpolation แบบ screen-space ของสิ่งอย่าง texture coordinate จะผิดเพี้ยนให้เห็นชัด ๆ ทันทีที่มันเอียง เซคชันนี้จะอธิบายว่าทำไม และวิธีแก้ทำงานยังไง

ปัญหาคือ: perspective projection (เซคชัน 5 และบทที่ 2.1 เซคชัน 10) ไม่ใช่การดำเนินการเชิงเส้นตรง — มันหารด้วย w ซึ่งบีบจุดที่อยู่ไกลเข้าหากันแบบไม่เป็นเชิงเส้น (non-linear) เส้นตรงใน world space แบบ 3D จะไม่คงระยะห่างเท่ากันอีกต่อไปเมื่อถูก project ลงบนจอ จุดที่อยู่ใกล้กล้องจะกระจายออก จุดที่อยู่ไกลจะกระจุกเข้าหากัน แต่น้ำหนัก barycentric ที่คำนวณตรง ๆ จากตำแหน่งพิกเซลใน screen-space นั้นสมมติว่าระยะห่างเท่ากันหมด ผลลัพธ์คือ: การ interpolate attribute โดยใช้น้ำหนักแบบ screen-space ธรรมดา จะได้ค่าที่ผิด ทุกครั้งที่มุมของสามเหลี่ยมอยู่คนละความลึกกัน

a floor grid receding away from the camera: perspective-correct (right): naive screen-space lerp (wrong): +-+-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+ each square shrinks squares bunch up unevenly, smoothly and evenly stretch, and "swim" as the toward the horizon camera moves -- the classic PS1-era wobbly-texture look

นี่คือตัวอย่างตัวเลขที่ไล่ให้ดู ลองนึกภาพขอบหนึ่งของสามเหลี่ยม: vertex 0 อยู่ใกล้กล้อง (clip-space w = 1) vertex 1 อยู่ไกลออกไป (clip-space w = 4) และแต่ละตัวพก texture coordinate ไว้: u = 0 ที่ vertex 0, u = 1 ที่ vertex 1 ค่า u ที่ควรอยู่ตรงจุดกึ่งกลางใน screen-space ระหว่างสองจุดนี้ควรเป็นเท่าไหร่?

#include <iostream>

int main()
{
    float w0 = 1.0f, w1 = 4.0f; // vertex 0 is near, vertex 1 is far
    float u0 = 0.0f, u1 = 1.0f; // texture coordinate at each vertex
    float t = 0.5f;              // the SCREEN-SPACE midpoint (halfway in pixels)

    // WRONG: plain linear interpolation using the screen-space t.
    float uNaive = u0 * (1.0f - t) + u1 * t;

    // CORRECT: interpolate (attribute / w) and (1 / w) using the same
    // screen-space t, THEN divide -- this is perspective-correct
    // interpolation, and it is what real GPU rasterizers actually do.
    float numerator   = (u0 / w0) * (1.0f - t) + (u1 / w1) * t;
    float denominator = (1.0f / w0) * (1.0f - t) + (1.0f / w1) * t;
    float uCorrect = numerator / denominator;

    std::cout << "naive u   = " << uNaive   << "  (WRONG)\n";
    std::cout << "correct u = " << uCorrect << "  (what the true 3D midpoint has)\n";
}

Output:

naive u   = 0.5  (WRONG)
correct u = 0.2  (what the true 3D midpoint has)

นี่ไม่ใช่ความคลาดเคลื่อนจากการปัดเศษ — มันเป็นตัวเลขที่ต่างกันไปเลย จุดกึ่งกลางใน screen-space ระหว่าง vertex ใกล้กับ vertex ไกล ไม่ตรงกับจุดกึ่งกลางตามขอบ 3D ดั้งเดิม เพราะเรขาคณิตของ vertex ที่อยู่ไกลถูก "บีบอัด" มากกว่าโดย perspective divide จุดกึ่งกลาง 3D ที่แท้จริงจึงตกลงมาใกล้กับ texture coordinate ของ vertex ใกล้มากกว่า (0.2 ไม่ใช่ 0.5) เมื่อวัดย้อนกลับใน screen-space การ interpolate attribute / w กับ 1 / w แบบเชิงเส้น แล้วค่อยหารกัน จะหักล้างการบีบอัดนั้นออกไปพอดี และได้ค่าที่ถูกต้องกลับมา

Tip PlayStation รุ่นแรก (1994) ไม่มีฮาร์ดแวร์รองรับ perspective-correct interpolation เลย — texture coordinate ถูก interpolate ด้วยวิธีแบบไร้เดียงสาราคาถูกอย่างที่แสดงข้างบน นั่นแหละคือสาเหตุตรง ๆ ของ texture ที่บิดโยกไปมาแบบที่คนจำได้จากเกมยุค PS1 โดยเฉพาะบนพื้นและกำแพงที่ทอดยาวออกไปไกล ๆ GPU สมัยใหม่ทำเวอร์ชัน 1/w ที่ถูกต้องให้อัตโนมัติ ในฮาร์ดแวร์แบบ fixed-function สำหรับทุกสามเหลี่ยม โดยไม่มีต้นทุนเพิ่มสำหรับคุณในฐานะโปรแกรมเมอร์เลย

ตอนไหนที่คุณต้องคิดเรื่องนี้เอง: แทบจะไม่เคยเลยถ้าใช้ Unity หรือ Unreal — ทั้งสองเอนจินและเป้าหมาย GPU ของมันทำ perspective-correct interpolation ให้คุณเสมอ มันสำคัญตอนที่คุณเขียน software rasterizer ของตัวเอง (เซคชัน 12) ตอน debug บั๊ก texture บิดเบี้ยวบน path แบบ fixed-function เก่า ๆ หรือ mobile รุ่นล่าง ๆ หรือแค่เข้าใจว่าทำไมการหารนี้ถึงมีอยู่ แทนที่จะท่องจำมันเป็นกฎที่ไม่มีคำอธิบาย

11. Double Buffering: ทำไมจอถึงไม่ฉีก (Tear)

ทุกสเตจที่ผ่านมาจบลงด้วยสีที่ไปตกอยู่ใน framebuffer — บล็อกหน่วยความจำที่เก็บภาพเต็ม ๆ หนึ่งภาพ หนึ่งสีต่อหนึ่งพิกเซล จอของคุณอ่านจาก framebuffer ต่อเนื่อง จากแถวบนไปแถวล่าง รีเฟรชปกติ 60, 120, หรือ 144 ครั้งต่อวินาที (เรียกว่า refresh rate) สิ่งนี้สร้างปัญหาเรื่องจังหวะเวลาขึ้นมา: จะเกิดอะไรขึ้นถ้า GPU ยังวาดเฟรมใหม่ค้างอยู่ครึ่งทาง ลงในหน่วยความจำก้อนเดียวกันเป๊ะกับที่จอกำลังอ่านอยู่ตอนนั้น?

ถ้ามี framebuffer แค่ตัวเดียว จออาจจะจบลงด้วยการแสดงครึ่งบนของเฟรมเก่ากับครึ่งล่างของเฟรมใหม่พร้อมกันในภาพเดียว เพราะ GPU เขียนทับหน่วยความจำระหว่างที่กำลังสแกนอยู่ รอยตะเข็บที่มองเห็นได้นี้เรียกว่า screen tearing

SINGLE buffer (tears): monitor scans this buffer top-to-bottom while the GPU is ALSO writing into it -- if the GPU writes a new frame partway through a scan, the monitor shows OLD top / NEW bottom in one image: +-------------------+ | frame N (old) | <- monitor already scanned this part |- - - - - - - - - -| <- GPU swapped mid-scan: visible seam | frame N+1 (new) | <- monitor scans this part next +-------------------+

ทางแก้คือ double buffering: เก็บ framebuffer ไว้สองตัว จอจะอ่านจาก front buffer เสมอ ซึ่งเป็นเฟรมที่เสร็จสมบูรณ์แล้ว ส่วน GPU จะวาดเฟรมถัดไปลงอีกตัวเสมอ เรียกว่า back buffer ซึ่งจอจะไม่มีทางเห็นเลยตราบใดที่มันยังวาดไม่เสร็จ พอ back buffer เสร็จสมบูรณ์แล้ว buffer ทั้งสองจะสลับบทบาทกัน — ในอุดมคติจะจับจังหวะให้เกิดขึ้นแค่ระหว่างรอบสแกนของจอเท่านั้น เรียกว่า VSync (Vertical Synchronization) — เพื่อไม่ให้จอจับภาพเฟรมที่วาดค้างอยู่ได้เลย

DOUBLE buffering: frame 1: [FRONT: showing frame A] [BACK: GPU drawing frame B] | | frame B finishes, buffers swap (ideally at VSync) v frame 2: [FRONT: showing frame B] [BACK: GPU drawing frame C] | | swap again v frame 3: [FRONT: showing frame C] [BACK: GPU drawing frame D] the monitor only ever reads a COMPLETE frame -- never a half-drawn one

ในโค้ด สิ่งนี้จะปรากฏเป็นการเรียกฟังก์ชันหนึ่งครั้งท้ายสุดของ render loop ของคุณ — คือขั้นตอน Render() เป๊ะ ๆ จาก game loop ที่คุณเคยเห็นในบทที่ 6.7:

while (gameIsRunning)
{
    ProcessInput();
    Update(deltaTime);
    DrawEverythingIntoBackBuffer();  // every stage of this whole chapter
    SwapBuffers();                    // back buffer becomes the new front buffer
}

SwapBuffers() (ชื่อฟังก์ชันจริง ๆ จะต่างกันไป: glfwSwapBuffers, Present(), และอื่น ๆ แล้วแต่ API) ทำการสลับ buffer เป๊ะ ๆ ตามที่แสดงในไดอะแกรมข้างบน ทุกเฟรมที่คุณเคยเห็นถูกเรนเดอร์โดย Unity หรือ Unreal จบลงด้วยการเรียกแบบนี้เสมอ

Tip บางเอนจินและ API ไปไกลกว่านั้นด้วย triple buffering: buffer ตัวที่สามทำให้ GPU วาดเฟรมถัดไปต่อได้เรื่อย ๆ แม้ back buffer ก่อนหน้าจะยังรอคิวกลายเป็น front buffer อยู่ แลกมากับ input lag (ความหน่วงระหว่างที่คุณกดปุ่มกับผลที่ปรากฏบนจอ) ที่เพิ่มขึ้นนิดหน่อย เพื่อให้เฟรมถูกส่งมอบได้นุ่มนวลขึ้นตอนที่เวลาต่อเฟรมไม่สม่ำเสมอ

12. สร้าง Software Rasterizer จิ๋ว ๆ: เอาทุกอย่างมาต่อกัน

ทุกไอเดียในบทนี้ — การเทสต์ coverage, น้ำหนัก barycentric, depth test — ปรากฏเป็นชิ้นเล็ก ๆ มาแล้วในเซคชัน 7 กับ 9 เซคชันสุดท้ายนี้จะเอาทั้งหมดมาต่อกันเป็นโปรแกรมเดียวที่สมบูรณ์และรันได้จริง: software rasterizer ของแท้ เล็กพอที่จะอ่านจากบนลงล่างได้รวดเดียว ทำในลูป CPU ลูปเดียวเป๊ะ ๆ ในสิ่งที่ฮาร์ดแวร์ fixed-function ของ GPU จริง ๆ ทำข้ามคอร์ขนานนับพันตัว

#include <iostream>
#include <vector>
#include <string>

struct Vec2 { float x, y; };

float edgeFunction(Vec2 a, Vec2 b, Vec2 p)
{
    return (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
}

struct Triangle { Vec2 a, b, c; float depth; char symbol; };

// This one function is the whole rendering pipeline, shrunk down:
// - the "vertices" (a, b, c) already arrived in screen space (section 5)
// - this loop over pixels IS rasterization (section 7)
// - the edge-function test IS the inside test (section 7)
// - the depth comparison IS the z-buffer / depth test (section 9)
// - framebuffer[y][x] = symbol IS writing the final framebuffer (section 11)
void rasterize(const Triangle& tri,
                std::vector<std::vector<float>>& depthBuffer,
                std::vector<std::string>& framebuffer)
{
    int H = (int)framebuffer.size();
    int W = (int)framebuffer[0].size();

    for (int y = 0; y < H; y++)
    {
        for (int x = 0; x < W; x++)
        {
            Vec2 p = { x + 0.5f, y + 0.5f };

            float w0 = edgeFunction(tri.b, tri.c, p);
            float w1 = edgeFunction(tri.c, tri.a, p);
            float w2 = edgeFunction(tri.a, tri.b, p);

            bool inside = (w0 >= 0 && w1 >= 0 && w2 >= 0) ||
                          (w0 <= 0 && w1 <= 0 && w2 <= 0);
            if (!inside) continue;

            if (tri.depth < depthBuffer[y][x])
            {
                depthBuffer[y][x] = tri.depth;
                framebuffer[y][x] = tri.symbol;
            }
        }
    }
}

int main()
{
    const int W = 18, H = 11;
    std::vector<std::vector<float>> depthBuffer(H, std::vector<float>(W, 1e9f));
    std::vector<std::string> framebuffer(H, std::string(W, '.'));

    // Two triangles submitted in an arbitrary order -- the near one first
    // this time, on purpose, to prove the depth test (not draw order) wins.
    Triangle nearTri = { {6,3}, {12,3}, {9,7}, 0.3f, '2' };
    Triangle farTri  = { {2,1}, {15,1}, {8,9}, 0.8f, '1' };

    rasterize(nearTri, depthBuffer, framebuffer);
    rasterize(farTri,  depthBuffer, framebuffer);

    for (const std::string& row : framebuffer)
    {
        std::cout << row << "\n";
    }
}

Output:

..................
..1111111111111...
...11111111111....
....112222221.....
.....1122221......
.....111221.......
......1111........
.......11.........
..................
..................
..................

ภาพเดียวกันกับเซคชัน 9 แม้จะสลับลำดับการวาดแล้วก็ตาม — สามเหลี่ยมใกล้ (2) ยังโผล่ทะลุสามเหลี่ยมไกล (1) ได้ถูกต้องตรงจุดที่ซ้อนทับกัน โปรแกรมประมาณ 40 บรรทัดนี้ ในทุกความหมายที่สำคัญ คือ GPU จริง ๆ (แม้จะเล็ก ช้า และไม่มีสี): มันรับสามเหลี่ยมเข้ามา, rasterize มัน, เทสต์ความลึกของมัน, และเขียนลง framebuffer โดยใช้สเตจต่าง ๆ จากไดอะแกรมของเซคชัน 3 เป๊ะ ๆ ลบแค่ vertex กับ fragment shader (ที่คุณเขียนแยกไปแล้วในเซคชัน 5 กับ 8) และลบความขนานมหาศาลที่ GPU จริง ๆ ใช้กับทุกอย่างนี้ออกไป

Tip นี่ไม่ใช่การพูดเกินจริงแบบของเล่นเด็กเล่น — เกม 3D ยุคแรก ๆ รวมถึงบางส่วนของ software renderer ใน Quake ภาคแรก ก่อนที่การ์ดจอ 3D สำหรับผู้บริโภคจะแพร่หลาย เรนเดอร์ทั้งฉากด้วยวิธีนี้เป๊ะ ๆ: CPU วนลูปข้ามสามเหลี่ยม เทสต์ coverage ของพิกเซล แล้วเขียนตรงลง buffer ที่แสดงบนจอ GPU ไม่ได้มาแทนที่ pipeline นี้ มันแค่เอาฮาร์ดแวร์ขนานมหาศาลเฉพาะทางมาแทนที่ลูป CPU โดยรันขั้นตอนเดียวกันเป๊ะ ๆ

ถ้าอยากต่อยอดเอง: เพิ่มการ interpolate สีแบบถ่วงน้ำหนัก barycentric จากเซคชัน 7 เข้าไปแทนที่ symbol แบนราบต่อสามเหลี่ยม แล้วคุณจะได้ software renderer จิ๋ว ๆ ที่สมบูรณ์ แม้จะช้ามาก ที่มี texture และ shading ครบ — สร้างขึ้นทั้งหมดจากไอเดียที่คุณเข้าใจทีละสเตจแล้วตอนนี้ แทนที่จะเป็นกล่องดำที่ไม่มีคำอธิบายกล่องเดียว

13. คำศัพท์ (Glossary)

14. แบบฝึกหัด

Exercise 1 — ไล่ Vertex ผ่าน Pipeline vertex shader ตัวหนึ่งคูณ vertex แบบ local-space (1, 1, 4) ด้วยเมทริกซ์ MVP นี้ (แถวล่างสุดของมันคัดลอกความลึกเข้าไปใน w บวกค่าคงที่อีกนิด เหมือนตัวอย่างที่ไล่ให้ดูในเซคชัน 5):
M = | 1  0  0  0 |
    | 0  1  0  0 |
    | 0  0  1  0 |
    | 0  0  1  2 |
ด้วยมือ (หรือเขียนโค้ด) ให้คำนวณผลลัพธ์ clip-space จากนั้นหา NDC หลังทำ perspective divide แล้วสุดท้ายหาตำแหน่งบนจอสำหรับ render target ขนาด 400x300 แสดงผลลัพธ์ระหว่างทางทั้งสามค่า
ดูเฉลย
#include <iostream>

int main()
{
    // clip = M * (1, 1, 4, 1)
    float clipX = 1*1 + 0*1 + 0*4 + 0*1;   // = 1
    float clipY = 0*1 + 1*1 + 0*4 + 0*1;   // = 1
    float clipZ = 0*1 + 0*1 + 1*4 + 0*1;   // = 4
    float clipW = 0*1 + 0*1 + 1*4 + 2*1;   // = 6

    float ndcX = clipX / clipW;
    float ndcY = clipY / clipW;

    float screenX = (ndcX * 0.5f + 0.5f) * 400.0f;
    float screenY = (1.0f - (ndcY * 0.5f + 0.5f)) * 300.0f;

    std::cout << "clip   = (" << clipX << ", " << clipY << ", " << clipZ << ", " << clipW << ")\n";
    std::cout << "ndc    = (" << ndcX << ", " << ndcY << ")\n";
    std::cout << "screen = (" << screenX << ", " << screenY << ")\n";
}

Output:

clip   = (1, 1, 4, 6)
ndc    = (0.166667, 0.166667)
screen = (233.333, 125)

แถวล่างสุด {0, 0, 1, 2} หมายความว่า w = z + 2 = 6 ดังนั้นการหาร x กับ y ด้วย 6 จะทำให้มันหดลงเยอะมาก — vertex ตัวนี้อยู่ลึกเข้าไปในฉากพอสมควร NDC ที่ได้ (0.167, 0.167) อยู่ใกล้กับจุดกึ่งกลางของจอ แค่เอียงไปทางขวาล่างนิดหน่อย ตกลงที่พิกเซล (233, 125) บน render target ขนาด 400x300

Exercise 2 — คำนวณ Barycentric Weights ด้วยมือ สามเหลี่ยม A = (0, 0), B = (6, 0), C = (0, 6) ให้คำนวณน้ำหนัก barycentric ของจุด P = (2, 2) โดยใช้สูตร edge-function จากเซคชัน 7 P อยู่ในสามเหลี่ยมไหม? แต่ละ vertex ยังพกค่าความลึกไว้ด้วย: zA = 1.0, zB = 2.0, zC = 3.0 rasterizer จะ interpolate ความลึกให้ P เป็นเท่าไหร่?
ดูเฉลย
#include <iostream>

struct Vec2 { float x, y; };

float edgeFunction(Vec2 a, Vec2 b, Vec2 p)
{
    return (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
}

int main()
{
    Vec2 A = {0,0}, B = {6,0}, C = {0,6}, P = {2,2};

    float area = edgeFunction(A, B, C);
    float wA = edgeFunction(B, C, P) / area;
    float wB = edgeFunction(C, A, P) / area;
    float wC = edgeFunction(A, B, P) / area;

    float zA = 1.0f, zB = 2.0f, zC = 3.0f;
    float depth = wA*zA + wB*zB + wC*zC;

    std::cout << "weights = (" << wA << ", " << wB << ", " << wC << ")\n";
    std::cout << "interpolated depth = " << depth << "\n";
}

Output:

weights = (0.333333, 0.333333, 0.333333)
interpolated depth = 2

P = (2, 2) คือจุดเซนทรอยด์ (centroid) เป๊ะ ๆ ของสามเหลี่ยมนี้ (ค่าเฉลี่ยของสามมุม) น้ำหนักทั้งสามเลยออกมาเท่ากันที่ 1/3 — และเนื่องจากทั้งสามค่าเป็นบวกและรวมกันได้ 1 แปลว่า P อยู่ข้างใน ความลึกที่ interpolate ออกมาก็คือค่าเฉลี่ยของความลึกทั้งสาม vertex ธรรมดา ๆ (1 + 2 + 3) / 3 = 2 ซึ่งก็สมเหตุสมผล: เซนทรอยด์ "ใกล้เท่า ๆ กัน" กับทั้งสามมุม มันเลยได้รับส่วนแบ่งเท่า ๆ กันจากค่าของแต่ละมุม

Exercise 3 — หาบั๊กใน Z-Buffer เพื่อนร่วมทีมเขียนบรรทัด depth-test นี้ไว้ข้างใน software rasterizer แล้วบอกว่าสามเหลี่ยมที่อยู่ข้างหลังสามเหลี่ยมอื่นดันโผล่มาอยู่ข้างหน้าผิด ๆ ตลอด หาบั๊กแล้วแก้ให้ถูก
if (fragmentDepth > depthBuffer[y][x])
{
    depthBuffer[y][x] = fragmentDepth;
    framebuffer[y][x] = color;
}
ดูเฉลย

การเปรียบเทียบกลับด้าน > แปลว่า "เขียนก็ต่อเมื่อความลึกของ fragment นี้ มากกว่า ค่าที่เก็บไว้" — แต่ในธรรมเนียมนี้ (ตรงกับเซคชัน 9) ความลึกน้อยกว่าหมายถึงใกล้กล้องมากกว่า และของที่ใกล้กว่าควรเป็นผู้ชนะ ตามที่เขียนไว้ fragment ที่ไกลกว่าทุกตัวที่มาทีหลัง fragment ที่ใกล้กว่า จะเขียนทับมันผิด ๆ ซึ่งตรงกับอาการที่บอกไว้เป๊ะ ๆ: สามเหลี่ยมไกลถูกวาดทับสามเหลี่ยมใกล้ วิธีแก้คือกลับด้านการเปรียบเทียบ:

if (fragmentDepth < depthBuffer[y][x])
{
    depthBuffer[y][x] = fragmentDepth;
    framebuffer[y][x] = color;
}

ตอนนี้ fragment จะเขียนทับ buffer ก็ต่อเมื่อมันใกล้กล้องมากกว่าของที่มีอยู่แล้วจริง ๆ เท่านั้น ซึ่งเป็นประเด็นทั้งหมดของ depth test จากเซคชัน 9 — fragment ที่ใกล้ที่สุดในแต่ละพิกเซลชนะเสมอ ไม่ว่าสามเหลี่ยมจะถูกวาดตามลำดับไหนก็ตาม

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