บทที่ผ่านมาทั้งหมดปฏิบัติกับหน้าจอเหมือนเป็นของที่ทำงานได้เองอยู่แล้ว คุณแค่ตั้งค่า material เรียก Instantiate() แล้วโมเดล 3D ก็โผล่ขึ้นมา บทนี้จะเปิดกล่องดำนั้นออกมาดู บทที่ 2.1 สร้างคณิตศาสตร์ที่จำเป็นไว้แล้ว — เมทริกซ์ model, view, และ projection ที่แปลงจุด 3D ให้กลายเป็นตำแหน่งบนหน้าจอ บทนี้จะแสดงให้เห็นว่า GPU (Graphics Processing Unit หรือชิปที่วาดเกมของคุณ) เอาคณิตศาสตร์นั้นไปทำอะไรต่อ ทีละสเตจ ตั้งแต่สามเหลี่ยมเดียวที่นอนอยู่ในหน่วยความจำ ไปจนถึงพิกเซลสีที่นอนอยู่ใน framebuffer (บล็อกหน่วยความจำที่เก็บภาพที่เสร็จแล้ว) พอจบบทนี้ คุณจะได้เขียน GPU เวอร์ชันซอฟต์แวร์เล็ก ๆ ของตัวเอง จำลอง core drawing loop ด้วย C++ ธรรมดา เพื่อให้ไม่มีสเตจไหนรู้สึกเหมือนเวทมนตร์อีกต่อไป
โมเดล 3D ทุกชิ้นในเกม ไม่ว่าจะเป็นตัวละคร ก้อนหิน หรือดาบ จริง ๆ แล้วก็คือกองสามเหลี่ยม (เรียกว่า mesh) สามเหลี่ยมแต่ละรูปถูกกำหนดด้วยจุดมุมสามจุดที่เรียกว่า vertices (เอกพจน์คือ vertex) และแต่ละ vertex จะพกตำแหน่งของตัวเองไว้ บ่อยครั้งก็มีข้อมูลเพิ่มด้วย เช่น สี, texture coordinate, หรือทิศทางที่มันหันหน้าไป (เรียกว่า normal) การเอากองสามเหลี่ยมพวกนี้มาแปลงเป็นภาพที่คุณเห็นบนจอ เรียกว่า rendering และลำดับขั้นตอนตายตัวที่ GPU รันเพื่อทำสิ่งนี้ เรียกว่า rendering pipeline
บทนี้จะพาไปดูทุกสเตจระหว่างภาพสองภาพนั้น: vertex เข้าไปใน vertex shader ถูกจัดกลุ่มเป็นสามเหลี่ยม ถูกแปลงเป็นพิกเซลผู้สมัคร (candidate pixel) ด้วย rasterization ถูกลงสีด้วย fragment shader แล้วสุดท้ายก็ไปจบที่ framebuffer ที่จอของคุณแสดงผล นี่คือทริปทั้งหมดในบรรทัดเดียว — แต่ละลูกศรจะมีเซคชันของตัวเองในบทนี้ทีหลัง:
เรื่องพวกนี้ไม่ได้เจาะจงแค่ Unity หรือ Unreal ทั้งสองเอนจิน รวมถึงเอนจิน 3D แบบเรียลไทม์ตัวอื่น ๆ ต่างก็นั่งอยู่บน pipeline นี้เป๊ะ ๆ ผ่าน graphics API (DirectX, Vulkan, Metal, หรือ OpenGL) ที่คุยกับไดรเวอร์ของ GPU คุณ พอคุณไปเขียน shader ในคอร์สหลังจากนี้ คุณกำลังเขียนโค้ดที่เสียบเข้าไปตรง ๆ ในสองสเตจจากทั้งหมดนี้ การเข้าใจ pipeline ทั้งก้อนก่อน คือสิ่งที่ทำให้โค้ด shader มีเหตุผล แทนที่จะรู้สึกเหมือนคาถาเวทมนตร์ที่ก็อปมาจากทูโทเรียล
ก่อนจะไปดูแต่ละสเตจของ pipeline ทีละอัน มันช่วยได้ถ้าปักไอเดียหนึ่งไว้ในหัวให้แน่น ๆ ก่อน: GPU ถูกสร้างมาต่างจาก CPU โดยสิ้นเชิง และรูปร่างทั้งหมดของ pipeline จะเข้าใจได้ก็ต่อเมื่อรู้ว่าทำไม
CPU (Central Processing Unit — ชิปที่รันโลจิกเกมปกติ, สคริปต์ C#, และ Update() ของคุณ) มีคอร์ทรงพลังอยู่ไม่กี่ตัว แต่ละคอร์ทำงานซับซ้อนและคาดเดายากได้ เช่น ไล่ pointer, แตกกิ่ง (branch) ไปมามั่ว ๆ, รันโค้ดที่ต่างไปจากคอร์ข้าง ๆ โดยสิ้นเชิง ส่วน GPU ใช้แนวทางตรงข้ามกัน: คอร์เล็ก ๆ ง่าย ๆ นับพันตัว ทุกตัวรันโปรแกรมจิ๋ว ๆ ตัวเดียวกันพร้อมกัน แต่ละตัวทำงานกับข้อมูลคนละก้อน
นี่แหละคือเหตุผลที่ 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 เป็นล้าน ๆ ตัวและพิกเซลนับสิบล้านตัวได้ในทุก ๆ เฟรม ไม่ใช่เพราะแต่ละคอร์เร็ว แต่เพราะมันมีเยอะมากจนทำงานพร้อมกันได้
if มีต้นทุนแพงกว่าบน GPU เทียบกับบน CPU คอร์ที่อยู่ใกล้กันจะถูกจัดกลุ่มและถูกบังคับให้เดินพร้อมกันเป๊ะ ๆ (เรียกว่า warp หรือ wavefront แล้วแต่ค่ายฮาร์ดแวร์) ถ้าคอร์บางตัวในกลุ่มเข้า branch if ส่วนตัวอื่นเข้า else ฮาร์ดแวร์จะรันทั้งสอง branch ให้ทั้งกลุ่ม แล้วทิ้งครึ่งที่ผิดของแต่ละคอร์ทิ้งไป — เรียกว่า divergence shader ที่เต็มไปด้วย branch อาจช้ากว่า shader ที่หลีกเลี่ยงมันมาก แม้จะดูเหมือนทำงาน "เยอะกว่า" โดยรวมก็ตามทำไมเรื่องนี้ถึงสำคัญกับคุณ: คุณจะไม่ได้เขียน scheduler ของ GPU เอง แต่ทุกการออกแบบที่เหลือในบทนี้ — การรัน shader จิ๋วตัวเดียวกันต่อ vertex แล้วก็ต่อพิกเซลอีกที โดยไม่มี state ใด ๆ ใช้ร่วมกันระหว่างการเรียกแต่ละครั้ง — มีอยู่ก็เพราะรูปร่างของฮาร์ดแวร์แบบนี้ จำภาพนี้ไว้ในหัวตลอดบทที่เหลือ: โปรแกรมเล็ก ๆ ตัวเดียว ถูกรันซ้ำจำนวนมหาศาล พร้อมกัน บนข้อมูลที่เป็นอิสระจากกัน
ทีนี้มาดูภาพเต็ม ทีละสเตจ บางสเตจเป็น programmable (คุณเขียนโค้ดเองว่าจะรันอะไรตรงนั้น — คือ shader) และบางสเตจเป็น fixed-function (ฮาร์ดแวร์ของ GPU ทำงานตายตัวที่คุณเขียนใหม่ไม่ได้ ปรับได้แค่ตั้งค่าบางอย่าง)
มีสองสเตจที่เป็น programmable: vertex shader กับ fragment shader (บาง API เรียกว่า pixel shader — ไอเดียเดียวกัน แค่ชื่อต่างกัน) นี่คือสองจุดที่คุณ ในฐานะโปรแกรมเมอร์ เขียนโค้ดที่ GPU รันจริง ๆ ส่วนสเตจอื่น ๆ ทั้งหมด — การรวม vertex เป็นสามเหลี่ยม, การหาว่าสามเหลี่ยมคลุมพิกเซลไหนบ้าง, การเทสต์ความลึก — ทำโดยวงจรฮาร์ดแวร์ตายตัวที่ถูกขัดเกลามาหลายสิบปีในการออกแบบ GPU ซึ่งคุณแค่ตั้งค่า ไม่ได้เขียนใหม่
ส่วนที่เหลือของบทนี้จะเดินตามไดอะแกรมจากบนลงล่าง ทีละสเตจ พร้อมเขียนโค้ด C++ เล็ก ๆ สำหรับแต่ละอันให้คุณเห็นชัด ๆ ว่าเกิดอะไรขึ้นจริง ๆ แทนที่จะเชื่อกล่องดำเฉย ๆ
บทที่ 2.1 สร้างเมทริกซ์ที่บททั้งบทนี้รันอยู่บนนั้น ก่อนจะแตะ vertex shader เอง นี่คือการทบทวนแบบเร็ว ๆ ของ coordinate space ที่ vertex หนึ่งจะเดินทางผ่าน เพราะงานทั้งหมดของ vertex shader ก็คือการย้ายจุดหนึ่งผ่านห่วงโซ่นี้เป๊ะ ๆ:
ถ้าชื่อทั้งสี่นี้ชื่อไหนฟังดูไม่คุ้น ให้ย้อนกลับไปอ่านเซคชัน 9 ของบทที่ 2.1 ก่อนอ่านต่อ — บทนี้สมมติว่าคุณรู้อยู่แล้วว่าเมทริกซ์ model, view, และ projection ถูกสร้างและคูณเข้าด้วยกันยังไง บทนี้จะรับช่วงต่อจากตรงที่บทนั้นจบไว้พอดี: คณิตศาสตร์นี้รันตรงไหนจริง ๆ ข้างใน GPU pipeline? คำตอบคือ: รันอยู่ข้างใน vertex shader ทั้งหมด ซึ่งเป็นเซคชันถัดไป
vertex shader คือสเตจ programmable ตัวแรก สัญญา (contract) ของมันเข้มงวดและง่ายมาก: ตำแหน่ง vertex หนึ่งตัว (พร้อมข้อมูลอื่น ๆ ของมัน) เข้าไป ตำแหน่งใน clip-space หนึ่งตัว (พร้อมข้อมูลอื่น ๆ ของมัน) ออกมา มันมองไม่เห็น vertex ตัวอื่นเลย เพิ่มหรือลบ vertex ไม่ได้ และยังไม่รู้อะไรเกี่ยวกับพิกเซลเลย สัญญาที่แคบขนาดนี้แหละคือสิ่งที่ทำให้ GPU รันมันกับ vertex นับพันตัวพร้อมกันได้ ตามที่พูดไว้ในเซคชัน 2
ในเอนจินจริง เมทริกซ์ 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 จริง ๆ จะหมุน, สเกล, และเลื่อนตำแหน่งมันด้วย
ผลลัพธ์ของ 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 นับพันตัว
w ใน clip-space ไม่ได้เท่ากับ 1 เสมอไป ถ้าคุณเคยอ่านค่าตำแหน่ง clip-space กลับมาด้วยมือ (เช่น ใน custom shader effect) แล้วหารแค่ x กับ y ด้วย w โดยลืม z ไป จะได้ค่าความลึกที่พังไป — และถ้าข้ามการหารไปเลยทั้งหมด จะได้พิกัดที่สเกลผิดจนไม่ใช่พิกเซลเลยด้วยซ้ำหลังจาก 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)
มีอีกสองเรื่องที่เกิดขึ้นรอบ ๆ สเตจนี้ ทั้งคู่เป็น fixed-function: clipping ตัดส่วนของสามเหลี่ยมที่ยื่นออกไปนอกปริมาตร clip-space ที่มองเห็นได้ (สามเหลี่ยมที่ครึ่งหนึ่งอยู่นอกจอจะถูกตัด ไม่ใช่ทิ้งทั้งรูป) และ backface culling สามารถทิ้งสามเหลี่ยมที่หันหน้าออกจากกล้องทั้งรูปได้เลย โดยดูจาก winding order ของมัน (สาม vertex ของมันเรียงตามเข็มนาฬิกาหรือทวนเข็มนาฬิกาบนจอ) การ cull ช่วยประหยัด GPU จากการ rasterize หรือลงสีพิกเซลที่คุณไม่มีทางเห็นอยู่แล้ว เช่น ด้านในหัวของตัวละคร
Rasterization คือจุดที่สามเหลี่ยม ซึ่งเป็นรูปทรงทางคณิตศาสตร์ที่ประกอบจากจุดสามจุดกับขอบเส้นตรง ถูกแปลงเป็นพิกเซลจริง ๆ มันตอบคำถามเดียวซ้ำไปซ้ำมา สำหรับทุกพิกเซลที่อยู่ใกล้สามเหลี่ยม: จุดศูนย์กลางของพิกเซลนี้อยู่ในสามเหลี่ยมไหม?
การเทสต์แบบคลาสสิกใช้ 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 และถูกเทสต์กับพิกเซลจำนวนมากพร้อมกันแทนที่จะทำทีละตัว
x + 0.5, y + 0.5) ไม่ใช่มุมของมัน เป็นทางเลือกที่จงใจและเป็นมาตรฐาน — มันทำให้ขอบสามเหลี่ยมดูสม่ำเสมอไม่ว่าจะวิ่งไปทิศไหน GPU บางตัวสุ่มตัวอย่างหลายจุดต่อพิกเซลแทนที่จะเอาแค่จุดศูนย์กลาง เรียกว่า MSAA (Multisample Anti-Aliasing) แล้วผสมผลลัพธ์เข้าด้วยกันเพื่อทำให้ขอบสามเหลี่ยมที่หยักดูนุ่มขึ้นค่า edge function ทั้งสามตัวที่ใช้ข้างบนนั้นไม่ได้ถูกทิ้งไปใน rasterizer จริง ๆ — เมื่อหารด้วยพื้นที่รวมของสามเหลี่ยม มันจะกลายเป็น barycentric coordinates: น้ำหนักสามค่า (wA, wB, wC) หนึ่งค่าต่อหนึ่ง vertex ซึ่งรวมกันได้ 1 เสมอสำหรับจุดใด ๆ ที่อยู่ในสามเหลี่ยม มันตอบคำถามที่สองซึ่งสำคัญพอ ๆ กัน: ไม่ใช่แค่พิกเซลนี้อยู่ข้างในไหม แต่มันใกล้กับแต่ละมุมทั้งสามแค่ไหน?
น้ำหนักพวกนี้แหละคือสิ่งที่ทำให้ข้อมูลแบนราบต่อ 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 หนึ่ง ๆ ล้วนมาจากข้อมูลที่จริง ๆ แล้วมีอยู่แค่สามมุมเท่านั้น
เมื่อ 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 ตอนนี้มาตัดสินว่าพิกเซลหนึ่งดูสว่างแค่ไหน
fragment shader ยังสามารถ sample texture ได้ด้วย (ภาพที่ห่อหุ้มไว้บนพื้นผิว โดยใช้ texture coordinate ที่ interpolate มาจากเซคชัน 7) แทนที่ หรือผสมกับสีพื้นฐานแบบแบน ๆ ก็ได้ — หัวข้อนั้นเต็ม ๆ จะเป็นบทในอนาคตของตัวมันเอง แต่เซคชัน 10 ของบทนี้จะอธิบายรายละเอียดสำคัญอันหนึ่งเรื่องการ interpolate texture coordinate พวกนั้นให้ถูกต้อง
สามเหลี่ยมถูก rasterize และลงสีตามลำดับใดลำดับหนึ่ง แต่ลำดับนั้นไม่เกี่ยวอะไรเลยกับว่าอันไหนอยู่ใกล้กล้องที่สุดจริง ๆ ถ้าไม่มีตัวช่วยเพิ่มเติม สามเหลี่ยมของกำแพงที่ถูก rasterize หลังสามเหลี่ยมของตัวละคร จะทับตัวละครบนจอไปเฉย ๆ ทั้งที่กำแพงอยู่ข้างหลังมัน การเรียงสามเหลี่ยมทุกรูปตามระยะทางก่อนวาด (painter's algorithm เหมือนจิตรกรที่วาดจากหลังมาหน้า) ฟังดูเหมือนจะแก้ปัญหาได้ แต่มันพังทันทีที่สามเหลี่ยมสองรูปซ้อนทับหรือแทงทะลุกัน — ไม่มีลำดับที่ถูกต้องเพียงหนึ่งเดียวสำหรับสามเหลี่ยมสองรูปที่ตัดกัน
ทางแก้จริง ๆ ทำงานต่อ พิกเซล ไม่ใช่ต่อสามเหลี่ยม: นอกจากสีของ framebuffer แล้ว GPU ยังเก็บ buffer ตัวที่สองขนาดเท่ากันไว้ด้วย เรียกว่า depth buffer (หรือ z-buffer) เก็บความลึกของ fragment ที่ใกล้ที่สุดที่เคยเขียนลงพิกเซลนั้นไว้ ทุก fragment ใหม่จะถูกเทียบความลึกกับค่านั้นก่อนที่สีของมันจะได้รับอนุญาตให้ทับอะไรก็ตาม — การเปรียบเทียบนี้คือ depth test สเตจ fixed-function ที่อยู่ต่อจาก fragment shader ในไดอะแกรมของเซคชัน 3
#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 ได้เร็วที่สุด — แล้วภาพก็จะยังออกมาถูกต้องอยู่ดี
เซคชัน 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 ธรรมดา จะได้ค่าที่ผิด ทุกครั้งที่มุมของสามเหลี่ยมอยู่คนละความลึกกัน
นี่คือตัวอย่างตัวเลขที่ไล่ให้ดู ลองนึกภาพขอบหนึ่งของสามเหลี่ยม: 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 แบบเชิงเส้น แล้วค่อยหารกัน จะหักล้างการบีบอัดนั้นออกไปพอดี และได้ค่าที่ถูกต้องกลับมา
1/w ที่ถูกต้องให้อัตโนมัติ ในฮาร์ดแวร์แบบ fixed-function สำหรับทุกสามเหลี่ยม โดยไม่มีต้นทุนเพิ่มสำหรับคุณในฐานะโปรแกรมเมอร์เลยตอนไหนที่คุณต้องคิดเรื่องนี้เอง: แทบจะไม่เคยเลยถ้าใช้ Unity หรือ Unreal — ทั้งสองเอนจินและเป้าหมาย GPU ของมันทำ perspective-correct interpolation ให้คุณเสมอ มันสำคัญตอนที่คุณเขียน software rasterizer ของตัวเอง (เซคชัน 12) ตอน debug บั๊ก texture บิดเบี้ยวบน path แบบ fixed-function เก่า ๆ หรือ mobile รุ่นล่าง ๆ หรือแค่เข้าใจว่าทำไมการหารนี้ถึงมีอยู่ แทนที่จะท่องจำมันเป็นกฎที่ไม่มีคำอธิบาย
ทุกสเตจที่ผ่านมาจบลงด้วยสีที่ไปตกอยู่ใน framebuffer — บล็อกหน่วยความจำที่เก็บภาพเต็ม ๆ หนึ่งภาพ หนึ่งสีต่อหนึ่งพิกเซล จอของคุณอ่านจาก framebuffer ต่อเนื่อง จากแถวบนไปแถวล่าง รีเฟรชปกติ 60, 120, หรือ 144 ครั้งต่อวินาที (เรียกว่า refresh rate) สิ่งนี้สร้างปัญหาเรื่องจังหวะเวลาขึ้นมา: จะเกิดอะไรขึ้นถ้า GPU ยังวาดเฟรมใหม่ค้างอยู่ครึ่งทาง ลงในหน่วยความจำก้อนเดียวกันเป๊ะกับที่จอกำลังอ่านอยู่ตอนนั้น?
ถ้ามี framebuffer แค่ตัวเดียว จออาจจะจบลงด้วยการแสดงครึ่งบนของเฟรมเก่ากับครึ่งล่างของเฟรมใหม่พร้อมกันในภาพเดียว เพราะ GPU เขียนทับหน่วยความจำระหว่างที่กำลังสแกนอยู่ รอยตะเข็บที่มองเห็นได้นี้เรียกว่า screen tearing
ทางแก้คือ double buffering: เก็บ framebuffer ไว้สองตัว จอจะอ่านจาก front buffer เสมอ ซึ่งเป็นเฟรมที่เสร็จสมบูรณ์แล้ว ส่วน GPU จะวาดเฟรมถัดไปลงอีกตัวเสมอ เรียกว่า back buffer ซึ่งจอจะไม่มีทางเห็นเลยตราบใดที่มันยังวาดไม่เสร็จ พอ back buffer เสร็จสมบูรณ์แล้ว buffer ทั้งสองจะสลับบทบาทกัน — ในอุดมคติจะจับจังหวะให้เกิดขึ้นแค่ระหว่างรอบสแกนของจอเท่านั้น เรียกว่า VSync (Vertical Synchronization) — เพื่อไม่ให้จอจับภาพเฟรมที่วาดค้างอยู่ได้เลย
ในโค้ด สิ่งนี้จะปรากฏเป็นการเรียกฟังก์ชันหนึ่งครั้งท้ายสุดของ 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 จบลงด้วยการเรียกแบบนี้เสมอ
ทุกไอเดียในบทนี้ — การเทสต์ 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 จริง ๆ ใช้กับทุกอย่างนี้ออกไป
ถ้าอยากต่อยอดเอง: เพิ่มการ interpolate สีแบบถ่วงน้ำหนัก barycentric จากเซคชัน 7 เข้าไปแทนที่ symbol แบนราบต่อสามเหลี่ยม แล้วคุณจะได้ software renderer จิ๋ว ๆ ที่สมบูรณ์ แม้จะช้ามาก ที่มี texture และ shading ครบ — สร้างขึ้นทั้งหมดจากไอเดียที่คุณเข้าใจทีละสเตจแล้วตอนนี้ แทนที่จะเป็นกล่องดำที่ไม่มีคำอธิบายกล่องเดียว
(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
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 ซึ่งก็สมเหตุสมผล: เซนทรอยด์ "ใกล้เท่า ๆ กัน" กับทั้งสามมุม มันเลยได้รับส่วนแบ่งเท่า ๆ กันจากค่าของแต่ละมุม
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 ที่ใกล้ที่สุดในแต่ละพิกเซลชนะเสมอ ไม่ว่าสามเหลี่ยมจะถูกวาดตามลำดับไหนก็ตาม