7.8 เทคนิค Real-Time Rendering

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

renderer ที่ปล่อยจริงทำเงา, global illumination, anti-aliasing (TAA) และ GPU-driven rendering ยังไง — ส่วนใหญ่เรียนจากทอล์ก SIGGRAPH

ทุกเทคนิคในบทนี้มีอยู่เพราะกฎข้อเดียวที่หลีกเลี่ยงไม่ได้: เกมต้องวาดภาพใหม่หลายครั้งต่อวินาที ตลอดเวลา โดยห้าม miss deadline เด็ดขาด renderer ของหนังสามารถใช้เวลาสิบนาทีต่อหนึ่งเฟรมของหนัง Pixar ได้ แต่ game engine มีเวลาแค่ไม่กี่มิลลิวินาที บทนี้พูดถึงกลเม็ดที่ engine ที่วางขายจริง — Unreal, Unity HDRP, และ engine ของสตูดิโออย่าง HoYoverse กับ Riot — ใช้เพื่อปลอมเงา, ทำขอบให้เนียน, แสงที่สะท้อนหลายทอด, และภาพสะท้อน ให้ดีพอและเร็วพอจนผู้เล่นไม่ทันสังเกตว่ามันเป็นทางลัด บทก่อนหน้าสอน rendering pipeline และ shader ในภาพรวมไปแล้ว (vertex เข้า pixel ออก) บทนี้จะพูดถึงเทคนิคที่มีชื่อเรียกเฉพาะ ซึ่งสร้างอยู่บน pipeline นั้น และทำให้ภาพ real-time ดูสมจริง

1. "Real-Time" คืออะไร: Frame Budget

เกมที่วิ่งที่ 60 frames per second (FPS, จำนวนภาพทั้งภาพที่แสดงต่อวินาที) ต้องทำเฟรมใหม่ให้เสร็จทุก 16.6 มิลลิวินาที (ms, หนึ่งในพันวินาที) ถ้าอะไรก็ตามในเฟรมนั้น — game logic, physics, animation, การ render — ใช้เวลานานกว่านั้น frame rate จะตกและเกมจะกระตุกให้เห็นชัด ขีดจำกัดเวลานี้เรียกว่า frame budget


#include <cstdio>

int main() {
    double target_fps = 60.0;
    double frame_budget_ms = 1000.0 / target_fps;
    printf("Target FPS: %.1f\n", target_fps);
    printf("Frame budget: %.3f ms\n", frame_budget_ms);
    return 0;
}

Output:


Target FPS: 60.0
Frame budget: 16.667 ms

16.667 ms คือ budget ทั้งหมด — game logic, physics, animation, audio, AI, และ rendering ต้องยัดใส่เข้าไปให้พอในนั้น และตัว rendering เองมักจะกินแค่บางส่วนของ budget นั้นด้วยซ้ำ เกมที่เล็ง 30 FPS จะได้ budget เพิ่มเป็นสองเท่า (33.3 ms) ส่วนเกมยิงแข่งขันที่เล็ง 120 FPS จะเหลือ budget น้อยกว่าครึ่งหนึ่ง (8.3 ms) ทุกเทคนิคในบทนี้คือการแลกเปลี่ยน: ซื้อความสมจริงได้เท่าไรด้วยกี่มิลลิวินาที

ทบทวน pipeline คร่าว ๆ จากบทก่อนหน้า: CPU ตัดสินใจว่าจะวาดอะไรแล้วส่งคำสั่งออกไป, GPU (Graphics Processing Unit) แปลง triangle เป็น pixel ผ่านขั้นตอน vertex shading, rasterization, และ fragment shading แล้วผลลัพธ์ก็ไปโผล่บนจอ

CPU GPU ----------------------------- ------------------------------------ game logic, physics visibility culling -> vertex shading build the draw call list rasterization (triangle -> pixel) submit commands -> fragment/pixel shading post-processing, present to screen

จากนี้ไปทั้งบทคือเรื่องที่เกิดขึ้นในคอลัมน์ GPU นั้น: จะเอาเงา, ขอบเนียน, แสงทางอ้อม, และภาพสะท้อนออกมาจากมันได้อย่างไรโดยไม่ทำ budget แตก

2. Shadow Maps: หา Depth จากมุมมองของแสง

เงาจริง ๆ แล้วคือคำถามเดียวที่ถูกถามซ้ำทุก pixel บนจอ: "มีอะไรกั้นระหว่างจุดนี้กับแสงหรือเปล่า" การเช็คแบบตรง ๆ สำหรับทุกแสงและทุก pixel โดย test กับทุก object อื่นในฉาก ช้าเกินไปสำหรับ frame budget เทคนิคมาตรฐานที่ใช้แก้เรื่องนี้เรียกว่า shadow map มันเปลี่ยนคำถามที่แพงนั้นให้เหลือแค่การเปรียบเทียบตัวเลขตัวเดียว

ไอเดียคือ render สองรอบ (two-pass) รอบแรก เอากล้องไปวาง ที่ตำแหน่งแสง แล้ว render ฉากจากตรงนั้น แต่สนใจแค่ depth (ระยะห่างจากแสง) ไม่สนใจสี อะไรก็ตามที่แสง "เห็น" ก่อนในแต่ละจุดของมุมมองมัน คือ surface ที่ใกล้แสงที่สุดตามทิศทางนั้น — เรียกสิ่งนี้ว่า depth buffer (grid ของระยะห่าง หนึ่งค่าต่อหนึ่ง pixel เรียกอีกชื่อว่า z-buffer) และเมื่อมันถูกใช้แบบนี้ก็คือ shadow map นั่นเอง รอบที่สอง render ตามปกติจากกล้องผู้เล่น และสำหรับทุก pixel ให้ project ตำแหน่งของ pixel นั้นเข้าไปในมุมมองของแสง แล้วเทียบระยะห่างกับสิ่งที่ shadow map บันทึกไว้แล้ว ถ้า pixel นั้นอยู่ไกลจากแสงมากกว่าที่ shadow map บอกว่า surface ที่ใกล้ที่สุดอยู่ตรงไหน แปลว่ามีอะไรบางอย่างกั้นแสงอยู่ — pixel นั้นอยู่ในเงา

PASS 1 - shadow pass (จากมุม LIGHT) PASS 2 - camera pass (render ปกติ) -------------------------------------- -------------------------------------- แสงมองไปที่ฉาก กล้องมองไปที่ฉาก render เฉพาะ DEPTH (ไม่เอาสี) สำหรับทุก pixel ที่มองเห็น: เก็บ depth ที่ใกล้ที่สุดต่อ texel project จุดเข้าไปใน light space -> shadow_map[texel] = depth เทียบ depth กับ shadow_map[texel] ใกล้กว่าหรือเท่ากัน -> โดนแสง ไกลกว่า -> อยู่ในเงา

นี่คือการเปรียบเทียบนั้นในรูปแบบ simulation เล็ก ๆ ให้มอง shadow map เป็น array ธรรมดา (array แบบเดียวกับที่เรียนไปในบทก่อน ๆ) — หนึ่งแถวของค่า depth ตามที่แสงมองเห็น โดยมีกล่องวางอยู่กลางมุมมองของมัน (texel 3 กับ 4 ใกล้ แปลว่ามีอะไรบางอย่างอยู่ตรงนั้น):


#include <cstdio>

// Depth values captured from the LIGHT's point of view (a tiny shadow map).
// Index = which texel; value = distance from the light to the surface it sees there.
float shadow_map[8] = { 5.0f, 5.0f, 5.0f, 2.0f, 2.0f, 5.0f, 5.0f, 5.0f };

// texel: which shadow-map texel this point projects onto
// dist_from_light: the ACTUAL distance from the light to this surface point
bool is_lit(int texel, float dist_from_light) {
    float bias = 0.01f; // a small offset explained below
    float closest = shadow_map[texel];
    return dist_from_light <= closest + bias;
}

int main() {
    // Point A: on the floor at texel 3, and it IS the closest surface there (2.0)
    printf("Point A lit? %s\n", is_lit(3, 2.0f) ? "yes" : "no");
    // Point B: on the floor under the box, texel 3, but the box (2.0) is closer
    printf("Point B lit? %s\n", is_lit(3, 4.5f) ? "yes" : "no");
    return 0;
}

Output:


Point A lit? yes
Point B lit? no

ระยะจริงของ Point A จากแสง (2.0) ตรงกับที่ shadow map บันทึกไว้ว่าเป็น surface ที่ใกล้ที่สุดที่ texel นั้น เลยไม่มีอะไรกั้น: โดนแสง Point B อยู่ไกลจากแสงมากกว่า (4.5) เมื่อเทียบกับ surface ที่ใกล้ที่สุดที่ shadow map บันทึกไว้ (2.0) — มีอะไรบางอย่าง (กล่อง) กั้นอยู่ระหว่างมันกับแสง เลยอยู่ในเงา การเปรียบเทียบตัวเดียวนี้ ทำซ้ำต่อ pixel ต่อแสง คือ algorithm ทั้งหมดที่ engine จริง ๆ รันอยู่

Shadow Acne และเรื่อง Bias

สังเกตตัวแปร bias ที่ใส่เข้าไปก่อนเปรียบเทียบ ถ้าไม่มีมัน surface อาจจะบังเงาตัวเอง (self-shadow) ผิด ๆ ได้ shadow map มี resolution และความละเอียดของ depth จำกัด ทำให้พื้นราบที่โดนแสงในมุมเฉียง ๆ อาจปัดเศษ depth ของตัวเองขึ้นหรือลงต่างกันเล็กน้อยระหว่างสองรอบ render ทำให้บาง pixel เทียบกับ depth จริงของตัวเองแล้ว fail ผลลัพธ์คือลายจุด ๆ ของเงาที่ไม่ควรมีบน surface ที่ควรจะโดนแสงเต็ม ๆ เรียกว่า shadow acne การดันจุดเปรียบเทียบเข้าหาแสงเล็กน้อย (ตัว bias) ให้ surface มีที่ผ่อนปรนนิดหน่อยจนไม่ fail กับตัวเองอีก

ไม่มี bias มี bias ------------ --------- surface depth == shadow_map depth จุดเปรียบเทียบถูกดันเข้าหา -> การปัดเศษทำให้บาง texel แสงเล็กน้อยก่อน fail กับตัวเอง -> ไม่มี false self-shadow แล้ว ("shadow acne" ลายจุด ๆ) (bias มากไปทำให้เงาหลุด ออกจาก object เรียกว่า "peter-panning")
Common mistake ใส่ bias เยอะไว้ก่อนเพื่อความชัวร์ bias น้อยไปทำให้เกิด acne แต่ bias มากไปทำให้เงาลอยออกจากฐานของ object ที่สร้างมันอย่างเห็นได้ชัด เรียกว่า peter-panning (object ดูเหมือนลอยอยู่ เพราะไม่มีเงาผูกมันไว้กับพื้นที่อยู่ใต้มันโดยตรง) bias เป็นค่าที่ต้องปรับต่อฉาก ไม่ใช่ค่าคงที่ที่ตั้งครั้งเดียวแล้วลืมได้
Tip กลเม็ดที่ใช้กันบ่อยเพื่อสู้กับ peter-panning โดยไม่ต้องเพิ่ม bias มาก ๆ: render shadow map โดยใช้ back face ของ object แทน front face (สลับว่าด้านไหนของ triangle นับเป็น "front" เฉพาะตอน render shadow pass) เพราะ back face ของ object ที่ทึบตัน โดยธรรมชาติจะอยู่ไกลจากแสงมากกว่า front face นิดหน่อยอยู่แล้ว เลยได้ที่ผ่อนปรนเพิ่มโดยไม่ต้องใช้ bias เป็นตัวเลขมาก

3. Cascaded Shadow Maps: เงาในฉากขนาดใหญ่

shadow map หนึ่งอันมีจำนวน texel คงที่ สมมติ 2048x2048 ถ้าต้องครอบคลุมระยะการมองเห็นหลายร้อยเมตร (level แบบ open-world ขนาดใหญ่) แต่ละ texel ก็ต้องครอบคลุมพื้นที่ในโลกจริงก้อนใหญ่ และเงาที่อยู่ใกล้กล้อง — ตรงจุดที่ผู้เล่นกำลังมองใกล้ ๆ พอดี — จะออกมาเป็นก้อน ๆ หยาบ ๆ แต่ถ้าปรับให้ shadow map มีขนาดพอดีกับพื้นที่ใกล้ ๆ เท่านั้น object ที่อยู่ไกลก็จะเสียเงาไปเลยทันทีที่ออกจากพื้นที่แคบ ๆ นั้น

Cascaded shadow maps (CSM) แก้ปัญหานี้ด้วยการไม่ใช้ shadow map แค่อันเดียว view frustum ของกล้อง (ปริมาตรทรงพีระมิดที่กล้องมองเห็น) ถูกหั่นออกเป็นช่วงระยะทางหลายช่วงเรียกว่า cascade — ใกล้, กลาง, ไกล — และแต่ละ cascade มี shadow map เป็นของตัวเอง ขนาดพอดีกับช่วงนั้น cascade ที่ใกล้ครอบคลุมพื้นที่เล็กแต่ texel หนาแน่นสูง (เงาคมชัดใกล้กล้อง ที่ตรงนั้นสำคัญที่สุด) ส่วน cascade ที่ไกลครอบคลุมพื้นที่ใหญ่แต่ texel หนาแน่นต่ำ (เงาหยาบกว่าตอนอยู่ไกล ที่ผู้เล่นจะไม่ทันสังเกต)

ใกล้ ไกล |--- cascade 0 ---|--- cascade 1 ---|--- cascade 2 ---|--- cascade 3 ---| | res สูง | | | res ต่ำ | | (คมชัด | | | (ไกลออกไป | | ใกล้กล้อง) | | | เบลอได้ไม่เป็นไร)| กล้องอยู่ตรง "ใกล้"

วิธีที่นิยมใช้เลือกจุดตัด (split distance) คือผสมสองแบบง่าย ๆ เข้าด้วยกัน: uniform split (ช่วงระยะเท่ากันทุกช่วง ซึ่งเปลืองความละเอียดตรงที่ไกล) กับ logarithmic split (ช่วงที่ยาวขึ้นเรื่อย ๆ ตามระยะทาง ให้เข้ากับที่มุมมอง perspective ทำให้ของที่อยู่ไกลดูเล็กลงอยู่แล้ว) engine จริง ๆ จะผสมสองแบบนี้ด้วยน้ำหนักที่ปรับได้ เรียก lambda:


#include <cstdio>
#include <cmath>

// Compute 4 cascade split distances between the near and far planes,
// blending a logarithmic split and a uniform split.
void compute_splits(float near_p, float far_p, int count, float lambda, float out[]) {
    for (int i = 1; i <= count; ++i) {
        float p = (float)i / (float)count;
        float log_split = near_p * powf(far_p / near_p, p);
        float uniform_split = near_p + (far_p - near_p) * p;
        out[i - 1] = lambda * log_split + (1.0f - lambda) * uniform_split;
    }
}

// Which cascade should shade a pixel at this view-space depth?
int pick_cascade(float view_depth, float splits[], int count) {
    for (int i = 0; i < count; ++i) {
        if (view_depth <= splits[i]) return i;
    }
    return count - 1;
}

int main() {
    float splits[4];
    compute_splits(0.1f, 200.0f, 4, 0.5f, splits);
    for (int i = 0; i < 4; ++i)
        printf("Cascade %d ends at %.2f units\n", i, splits[i]);

    printf("A pixel at depth 12.0 uses cascade %d\n", pick_cascade(12.0f, splits, 4));
    printf("A pixel at depth 150.0 uses cascade %d\n", pick_cascade(150.0f, splits, 4));
    return 0;
}

Output:


Cascade 0 ends at 25.37 units
Cascade 1 ends at 52.26 units
Cascade 2 ends at 89.97 units
Cascade 3 ends at 200.00 units
A pixel at depth 12.0 uses cascade 0
A pixel at depth 150.0 uses cascade 3

สังเกตว่า cascade กว้างขึ้นเรื่อย ๆ ตามระยะทางที่เพิ่มขึ้น: cascade แรกครอบคลุมแค่ประมาณ 25 หน่วย แต่ cascade สุดท้ายครอบคลุมมากกว่า 100 หน่วยด้วยตัวมันเองเลย pixel ที่อยู่ใกล้ (depth 12.0) ตกอยู่ใน cascade 0 ที่แคบและ resolution สูง ส่วน pixel ที่อยู่ไกล (depth 150.0) ตกอยู่ใน cascade 3 ที่กว้างและ resolution ต่ำ แต่ละ cascade ต้องมี shadow-map render pass เป็นของตัวเอง ดังนั้น 4 cascade ก็แปลว่าประมาณ 4 shadow pass ต่อแสงหนึ่งดวง — cascade คือการแลก render pass ที่เพิ่มขึ้นกับคุณภาพเงาที่ยังคมชัดใกล้กล้องไม่ว่าจะมองไปไกลแค่ไหน

Common mistake เลือกจุดตัดระหว่าง cascade แบบไม่มี overlap เลย ทำให้เกิดอาการ "popping" หรือเส้นตะเข็บที่เห็นได้ชัด ตรงที่คุณภาพเงาของ object กระโดดเปลี่ยนทันทีตอนมันข้าม cascade boundary โดยเฉพาะถ้า object กำลังเคลื่อนที่ engine จริง ๆ จะ blend cascade สองอันที่อยู่ติดกันในโซน overlap เล็ก ๆ ใกล้ boundary เพื่อให้การเปลี่ยนไม่ใช่ขอบคมแบบทันที

4. ทำขอบเงาให้นุ่ม: PCF (Percentage-Closer Filtering)

การ lookup shadow map แค่ครั้งเดียวให้คำตอบ yes/no คมชัดต่อ pixel ซึ่งทำให้ขอบเงาคมกริบเกินจริง — นับเป็น aliasing รูปแบบหนึ่งด้วยเหมือนกัน (section 5 จะพูดเรื่องนี้ในภาพรวม) เพราะขอบเงาจริง ๆ จะค่อย ๆ นุ่มขึ้นเมื่อห่างออกจาก object ที่สร้างมัน PCF (percentage-closer filtering) แก้เรื่องนี้แบบถูก ๆ: แทนที่จะ test แค่ texel เดียวของ shadow map ให้ test หลาย texel ในพื้นที่เล็ก ๆ รอบจุดนั้น แล้วเฉลี่ยว่ากี่ตัวที่บอกว่า "โดนแสง" ผลลัพธ์ที่ได้คือเศษส่วนระหว่าง 0 (มืดเต็มที่) ถึง 1 (โดนแสงเต็มที่) แทนที่จะเป็นคำตอบ binary แบบคม ๆ ซึ่งอ่านออกมาเป็นขอบที่ไล่ระดับนุ่ม ๆ

texel ของ shadow map รอบจุดที่ shade: 5 5 5 5 [2] 5 <- texel กลางที่กำลัง shade 5 5 5 PCF test ทั้ง 9 ตัว ไม่ใช่แค่ตัวกลาง แล้วเฉลี่ยผล pass/fail: -> ได้ shadow factor แบบผสม แทนที่จะเป็น 0 หรือ 1 แบบคม ๆ

#include <cstdio>

// Shadow map depths as seen from the light. 2.0 = top of a box, 5.0 = the floor beyond it.
float shadow_map[7][7] = {
    {5,5,5,5,5,5,5},
    {5,5,5,5,5,5,5},
    {5,5,2,2,2,5,5},
    {5,5,2,2,2,5,5},
    {5,5,2,2,2,5,5},
    {5,5,5,5,5,5,5},
    {5,5,5,5,5,5,5}
};

// Percentage-Closer Filtering: sample a 3x3 grid around the texel and
// average how many samples say "lit" instead of trusting a single texel.
float pcf_shadow(int cx, int cy, float dist_from_light) {
    float bias = 0.01f;
    int lit_count = 0;
    for (int dy = -1; dy <= 1; ++dy)
        for (int dx = -1; dx <= 1; ++dx)
            if (dist_from_light <= shadow_map[cy + dy][cx + dx] + bias)
                lit_count++;
    return (float)lit_count / 9.0f;
}

int main() {
    float d = 2.4f; // actual distance from light to this floor point
    printf("Deep inside the box's shadow  (3,3): %.3f\n", pcf_shadow(3, 3, d));
    printf("Right at the shadow's edge    (2,2): %.3f\n", pcf_shadow(2, 2, d));
    printf("Mostly outside the shadow     (1,1): %.3f\n", pcf_shadow(1, 1, d));
    return 0;
}

Output:


Deep inside the box's shadow  (3,3): 0.000
Right at the shadow's edge    (2,2): 0.556
Mostly outside the shadow     (1,1): 0.889

ลึกเข้าไปในเงาของกล่อง ทั้ง 9 texel ที่ sample เห็นตรงกันว่าโดนบัง: 0.000 มืดเต็มที่ ตรงขอบของกล่องพอดี พื้นที่ 3x3 ที่ sample คร่อมทั้งฝั่งที่โดนบังและฝั่งที่โล่ง: 0.556 ค่าเทาผสม — นี่คือขอบนุ่ม ๆ ที่เกิดขึ้น ส่วนที่เกือบพ้นเงาแล้ว มีแค่ texel เดียวที่ยัง disagree: 0.889 เกือบโดนแสงเต็มที่ engine จริง ๆ มักใช้ sample มากกว่านี้ (16 ตัวขึ้นไป มักจัดเรียงแบบหมุน หรือ Poisson-disk แทนที่จะเป็น grid ธรรมดา เพื่อไม่ให้เห็น pattern ซ้ำ ๆ) และสามารถขยาย sample radius ตามระยะห่างจาก object ที่สร้างเงาได้ เรียกว่า PCSS (percentage-closer soft shadows) ซึ่งทำให้เงาที่อยู่ใกล้ตัว caster คมกว่า และเงาที่อยู่ไกลจาก caster นุ่มกว่า — ตรงกับพฤติกรรมเงาจริงภายใต้แสงแบบ area light แนวคิดหลักยังคงเหมือนที่แสดงข้างบน: เฉลี่ยหลาย ๆ test แทนที่จะเชื่อแค่ตัวเดียว

5. ทำไมขอบถึงดูเป็นรอยหยัก: Aliasing และ Point Sampling

ขอบของ triangle ในทางคณิตศาสตร์คือเส้นตรงเป๊ะ ๆ แต่จอเป็น grid ของ pixel สี่เหลี่ยมขนาดคงที่ และ rasterizer (ขั้นตอน hardware ตายตัวของ GPU ที่ตัดสินว่า triangle ครอบคลุม pixel ไหนบ้าง) ปกติตัดสินโดยเช็คจุดเดียว — ปกติคือจุดกึ่งกลางของ pixel — แล้วถามว่า "จุดเดียวนี้อยู่ในหรือนอก triangle" การตัดสินแบบ binary นี้ ทำซ้ำที่ละ pixel เปลี่ยนขอบแนวทแยงหรือขอบโค้งที่เนียนให้กลายเป็นบันได ปัญหาทั่วไปแบบนี้ — รูปทรงต่อเนื่องถูกแทนด้วย grid ของ sample แบบไม่ต่อเนื่องคงที่จนเสียข้อมูลไป — เรียกว่า aliasing และมันคือสาเหตุที่ขอบแนวทแยงกับขอบโค้งในเกมดูเป็น "รอยหยัก" ถ้าไม่ทำอะไรเพิ่มเติม


#include <cstdio>

// A triangle's edge, simplified to the line x + y = 8.
// A pixel counts as "inside" if its CENTER point satisfies the test.
bool inside_triangle(int x, int y) {
    float cx = x + 0.5f;
    float cy = y + 0.5f;
    return (cx + cy) < 8.0f;
}

int main() {
    for (int y = 0; y < 8; ++y) {
        for (int x = 0; x < 8; ++x) {
            putchar(inside_triangle(x, y) ? '#' : '.');
        }
        putchar('\n');
    }
    return 0;
}

Output:


#######.
######..
#####...
####....
###.....
##......
#.......
........
ขอบแนวทแยงจริง (คณิตศาสตร์) pixel ที่ rasterize แล้ว (point sampling) / . . . # / . . # # / . # # # / # # # .

ขอบเขตทางคณิตศาสตร์คือเส้นทแยงตรงเส้นเดียว แต่สิ่งที่ถูกวาดออกมาจริงคือบันได เพราะแต่ละ pixel ถามแค่ "จุดกึ่งกลางของฉันอยู่ในหรือนอก" โดยไม่มีแนวคิดเรื่อง "ฉันถูกครอบคลุม 30%" เลย เทคนิค anti-aliasing (AA) ทั้งสามที่จะพูดถึงต่อไปนี้ คือคำตอบที่ต่างกันของปัญหาเดียวกัน: หาคำตอบที่ดีกว่าการ sample จุดเดียวต่อ pixel โดยไม่ต้องจ่ายราคาเต็ม ๆ อย่างการ render ภาพที่ resolution สูงกว่าปกติ 4 เท่าหรือ 16 เท่าแล้วค่อยย่อลง (เรียกว่า supersampling หรือ SSAA ซึ่งได้ผลจริงแต่แพงเพราะต้องรัน pipeline ทั้งหมด รวมถึง shading ซ้ำหลายรอบต่อ pixel สุดท้ายหนึ่งตัว)

6. MSAA: Multi-Sample Anti-Aliasing

MSAA (multi-sample anti-aliasing) ปรับปรุงจาก test แบบ sample จุดกึ่งกลางเดียวใน section 5 โดยไม่ต้องจ่ายราคาเต็มของ supersampling กลเม็ดคือ: test coverage (จุดย่อยใน pixel นี้อยู่ใน triangle ไหม) ที่หลายตำแหน่งคงที่ภายใน pixel เดียว แต่รัน fragment shader (การคำนวณสี/แสงจริง ๆ) แค่ ครั้งเดียว ต่อ pixel ต่อ triangle สีสุดท้ายของ pixel จะ blend ตามสัดส่วนว่า sample point กี่ตัวที่ถูกครอบคลุม

หนึ่ง pixel มี MSAA 4 sample point (x = sample): +-----------+ | x x | แต่ละ x ถูก test กับขอบของ triangle | | ขอบครอบคลุม x บางตัวแต่ไม่ครอบคลุมบางตัว | x x | -> สี pixel = blend ตามน้ำหนักว่า x กี่ตัวที่อยู่ข้างใน +-----------+

#include <cstdio>

bool inside_triangle(float x, float y) {
    return (x + y) < 7.0f;
}

// 4x MSAA: test 4 sub-pixel sample points instead of just the pixel center.
float msaa4_coverage(int px, int py) {
    float offsets[4][2] = {
        {0.25f, 0.25f}, {0.75f, 0.25f},
        {0.25f, 0.75f}, {0.75f, 0.75f}
    };
    int covered = 0;
    for (int i = 0; i < 4; ++i) {
        float sx = px + offsets[i][0];
        float sy = py + offsets[i][1];
        if (inside_triangle(sx, sy)) covered++;
    }
    return covered / 4.0f;
}

int main() {
    printf("Pixel (2,3) coverage: %.2f\n", msaa4_coverage(2, 3));
    printf("Pixel (3,3) coverage: %.2f\n", msaa4_coverage(3, 3));
    printf("Pixel (4,3) coverage: %.2f\n", msaa4_coverage(4, 3));
    return 0;
}

Output:


Pixel (2,3) coverage: 1.00
Pixel (3,3) coverage: 0.25
Pixel (4,3) coverage: 0.00

Pixel (2,3) อยู่ในกล่องของ triangle เต็มตัว: ทั้ง 4 sample เห็นตรงกัน coverage 1.00 สีเต็ม Pixel (4,3) อยู่นอกเต็มตัว: coverage 0.00 Pixel (3,3) คร่อมขอบพอดี: มีแค่ 1 ใน 4 sample ที่อยู่ข้างใน coverage 0.25 ดังนั้นสี pixel สุดท้ายจะ blend สีของ triangle 25% กับสีของอะไรก็ตามที่อยู่ข้างหลังมัน — ขอบที่ถูกครอบคลุมบางส่วนแบบนุ่ม ๆ แทนที่จะเป็นการกระโดดแบบคม ๆ เพราะ fragment shader ที่แพงยังรันแค่ครั้งเดียวต่อ pixel (ไม่ใช่ 4 ครั้ง) MSAA จึงถูกกว่า supersampling 4 เท่าแบบเต็ม ๆ มาก ในขณะที่ยังทำให้ขอบทาง geometry เนียนขึ้นได้

Common mistake คาดหวังว่า MSAA จะแก้ทุกอย่างที่ดูเป็นรอยหยัก MSAA test แค่ coverage ทาง geometry เท่านั้น — ขอบของ triangle มันไม่ช่วยอะไรกับ aliasing ที่มาจากข้างใน shader เอง เช่น specular highlight เล็ก ๆ สว่าง ๆ ที่กระพริบต่าง pixel หรือ texture ใบไม้ที่ใช้ alpha-testing (ตัดออกแบบคม ไม่ใช่ขอบที่ blend นุ่ม ๆ) เพื่อตัด shape ใบไม้ออกจาก quad แบน ๆ ขอบพวกนั้นถูกตัดสินใจข้างใน fragment shader ไม่ใช่โดย coverage test ของ rasterizer เลย MSAA จึงไม่ทำให้มันเนียนขึ้น

7. FXAA: Post-Process Anti-Aliasing แบบเร็ว

MSAA ต้องเข้าถึงข้อมูล geometry และ coverage ตอนที่ฉากกำลังถูก rasterize FXAA (fast approximate anti-aliasing) ใช้วิธีที่ต่างออกไปเลย: มันรัน หลังจาก ภาพทั้งภาพถูก render เสร็จแล้ว เป็น post-processing pass (ขั้นตอนประมวลผลภาพที่ทำกับภาพ 2D สุดท้าย โดยไม่รู้อะไรเลยเกี่ยวกับฉาก 3D ที่สร้างมันขึ้นมา) มันสแกนภาพที่เสร็จแล้วเพื่อหาความสว่างที่กระโดดแบบคม ๆ — high-contrast edge ซึ่งมักหมายถึงขอบ geometry ที่เป็นรอยหยัก — แล้ว blend pixel พวกนั้นกับเพื่อนบ้านเพื่อทำให้นุ่มขึ้น


#include <cstdio>
#include <cmath>

// A row of pixel brightness values (0..1), with a hard edge in the middle.
float pixels[8] = {0.9f, 0.9f, 0.9f, 0.9f, 0.1f, 0.1f, 0.1f, 0.1f};

int main() {
    float smoothed[8];
    for (int i = 0; i < 8; ++i) smoothed[i] = pixels[i];

    for (int i = 1; i < 7; ++i) {
        float left = pixels[i - 1];
        float right = pixels[i + 1];
        float contrast = fabsf(left - right);
        if (contrast > 0.3f) {
            // High contrast: likely an edge. Blend this pixel with its neighbors.
            smoothed[i] = (left + pixels[i] + right) / 3.0f;
        }
    }

    for (int i = 0; i < 8; ++i) printf("%.2f ", smoothed[i]);
    printf("\n");
    return 0;
}

Output:


0.90 0.90 0.90 0.63 0.37 0.10 0.10 0.10

แถวเดิมกระโดดจาก 0.9 ไป 0.1 แบบทันทีในก้าวเดียว — ขอบคม ๆ การเช็ค high-contrast จับ pixel ใกล้จุดกระโดดนั้นแล้ว blend กับเพื่อนบ้าน เปลี่ยนการกระโดดทันทีให้กลายเป็นทางลาดสั้น ๆ: 0.9, 0.63, 0.37, 0.1 ตอนนี้ขอบกลายเป็น gradient สองสาม pixel แทนที่จะเป็นเส้นคม ๆ เส้นเดียว ซึ่งเมื่อมองจากระยะปกติจะดูเนียน FXAA เป็นที่นิยมเพราะถูกมาก (แค่ pass เดียวผ่านภาพสุดท้าย ไม่ต้อง render geometry เพิ่ม) และใช้ได้กับ renderer แบบไหนก็ได้ แต่เพราะมันดูแค่ contrast แบบ 2D มันจึงแยกไม่ออกระหว่างขอบ geometry จริง ๆ กับรายละเอียดของ texture ที่ contrast สูง เช่นเส้นที่วาดไว้ หรือตัวหนังสือเล็ก ๆ

Common mistake พึ่ง FXAA กับฉากที่มี UI หรือตัวหนังสือเยอะ ๆ เพราะ FXAA จะ blend ขอบที่ contrast สูงทุกอันที่มันเจอ มันสามารถทำให้ตัวหนังสือเล็ก ๆ กับไอคอน UI ที่คมชัดเบลอได้อย่างเห็นได้ชัด นี่คือเหตุผลที่ engine ส่วนใหญ่ render UI แยก pass ต่างหาก หลังจากที่ post-processing AA ถูกใช้กับฉาก 3D ที่อยู่ข้างใต้มันแล้ว

8. TAA: Temporal Anti-Aliasing

MSAA ใช้ sample เพิ่มขึ้น ภายใน หนึ่งเฟรม TAA (temporal anti-aliasing) ใช้ sample เพิ่มขึ้น ข้ามเฟรม แทน: ทุกเฟรม ตำแหน่ง sample จะขยับเล็กน้อย (เรียกว่า jitter ค่า offset ระดับ sub-pixel ที่เปลี่ยนไปเรื่อย ๆ) แล้วเฟรมใหม่ที่ jitter แล้วนั้นจะถูก blend เข้ากับ history ที่สะสมมาจากเฟรมก่อน ๆ เมื่อเวลาผ่านไปหลายเฟรม มันจะ converge เข้าใกล้คำตอบเดียวกับที่ multi-sample test แบบทำในเฟรมเดียวจะให้ — แต่กระจายออกไปตามเวลา แทบไม่มีค่าใช้จ่ายเพิ่มต่อเฟรมเลย

การใช้ pixel จากเฟรมก่อนหน้าซ้ำ ต้องรู้ว่า surface point นั้น อยู่ตรงไหนตอนนี้ เพราะกล้องหรือ object อาจจะขยับไปแล้ว engine จะคำนวณ velocity buffer (เรียกอีกชื่อว่า motion vector buffer): สำหรับทุก pixel surface point นี้ขยับไปบนจอเท่าไรตั้งแต่เฟรมที่แล้ว vector นั้นถูกใช้เพื่อ reproject — หาว่า surface point เดียวกันนี้อยู่ตรงไหนในภาพของเฟรมก่อนหน้า — ก่อนจะ blend

เฟรมก่อนหน้า เฟรมปัจจุบัน --------------- -------------- [ . . H . . . ] [ . . . P . . ] | | | velocity vector: surface point นี้ | ขยับไปตรงไหนบนจอ? +----------------------------------------->| sample history buffer ที่ H, reproject มาที่ตำแหน่งของ P, blend กับ sample ใหม่

นี่คือขอบ triangle เดิมจากตัวอย่าง MSAA แต่ sample จุดที่ jitter แล้วหนึ่งจุดต่อเฟรม แทนที่จะเป็นสี่จุดในเฟรมเดียว แล้ว blend เข้า history ที่วิ่งอยู่ด้วยน้ำหนักคงที่:


#include <cstdio>

bool inside_triangle(float x, float y) {
    return (x + y) < 7.0f;
}

int main() {
    // TAA jitters the sample point to a different sub-pixel offset every
    // frame, then blends the new sample into the accumulated history.
    float jitter_x[4] = {0.25f, 0.75f, 0.25f, 0.75f};
    float jitter_y[4] = {0.25f, 0.25f, 0.75f, 0.75f};
    int px = 3, py = 3;

    float history = 0.5f; // first guess, no history yet
    float alpha = 0.25f;  // how much weight the new frame gets

    for (int frame = 0; frame < 4; ++frame) {
        float sample = inside_triangle(px + jitter_x[frame], py + jitter_y[frame]) ? 1.0f : 0.0f;
        history = history * (1.0f - alpha) + sample * alpha;
        printf("Frame %d: jittered sample=%.1f  accumulated=%.4f\n", frame, sample, history);
    }
    return 0;
}

Output:


Frame 0: jittered sample=1.0  accumulated=0.6250
Frame 1: jittered sample=0.0  accumulated=0.4688
Frame 2: jittered sample=0.0  accumulated=0.3516
Frame 3: jittered sample=0.0  accumulated=0.2637

นี่คือ jitter offset ทั้งสี่ตัวเดียวกันเป๊ะ ๆ กับที่ใช้เป็นสี่ MSAA sample point ใน section 6 ที่ pixel (3,3) ได้ค่า coverage จริงเป็น 0.25 ตรงนี้ กระจายออกไปทีละเฟรมทั้งสี่เฟรมแล้ว blend เข้าด้วยกัน ค่าที่สะสมกำลังค่อย ๆ ลดลงเข้าใกล้ 0.25 เหมือนกัน — TAA สร้างคำตอบแบบเดียวกับ MSAA ขึ้นมาใหม่ โดยใช้เวลาแทนที่จะใช้ sample เพิ่มในเฟรมเดียว ด้วยต้นทุนต่อเฟรมที่น้อยกว่ามาก

Common mistake เชื่อ history buffer แบบไม่ตั้งคำถาม ถ้า object ขยับเร็ว ถ้ากล้องตัดฉาก หรือถ้ามีอะไรใหม่ที่โผล่มาให้เห็นซึ่งเมื่อเฟรมที่แล้วยังถูกบังอยู่ (เรียกว่า disocclusion surface ที่เพิ่งถูกเปิดเผยเพราะสิ่งที่บังมันขยับออกไป) velocity buffer จะไม่มีคำตอบที่ถูกต้อง หรือชี้ไปที่ข้อมูลสีที่ไม่ใช่ของ surface นั้นแล้ว การ blend history ที่ผิดแบบนี้เข้าไปทำให้เกิดรอยเปื้อนลากยาวข้างหลังขอบที่เคลื่อนที่เร็ว เรียกว่า ghosting วิธีแก้มาตรฐานคือ neighborhood clamping: ก่อนจะยอมรับ history sample ให้ clamp สีของมันให้อยู่ในช่วงสีที่เห็นใน pixel เพื่อนบ้านของเฟรมปัจจุบัน เพื่อให้ history ที่ผิดพลาดมาก ๆ ถูกดึงกลับมาให้ดูสมเหตุสมผล แทนที่จะเปื้อนไปทั่วจอ นี่คือปัญหาการปรับจูนที่ TAA ทุก implementation ต้องสมดุลเอง — clamp แรงไปก็เสียประโยชน์ของ anti-aliasing, clamp หลวมไปก็ปล่อยให้ ghosting หลุดออกมา
Tip ถ้าขยาย trace ข้างบนออกไปเป็น 8 เฟรม (วน jitter offset สี่ตัวเดิมซ้ำเป็นรอบที่สอง) จะเห็นว่าค่าที่สะสมไม่ได้นิ่งอยู่ที่ 0.25 พอดีเป๊ะ — มันแกว่งตามว่า sample ล่าสุดที่เห็นคืออันไหน เพราะน้ำหนัก blend ที่คงที่ให้ความสำคัญกับเฟรมล่าสุดมากกว่าเฟรมเก่า นี่คือเหตุผลที่ TAA implementation ที่วางขายจริงไม่ใช้น้ำหนักคงที่แบบเรียบ ๆ พวกมันผสมกับ clamping และบางทีก็ใช้น้ำหนักแบบแปรผันที่เริ่มสูงกว่า (เชื่อข้อมูลใหม่มากกว่าตอน history ยังสั้น) แล้วค่อยลดลงเมื่อ history สะสมมากพอแล้ว Exercise 2 ท้ายบทนี้จะไล่เรื่องนี้ให้ดูแบบเต็ม ๆ

9. Forward Shading กับ Deferred Shading

การคำนวณแสงต้องเกิดขึ้นที่ไหนสักที่ใน pipeline: สำหรับทุก pixel ที่มองเห็น ต้องรวม material ของ surface นั้นเข้ากับทุกแสงที่ส่องมาถึงมัน มีสองวิธีที่ต่างกันมากในการจัดระเบียบงานนี้

Forward shading คือวิธีตรงไปตรงมา: สำหรับแต่ละ object สำหรับแต่ละแสงที่อาจส่งผลต่อมัน คำนวณแสงตรงนั้นเลยใน fragment shader ของ object ทีละ triangle มันง่ายและทำงานได้ดีตามธรรมชาติกับ transparency และ MSAA แต่มันมีปัญหา — overdraw (shade pixel เดียวกันบนจอมากกว่าหนึ่งครั้ง เพราะ object ที่อยู่ใกล้กว่าถูกวาดทับ object ที่อยู่ไกลกว่าซึ่งจ่ายค่า shading เต็ม ๆ ไปแล้ว) ยิ่งคูณแย่ลงเรื่อย ๆ เมื่อจำนวนแสงเยอะขึ้น เพราะทุก pixel ที่ถูก overdraw ต้องจ่ายค่าทุกแสงซ้ำอีกรอบ

Deferred shading แยกงานออกเป็นสอง pass แทน pass แรกเรียก geometry pass วาดทุก object แต่ยังไม่คำนวณแสงเลย — มันแค่เขียนข้อมูล material ของแต่ละ pixel (สีของ surface, ทิศทาง normal, depth, roughness) ลงใน texture เต็มจอชุดหนึ่งเรียกว่า G-buffer (geometry buffer) pass ที่สองเรียก lighting pass รันครั้งเดียวต่อ pixel สุดท้ายที่มองเห็นบนจอ อ่าน G-buffer แล้ววนลูปแสงที่ส่องถึง pixel นั้น การคำนวณแสงจะรันเฉพาะ pixel ที่มองเห็นจริง ๆ บนจอเท่านั้น ไม่ใช่ทุก layer ที่ถูก overdraw ข้างใต้มัน

FORWARD DEFERRED -------- -------- สำหรับแต่ละ object: PASS 1 - geometry: เขียน G-buffer ครั้งเดียว สำหรับแต่ละแสงที่โดน object นั้น: [Albedo][Normal][Depth][Rough/Metal] shade pixel ด้วยแสงนั้น PASS 2 - lighting: อ่าน G-buffer ครั้งเดียว -> งาน shading = แสง x object x overdraw สำหรับแต่ละแสง: สะสมสี pixel ที่โดนแสงบนจอ -> งาน = แสง x screen pixel, ไม่มี overdraw

simulation เล็ก ๆ จะทำให้เห็นความต่างชัดขึ้น สมมติฉากหนึ่งมี 50 object แต่ละอันครอบคลุมจอเฉลี่ย 2000 pixel (มี overlap กันบ้าง) มีแสง 40 ดวง และภาพสุดท้ายที่มองเห็นมี 80,000 pixel:


#include <cstdio>

int main() {
    int objects = 50;
    int pixels_per_object = 2000; // average screen coverage, including overlap
    int lights = 40;              // e.g. many small point lights in a level

    // Forward: naively shades EVERY light against EVERY pixel of EVERY
    // object, even pixels that later get drawn over by something closer.
    long forward_ops = (long)objects * pixels_per_object * lights;

    // Deferred: pay the material write once per pixel (geometry pass),
    // then pay the lighting cost once per pixel per light, on the FINAL
    // visible pixels only - overdraw never reaches the lighting pass.
    long screen_pixels = 80000;
    long gbuffer_ops = screen_pixels;
    long lighting_ops = screen_pixels * lights;
    long deferred_ops = gbuffer_ops + lighting_ops;

    printf("Forward shading operations:  %ld\n", forward_ops);
    printf("Deferred shading operations: %ld\n", deferred_ops);
    return 0;
}

Output:


Forward shading operations:  4000000
Deferred shading operations: 3280000

สังเกตว่า objects x pixels_per_object (100,000) มากกว่า screen_pixels สุดท้าย (80,000) — ช่องว่าง 1.25 เท่านั้นคือ overdraw: มี pixel บนจอบางตัวที่ถูก shade มากกว่าหนึ่ง object ที่ซ้อนกันในเวอร์ชัน forward Deferred shading จ่ายค่า material แค่ครั้งเดียวต่อ pixel สุดท้ายจริง ๆ และไม่จ่ายค่า lighting ซ้ำอีกสำหรับ pixel ที่สุดท้ายถูกบังอยู่ ช่องว่างระหว่าง forward กับ deferred จะโตขึ้นเร็วมากเมื่อจำนวนแสงเพิ่มขึ้น นี่คือเหตุผลที่ deferred shading ได้รับความนิยมโดยเฉพาะกับฉากที่มีแสง dynamic เล็ก ๆ จำนวนมาก

Tip G-buffer ที่มี render target แบบ full-resolution หลายชุด (albedo, normal, depth, roughness/metallic บางทีมีมากกว่านั้น) ใช้ GPU memory bandwidth จริง ๆ ทั้งตอนเขียนและตอนอ่านทีหลัง engine จะบีบอัดข้อมูลให้แน่นเพื่อลดปัญหานี้ — เช่นเก็บ normal vector (ที่ปกติต้องใช้ 3 ตัวเลข x/y/z) ด้วยแค่ 2 ตัวเลขโดยใช้เทคนิค encoding (octahedral encoding) เพราะ component ที่สามของ vector ที่ normalize แล้วมักจะคำนวณกลับได้จากอีกสองตัว
Common mistake คิดว่า deferred shading เป็นตัวแทนที่ดีกว่า forward shading ในทุกกรณี Deferred มีปัญหากับ transparency (G-buffer เก็บได้แค่ surface เดียวต่อ pixel แต่ transparent object ต้อง blend กับสิ่งที่อยู่ข้างหลังมัน) และเข้ากับ MSAA ได้ไม่ตรงไปตรงมา (G-buffer ต้องเก็บหลาย sample ต่อ pixel ซึ่งคูณต้นทุน memory ที่ใหญ่อยู่แล้วให้ใหญ่ขึ้นไปอีก) เพราะแบบนี้ engine ที่วางขายจริงหลายตัวใช้แบบผสม: deferred (หรือแบบที่เรียกว่า clustered หรือ Forward+ shading ซึ่งยัง shade ใน pass เดียวเหมือน forward แต่จัดเรียงแสงลงใน grid cell แบบ screen-space ก่อน ให้แต่ละ pixel test แค่แสงที่อยู่ใกล้ ๆ) สำหรับ object ทึบ ๆ และ forward pass แยกต่างหากสำหรับ transparent object อย่างกระจก ใบไม้ และ particle effect

10. เทคนิคประมาณ Global Illumination

Global illumination (GI) คือแสงที่กระเด้งมากกว่าหนึ่งครั้งก่อนจะมาถึงตา — กำแพงสีขาวที่โดนแสงแดดตรง ๆ ก็ทำให้ห้องรอบ ๆ มันสว่างขึ้นด้วย พรมสีแดงก็ย้อมสีแดงจาง ๆ ให้กำแพงข้าง ๆ มันได้ การ simulate เรื่องนี้แบบเป๊ะ ๆ ต้องไล่ตามว่าแสงกระเด้งไปทั่วทั้งฉากยังไง ซึ่งช้าเกินไปมากสำหรับ frame budget engine แบบ real-time ใช้การประมาณค่าเฉพาะจุดจำนวนหนึ่งแทน แต่ละอันครอบคลุมส่วนที่ต่างกันของสิ่งที่ GI แบบเต็ม ๆ จะให้ได้

Light Probe: แสงทางอ้อมที่คำนวณล่วงหน้าไว้เป็นจุด ๆ

Light probe คือจุดหนึ่งใน level ที่ล่วงหน้ามาก่อน (ปกติตอนสร้าง level ไม่ใช่ตอนเกมกำลังรัน) engine คำนวณว่าแสงทางอ้อมตรงจุดนั้นหน้าตาเป็นยังไง — แสงที่กระเด้งมาจากกำแพง, พื้น, และ geometry ที่หยุดนิ่งอื่น ๆ probe ถูกวางกระจายไปทั่ว level มักจะถี่กว่าในพื้นที่ที่สำคัญทางสายตา ตอนเกมรันจริง object ที่เคลื่อนที่ได้ (ซึ่งไม่สามารถมี lighting ที่ bake ไว้ล่วงหน้าของตัวเองได้ เพราะมันอยู่ตรงไหนก็ได้) จะหา probe ที่ใกล้ที่สุดแล้ว blend ระหว่างพวกมันตามระยะห่าง เพื่อประมาณแสงทางอ้อมที่กระทบมันตอนนี้แบบถูก ๆ

P1(หรี่) P2(สว่าง ใกล้หน้าต่าง) o--------------------o | | | [ลัง] | ลังจะ blend probe ที่ใกล้ที่สุด | | ตามระยะห่างจากแต่ละอัน o--------------------o P3(หรี่) P4(สว่าง)

#include <cstdio>

// Two light probes along a hallway, storing indirect light (simplified to
// one brightness number instead of full RGB for clarity).
float probe_A_light = 0.2f; // near a dark corner
float probe_B_light = 0.8f; // near a bright window

int main() {
    float probe_A_pos = 0.0f;
    float probe_B_pos = 10.0f;
    float object_pos = 7.0f; // a crate somewhere between the two probes

    float t = (object_pos - probe_A_pos) / (probe_B_pos - probe_A_pos);
    float indirect_light = probe_A_light * (1.0f - t) + probe_B_light * t;

    printf("Blend factor t = %.2f\n", t);
    printf("Interpolated indirect light on the crate: %.3f\n", indirect_light);
    return 0;
}

Output:


Blend factor t = 0.70
Interpolated indirect light on the crate: 0.620

ลังอยู่ 70% ของทางจาก probe ที่หรี่ไปยัง probe ที่สว่าง มันเลยรับ 70% ของความต่างความสว่างระหว่างสองอัน: 0.62 สว่างกว่ามุมที่มืดแต่หรี่กว่าตรงข้างหน้าต่างพอดี light probe จริง ๆ เก็บข้อมูลแสงแบบทรงกลมเต็ม ๆ (แสงที่มาจากทุกทิศทาง ไม่ใช่แค่ตัวเลขเดียว) และ engine จริง ๆ blend probe ที่อยู่ใกล้เคียงมากกว่าสองอันพร้อมกัน แต่แนวคิดหลัก — sample จุดที่คำนวณไว้ล่วงหน้าไม่กี่จุดแล้ว interpolate — คือแบบนี้เป๊ะ

Screen-Space Reflections (SSR)

Screen-space reflections เอาข้อมูลที่ renderer มีอยู่แล้วบนจอมาใช้ซ้ำ — color buffer กับ depth buffer — เพื่อประมาณภาพสะท้อนโดยไม่ต้องไล่ ray ผ่านฉาก 3D เต็ม ๆ reflection ray ถูก march (เดินหน้าไปทีละนิด) เริ่มจาก surface ที่สะท้อนแสง และในแต่ละก้าว depth ปัจจุบันของมันจะถูกเทียบกับสิ่งที่ depth buffer บันทึกไว้แล้วที่ตำแหน่งนั้นบนจอ เมื่อ depth ของ ray ตามทันหรือแซง depth buffer ตรงนั้น pixel นั้นจะถูกนับเป็นจุดที่ชน แล้วสีที่ render ไว้แล้วของมันจะถูกใช้ซ้ำเป็นภาพสะท้อน


#include <cstdio>

// Depth buffer for a row of screen pixels the reflection ray crosses.
// (Distance from camera; a crate close to the camera sits at index 4-7.)
float depth_buffer[10] = {5,5,5,5, 2,2,2,2, 5,5};

int main() {
    float ray_depth = 1.0f; // the ray starts close to the camera
    float step = 0.5f;      // and marches away from it, pixel by pixel

    for (int px = 0; px < 10; ++px) {
        printf("pixel %d: ray_depth=%.2f buffer_depth=%.2f", px, ray_depth, depth_buffer[px]);
        if (ray_depth >= depth_buffer[px]) {
            printf("  -> HIT: use color at this pixel as the reflection\n");
            break;
        }
        printf("  (ray is still in front of the surface)\n");
        ray_depth += step;
    }
    return 0;
}

Output:


pixel 0: ray_depth=1.00 buffer_depth=5.00  (ray is still in front of the surface)
pixel 1: ray_depth=1.50 buffer_depth=5.00  (ray is still in front of the surface)
pixel 2: ray_depth=2.00 buffer_depth=5.00  (ray is still in front of the surface)
pixel 3: ray_depth=2.50 buffer_depth=5.00  (ray is still in front of the surface)
pixel 4: ray_depth=3.00 buffer_depth=2.00  -> HIT: use color at this pixel as the reflection

ray march ไปข้างหน้า อยู่ "ข้างหน้า" กำแพงไกล (depth 5.00) ตลอดสี่ pixel แรก แล้วที่ pixel 4 depth ของมันเอง (3.00) ตามทัน depth ของลังที่อยู่ใกล้ (2.00) แล้ว — ชน สีที่ render ไว้แล้วที่ pixel 4 จะถูกใช้ซ้ำเป็นสีของภาพสะท้อน จุดติดขัดอยู่ในชื่อของมันเลย: screen-space ถ้าสิ่งที่ควรจะสะท้อนอยู่นอกจอ หรือถูกบังจากมุมมองของกล้อง สีของมันไม่เคยถูก render ไว้ตั้งแต่แรก ray march เลย fail ที่จะหาจุดชน — engine จะ fallback ไปใช้ reflection probe ที่ bake ไว้ล่วงหน้า (คล้าย ๆ light probe แต่เก็บภาพ 360 องศาเต็ม ๆ แทนที่จะเป็นค่าความสว่างค่าเดียว) เมื่อไรก็ตามที่ SSR หาอะไรไม่เจอ

Screen-Space Ambient Occlusion (SSAO)

Ambient occlusion (AO) คือความมืดนุ่ม ๆ ที่เห็นตามมุม, รอยพับ, และตรงที่ surface สองอันมาชิดกันใกล้ ๆ — รอยแยกจะได้รับ ambient light น้อยกว่า surface ราบเรียบที่เปิดโล่ง เพราะ geometry ที่อยู่ใกล้ ๆ บังแสงจากท้องฟ้า/สิ่งแวดล้อมบางส่วนที่ปกติจะส่องมาถึง SSAO ประมาณค่านี้โดยใช้แค่ depth buffer: สำหรับแต่ละ pixel sample depth ของ pixel ใกล้เคียง ถ้าเพื่อนบ้านตัวไหนอยู่ใกล้กล้องกว่าที่ควรอย่างเห็นได้ชัด นับว่ามันบังแสงบางส่วน

กำแพง กำแพง \ / \ มืด / <- รอยแยก: depth ของเพื่อนบ้านใกล้กว่า \ AO=0 / จุดนี้ -> นับเป็นตัวบัง ทำให้มืดลง \ / -------OO------- <- พื้นโล่ง: ไม่มีเพื่อนบ้านที่ใกล้กว่า AO ใกล้ 1.0 -> ยังคงสว่าง

#include <cstdio>

// Scene depth (distance from camera) looking into a narrow crevice.
// The crevice walls (4.0) are close to the camera; the back of the
// crevice (7.0) is farther away and partly boxed in by the walls.
float depth[5][5] = {
    {10,10,10,10,10},
    {10, 4, 4, 4,10},
    {10, 4, 7, 4,10},
    {10, 4, 4, 4,10},
    {10,10,10,10,10}
};

// SSAO: check nearby depth samples. A neighbor that is noticeably CLOSER
// to the camera than this pixel blocks some of the ambient light that
// would otherwise reach it.
float ssao(int cx, int cy) {
    float center = depth[cy][cx];
    int occluders = 0, samples = 0;
    for (int dy = -1; dy <= 1; ++dy) {
        for (int dx = -1; dx <= 1; ++dx) {
            if (dx == 0 && dy == 0) continue;
            float neighbor = depth[cy + dy][cx + dx];
            if (neighbor < center - 0.5f) occluders++;
            samples++;
        }
    }
    return 1.0f - (float)occluders / (float)samples; // 1.0 = open, 0.0 = dark
}

int main() {
    printf("AO on the crevice wall (1,1): %.3f\n", ssao(1, 1));
    printf("AO at the crevice back (2,2): %.3f\n", ssao(2, 2));
    return 0;
}

Output:


AO on the crevice wall (1,1): 1.000
AO at the crevice back (2,2): 0.000

จุดที่ขอบของรอยแยก (1,1) ไม่มีเพื่อนบ้านตัวไหนใกล้กว่าตัวมันเองเลย AO เลยยังเป็น 1.000 เปิดโล่งเต็มที่ จุดที่อยู่ลึกสุดของรอยแยก (2,2) ถูกล้อมรอบทั้ง 8 ด้านด้วยกำแพงรอยแยกที่ใกล้กว่า ทุกเพื่อนบ้านเลยนับเป็นตัวบัง: AO ตกลงมาที่ 0.000 มืดเต็มที่ เอาค่านี้ไปคูณกับ ambient/indirect light term ใน lighting ของเรา มุมกับรอยพับก็จะมืดลงเองโดยธรรมชาติ

Common mistake คาดหวังว่า SSR กับ SSAO จะถูกต้องตามฟิสิกส์จริง ทั้งคู่เห็นแค่สิ่งที่อยู่บนจออยู่แล้วเท่านั้น ภาพสะท้อนของอะไรที่อยู่หลังกล้อง, ตัวบังที่อยู่นอกเฟรมพอดี, หรือ geometry ที่ถูกบังโดย object อื่น จะหายไปเฉย ๆ เพราะไม่มีข้อมูล depth หรือสีของมันเลย นี่คือ trade-off หลักของทุกเทคนิคแบบ screen-space ใน section นี้: ถูก เพราะใช้ข้อมูลที่มีอยู่แล้วซ้ำ แต่ไม่ครบ เพราะ "อยู่บนจอตอนนี้" ไม่เหมือนกับ "มีอยู่จริงในฉาก"

11. GPU-Driven Rendering และ Culling

การ render object ที่กล้องมองไม่เห็นเลยเสียเวลา pipeline ทั้งหมดไปเปล่า ๆ Culling คือการตัดสินใจ ก่อนที่จะทำงานที่แพงนั้น ว่า object ไหนสามารถข้ามไปได้เลย

รูปแบบที่ง่ายและใช้บ่อยที่สุดคือ frustum culling: test bounding volume ของแต่ละ object (รูปทรงง่าย ๆ อย่าง sphere หรือ box ที่ครอบ mesh จริงไว้ทั้งหมด ถูกกว่าการ test ทุก triangle) กับ frustum ของกล้อง (ปริมาตรทรงพีระมิดที่กล้องมองเห็น ถูกจำกัดด้วย near plane, far plane, และขอบของ field of view) อะไรก็ตามที่อยู่นอก frustum ทั้งหมดจะถูกข้ามไปก่อนที่แม้แต่ vertex เดียวของมันจะถูกส่งไปที่ GPU

far plane | | | FRUSTUM | | [ ลัง: OK ] | camera *-|---------------------- | [ หิน: ไกลไป ] X (นอก far plane) | [ อยู่ข้าง ๆ ] X (นอก side plane) | | near plane

#include <cstdio>
#include <cmath>

struct Sphere { float x, z, radius; };

// A simplified 2D frustum: camera at the origin looking down +z,
// with a field-of-view half-angle, and near/far clip distances.
bool in_frustum(Sphere s, float near_d, float far_d, float half_angle_rad) {
    if (s.z + s.radius < near_d) return false; // fully behind the near plane
    if (s.z - s.radius > far_d) return false;  // fully beyond the far plane

    // Distance the frustum's side plane is from the view axis at this depth.
    float side_limit = s.z * tanf(half_angle_rad);
    if (fabsf(s.x) - s.radius > side_limit) return false; // outside the side plane
    return true;
}

int main() {
    float near_d = 0.5f, far_d = 100.0f, half_angle = 0.6f; // about 34 degrees

    Sphere crate    = {2.0f, 20.0f, 1.0f};   // near the camera's path
    Sphere far_rock = {5.0f, 200.0f, 3.0f};  // way past the far plane
    Sphere off_side = {90.0f, 20.0f, 1.0f};  // way off to the side

    printf("crate visible?    %s\n", in_frustum(crate, near_d, far_d, half_angle) ? "yes" : "no");
    printf("far_rock visible? %s\n", in_frustum(far_rock, near_d, far_d, half_angle) ? "yes" : "no");
    printf("off_side visible? %s\n", in_frustum(off_side, near_d, far_d, half_angle) ? "yes" : "no");
    return 0;
}

Output:


crate visible?    yes
far_rock visible? no
off_side visible? no

ลังผ่านทั้งสามการเช็คและถูกวาด หินไม่ผ่านการเช็ค far-plane ไปเลย — มันไกลเกินไปอยู่ดี ไม่ว่ามุมจะเป็นยังไงก็ตาม object ที่อยู่ข้าง ๆ ผ่านการเช็ค near/far แต่ไม่ผ่านการเช็ค side-plane — มันอยู่ในระยะที่วาดได้ แต่อยู่นอก field of view ของกล้องไปมาก การวาดมันจึงเสียงานเปล่า

Frustum culling จับได้แค่ object ที่อยู่นอก view volume ของกล้อง Occlusion culling จับกรณีที่ต่างออกไป: object ที่อยู่ใน frustum เต็ม ๆ แต่ถูกบังมิดจากอะไรที่อยู่ใกล้กว่า — เช่นห้องที่อยู่หลังกำแพง เทคนิคที่นิยมใช้คือ Hi-Z (hierarchical depth buffer): สร้าง pyramid แบบ mipmap ของ depth buffer (แต่ละ level เก็บ depth ที่ไกลที่สุดของกลุ่ม pixel จาก level ข้างล่าง) ทำให้การ test ว่า bounding volume ของ object ถูกบังมิดหลัง geometry ที่มีอยู่แล้วหรือเปล่า ทำได้ด้วยการ lookup แบบหยาบ ๆ ไม่กี่ครั้ง แทนที่จะเทียบกับทุก pixel ที่มันอาจครอบคลุม

การทำ culling ทั้งหมดนี้บน CPU ทีละ object กลายเป็นคอขวดของตัวมันเองเมื่อฉากมี object เป็นแสนตัว — แม้แต่ test ที่ถูกต่อ object ก็สะสมกันจนหนัก และทุก draw call ที่ CPU ตัดสินใจส่งออกไปก็เสียเวลาข้ามไปหา GPU GPU-driven rendering ย้ายการตัดสินใจไปไว้บน GPU เอง: compute shader (โปรแกรม GPU แบบทั่วไป ไม่ผูกกับ vertex หรือ pixel ตัวใดตัวหนึ่ง) test visibility ของ object ทั้งหมดพร้อมกันแบบขนาน แล้วเขียนตัวที่รอดลงใน buffer ของ indirect draw call (คำสั่ง draw ที่ parameter ของมัน เช่นว่าจะวาด object ไหน ถูกอ่านจาก GPU memory แทนที่จะถูกระบุไว้ล่วงหน้าโดย CPU) CPU แค่ต้องสั่ง dispatch ครั้งเดียว GPU ตัดสินใจเองว่าจะวาดอะไรแล้ววาดมันเลย โดย CPU เข้ามาเกี่ยวข้องน้อยลงมากต่อ object

CPU-driven (แบบดั้งเดิม) GPU-driven ------------------------ ---------- CPU วน loop ทุก object: CPU: dispatch compute shader ตัวเดียว test frustum / occlusion GPU: cull object ทั้งหมดแบบขนาน ถ้ามองเห็น: ส่ง draw call เขียนตัวที่รอดลงใน (ช้าเมื่อจำนวน object เยอะมาก) indirect draw-call buffer GPU: execute indirect draw (CPU แทบไม่เกี่ยวหลัง dispatch)
Tip Culling จะช่วยประหยัดเวลาก็ต่อเมื่อ test เองถูกกว่าสิ่งที่มันอาจจะข้ามไปได้ bounding-sphere test แทบไม่เสียอะไรเลยเมื่อเทียบกับการ render mesh 50,000 triangle นี่คือเหตุผลที่ culling check มักใช้รูปทรงประมาณค่าถูก ๆ (sphere หรือ box) แทนที่จะ test geometry จริงตรง ๆ

12. รวมทุกอย่างเข้าด้วยกัน: หนึ่งเฟรมสมัยใหม่ ทีละ Pass

ไม่มีเทคนิคไหนที่รันแยกโดด ๆ เลย หนึ่งเฟรมทั่วไปใน deferred renderer สมัยใหม่รัน pass ของมันตามลำดับที่แน่นอน เพราะแต่ละ pass มักจะขึ้นอยู่กับข้อมูลที่ pass ก่อนหน้าสร้างไว้

shadow pass (ต่อแสง) -> depth prepass -> G-buffer pass -> SSAO -> lighting pass -> SSR -> forward pass (transparent) -> TAA resolve -> tonemap/post -> present

อ่านจากซ้ายไปขวา: shadow pass render shadow map ของแต่ละแสงก่อน (section 2-4) เพราะ pass หลักของฉากจะต้อง sample มันทีหลัง depth prepass มักจะ render แค่ depth ของ object ทึบก่อนอย่างอื่นทั้งหมด เพื่อให้ pass ทีหลังข้าม pixel ที่สุดท้ายถูกบังโดยอะไรที่อยู่ใกล้กว่าได้ ตัด overdraw ตั้งแต่ก่อน G-buffer pass จะเริ่มด้วยซ้ำ G-buffer pass (section 9) เขียนข้อมูล material ครั้งเดียวต่อ pixel ที่มองเห็น SSAO (section 10) รันต่อจากนั้นเพราะมันต้องการแค่ข้อมูล depth/normal ที่ G-buffer เพิ่งสร้างเสร็จ lighting pass อ่าน G-buffer, shadow map, และผล SSAO พร้อมกันเพื่อสร้างภาพที่โดนแสงแล้ว SSR (section 10) รันหลัง lighting เพราะมันต้องการ color buffer ที่ shade แล้วเพื่อ sample ภาพสะท้อน forward pass แยกต่างหากจัดการ transparent object เป็นลำดับสุดท้ายในบรรดา geometry 3D เพราะมันต้อง blend กับทุกอย่างที่วาดไว้ข้างหลังมันแล้ว TAA (section 8) resolve เฟรมที่ jitter แล้วเทียบกับ history ใกล้ ๆ ตอนท้าย ก่อน tonemapping (แปลงค่าความสว่างที่ render ไว้ให้เป็นสีที่แสดงผลได้จริง) และ post-processing อื่น ๆ เพื่อให้ขั้นตอนสุดท้ายเหล่านั้นทำงานกับภาพที่นิ่งและ anti-alias แล้ว ไม่ใช่ภาพที่ยัง jitter อยู่

engine ของแต่ละสตูดิโอปรับลำดับนี้แตกต่างกันไปบ้าง และบาง pass (อย่าง SSR หรือ SSAO) บางทีก็ถูกตัดออกเลยบน hardware ระดับล่างเพื่อประหยัด budget แต่รูปทรงโดยรวม — เงาและ depth ก่อน แล้วข้อมูล material แล้วแสง แล้ว screen-space effect ที่ใช้สิ่งที่วาดไว้แล้วซ้ำ แล้ว temporal resolve แล้วสุดท้าย color grading — เกือบจะเป็นสากลในบรรดา engine ที่วางขายจริง

13. จะเรียนเรื่องพวกนี้ให้ได้จริง ๆ ต้องทำยังไง

บทนี้ให้แนวคิดและ simulation เล็ก ๆ ที่ลองทำมือให้กับแต่ละเทคนิค นั่นพอสำหรับจำไอเดียพวกนี้ได้และคิดเรื่อง trade-off ของมันได้ แต่การ implement มันจริง ๆ ใน renderer ที่ทำงานได้จริงเป็นทักษะที่ลึกกว่าและต่างออกไป และตรงนั้นแหละที่ความเข้าใจจะติดอยู่จริง ๆ เส้นทางที่ใช้ได้จริง:

อ่าน talk/paper -> implement เวอร์ชันเล็กที่สุด -> ตรวจด้วย graphics debugger (RenderDoc/PIX/Nsight) -> เทียบกับ reference -> ปรับ parameter -> ทำซ้ำ
Tip เราใช้ RenderDoc กับเกมที่เราไม่ได้เขียนเองได้เหมือนกัน ไม่ใช่แค่โปรเจกต์ของเราเอง การ capture เฟรมจากเกมที่วางขายจริงแล้วไล่ดู G-buffer layout จริง หรือ shadow cascade setup จริงของมัน เป็นวิธีที่เร็วที่สุดวิธีหนึ่งที่จะเห็นว่าสตูดิโอจริง ๆ แก้ปัญหาที่บทนี้พูดถึงยังไงบ้าง

14. Glossary

15. Exercises

Exercise 1 — ทายค่า PCF ใช้ array shadow_map และฟังก์ชัน pcf_shadow เดิมจาก Section 4 ให้ลองคำนวณด้วยมือว่า pcf_shadow(4, 3, 2.4f) ควรได้ผลลัพธ์อะไร — จุดที่อยู่บนขอบด้านขวาของเงากล่อง แสดงว่า texel ไหนใน 9 ตัวที่ sample เป็นตัวบัง (occluder) และตัวไหนโดนแสง แล้วเขียนโปรแกรมเล็ก ๆ เพื่อเช็คคำตอบ
Show answer

พื้นที่ 3x3 รอบ (cx=4, cy=3) ครอบคลุมแถวที่ 2, 3, และ 4 คอลัมน์ 3, 4, และ 5 ดูใน array shadow_map แถวที่ 2-4 ทั้งหมดเป็น {5,5,2,2,2,5,5} ดังนั้นคอลัมน์ 3, 4, และ 5 ของแต่ละแถวนั้นคือ 2, 2, 5 นั่นได้ค่า sample ทั้ง 9 ตัว: 2,2,5, 2,2,5, 2,2,5 — หก texel ที่ depth 2 (เป็นตัวบัง เพราะ 2.4 <= 2.01 เป็น false) และสาม texel ที่ depth 5 (โดนแสง เพราะ 2.4 <= 5.01 เป็น true) ดังนั้น lit_count = 3 และผลลัพธ์ที่คาดไว้คือ 3 / 9 = 0.333


#include <cstdio>

float shadow_map[7][7] = {
    {5,5,5,5,5,5,5},
    {5,5,5,5,5,5,5},
    {5,5,2,2,2,5,5},
    {5,5,2,2,2,5,5},
    {5,5,2,2,2,5,5},
    {5,5,5,5,5,5,5},
    {5,5,5,5,5,5,5}
};

float pcf_shadow(int cx, int cy, float dist_from_light) {
    float bias = 0.01f;
    int lit_count = 0;
    for (int dy = -1; dy <= 1; ++dy)
        for (int dx = -1; dx <= 1; ++dx)
            if (dist_from_light <= shadow_map[cy + dy][cx + dx] + bias)
                lit_count++;
    return (float)lit_count / 9.0f;
}

int main() {
    printf("PCF at (4,3): %.3f\n", pcf_shadow(4, 3, 2.4f));
    return 0;
}

PCF at (4,3): 0.333

ยืนยันแล้ว: 0.333 แปลว่าจุดนี้บนขอบขวาของเงากล่องโดนแสงหนึ่งในสาม เมื่อรวมกับค่าจาก Section 4 — 0.000 ลึกเข้าไปข้างใน, 0.556 ตรงขอบซ้าย, 0.889 เกือบพ้นเงาแล้ว — เงาก็อ่านออกมาเป็น gradient นุ่ม ๆ ตลอดทั้งขอบของมัน ตรงกับผลที่ PCF ตั้งใจจะสร้างเป๊ะ

Exercise 2 — ขยาย TAA เป็น 8 เฟรม เอาโค้ดสะสม TAA จาก Section 8 มาปรับให้รัน 8 เฟรมแทนที่จะเป็น 4 เฟรม โดยวน jitter offset ทั้งสี่ตัวเดิมซ้ำเป็นรอบที่สอง (frame index modulo 4) พิมพ์ค่าที่สะสมทุกเฟรม มันนิ่งอยู่ที่ 0.25 พอดีเป๊ะไหม ซึ่งเป็นค่าเฉลี่ยจริง อธิบายว่าสังเกตเห็นอะไร
Show answer

#include <cstdio>

bool inside_triangle(float x, float y) {
    return (x + y) < 7.0f;
}

int main() {
    float jitter_x[4] = {0.25f, 0.75f, 0.25f, 0.75f};
    float jitter_y[4] = {0.25f, 0.25f, 0.75f, 0.75f};
    int px = 3, py = 3;

    float history = 0.5f;
    float alpha = 0.25f;

    for (int frame = 0; frame < 8; ++frame) {
        int j = frame % 4; // cycle through the same 4 jitter offsets again
        float sample = inside_triangle(px + jitter_x[j], py + jitter_y[j]) ? 1.0f : 0.0f;
        history = history * (1.0f - alpha) + sample * alpha;
        printf("Frame %d: jittered sample=%.1f  accumulated=%.4f\n", frame, sample, history);
    }
    return 0;
}

Frame 0: jittered sample=1.0  accumulated=0.6250
Frame 1: jittered sample=0.0  accumulated=0.4688
Frame 2: jittered sample=0.0  accumulated=0.3516
Frame 3: jittered sample=0.0  accumulated=0.2637
Frame 4: jittered sample=1.0  accumulated=0.4478
Frame 5: jittered sample=0.0  accumulated=0.3358
Frame 6: jittered sample=0.0  accumulated=0.2519
Frame 7: jittered sample=0.0  accumulated=0.1889

มันไม่ได้นิ่งอยู่ที่ 0.25 หลัง sample ของเฟรม 4 ที่เป็น 1.0 ค่าที่สะสมกระโดดขึ้นไปที่ 0.4478 แล้วค่อย ๆ ลดลงไปที่ 0.1889 ในเฟรม 7 — ต่ำกว่าตอนที่มันอยู่ในเฟรม 3 (0.2637) ด้วยซ้ำ น้ำหนัก blend ที่คงที่ (alpha = 0.25) คือ exponential moving average ซึ่งให้น้ำหนักกับเฟรมล่าสุดมากกว่าเฟรมเก่าเสมอ เพราะ sample "สว่าง" จริง ๆ เกิดขึ้นแค่ครั้งเดียวทุก 4 เฟรม พอมันโผล่มาปุ๊บค่าเฉลี่ยก็พุ่งขึ้น และก่อนที่มันจะโผล่มาอีกครั้งค่าเฉลี่ยก็ค่อย ๆ ลดลงเข้าหา sample "มืด" ที่ครอบงำเฟรมระหว่างกลาง มันไม่เคย converge ไปที่ตัวเลขคงที่ตัวเดียวจริง ๆ สำหรับ pattern ที่วนซ้ำแบบนี้ — มันแกว่งอยู่รอบ ๆ ค่าเฉลี่ยจริงแทน นี่คือเหตุผลเป๊ะ ๆ ที่ TAA implementation จริง ๆ จับคู่ blend weight เข้ากับ neighborhood clamping (warning box ของ Section 8) และมักใช้น้ำหนักพิเศษที่สูงกว่าสำหรับไม่กี่เฟรมแรกหลังจากมีอะไรโผล่มาใหม่ แทนที่จะเชื่อ alpha ค่าเดียวตลอดไป

Exercise 3 — เลือก Pipeline เรากำลัง optimize ฉากหนึ่งที่มีแสง dynamic เล็ก ๆ ประมาณ 200 ดวง กับใบไม้แบบ alpha-tested ที่หนาแน่นและซ้อนทับตัวเองเยอะมาก (overdraw เยอะ) เราจะเอียงไปทาง forward หรือ deferred shading สำหรับพื้นดินกับก้อนหินที่ทึบ และจะเลือก MSAA หรือ TAA สำหรับ anti-aliasing อธิบายเหตุผลสักสองสามประโยค แล้วบอกว่าจะทำอะไรต่างออกไปสำหรับตัวใบไม้เอง
Show answer

สำหรับพื้นดินกับก้อนหินที่ทึบ forward shading ธรรมดาจะคูณแสง 200 ดวงกับทุก pixel ที่ถูก overdraw ซึ่งแพงขึ้นเร็วมาก (ตัวอย่างที่คำนวณไว้ใน Section 9 แสดงช่องว่างแบบนี้เป๊ะ) G-buffer pass แบบ deferred ล้วน ๆ แก้ปัญหาจำนวนแสงได้ดี เพราะ lighting รันแค่ครั้งเดียวต่อ pixel สุดท้ายที่มองเห็น ไม่ว่าจะมีแสงกี่ดวงก็ตาม — แต่ deferred จัดการใบไม้แบบ alpha-tested ที่ซ้อนทับกันเยอะได้ไม่ดี เพราะ G-buffer เก็บได้แค่ surface เดียวต่อ pixel แต่ใบไม้ต้องการหลาย layer ที่ alpha-tested ซ้อนทับกัน คำตอบที่ engine ที่วางขายจริงส่วนใหญ่ใช้คือแบบผสม: deferred (หรือ Forward+/clustered shading, Section 9) สำหรับพื้นดินกับก้อนหินที่ทึบ และ forward pass แยกต่างหากสำหรับใบไม้ รันหลัง lighting pass ของ deferred เพื่อให้ใบไม้ blend และ alpha-test กับสิ่งที่วาดไว้ข้างหลังมันแล้วได้ถูกต้อง

สำหรับ anti-aliasing ใบไม้แบบ alpha-tested คือกรณีที่ MSAA มีปัญหาเป๊ะ ๆ (warning box ของ Section 6) — MSAA ทำให้เนียนแค่ขอบ triangle เท่านั้น ไม่ใช่ขอบที่ตัดออกข้างในของ alpha channel ของ texture ใบไม้ ดังนั้นขอบใบไม้จะยังดูเป็นรอยหยักอยู่ดีแม้เปิด MSAA แล้ว TAA ทำให้ขอบที่ตัดออกแบบนั้นเนียนขึ้นด้วย เพราะมันทำงานกับภาพสุดท้ายที่ render แล้วผ่านช่วงเวลา ไม่ใช่แค่ coverage ทาง geometry เท่านั้น แลกกับต้องจัดการอย่างระมัดระวัง (velocity buffer, clamping) สำหรับรายละเอียดความถี่สูงเล็ก ๆ ของใบไม้ เพื่อไม่ให้มัน ghost หรือเปื้อนตอนมันไหวหรือตอนกล้องเคลื่อนผ่านมัน เมื่อพิจารณาใบไม้แบบ alpha-tested ที่หนาแน่นแล้ว TAA เหมาะสมกว่าโดยรวมในกรณีนี้ จับคู่กับ pipeline แบบ forward+deferred ผสมที่อธิบายไว้ข้างบน

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