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

Mô hình Phong/Blinn-Phong ở Bài 5 cho kết quả đẹp nhưng mang tính kinh nghiệm (empirical) — các hệ số shininess hay specular được chỉnh bằng cảm tính chứ không dựa trên vật lý thực. Physically-Based Rendering (PBR) thay đổi triết lý đó: nó mô phỏng cách ánh sáng tương tác với bề mặt dựa trên các định luật bảo toàn năng lượng và lý thuyết vi mặt (microfacet theory). Nhờ vậy, cùng một vật liệu sẽ trông nhất quán dưới mọi điều kiện ánh sáng — đây là lý do PBR trở thành chuẩn công nghiệp trong Unreal Engine, Unity, Blender, glTF và mọi DCC hiện đại.

1. Vì sao cần PBR? Bảo toàn năng lượng & lý thuyết vi mặt

Một mô hình chiếu sáng "đúng vật lý" phải tuân thủ hai nguyên tắc nền tảng mà Phong truyền thống vi phạm:

  • Bảo toàn năng lượng (Energy Conservation): Tổng năng lượng ánh sáng phản xạ khỏi bề mặt không bao giờ được lớn hơn năng lượng tới. Trong Phong, khi tăng cả Diffuse lẫn Specular, vật thể có thể "phát sáng" hơn cả nguồn sáng — điều bất khả thi trong tự nhiên. PBR ràng buộc: kD + kS ≤ 1.
  • Lý thuyết vi mặt (Microfacet Theory): Mọi bề mặt ở cấp độ hiển vi đều là tập hợp vô số gương phẳng tí hon gọi là microfacets. Độ nhám (roughness) quyết định các gương này hướng loạn xạ tới mức nào. Bề mặt nhẵn (roughness thấp) có microfacets gần như cùng hướng → phản xạ gương sắc nét; bề mặt nhám (roughness cao) có microfacets hỗn loạn → highlight bị tán rộng và mờ.

Yếu tố quyết định một microfacet có phản chiếu ánh sáng từ nguồn L tới mắt V hay không chính là vector trung tuyến (halfway vector) H = normalize(L + V). Chỉ những microfacet có pháp tuyến trùng với H mới góp phần vào highlight. Đây là hạt nhân toán học của toàn bộ PBR.

2. Phương trình phản xạ & BRDF Cook-Torrance

PBR giải gần đúng phương trình kết xuất (Rendering Equation) của Kajiya (1986). Với chiếu sáng trực tiếp từ các nguồn sáng điểm, ánh sáng phản xạ ra hướng mắt Lo được tính bằng tổng đóng góp của từng nguồn sáng:

Lo(v) = Σ [ fr(v, l) × Li(l) × (n · l) ]
  trong đó fr = kD × (albedo / π)  +  kS × fCook-Torrance

Phần khuếch tán (diffuse) dùng mô hình Lambert albedo/π. Phần phản xạ gương (specular) dùng BRDF Cook-Torrance — gồm ba hàm nhân với nhau, thường gọi tắt là DFG:

fCook-Torrance = ( D × F × G ) / ( 4 × (n · v) × (n · l) )
  • D — Normal Distribution Function (Trowbridge-Reitz GGX): Ước lượng tỉ lệ microfacets có pháp tuyến trùng H. Quyết định hình dạng & kích thước của highlight theo roughness.
  • F — Fresnel (Schlick approximation): Tỉ lệ ánh sáng được phản xạ so với khúc xạ, tăng mạnh khi nhìn lướt (grazing angle). Quyết định màu phản chiếu & hiệu ứng viền sáng.
  • G — Geometry Function (Smith + Schlick-GGX): Mô phỏng hiện tượng microfacets tự che bóng & che khuất lẫn nhau (self-shadowing / masking), rõ rệt ở bề mặt nhám.

Dưới đây là cài đặt đầy đủ ba hàm này trong GLSL — đây chính là trái tim của mọi engine PBR:

cook_torrance.frag.glsl
#define PI 3.14159265359

// D: Phân bố pháp tuyến microfacet (Trowbridge-Reitz GGX)
float DistributionGGX(vec3 N, vec3 H, float roughness) {
  float a  = roughness * roughness;          // Disney remap: dùng roughness^2
  float a2 = a * a;
  float NdotH  = max(dot(N, H), 0.0);
  float NdotH2 = NdotH * NdotH;
  float denom = (NdotH2 * (a2 - 1.0) + 1.0);
  denom = PI * denom * denom;
  return a2 / max(denom, 1e-4);
}

// G: Hàm hình học (Smith) — kết hợp masking & shadowing
float GeometrySchlickGGX(float NdotX, float roughness) {
  float r = roughness + 1.0;
  float k = (r * r) / 8.0;                    // hằng số k cho chiếu sáng trực tiếp
  return NdotX / (NdotX * (1.0 - k) + k);
}
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
  float NdotV = max(dot(N, V), 0.0);
  float NdotL = max(dot(N, L), 0.0);
  return GeometrySchlickGGX(NdotV, roughness) * GeometrySchlickGGX(NdotL, roughness);
}

// F: Xấp xỉ Fresnel-Schlick
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
  return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

3. Quy trình Metallic-Roughness & tham số F0

glTF 2.0 và hầu hết engine hiện đại dùng quy trình Metallic-Roughness vì nó trực quan với nghệ sĩ và chỉ cần ít texture. Một vật liệu được mô tả bằng 3 thông số chính:

  • Albedo (Base Color): Màu cơ bản của bề mặt (đã loại bỏ thông tin ánh sáng/bóng).
  • Metallic (0 → 1): Vật liệu là kim loại (1) hay phi kim/điện môi (0). Quyết định cách tính hệ số phản xạ cơ sở F0.
  • Roughness (0 → 1): Độ nhám bề mặt — nhẵn bóng (0) đến mờ đục (1).

Điểm mấu chốt nằm ở F0 (phản xạ khi nhìn vuông góc). Điện môi (gỗ, nhựa, da, đá) có F0 gần như không màu, xấp xỉ 0.04 (4%). Kim loại thì ngược lại: chúng không có khuếch tán (kD = 0) và F0 chính là màu albedo của chúng (vàng phản chiếu ánh vàng, đồng phản chiếu ánh đỏ-cam). Ta nội suy giữa hai trạng thái bằng tham số metallic:

pbr_main.frag.glsl
void main() {
  vec3  N = normalize(v_normal);
  vec3  V = normalize(u_viewPos - v_fragPos);

  // Điện môi: F0 = 0.04. Kim loại: F0 = albedo. Nội suy theo metallic.
  vec3 F0 = mix(vec3(0.04), u_albedo, u_metallic);

  vec3 Lo = vec3(0.0);
  // --- Vòng lặp qua từng nguồn sáng (ở đây là 1 nguồn điểm) ---
  vec3  L = normalize(u_lightPos - v_fragPos);
  vec3  H = normalize(V + L);
  float dist        = length(u_lightPos - v_fragPos);
  float attenuation = 1.0 / (dist * dist);          // suy giảm theo bình phương khoảng cách
  vec3  radiance    = u_lightColor * attenuation;

  // BRDF Cook-Torrance
  float NDF = DistributionGGX(N, H, u_roughness);
  float G   = GeometrySmith(N, V, L, u_roughness);
  vec3  F   = fresnelSchlick(max(dot(H, V), 0.0), F0);

  vec3  numerator   = NDF * G * F;
  float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 1e-4;
  vec3  specular    = numerator / denominator;

  // kS = Fresnel; kD là phần năng lượng còn lại (bảo toàn năng lượng)
  vec3 kS = F;
  vec3 kD = (vec3(1.0) - kS) * (1.0 - u_metallic); // kim loại không có diffuse

  float NdotL = max(dot(N, L), 0.0);
  Lo += (kD * u_albedo / PI + specular) * radiance * NdotL;

  vec3 ambient = vec3(0.03) * u_albedo;             // ánh sáng môi trường tối giản
  vec3 color   = ambient + Lo;

  // HDR -> LDR: tone mapping Reinhard + hiệu chỉnh gamma sRGB
  color = color / (color + vec3(1.0));
  color = pow(color, vec3(1.0 / 2.2));
  gl_FragColor = vec4(color, 1.0);
}

4. Demo tương tác: Quả cầu PBR Cook-Torrance thời gian thực

Hãy tự tay khám phá không gian vật liệu PBR. Kéo các thanh trượt MetallicRoughness để quan sát cùng một quả cầu biến đổi từ nhựa mờ → kim loại bóng loáng. Chú ý cách highlight thu nhỏ và sắc nét lại khi giảm roughness, và cách màu phản chiếu chuyển sang ánh kim khi tăng metallic — tất cả được tính theo BRDF Cook-Torrance ngay trên GPU của bạn:

🎮 Demo tương tác: PBR Cook-Torrance Sphere

5. Tone Mapping & Gamma Correction — bước không thể thiếu

Tính toán PBR diễn ra trong không gian tuyến tính (linear HDR), nơi giá trị màu có thể vượt xa 1.0 (ví dụ một highlight kim loại có thể đạt giá trị 15.0). Màn hình lại chỉ hiển thị được dải [0, 1] trong không gian sRGB phi tuyến. Nếu bỏ qua bước chuyển đổi, ảnh sẽ bị cháy sáng (clipping) và tối thui sai lệch. Vì vậy mọi pipeline PBR luôn kết thúc bằng hai bước:

  1. Tone Mapping (HDR → LDR): Nén dải động cao về [0, 1]. Reinhard color / (color + 1) là cách đơn giản nhất; các engine AAA thường dùng đường cong ACES Filmic cho màu sắc điện ảnh hơn.
  2. Gamma Correction (Linear → sRGB): Nâng màu lên lũy thừa 1/2.2 để bù lại đường cong gamma của màn hình, giúp độ sáng cảm nhận đúng như mắt người.

Bỏ qua gamma correction là lỗi kinh điển khiến vật thể PBR trông "nhựa" và tương phản sai. Bạn có thể thử xóa dòng pow(color, vec3(1.0/2.2)) trong demo trên để thấy sự khác biệt rõ rệt.

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

Trắc nghiệm 1: Tham số F0

Trong quy trình Metallic-Roughness, vì sao hệ số phản xạ cơ sở F0 của vật liệu kim loại lại được gán bằng chính màu albedo của nó?

Trắc nghiệm 2: Hàm phân bố D (GGX)

Khi tăng giá trị roughness trong hàm phân bố GGX (D), điều gì xảy ra với vùng highlight phản xạ gương trên bề mặt?

Trắc nghiệm 3: Bảo toàn năng lượng

Trong cài đặt PBR, dòng vec3 kD = (vec3(1.0) - kS) * (1.0 - u_metallic); phục vụ mục đích chính nào?

Tải mã nguồn thực hành

File chứa shader Cook-Torrance PBR hoàn chỉnh và demo quả cầu vật liệu để bạn tự thử nghiệm:

Tải về mã nguồn PBR mẫu

Related Articles

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

Lesson 14: Environment Mapping, Skybox & IBL Bài 14: Environment Mapping, Skybox & IBL Lesson 12: Real-time Shadow Mapping Bài 12: Đổ Bóng Real-time (Shadow Mapping) Back to WebGL Course Overview Quay lại Lộ trình WebGL Series