7.9 Ray Tracing

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

จำลองแสงด้วยการยิง ray — path tracing, acceleration structure แบบ BVH และ hardware ray tracing (DXR / Vulkan RT)

บทที่ 7.1 เราสร้าง rendering pipeline ที่เริ่มจาก triangle แล้วจบที่ pixel: ใส่ vertex เข้าไป, rasterizer จะคำนวณว่า pixel ไหนอยู่ในแต่ละ triangle บ้าง แล้ว fragment shader ก็ใส่สีให้ Ray tracing ทำงานย้อนกลับทั้งกระบวนการนี้เลย มันเริ่มจาก pixel ยิง ray (เส้นตรงสมมติ) ออกไปในฉาก 3D ตามแนวสายตาของ pixel นั้น แล้วถามคำถามเดิมซ้ำ ๆ ว่า ray เส้นนี้ชนอะไรก่อน บทนี้เราจะสร้าง ray tracer เล็ก ๆ ด้วย C++ ธรรมดา ทีละส่วน ตั้งแต่ camera ray, ray-sphere กับ ray-triangle intersection (เอา barycentric coordinates จาก 7.1 มาใช้ซ้ำ), shadow, reflection, path tracing แบบเต็มรูปแบบ ไปจนถึง BVH ซึ่งเป็น acceleration structure ที่ทำให้ทุกอย่างข้างต้นเร็วพอจะใช้งานจริงได้ จบด้วยว่า GPU จริง ๆ ทำเรื่องนี้ในฮาร์ดแวร์ยังไง (DXR, Vulkan RT) และเกมยุคใหม่ผสม rasterization กับ ray tracing ในเฟรมเดียวกันยังไง

1. จาก Pixel สู่ Triangle: Ray Tracing พลิกทิศทาง Pipeline

Rasterizer ที่เราสร้างใน 7.1 จะ loop วน triangle ทีละอัน สำหรับแต่ละ triangle มันถามว่า "triangle นี้คลุม pixel ไหนบ้าง" ส่วน ray tracing จะ loop วน pixel แทน สำหรับแต่ละ pixel มันถามว่า "ray จาก camera ที่ยิงผ่าน pixel นี้ ชน triangle (หรือ sphere หรือรูปทรงอื่น) อันไหนก่อน" ภาพผลลัพธ์สุดท้ายเหมือนกัน แค่ทิศทางของ loop กลับด้านกัน

RASTERIZATION (7.1) RAY TRACING (this chapter) for each triangle: for each pixel: for each pixel it might cover: shoot a ray through it is this pixel inside it? find what it hits first if yes, shade it shade what it hit triangle --> pixels pixel --> ray --> triangle

ทำไมต้องทำย้อนทิศทางด้วย เพราะ "ยิง ray ออกไปแล้วดูว่ามันชนอะไร" คือสิ่งที่แสงจริง ๆ ทำในโลกจริงเป๊ะ ๆ แค่ทำย้อนกลับ (แสงจริงเดินทางจากหลอดไฟเข้าตาเรา ส่วน ray tracer เริ่มจากตา/camera แล้วไล่ย้อนกลับไปหาว่าอะไรเป็นตัวส่งแสงมาที่นี่) นั่นแปลว่า effect ที่ทำยากมากถ้าจะปลอมใน rasterizer อย่างเช่น reflection แบบกระจกที่แม่นยำ, shadow จากแหล่งแสงรูปทรงไหนก็ได้, แสงที่กระเด้งจากผนังหนึ่งไปส่องอีกผนังหนึ่ง — จะได้มาแทบจะฟรี ๆ ทันทีที่ตอบคำถาม "ray นี้ชนอะไร" ได้ ในบทนี้เราจะสร้างทั้งสามอย่างนี้โดยใช้ function เดียวกันเป๊ะ ๆ

Tip ray tracing ไม่ได้มาแทน rasterizer จาก 7.1 ในเกมส่วนใหญ่ ตามที่ section 13 จะพูดถึง เกมยุคใหม่ยัง rasterize เกือบทุกอย่าง (เพราะถูกกว่ามาก) แล้วใช้ ray tracing แค่กับ effect จำนวนหนึ่งที่จำเป็นต้องใช้จริง ๆ การเรียนทั้งสองแบบทำให้เราเข้าใจเครื่องมือทั้งกล่อง ไม่ใช่แค่ค้อนอันเดียว

2. Ray, Camera และ Ray หนึ่งเส้นต่อหนึ่ง Pixel

ray คือวิธีง่ายที่สุดในการอธิบายเส้นตรงเส้นหนึ่งด้วยจุดเริ่มต้นกับทิศทาง: origin (จุดในพื้นที่ 3D) กับ direction (unit vector คือ vector ที่มีความยาวเท่ากับ 1 พอดี ชี้ไปทางที่ ray เดินทาง) จุดใด ๆ บน ray คำนวณได้จาก origin + t * direction โดย t คือตัวเลขตัวเดียว — t ยิ่งมากยิ่งอยู่ไกลตามแนว ray ส่วนนี้เอา Vec3 กับ vector math จาก 2.1 มาใช้ซ้ำ ไม่มีอะไรใหม่

#include <cstdio>
#include <cmath>

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

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

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

Vec3 normalize(Vec3 v)
{
    float len = sqrtf(dot(v, v));
    return { v.x / len, v.y / len, v.z / len };
}

struct Ray
{
    Vec3 origin;
    Vec3 dir;   // must always be unit length (length 1)
};

การจะ render ภาพหนึ่งภาพ เราต้องมี ray หนึ่งเส้นต่อหนึ่ง pixel โดย ray ทุกเส้นเริ่มจาก camera แล้วกระจายออกไปผ่าน grid ของ pixel สมมติที่วางอยู่ห่างจาก camera คงที่ระยะหนึ่ง (เรียกว่า image plane) นี่คือภาพสะท้อนกลับของ projection math จาก 2.1: แทนที่จะแปลงจุด 3D เป็น pixel 2D เรากลับแปลง pixel 2D ย้อนกลับเป็นทิศทาง 3D

Ray makeCameraRay(int px, int py, int width, int height)
{
    Vec3 camPos = { 0.0f, 0.0f, 0.0f };             // camera sits at the origin...
    float u = (px + 0.5f) / width  * 2.0f - 1.0f;   // pixel center mapped to -1..1
    float v = 1.0f - (py + 0.5f) / height * 2.0f;   // flipped so +y is up on screen
    Vec3 screenPoint = { u, v, -1.0f };              // ...looking down -z
    return { camPos, normalize(screenPoint - camPos) };
}

int main()
{
    Ray r = makeCameraRay(0, 0, 4, 4);   // top-left pixel of a 4x4 image
    printf("origin = (%.2f, %.2f, %.2f)\n", r.origin.x, r.origin.y, r.origin.z);
    printf("dir    = (%.2f, %.2f, %.2f)\n", r.dir.x, r.dir.y, r.dir.z);
}

Output:

origin = (0.00, 0.00, 0.00)
dir    = (-0.51, 0.51, -0.69)
camera image plane (grid of pixels) scene * +--+--+--+--+ +-------+ | | | | | | | | +---------------------+--+--X--+--+---------------->| O | | | | | | | | | + +--+--+--+--+ +-------+ The ray starts at the camera (*), passes straight through one pixel (X) on the image plane, and keeps going in a straight line until it hits the sphere (O) in the scene. Every pixel on screen gets its own ray.

pixel (0, 0) คือมุมซ้ายบน ดังนั้น u กับ v จะไปอยู่ที่ปลายสุดด้าน negative/positive ของช่วงตัวเลขทั้งคู่ ซึ่งตรงกับ output ที่ได้พอดี: ทิศทางชี้ไปทางซ้ายบน (x ติดลบ, y เป็นบวก) และพุ่งเข้าไปในฉาก (z ติดลบ) ถ้าจะ render ภาพเต็ม ๆ ก็แค่เรียก makeCameraRay ทีละครั้งต่อ pixel ใน loop width * height แล้ว trace แต่ละเส้นแยกจากกันอิสระ นี่คือเหตุผลที่ ray tracing มักถูกเรียกว่า "embarrassingly parallel" (ขนานได้แบบง่ายสุด ๆ) เพราะ ray ของแต่ละ pixel ไม่ขึ้นกับ ray ของ pixel อื่นเลย

3. Ray-Sphere Intersection

ทีนี้มาถึงคำถามจริง: ถ้าให้ ray มาเส้นหนึ่ง มันชนรูปทรงนั้นไหม แล้วชนที่ไหน sphere คือ case ที่ง่ายที่สุด และเป็นตัวอุ่นเครื่องที่ดีก่อนไปเจอ triangle sphere ก็คือ "จุดทุกจุดที่อยู่ห่างจาก center เท่ากับ radius" ซึ่งเขียนเป็นสมการได้ว่า dot(p - center, p - center) = radius * radius แทนค่า p = origin + t * dir เข้าไป จะได้สมการ quadratic ของ t — รูปแบบเดียวกับ a*t^2 + b*t + c = 0 ที่เราเรียนใน algebra พื้นฐาน แค่เปลี่ยนสัมประสิทธิ์มาเป็น vector dot product แทน

struct Sphere { Vec3 center; float radius; };

// Returns true if the ray hits the sphere; fills t with the distance to
// the closest hit in front of the ray.
bool hitSphere(const Sphere& s, const Ray& r, float& t)
{
    Vec3 oc = r.origin - s.center;
    float a = dot(r.dir, r.dir);            // = 1 if dir is normalized
    float b = 2.0f * dot(oc, r.dir);
    float c = dot(oc, oc) - s.radius * s.radius;
    float disc = b*b - 4*a*c;               // the quadratic discriminant

    if (disc < 0.0f) return false;          // negative -> ray misses the sphere entirely

    float sqrtDisc = sqrtf(disc);
    float t0 = (-b - sqrtDisc) / (2*a);      // the closer of the two roots
    float t1 = (-b + sqrtDisc) / (2*a);      // the farther of the two roots

    if (t0 > 0.001f) { t = t0; return true; }   // normal case: hit the near side
    if (t1 > 0.001f) { t = t1; return true; }   // camera is inside the sphere
    return false;                                // both hits are behind the ray
}

int main()
{
    Sphere s = { {0.0f, 0.0f, -5.0f}, 1.0f };
    Ray r = { {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f} };

    float t;
    if (hitSphere(s, r, t))
    {
        Vec3 hitPoint = r.origin + r.dir * t;
        printf("hit at t = %.2f, point = (%.2f, %.2f, %.2f)\n",
               t, hitPoint.x, hitPoint.y, hitPoint.z);
    }
    else
    {
        printf("miss\n");
    }
}

Output:

hit at t = 4.00, point = (0.00, 0.00, -4.00)

center ของ sphere อยู่ที่ z = -5 รัศมี 1 ดังนั้นจุดบนผิว sphere ที่ใกล้ที่สุดตามแนวแกน -z คือ z = -4 — ตรงกับที่โค้ดคำนวณได้เป๊ะ สมการ quadratic มี root จริงได้ 0, 1 หรือ 2 ตัว ซึ่งตรงกับ 3 กรณีทางเรขาคณิต: ไม่มี root เลย (discriminant ติดลบ) แปลว่าเส้นของ ray ไม่แตะ sphere เลย, มี 2 root แปลว่าเส้นของ ray เจาะทะลุ sphere เข้าที่ t0 แล้วออกที่ t1, ส่วน root ซ้ำกัน (discriminant เท่ากับศูนย์พอดี) แปลว่า ray แค่เฉียดผิว sphere ที่จุดสัมผัสจุดเดียว

ray: origin + t * dir origin *------------------o-----------o----------> t0 t1 (enters sphere) (exits sphere) The sphere sits on the segment between t0 and t1. t0 is the closer, visible surface point -- that is the one the camera actually sees.

การเช็ค t > 0.001f แทนที่จะเป็น t > 0.0f สำคัญกว่าที่เห็น floating-point rounding ทำให้จุดที่คำนวณว่า "อยู่บนผิวพอดี" อาจคลาดเคลื่อนเข้าไปข้างในหรือออกไปข้างนอกผิวนิดหน่อยได้ การใช้ threshold บวกเล็ก ๆ (เรียกว่า epsilon) แทนศูนย์ ช่วยป้องกันไม่ให้ ray ชนผิวเดิมที่มันเพิ่งเริ่มต้นซ้ำทันที — ปัญหานี้เราจะเจออีกครั้งใน section 7 แต่หนักกว่านี้

4. Ray-Triangle Intersection: Möller–Trumbore

mesh จริง ๆ ในเกมประกอบด้วย triangle ไม่ใช่ sphere ดังนั้นนี่คือ intersection test ที่สำคัญจริง ๆ กับฉากในเกม เหมือนกับ index buffer ที่เราสร้างใน 7.1 (list ของเลขจำนวนเต็มที่บอกว่า vertex สามตัวไหนประกอบกันเป็นแต่ละ triangle) mesh สำหรับ ray-traced ก็คือ list ของตำแหน่ง vertex บวกกับ list ของ triangle ที่อ้างอิงกลับไปที่ vertex ด้วย index อัลกอริทึมด้านล่างนี้ชื่อ Möller–Trumbore ตามชื่อนักวิจัยสองคนที่ตีพิมพ์มัน จะเทส ray กับจุดมุมทั้งสามของ triangle โดยตรง โดยไม่ต้องหาสมการ plane ของ triangle แยกเป็นขั้นตอนต่างหากเลย

Vec3 cross(Vec3 a, Vec3 b)
{
    return { a.y*b.z - a.z*b.y,
             a.z*b.x - a.x*b.z,
             a.x*b.y - a.y*b.x };
}

// a, b, c are the triangle's three corner positions. Returns true on a
// hit; fills t (distance along the ray) and u, v (barycentric weights).
bool hitTriangle(Vec3 a, Vec3 b, Vec3 c, const Ray& r,
                  float& t, float& u, float& v)
{
    const float EPS = 1e-7f;
    Vec3 edge1 = b - a;
    Vec3 edge2 = c - a;
    Vec3 pvec = cross(r.dir, edge2);
    float det = dot(edge1, pvec);

    if (fabsf(det) < EPS) return false;   // ray is parallel to the triangle's plane
    float invDet = 1.0f / det;

    Vec3 tvec = r.origin - a;
    u = dot(tvec, pvec) * invDet;
    if (u < 0.0f || u > 1.0f) return false;   // outside the triangle on this edge

    Vec3 qvec = cross(tvec, edge1);
    v = dot(r.dir, qvec) * invDet;
    if (v < 0.0f || u + v > 1.0f) return false;   // outside on one of the other edges

    t = dot(edge2, qvec) * invDet;
    return t > EPS;   // triangle is behind the ray -> not a real hit
}

int main()
{
    Vec3 a = { -1.0f, -1.0f, -5.0f };
    Vec3 b = {  1.0f, -1.0f, -5.0f };
    Vec3 c = {  0.0f,  1.0f, -5.0f };
    Ray r = { {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f} };

    float t, u, v;
    if (hitTriangle(a, b, c, r, t, u, v))
        printf("hit at t = %.2f, u = %.2f, v = %.2f\n", t, u, v);
    else
        printf("miss\n");
}

Output:

hit at t = 5.00, u = 0.25, v = 0.50

triangle นี้วางราบอยู่บน plane z = -5 ดังนั้น t = 5 ถูกต้องได้ทันที ส่วนที่น่าสนใจคือ u กับ v: มันคือ barycentric weight สองในสามตัวเดียวกันเป๊ะ ๆ กับ wA, wB, wC จาก rasterizer ใน 7.1 แค่เปลี่ยนชื่อ (u ที่นี่คือ wB, v ที่นี่คือ wC ส่วน weight ของ vertex a คือ 1 - u - v เสมอ) rasterizer คำนวณ weight พวกนี้จากตำแหน่ง 2D บนหน้าจอของ pixel โดยใช้ edge function ส่วน Möller–Trumbore คำนวณ weight ชุดเดียวกันเป๊ะ ๆ โดยตรงจาก ray 3D เป็นผลพลอยได้จากการหาจุดที่ชน ไม่ว่าจะทางไหน พอมี weight พวกนี้แล้ว การ interpolate อะไรก็ตามบนผิว triangle — ไม่ว่าจะเป็น shading normal ที่เนียน, texture coordinate หรือ vertex color — ก็คือการผสมแบบถ่วงน้ำหนัก (weighted blend) แบบเดียวกันเป๊ะ ๆ กับใน 7.1 section 7

triangle a, b, c with hit point P inside it: c /| / | / | / P | / | a-----b P = a*(1-u-v) + b*u + c*v u and v are the SAME barycentric weights from 7.1 -- they say how close the hit point sits to vertex b and vertex c respectively.
Common mistake ลืมไปว่า u กับ v คือ weight ของ b กับ c ไม่ใช่ a กับ b ถ้าสลับกันผิด ค่าที่ interpolate ออกมาสองในสามค่าจะสลับกันแบบเงียบ ๆ (เช่น texture ออกมากลับด้าน หรือ shading normal ชี้ผิดทิศ) โดยไม่ crash หรือ assertion fail เลยสักครั้ง ทำให้เป็นบั๊กที่หายากและน่ารำคาญมาก

5. หา Closest Hit ในฉาก

ฉากจริง ๆ มี object เยอะแยะ ray เส้นเดียวอาจผ่านใกล้ ๆ object หลายตัว ดังนั้นเราหยุดแค่ object ตัวแรกที่บังเอิญเทสแล้วชนไม่ได้ — เราต้องการตัวที่ ใกล้ที่สุด ตามแนว ray เพราะนั่นคือตัวที่บัง object อื่นไว้จริง ๆ วิธีแก้ก็ง่าย: เทสทุก object แล้วจำค่า t ที่เล็กที่สุดที่เจอไว้ แล้วเก็บ hit ไว้ก็ต่อเมื่อมันดีกว่าค่าที่ดีที่สุดตอนนี้เท่านั้น

#include <vector>

struct HitRecord
{
    float t;
    Vec3 point;
    Vec3 normal;
    int objectId;
};

bool traceClosest(const Ray& r, const std::vector<Sphere>& spheres, HitRecord& rec)
{
    float closestT = 1e30f;   // start impossibly far away
    bool hitAnything = false;

    for (size_t i = 0; i < spheres.size(); i++)
    {
        float t;
        if (hitSphere(spheres[i], r, t) && t < closestT)
        {
            closestT = t;
            hitAnything = true;
            rec.t = t;
            rec.point = r.origin + r.dir * t;
            rec.normal = normalize(rec.point - spheres[i].center);
            rec.objectId = (int)i;
        }
    }
    return hitAnything;
}

int main()
{
    std::vector<Sphere> spheres = {
        { {0.0f, 0.0f, -5.0f}, 1.0f },   // id 0
        { {0.0f, 0.0f, -3.0f}, 0.5f },   // id 1, closer to the camera
        { {2.0f, 0.0f, -5.0f}, 1.0f }    // id 2, off to the side, ray misses it
    };
    Ray r = { {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f} };

    HitRecord rec;
    if (traceClosest(r, spheres, rec))
        printf("closest hit: object %d at t = %.2f\n", rec.objectId, rec.t);
    else
        printf("no hit\n");
}

Output:

closest hit: object 1 at t = 2.50

object 0 จะโดนชนที่ t = 4 ส่วน object 2 ไม่โดนชนเลย (เส้นตรงของ ray ผ่านห่างจาก center ของมัน 2 หน่วย ซึ่งไกลกว่ารัศมี 1 ของมัน) แต่ object 1 ชนะที่ t = 2.5 เพราะเป็นสิ่งที่ใกล้ที่สุดจริง ๆ ที่อยู่ในเส้นทางของ ray ใน ray tracer จริง ๆ loop เดียวกันนี้จะเช็ค triangle ด้วย hitTriangle จาก section 4 ควบคู่ไปกับ sphere ด้วย — object ไหนคืนค่า t ที่ถูกต้องและเล็กที่สุด object นั้นคือตัวที่จะถูกนำไป shade เทคนิคทุกอย่างที่เหลือในบทนี้ (shadow, reflection, path tracing) สร้างขึ้นจากการเรียก function เดียวนี้ — หรือเวอร์ชันที่เร็วขึ้นจาก section 12 — ซ้ำ ๆ กัน

6. Shade จุดที่ชน: Normal กับแสงแบบง่าย ๆ

การหา hit บอกแค่ว่า ray ไปหยุดที่ ไหน จะแปลงเป็นสีได้ต้องมี surface normal (ทิศทางที่ตั้งฉากกับผิวตรงจุดนั้น ซึ่งเราคำนวณไว้แล้วข้างบนสำหรับ sphere ด้วย normalize(hitPoint - center)) กับทิศทางแสง โมเดลแสงที่ง่ายที่สุดคือตัวเดียวกับใน 7.3: Lambertian diffuse ความสว่างเป็นสัดส่วนกับ dot(normal, lightDir) แล้ว clamp ไว้ที่ศูนย์ เพื่อให้ผิวที่หันหลังให้แสงไม่ได้รับแสงเลย

Vec3 shade(const HitRecord& rec, Vec3 lightDir, Vec3 baseColor)
{
    float ndotl = dot(rec.normal, lightDir);
    if (ndotl < 0.0f) ndotl = 0.0f;   // light behind the surface adds nothing
    return baseColor * ndotl;
}

int main()
{
    HitRecord rec;
    rec.point = { 0.0f, 0.0f, -2.5f };
    rec.normal = { 0.0f, 0.0f, 1.0f };   // points straight back toward the camera

    Vec3 lightDir = normalize({1.0f, 1.0f, 1.0f});
    Vec3 color = shade(rec, lightDir, {0.8f, 0.2f, 0.2f});

    printf("color = (%.3f, %.3f, %.3f)\n", color.x, color.y, color.z);
}

Output:

color = (0.462, 0.115, 0.115)

นี่คือ N dot L term ตัวเดียวกันเป๊ะ ๆ จาก 7.3 แค่คำนวณที่จุดที่ ray หาเจอ แทนที่จะเป็นจุดที่ edge function ของ rasterizer interpolate มา — คณิตศาสตร์เหมือนกันทุกอย่าง เปลี่ยนแค่วิธีที่เราไปถึงจุดบนผิวเท่านั้น ทุกอย่างที่เหลือจาก 7.3 (specular highlight, PBR แบบ metallic/roughness workflow, Fresnel) เสียบเข้ากับขั้นตอน shading ของ ray tracer ได้แบบเดียวกับที่มันเสียบเข้ากับ fragment shader ของ rasterizer เลย ray tracing เปลี่ยนแค่วิธี หาผิว ไม่ได้เปลี่ยนวิธี ใส่แสง ให้ผิวหลังจากหาเจอแล้ว

7. Shadow: ยิง Ray ไปหาแสง

ตรงนี้คือจุดแรกที่ ray tracing รู้สึกต่างจาก rasterization จริง ๆ การจะรู้ว่าจุดหนึ่งอยู่ในเงาหรือเปล่า เรายิง ray เส้นที่สอง — เรียกว่า shadow ray — จากจุดที่ชนไปทางแสง แล้วถามว่า มีอะไรบังมันก่อนจะถึงแสงไหม ถ้ามีก็แปลว่าจุดนั้นอยู่ในเงา ถ้าไม่มีก็แปลว่าโดนแสง ไม่ต้องมี shadow map ไม่ต้องมี rendering pass แยกต่างหาก — ก็แค่เรียก hitSphere function ตัวเดียวกันเป๊ะจาก section 3 ซ้ำอีกครั้ง

bool inShadow(Vec3 hitPoint, Vec3 lightPos, const std::vector<Sphere>& spheres)
{
    Vec3 toLight = lightPos - hitPoint;
    float lightDist = sqrtf(dot(toLight, toLight));
    Vec3 shadowDir = normalize(toLight);

    // Start a little OFF the surface, not exactly on it -- see the
    // warning below for why this matters.
    Ray shadowRay = { hitPoint + shadowDir * 0.001f, shadowDir };

    for (size_t i = 0; i < spheres.size(); i++)
    {
        float t;
        if (hitSphere(spheres[i], shadowRay, t) && t < lightDist)
            return true;   // something sits between the point and the light
    }
    return false;
}
light * | | shadow ray, traced from the hit point toward the light | +-------+ | block | <- if the ray crosses this object, the point is IN SHADOW +-------+ | | o <- hit point (where the camera ray landed on the floor) ____________________________ floor ____________________________

สังเกต t < lightDist: ตัวบังที่อยู่ เลยแสงไป ไม่นับ นับเฉพาะตัวที่อยู่ระหว่างจุดกับแสงจริง ๆ เท่านั้น เอาอันนี้มารวมกับ shading ใน section 6 เพื่อทำให้จุดที่อยู่ในเงามืดลง เช่น if (inShadow(rec.point, lightPos, spheres)) color = color * 0.1f; แทนที่จะทำให้มันดำสนิทไปเลย ซึ่งเป็นการปลอมแสง ambient เล็กน้อยที่กระเด้งไปมาในฉาก

Common mistake — "shadow acne" การเริ่ม shadow ray ที่ hitPoint เป๊ะ ๆ โดยไม่ขยับมันไปข้างหน้าด้วย epsilon เล็ก ๆ (แบบที่โค้ดข้างบนทำด้วย + shadowDir * 0.001f) จะทำให้ ray ไปชนผิวเดิมที่มันเพิ่งออกมาซ้ำทันที เพราะ floating-point rounding บนพิกัดของจุดที่ชน อาการที่เห็นได้คือผิวที่ควรจะโดนแสงสม่ำเสมอ กลับมีจุดดำ ๆ กระจายแบบสุ่มเต็มไปหมด — อาการนี้มีชื่อเรียกด้วยว่า shadow acne เพราะมันหน้าตาเหมือนสิวเลย วิธีแก้เหมือนเดิมเสมอ: ขยับ origin ของ ray ใหม่ออกไปนิดหน่อยตามแนว normal หรือแนวทิศทาง ray ก่อนจะเอาไป trace

8. Reflection: ให้ Ray กระเด้ง

ผิวแบบกระจกทำงานคล้าย shadow ray เลย ต่างกันแค่แทนที่จะเล็งไปที่แสงตำแหน่งคงที่ เราเล็งไปที่ทิศทางที่ ray ขาเข้า กระเด้ง ไป ฟิสิกส์ก็คือกฎ "มุมตกกระทบเท่ากับมุมสะท้อน" แบบที่อาจเคยเจอใน optics: สะท้อนทิศทางขาเข้ารอบ ๆ surface normal

Vec3 reflect(Vec3 d, Vec3 n)
{
    return d - n * (2.0f * dot(d, n));
}

Vec3 traceRay(const Ray& r, const std::vector<Sphere>& spheres, int depth)
{
    if (depth >= 5) return { 0.0f, 0.0f, 0.0f };   // stop after 5 bounces

    HitRecord rec;
    if (!traceClosest(r, spheres, rec))
        return { 0.4f, 0.6f, 0.9f };   // sky color -- the ray hit nothing

    Vec3 direct = shade(rec, normalize({1.0f, 1.0f, 1.0f}), {0.8f, 0.2f, 0.2f});
    if (inShadow(rec.point, {5.0f, 5.0f, 5.0f}, spheres)) direct = direct * 0.1f;

    Vec3 bounceDir = reflect(r.dir, rec.normal);
    Ray bounceRay = { rec.point + bounceDir * 0.001f, bounceDir };   // same epsilon trick as section 7
    Vec3 reflected = traceRay(bounceRay, spheres, depth + 1);

    return direct * 0.7f + reflected * 0.3f;   // mix direct light with the reflection
}
n (surface normal) ^ | incoming | reflected ray \ | / \ | / angle a \|/ angle a (angle in == angle out, measured from the normal) --------o-------- surface

traceRay เรียกตัวเอง — ทุกครั้งที่กระเด้ง จะยิง ray ใหม่อีกเส้น หา closest hit ใหม่ แล้วผสมผลลัพธ์กลับเข้ามา parameter depth สำคัญมาก: ถ้าไม่จำกัด กระจกสองบานที่หันหน้าเข้าหากันจะทำให้ ray กระเด้งไปมาไม่มีที่สิ้นสุด แล้ว crash โปรแกรมด้วย stack overflow (recursion ที่ไม่มี base case ตรงกับอาการพังแบบที่เคยพูดถึงตอนเริ่มเรียน recursion ครั้งแรก) การจำกัด depth ไว้ที่ตัวเลขเล็ก ๆ อย่าง 5 ทำให้ recursion ของทุก ray มีขอบเขตแน่นอน และในทางปฏิบัติ กระจกที่สะท้อนกระจกที่สะท้อนกระจกอีกที ก็จะมืดลงเรื่อย ๆ และสังเกตเห็นได้น้อยลงทุกครั้งที่กระเด้งอยู่แล้ว

9. Path Tracing: แก้ Rendering Equation ด้วยการกระเด้งแบบสุ่ม

reflection ใน section 8 กระเด้งไปทิศทางเดียวคงที่เสมอ — เหมาะกับกระจกพอดี แต่ผิวจริง ๆ ส่วนใหญ่ (ผนัง, ผิวหนัง, ผ้า) กระจายแสงไปหลายทิศทางพร้อมกันแบบไม่สม่ำเสมอ คำอธิบายที่ถูกต้องทางฟิสิกส์เรียกว่า rendering equation ซึ่งบอกว่าแสงที่ออกจากจุดหนึ่งเท่ากับแสงที่จุดนั้นปล่อยออกมาเอง บวกกับผลรวมของทุกทิศทางที่แสงอาจเข้ามาได้ ว่าแสงมาจากทิศนั้นเท่าไหร่ คูณด้วยผิวสะท้อนมันได้เท่าไหร่ คูณด้วย cosine falloff term ตัวเดียวกับใน section 6

the rendering equation, in words: light leaving point P (toward the camera) = light P emits by itself (zero for anything but a light source) + for every direction light could arrive from, above the surface: light arriving from that direction x how much of it this surface reflects toward the camera x the N dot L cosine term, same idea as section 6 ...added up over ALL of those directions

"บวกรวมทุกทิศทาง" ก็คือ integral บน hemisphere — คำนวณตรง ๆ ให้แม่นยำแทบเป็นไปไม่ได้ ยกเว้นฉากที่ง่ายที่สุด Path tracing แก้ปัญหานี้ด้วยเทคนิคที่เรียกว่า Monte Carlo integration: แทนที่จะประเมินทุกทิศทาง ก็สุ่มเลือก ทิศทางเดียว ตาม ray เส้นนั้นไป แล้วเอาผลลัพธ์มาเป็นค่าประมาณคร่าว ๆ sample เดียวเป็นค่าประมาณที่แย่มากถ้าดูตัวเดียว แต่ ค่าเฉลี่ย ของ sample สุ่มจำนวนมาก ๆ จะลู่เข้าหาคำตอบจริงเรื่อย ๆ เป็น law of large numbers แบบเดียวกับที่ทำให้เหรียญที่แฟร์ออกหัวใกล้ 50% เมื่อโยนหลาย ๆ ครั้ง ถึงแม้แต่ละครั้งที่โยนจะทายไม่ได้เลยก็ตาม

#include <cstdlib>

Vec3 randomInHemisphere(Vec3 normal)
{
    while (true)
    {
        Vec3 p = { (rand() / (float)RAND_MAX) * 2.0f - 1.0f,
                   (rand() / (float)RAND_MAX) * 2.0f - 1.0f,
                   (rand() / (float)RAND_MAX) * 2.0f - 1.0f };
        if (dot(p, p) > 1.0f) continue;   // reject points outside the unit sphere
        p = normalize(p);
        if (dot(p, normal) < 0.0f) p = p * -1.0f;   // flip to the normal's side
        return p;
    }
}

Vec3 pathTrace(const Ray& r, const std::vector<Sphere>& spheres, int depth)
{
    if (depth >= 8) return { 0.0f, 0.0f, 0.0f };

    HitRecord rec;
    if (!traceClosest(r, spheres, rec))
        return { 0.4f, 0.6f, 0.9f };   // sky is the only light source here

    Vec3 bounceDir = randomInHemisphere(rec.normal);
    Ray bounceRay = { rec.point + bounceDir * 0.001f, bounceDir };
    Vec3 incoming = pathTrace(bounceRay, spheres, depth + 1);

    float albedo = 0.8f;   // how much light the surface keeps; the rest is absorbed
    return incoming * albedo;
}

Vec3 renderPixel(const Ray& camRay, const std::vector<Sphere>& spheres, int samples)
{
    Vec3 total = { 0.0f, 0.0f, 0.0f };
    for (int s = 0; s < samples; s++)
        total = total + pathTrace(camRay, spheres, 0);
    return total * (1.0f / samples);   // average of all samples = the Monte Carlo estimate
}

เพราะโค้ดนี้ขึ้นอยู่กับ rand() ตัวเลขที่ได้จะไม่เหมือนกันทุกครั้งที่รัน และไม่เหมือนกันในแต่ละเครื่อง เลยไม่มี output ที่ "ถูกต้อง" ตัวเดียวมาโชว์ตรงนี้ได้ สิ่งที่สำคัญคือ รูปแบบ ของผลลัพธ์เมื่อ samples เพิ่มขึ้น — สมมติว่าความสว่างจริงของ pixel หนึ่งคือ 0.50 นี่คือรูปแบบที่สมจริงว่าแต่ละ sample กับค่าเฉลี่ยสะสมของมันหน้าตาเป็นยังไง:

samples so far: 1 2 4 8 32 256 latest sample: 0.91 0.04 0.88 0.12 0.61 0.47 running average: 0.91 0.48 0.58 0.42 0.53 0.501 each single sample swings wildly -- but the average keeps drifting closer to the true brightness (0.50) as more samples pile in

นี่คือไอเดียเดียวกันกับ reflection ใน section 8 เป๊ะ ๆ แค่ทำให้กว้างขึ้น: แทนที่จะกระเด้งไปทิศทางคงที่ทิศเดียว ก็สุ่มเลือกทิศทางใหม่ทุกครั้ง แล้วปล่อยให้การเฉลี่ยทำหน้าที่ครอบคลุมทุกทิศทางที่ผิวจริง ๆ จะกระจายแสงออกไป รันด้วยจำนวน samples per pixel ที่มากพอ ภาพจะลู่เข้าหาภาพที่แม่นยำทางฟิสิกส์ — soft shadow เต็มรูปแบบ, color bleeding ระหว่างผิว (ผนังสีแดงย้อมสีชมพูให้พื้นสีขาวข้าง ๆ) และ reflection จะโผล่มาเองอัตโนมัติ โดยไม่ต้องเขียนโค้ดแยกสำหรับแต่ละอย่างเลย เพราะทั้งหมดนี้ก็แค่ "แสงกระเด้งไปมาแล้วเอามาเฉลี่ยกัน" เท่านั้นเอง

10. ทำไมภาพจาก Path Tracing ถึงมี Noise และเรื่อง Denoising

ยิ่งเฉลี่ยจาก sample น้อย ผลลัพธ์ยิ่ง noise เยอะและเป็นเม็ด ๆ มากขึ้น — ค่าที่แกว่งระหว่าง 0.04 กับ 0.91 ในตารางข้างบนจะไม่หักล้างกันหมดจนกว่าจะสะสม sample ได้เยอะพอ และคำว่า "เยอะพอ" สำหรับภาพที่เนียนสวย มักอยู่ที่หลักร้อยถึงหลักพัน samples ต่อ pixel ถ้า render ด้วย sample น้อยเกินไป pixel ข้าง ๆ กันที่ควรจะหน้าตาใกล้เคียงกัน จะมีความสว่างสุ่มต่างกันเห็นได้ชัด โดยเฉพาะบริเวณที่แสงเป็นแบบ indirect (กระเด้งมา) หรือ shadow เป็นแบบ soft

4 samples per pixel (noisy) 1024 samples per pixel (clean) . # . . # . # . . . . . . . . . # . # # . # . . . . # # # . . . . # . # . . # # . . # # # . . . # . . # # . # . . . . . . . . . same underlying scene -- too few random samples leaves visible speckle; enough samples and it settles into a smooth soft shadow

real-time ray tracing ไม่มีงบให้ใช้ sample หลักพันต่อ pixel — ตามที่ 7.8 พูดไว้ เกม 60 fps มีเวลาแค่ประมาณ 16 มิลลิวินาทีต่อทั้งเฟรม แม้แต่ offline film renderer ที่ใช้เวลาได้เป็นนาทีต่อเฟรม ก็ยังใช้ denoising เพื่อลดจำนวน sample ลงอยู่ดี ทางแก้ที่ใช้จริงคือ denoising: render ด้วย sample น้อยมาก ๆ (มักแค่ 1 ต่อ pixel ต่อ effect) แล้วรัน filter ที่ทำให้ noise เนียนขึ้นในภายหลัง โดยใช้ข้อมูลเพิ่มเติม — surface normal กับ depth ของแต่ละ pixel ไม่ใช่แค่สีที่มี noise ของมัน — เพื่อให้มัน blur ภายในผิวเดียวกัน แต่ไม่ blur ข้าม ขอบของผิว ซึ่งถ้าใช้ blur ธรรมดาจะทำภาพพังไปเลย

แหล่ง sample เพิ่มเติมที่ถูกอีกแบบคือ temporal accumulation: เพราะ camera กับ object ส่วนใหญ่ขยับแค่นิดเดียวระหว่างสองเฟรมที่ติดกัน เราสามารถ reproject ผลลัพธ์ของเฟรมก่อนหน้ามาทับกับเฟรมนี้แล้วผสมเข้าด้วยกันได้ ซึ่งเท่ากับกระจาย sample ใหม่ไม่กี่ตัวในเฟรมนี้ให้ครอบคลุมประวัติที่สะสมมาจากหลาย ๆ เฟรมไปในตัว

Vec3 accumulate(Vec3 previousAverage, Vec3 newSample, int frameCount)
{
    // a running average: each new frame's sample nudges the average
    // a little less as frameCount grows, without ever storing every
    // past sample
    return previousAverage + (newSample - previousAverage) * (1.0f / frameCount);
}
Tip เทคนิค running average นี้ควรจำไว้เป็นของตัวเองเลย เพราะมันให้ผลลัพธ์เหมือนกันเป๊ะ ๆ กับการบวก sample ทุกตัวแล้วหารทีเดียวตอนท้าย แต่ไม่ต้องเก็บค่าไว้เกินหนึ่งตัวเลย ซึ่งสำคัญมากเมื่อ "sample ทุกตัว" อาจหมายถึงประวัติเป็นพันเฟรม

11. ทำไมจะเทสทุก Triangle ไม่ได้: ความจำเป็นของ BVH

intersection function ทุกตัวที่เราทำมาจนถึงตอนนี้ loop วน ทุก object ในฉากสำหรับ ทุก ray ซึ่งไม่มีปัญหาถ้ามี sphere แค่สามลูก แต่จะพังไม่เป็นท่าเลยถ้าเป็นฉากเกมจริง ๆ

for (int y = 0; y < height; y++)
{
    for (int x = 0; x < width; x++)
    {
        Ray r = makeCameraRay(x, y, width, height);
        for (size_t i = 0; i < allTriangles.size(); i++)
        {
            // test this ray against every single triangle in the WHOLE scene
        }
    }
}

ลองคำนวณดู: ภาพขนาด 1920x1080 มีประมาณ 2,073,600 pixel ฉากเกมทั่ว ๆ ไปมี triangle เป็น 2,000,000 ได้สบาย ๆ แค่เทสทุก triangle กับทุก primary ray อย่างเดียวก็ประมาณ 2,073,600 * 2,000,000 หรือราว 4 ล้านล้าน ครั้งของการเทส ray-triangle แล้ว — นี่ยังไม่นับ shadow ray, reflection ray หรือการกระเด้งของ path tracing แม้แต่เส้นเดียว ซึ่งแต่ละเส้นต้องกวาดเทสทุก triangle เต็มรอบของตัวเองอีก ตามที่ 7.8 พูดไว้ เฟรมที่ 60 fps มีเวลาให้ใช้แค่ประมาณ 16 มิลลิวินาที แม้จะมองโลกในแง่ดีว่าเทสได้พันล้านครั้งต่อวินาที ก็ยังต้องใช้เวลามากกว่าหนึ่งชั่วโมงสำหรับเฟรมเดียวนี้ นี่ไม่ใช่แค่ "ช้าหน่อย" แต่ช้าเกินกว่าจะรันได้จริงหลายอันดับขนาดเลยทีเดียว

12. Bounding Volume Hierarchies (BVH): การสร้างและการ Traverse

ทางแก้คือเลิกเทส triangle แต่ละตัวก่อน แล้วเทสกล่องที่ถูกกว่าแทน BVH (Bounding Volume Hierarchy) จะห่อกลุ่มของ triangle ไว้ในกล่อง แล้วห่อกลุ่มของกล่องพวกนั้นไว้ในกล่องที่ใหญ่ขึ้นอีกที ไปเรื่อย ๆ จนเหลือกล่องเดียวที่ครอบทั้งฉาก — เป็น tree เหมือนกับ tree structure ที่เรียนในบท data structures เป๊ะ ๆ แค่เก็บกล่อง 3D แทนตัวเลข ถ้า ray ไม่โดนกล่องหนึ่ง รับประกันได้เลยว่ามันจะไม่โดนอะไรข้างในกล่องนั้นเลย ดังนั้นทั้ง subtree ข้างล่างจะถูกข้ามไปได้โดยไม่ต้องเทส triangle สักตัวเดียว

spatially, a scene's bounding boxes nest like this (2D, for simplicity): +------------------------------------------+ | root box: the whole scene | | +----------------+ +----------------+ | | | box A | | box B | | | | +---+ +---+ | | +---+ +---+ | | | | |A1 | |A2 | | | |B1 | |B2 | | | | | +---+ +---+ | | +---+ +---+ | | | +----------------+ +----------------+ | +------------------------------------------+ and as a tree, that same structure looks like this: root | +-- box A | | | +-- box A1 -- triangles 1, 2, 3 | +-- box A2 -- triangles 4, 5 | +-- box B | +-- box B1 -- triangles 6, 7 +-- box B2 -- triangles 8, 9, 10

แต่ละกล่องคือ AABB (Axis-Aligned Bounding Box คือกล่องที่หน้าตั้งฉากไปตามแกน x, y, z พอดี ทำให้ intersection math ถูกลง) การเทส ray กับกล่องหนึ่งใบใช้เทคนิคคลาสสิกชื่อ slab test: สำหรับแต่ละแกน คำนวณช่วงของ t ที่ ray อยู่ระหว่าง min กับ max ของกล่องบนแกนนั้น แล้วเอาทั้งสามช่วงมา intersect กัน ถ้าช่วงพวกนี้ไม่ overlap กันเลย แปลว่า ray ไม่โดนกล่อง

struct AABB { Vec3 min, max; };

bool hitAABB(const AABB& box, const Ray& r)
{
    float txMin = (box.min.x - r.origin.x) / r.dir.x;
    float txMax = (box.max.x - r.origin.x) / r.dir.x;
    if (txMin > txMax) { float tmp = txMin; txMin = txMax; txMax = tmp; }

    float tyMin = (box.min.y - r.origin.y) / r.dir.y;
    float tyMax = (box.max.y - r.origin.y) / r.dir.y;
    if (tyMin > tyMax) { float tmp = tyMin; tyMin = tyMax; tyMax = tmp; }

    if (txMin > tyMax || tyMin > txMax) return false;
    if (tyMin > txMin) txMin = tyMin;
    if (tyMax < txMax) txMax = tyMax;

    float tzMin = (box.min.z - r.origin.z) / r.dir.z;
    float tzMax = (box.max.z - r.origin.z) / r.dir.z;
    if (tzMin > tzMax) { float tmp = tzMin; tzMin = tzMax; tzMax = tmp; }

    if (txMin > tzMax || tzMin > txMax) return false;
    return true;
}

struct BVHNode
{
    AABB bounds;
    BVHNode* left;
    BVHNode* right;
    int triangleIndex;   // only meaningful on a leaf (left == right == nullptr)
};

bool hitBVH(const BVHNode* node, const Ray& r,
            const std::vector<Vec3>& triA, const std::vector<Vec3>& triB,
            const std::vector<Vec3>& triC, float& closestT)
{
    if (!hitAABB(node->bounds, r)) return false;   // box missed -> skip everything inside it

    if (node->left == nullptr && node->right == nullptr)
    {
        // leaf node: a real triangle, test it for real
        float t, u, v;
        int i = node->triangleIndex;
        if (hitTriangle(triA[i], triB[i], triC[i], r, t, u, v) && t < closestT)
        {
            closestT = t;
            return true;
        }
        return false;
    }

    bool hitLeft  = hitBVH(node->left,  r, triA, triB, triC, closestT);
    bool hitRight = hitBVH(node->right, r, triA, triB, triC, closestT);
    return hitLeft || hitRight;
}

การสร้าง BVH (ไม่ได้โชว์เต็ม ๆ ตรงนี้) ทำแค่ครั้งเดียวตอน mesh โหลดขึ้นมา ไม่ใช่ทำต่อ ray: เลือกแกนที่ triangle กระจายตัวมากที่สุดแบบ recursive แล้วแบ่งเป็นสองกลุ่มที่ขนาดใกล้เคียงกันตามแกนนั้น คำนวณกล่องครอบแต่ละกลุ่ม แล้ว recurse ต่อไปจนกว่ากลุ่มจะเล็กพอที่จะกลายเป็น leaf ถ้า BVH สร้างมาดี การเทส ray กับฉากที่มี triangle เป็นล้าน ๆ จะใช้แค่ประมาณ log(triangleCount) การเทสกล่อง แทนที่จะเป็น triangleCount การเทส triangle — สำหรับ 2,000,000 triangle นั่นแปลว่าเช็คกล่องถูก ๆ แค่ประมาณ 21 ครั้ง แทนที่จะเช็ค triangle แพง ๆ 2,000,000 ครั้ง ซึ่งนี่แหละคือสิ่งที่ทำให้การเทสเป็นล้านล้านครั้งจาก section 11 ยุบลงมาเหลือขนาดที่งบเฟรมรับไหว

Tip engine จริง ๆ แทบไม่เคยแบ่ง triangle เป็นสองครึ่งเท่า ๆ กันตามจำนวนหรอก มันใช้ cost model ที่เรียกว่า SAH (Surface Area Heuristic) ซึ่งประเมินว่าการแบ่งแต่ละแบบที่เป็นไปได้จะแพงแค่ไหนตอน trace โดยดูจาก surface area ของกล่อง แล้วเลือกแบบที่ถูกที่สุด แนวคิด tree หลัก ๆ ยังเหมือนเดิม แค่มีกฎที่ฉลาดขึ้นว่าจะตัดตรงไหน

13. Hardware Ray Tracing: DXR, Vulkan RT และ Hybrid Rendering

ทุกอย่างข้างบนรันบน CPU ได้สบาย ๆ สำหรับการเรียนรู้ แต่ GPU ยุคใหม่มีวงจรเฉพาะทาง (RT cores) ที่สร้างมาเพื่อทำ BVH traversal กับ triangle test จาก section 3, 4 และ 12 ในระดับฮาร์ดแวร์โดยเฉพาะ DXR (DirectX Raytracing) บน Windows กับ Vulkan RT บน Vulkan/cross-platform คือ graphics API ที่เปิดให้ใช้ฮาร์ดแวร์นี้ ทั้งสองตัวจัดเรียง BVH ของฉากเป็นสองชั้นแทนที่จะเป็น tree เดียวแบน ๆ: BLAS (Bottom-Level Acceleration Structure) เก็บ triangle geometry จริง ๆ ของ mesh หนึ่งตัว และ TLAS (Top-Level Acceleration Structure) เก็บ instance จำนวนมากของ BLAS แต่ละ instance มี transform ของตัวเอง — ดังนั้น mesh ต้นไม้เดียวกันร้อยชุดสามารถแชร์ BLAS ตัวเดียวกัน แล้วมีแค่ entry ถูก ๆ ร้อยตัวใน TLAS แทนที่จะต้องก็อป geometry ซ้ำร้อยครั้ง

แทนที่จะมี function เดียวที่เรียกต่อ ray แบบ traceClosest ข้างบน hardware ray tracing แบ่งงานออกเป็น GPU shader stage หลายขั้น แนวคิดคล้ายกับการแบ่ง vertex/fragment shader จาก 7.1 กับ 7.2:

Ray Generation Shader (runs once per pixel) | | TraceRay(...) v ----------------------------- | hardware walks the BVH | | (the BLAS/TLAS from | | above, in RT cores) | ----------------------------- | ________|________ | | ray hits ray hits something nothing | | v v Closest-Hit Shader Miss Shader (shade the surface, (return sky / maybe fire more background rays for shadows/ color) reflections)
// simplified HLSL-style pseudocode for a DXR ray generation shader --
// illustrating the shape of the API, not a full compilable listing
[shader("raygeneration")]
void RayGenMain()
{
    uint2 pixel = DispatchRaysIndex().xy;
    RayDesc ray = MakeCameraRay(pixel);   // same idea as section 2

    RayPayload payload;
    TraceRay(SceneTLAS, RAY_FLAG_NONE, 0xFF, 0, 0, 0, ray, payload);

    OutputImage[pixel] = payload.color;   // filled in by closest-hit or miss
}

ยังมี any-hit shader แบบ optional ด้วย (รันทุกครั้งที่มี candidate hit ตามแนว ray ไม่ใช่แค่ตัวที่ใกล้ที่สุด — ใช้กับพวก alpha-tested foliage ที่ ray ต้องทะลุผ่านส่วนโปร่งใสของ texture ใบไม้ได้) และ intersection shader (สำหรับรูปทรงกำหนดเองที่ไม่ใช่ triangle เช่น sphere จาก section 3) เกมส่วนใหญ่ข้ามทั้งสองตัวนี้ไปแล้วใช้แค่ triangle กับคู่ closest-hit/miss แบบปกติ

เพราะ path tracing แบบเต็มรูปแบบ (section 9) ยังแพงเกินไปมากสำหรับทุก pixel ของทุกเฟรมที่ 60 fps เกมที่ใช้ ray tracing เกือบทุกเกมทุกวันนี้จึงใช้ hybrid rendering: rasterize ฉากตามปกติก่อน (เร็ว และเป็นสิ่งที่ 7.1 กับ 7.8 พูดถึงไปแล้ว — สี, depth และ normal ไปลงใน G-buffer ผ่าน deferred shading) แล้วใช้งบ ray ที่จำกัดและน้อยมาก กับ effect ที่จำเป็นต้องใช้จริง ๆ เท่านั้น — shadow จาก area light, reflection แบบกระจก หรือแสง ambient ที่กระเด้งมานิดหน่อยสำหรับ global illumination — แล้วสุดท้ายค่อย denoise (section 10) แล้วผสมกับภาพที่ rasterize ไว้ rasterization วาดภาพเกือบทั้งหมด ส่วน ray tracing เติมเฉพาะจุดเล็ก ๆ น้อย ๆ ที่ปลอมให้ดูสมจริงด้วยวิธีอื่นไม่ได้

14. Glossary

15. แบบฝึกหัด

Exercise 1 — Ray-Sphere by Hand มี sphere อยู่ที่ center = (0, 0, -10) รัศมี radius = 2 มี ray เริ่มที่ origin = (0, 1, 0) ทิศทาง dir = (0, 0, -1) ใช้สูตร hitSphere จาก section 3 คำนวณ a, b, c, discriminant และ (ถ้าชน) ค่า t กับตำแหน่ง 3D ของจุดที่ชนใกล้ที่สุด จะคำนวณด้วยมือหรือเขียนโค้ดก็ได้
Show answer
int main()
{
    Sphere s = { {0.0f, 0.0f, -10.0f}, 2.0f };
    Ray r = { {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, -1.0f} };

    Vec3 oc = r.origin - s.center;
    float a = dot(r.dir, r.dir);
    float b = 2.0f * dot(oc, r.dir);
    float c = dot(oc, oc) - s.radius * s.radius;
    float disc = b*b - 4*a*c;

    printf("a = %.2f, b = %.2f, c = %.2f, disc = %.2f\n", a, b, c, disc);

    float t;
    if (hitSphere(s, r, t))
    {
        Vec3 p = r.origin + r.dir * t;
        printf("hit at t = %.3f, point = (%.2f, %.2f, %.2f)\n", t, p.x, p.y, p.z);
    }
}

Output:

a = 1.00, b = -20.00, c = 97.00, disc = 12.00
hit at t = 8.268, point = (0.00, 1.00, -8.27)

oc = origin - center = (0, 1, 10) a = dot(dir,dir) = 1 เพราะ dir เป็น unit length อยู่แล้ว b = 2*dot(oc,dir) = 2*(-10) = -20 c = dot(oc,oc) - radius^2 = (0+1+100) - 4 = 97 discriminant b*b - 4ac = 400 - 388 = 12 เป็นบวก แปลว่า ray ชนสองจุด จุดที่ใกล้กว่าคือ t0 = (20 - sqrt(12)) / 2 ≈ 8.268 ซึ่งอยู่ด้านหน้าของ ray เลยเป็นค่าที่ถูกรายงานออกมา จุดนี้อยู่ที่ y = 1 ตลอด (ray ไม่ขยับใน y หรือ x เลย ขยับแค่ z) ซึ่งตรงกับจุดที่ชน (0, 1, -8.27) เป๊ะ ๆ

Exercise 2 — Add a Ground Plane รูปทรงทุกอันที่ผ่านมาเป็น sphere หรือ triangle ทั้งนั้น ให้เขียน hitPlane สำหรับ plane แบนไม่มีที่สิ้นสุด ที่นิยามด้วยจุด planePoint บนระนาบและ unit normal ชื่อ planeNormal โดยใช้วิธี "แก้หา t" แบบเดียวกับ hitSphere: จุด p จะอยู่บน plane เมื่อ dot(p - planePoint, planeNormal) == 0 แทนค่า p = ray.origin + t * ray.dir แล้วแก้หา t ทดสอบด้วย ground plane ที่ y = 0 (คือ planePoint = (0,0,0), planeNormal = (0,1,0)) กับ ray ที่มองลงและมองไปข้างหน้า origin = (0, 2, 0), dir = normalize((0, -1, -1))
Show answer
bool hitPlane(Vec3 planePoint, Vec3 planeNormal, const Ray& r, float& t)
{
    float denom = dot(planeNormal, r.dir);
    if (fabsf(denom) < 1e-6f) return false;   // ray is parallel to the plane

    t = dot(planePoint - r.origin, planeNormal) / denom;
    return t > 0.001f;   // same epsilon idea as sections 3 and 7
}

int main()
{
    Vec3 planePoint = { 0.0f, 0.0f, 0.0f };
    Vec3 planeNormal = { 0.0f, 1.0f, 0.0f };
    Ray r = { {0.0f, 2.0f, 0.0f}, normalize({0.0f, -1.0f, -1.0f}) };

    float t;
    if (hitPlane(planePoint, planeNormal, r, t))
    {
        Vec3 p = r.origin + r.dir * t;
        printf("hit at t = %.3f, point = (%.2f, %.2f, %.2f)\n", t, p.x, p.y, p.z);
    }
    else
    {
        printf("miss (parallel or behind)\n");
    }
}

Output:

hit at t = 2.828, point = (0.00, 0.00, -2.00)

การหา t ก็ใช้การแทนค่าแบบเดียวกับที่ทำกับ sphere แค่ง่ายกว่า เพราะสมการของ plane เป็น linear ไม่ใช่ quadratic: dot(origin + t*dir - planePoint, normal) = 0 จัดรูปตรง ๆ ได้เป็น t = dot(planePoint - origin, normal) / dot(dir, normal) เพราะทิศทางของ ray นี้ normalize ให้ยาว 1 หน่วยแล้ว ค่า y-component กับ z-component จะเท่ากันคือ -1/sqrt(2) ≈ -0.707 ray ต้องลดลง 2 หน่วยใน y เพื่อไปถึง y = 0 ซึ่งใช้ t = 2 / 0.707 ≈ 2.828 แล้วไปลงที่ z = 0 + (-0.707 * 2.828) ≈ -2.0 — ตรงกับจุดที่ print ออกมาเป๊ะ ๆ

Exercise 3 — Find the Shadow Acne Bug ฉากของเพื่อนร่วมทีมคนหนึ่ง render ถูกต้องทุกอย่าง ยกเว้นผิวที่โดนแสงทุกผิวมีจุดดำสุ่มกระจายเต็มไปหมด แล้วกระพริบไปมาระหว่างเฟรม ตรงกับที่อธิบายไว้ใน warning box ของ section 7 หาบั๊กในโค้ดข้างล่างนี้แล้วแก้ให้ถูก
bool inShadow(Vec3 hitPoint, Vec3 lightPos, const std::vector<Sphere>& spheres)
{
    Vec3 toLight = lightPos - hitPoint;
    float lightDist = sqrtf(dot(toLight, toLight));
    Vec3 shadowDir = normalize(toLight);
    Ray shadowRay = { hitPoint, shadowDir };

    for (size_t i = 0; i < spheres.size(); i++)
    {
        float t;
        if (hitSphere(spheres[i], shadowRay, t) && t < lightDist)
            return true;
    }
    return false;
}
Show answer

origin ของ shadow ray ถูกตั้งเป็น hitPoint ตรง ๆ เป๊ะ โดยไม่มี epsilon offset เลย เพราะ hitPoint เองก็คำนวณมาจาก floating-point intersection test มันเลยคลาดเคลื่อนเข้าไปข้างในหรือออกไปข้างนอกผิวจริงนิดหน่อยแบบสุ่ม ๆ การ์ด t > 0.001f ของ hitSphere เองมักจะจับกรณีนี้ได้ แต่ไม่ได้มีระยะกันชนมากพอที่จะช่วย ray ที่เริ่มต้นแทบจะแปะติดกับ sphere ที่มันเพิ่งออกมาได้เสมอไป shadow ray เลยตรวจจับผิว origin ของตัวเองว่าเป็นตัวบังเป็นบางครั้ง — นี่คือ pattern ของ shadow acne ที่อธิบายไว้ใน section 7 เป๊ะ ๆ และมันกระพริบไปมาระหว่างเฟรมเพราะ floating-point rounding error ไม่ได้เอียงไปทางเดียวกันทุกครั้ง

bool inShadow(Vec3 hitPoint, Vec3 lightPos, const std::vector<Sphere>& spheres)
{
    Vec3 toLight = lightPos - hitPoint;
    float lightDist = sqrtf(dot(toLight, toLight));
    Vec3 shadowDir = normalize(toLight);

    // nudge the origin forward along the shadow ray's own direction
    Ray shadowRay = { hitPoint + shadowDir * 0.001f, shadowDir };

    for (size_t i = 0; i < spheres.size(); i++)
    {
        float t;
        if (hitSphere(spheres[i], shadowRay, t) && t < lightDist)
            return true;
    }
    return false;
}

วิธีแก้ ตรงกับโค้ดต้นฉบับใน section 7 คือขยับจุดเริ่มต้นของ ray ใหม่ออกไปนิดหน่อยตามทิศทางที่มันกำลังจะเดินทาง ก่อนจะเอาไปเทสกับฉาก เพื่อไม่ให้ ray เริ่มต้นใกล้กับผิว origin ของตัวเองมากจนชนมันเข้าโดยไม่ได้ตั้งใจ

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