This programming guide is only available in Vietnamese. Switch the language toggle to Vietnamese to read the full article.

Hầu hết các kỹ thuật đồ họa 3D chúng ta nghiên cứu từ Bài 1 tới nay đều xoay quanh mô hình Rasterization (CPU gửi tập hợp lưới đa giác đỉnh, GPU rasterize thành tam giác và vẽ lên màn hình). Tuy nhiên, có một trường phái đồ họa 3D khác cực kỳ mạnh mẽ, dựng hình hoàn hảo không cần dùng tới bất kỳ lưới tam giác nào, chạy hoàn toàn trong Fragment Shader bằng phương pháp Raymarching và toán học SDF.

1. Đồ họa Thủ tục (Procedural Graphics)

Thay vì dùng các dữ liệu chuẩn bị sẵn (như file ảnh chụp texture), đồ họa thủ tục sinh cấu trúc bề mặt trực tiếp bằng công thức toán học chạy trên GPU. Các thuật toán phổ biến bao gồm sóng hình sin tuần hoàn, vân gỗ thủ tục, nhiễu Perlin (Perlin Noise), và cấu trúc Fractal (Fractional Brownian Motion - fBm) để mô phỏng mây trời, địa hình núi non thực tế.

2. Khái niệm Signed Distance Fields (SDF)

Một hàm SDF (Signed Distance Field) là một hàm toán học nhận đầu vào là một điểm tọa độ 3D `p` và trả về khoảng cách ngắn nhất từ điểm đó đến ranh giới của vật thể:

  • Khoảng cách > 0: Điểm p nằm bên ngoài vật thể.
  • Khoảng cách = 0: Điểm p nằm chính xác trên bề mặt vật thể.
  • Khoảng cách < 0: Điểm p nằm sâu bên trong vật thể.

Ví dụ, phương trình SDF của hình cầu bán kính r đặt tại tâm tọa độ cực kỳ đơn giản:

float sdSphere(vec3 p, float r) {
    return length(p) - r;
}

Phương trình SDF của hình vòng xuyến (Torus) có bán kính chính R và bán kính ống phụ r:

float sdTorus(vec3 p, vec2 t) {
    vec2 q = vec2(length(p.xz) - t.x, p.y);
    return length(q) - t.y;
}
sdf_primitives.glsl
// SDF hình cầu bán kính r đặt tại gốc tọa độ
float sdSphere(vec3 p, float r) {
    // length(p) = khoảng cách từ p tới tâm; trừ r ra biên
    return length(p) - r;
}

// SDF hình hộp với nửa-kích-thước b (half-extents theo từng trục)
float sdBox(vec3 p, vec3 b) {
    // q > 0 ở các trục nằm ngoài hộp, = 0 nếu nằm trong
    vec3 q = abs(p) - b;
    // length(max(q,0)) lo phần ngoài, min(...,0) lo phần trong hộp
    return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

Sức mạnh thực sự của SDF nằm ở khả năng kết hợp nhiều hình khối lại với nhau chỉ bằng các phép toán `min`/`max` đơn giản trên giá trị khoảng cách trả về:

sdf_combine.glsl
// Hợp (Union): lấy bề mặt gần nhất giữa hai khối
float opUnion(float d1, float d2) { return min(d1, d2); }

// Giao (Intersection): chỉ giữ phần chung của hai khối
float opIntersect(float d1, float d2) { return max(d1, d2); }

// Trừ (Subtraction): khoét khối d2 ra khỏi khối d1
float opSubtract(float d1, float d2) { return max(d1, -d2); }

// Smooth-min: hợp hai khối với mối nối bo tròn mượt mà (k = độ mượt)
float smin(float a, float b, float k) {
    float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
    return mix(b, a, h) - k * h * (1.0 - h); // nội suy mượt giữa a và b
}

3. Giải thuật Raymarching (Bắn tia)

Trong Raymarching, chúng ta vẽ một hình phẳng phủ kín màn hình (giống như bài Hậu kỳ). Với mỗi điểm ảnh (pixel), chúng ta giả lập bắn một tia từ vị trí mắt camera ảo `ro` (Ray Origin) theo hướng đi qua pixel màn hình `rd` (Ray Direction).

Để tìm xem tia sáng có va chạm với vật thể nào trong không gian hay không, ta thực hiện thuật toán lặp nhảy bước (Ray Marching loop):

  1. Tính khoảng cách ngắn nhất `d` từ đầu tia hiện tại tới tất cả vật thể trong cảnh bằng các hàm SDF.
  2. Nếu `d` nhỏ hơn một ngưỡng va chạm cực nhỏ (ví dụ `0.001`), ta kết luận tia đã chạm bề mặt (Hit). Ta dừng lặp và tính toán ánh sáng tại điểm đó.
  3. Nếu `d` lớn hơn khoảng cách tối đa (ví dụ `10.0`), ta kết luận tia bay ra vô tận (Miss - vẽ màu bầu trời).
  4. Nếu chưa thỏa mãn cả 2 điều kiện trên, ta dịch đầu tia tiến lên phía trước một khoảng chính xác bằng `d` rồi lặp lại bước 1. Do `d` là khoảng cách ngắn nhất an toàn, tia sẽ không bao giờ đâm xuyên qua bất kỳ vật thể nào.
raymarch.glsl
// Sphere tracing: lặp tiến tia bằng đúng giá trị SDF mỗi bước
float raymarch(vec3 ro, vec3 rd) {
    float t = 0.0; // tổng quãng đường đã đi dọc tia
    for (int i = 0; i < 100; i++) {
        vec3 p = ro + t * rd;     // điểm hiện tại trên tia
        float d = map(p);         // khoảng cách an toàn tới bề mặt gần nhất

        // Hit: đã rất sát bề mặt -> dừng và trả về quãng đường
        if (d < 0.001) return t;

        // Miss: tia bay quá xa, coi như không chạm gì
        if (t > 100.0) break;

        // Nhảy đúng bằng d: vì d là bán kính quả cầu trống an toàn,
        // tia chắc chắn không xuyên thủng bề mặt nào (sphere tracing).
        t += d;
    }
    return -1.0; // không trúng vật thể nào
}

4. Tính toán pháp tuyến từ đạo hàm SDF (Gradient Normal)

Vì chúng ta không có các vector pháp tuyến đỉnh do không có lưới đa giác, pháp tuyến tại điểm va chạm `p` được tính toán bằng cách đo sự chênh lệch nhỏ (đạo hàm riêng) của hàm SDF xung quanh điểm đó theo 3 chiều:

vec3 getNormal(vec3 p) {
    const vec2 e = vec2(0.001, 0.0);
    float d = map(p); // map(p) trả về khoảng cách SDF chung của toàn bộ cảnh
    vec3 n = d - vec3(
        map(p - e.xyy),
        map(p - e.yxy),
        map(p - e.yyx)
    );
    return normalize(n);
}
🎮 Demo tương tác: Real-time Raymarching Sandbox (SDF Sphere & Torus)

5. Câu hỏi trắc nghiệm ôn tập

Trắc nghiệm 1: Bước nhảy khoảng cách trong Raymarching

Tại sao trong giải thuật Raymarching, độ dài của mỗi bước nhảy tia (step) lại được gán chính xác bằng khoảng cách SDF nhỏ nhất tại điểm đó?

Trắc nghiệm 2: SDF Sphere Equation

Phương trình SDF của hình cầu là: f(p) = length(p) - r. Ý nghĩa của phép tính length(p) là gì?

Trắc nghiệm 3: Ý nghĩa giá trị trả về của SDF

Một hàm Signed Distance Field (SDF) trả về điều gì, và dấu (sign) của giá trị đó cho ta biết gì?

Mã nguồn Shader Raymarching đầy đủ (Sphere, Torus & Shadows):

Tải về mã nguồn Raymarching

Related Articles

Bài viết liên quan trong series

Lesson 17: Performance Optimization & Instanced Rendering Bài 17: Tối Ưu Hóa Hiệu Năng Đồ Họa WebGL Lesson 15: Skeletal Animation & Skinning Bài 15: Hoạt Họa Xương & Skinning Back to WebGL Course Overview Quay lại Lộ trình WebGL Series