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

Ở Bài 13 ta đã xây dựng vật liệu PBR thuyết phục dưới ánh sáng từ các nguồn sáng điểm. Nhưng trong thế giới thực, phần lớn ánh sáng chiếu lên một vật thể không đến từ vài bóng đèn rời rạc mà đến từ toàn bộ môi trường xung quanh: bầu trời, mặt đất, những bức tường, các vật thể lân cận. Để mô phỏng điều đó, ta cần một cách lưu trữ "khung cảnh bao quanh" và lấy mẫu ánh sáng từ mọi hướng. Environment Mapping chính là kỹ thuật nền tảng cho việc này: nó dùng một cubemap để ghi lại môi trường, từ đó dựng được skybox, tính phản chiếu (reflection)khúc xạ (refraction), và cuối cùng mở đường cho Image-Based Lighting (IBL) — mảnh ghép còn thiếu của một pipeline PBR hoàn chỉnh.

1. Cubemap là gì?

Một cubemap là một texture đặc biệt gồm sáu mặt (six faces), mỗi mặt là một ảnh vuông, được sắp xếp như sáu mặt trong của một khối lập phương bao quanh điểm quan sát. Sáu mặt này tương ứng với sáu hướng trục: +X, -X, +Y, -Y, +Z, -Z. Thay vì lấy mẫu bằng tọa độ 2D (u, v) như texture phẳng, cubemap được lấy mẫu bằng một vector hướng 3 chiều vec3 dir. GPU sẽ phóng tia từ tâm khối lập phương theo hướng dir, xác định nó đâm vào mặt nào trong sáu mặt, rồi nội suy ra màu tại điểm va chạm. Nhờ đó cubemap trả lời được câu hỏi cốt lõi của environment mapping: "nhìn theo hướng này thì thấy gì trong môi trường?".

Trong WebGL, cubemap dùng mục tiêu (target) riêng là gl.TEXTURE_CUBE_MAP thay vì gl.TEXTURE_2D. Khi upload dữ liệu, ta phải nạp lần lượt từng mặt một qua sáu hằng số gl.TEXTURE_CUBE_MAP_POSITIVE_X đến gl.TEXTURE_CUBE_MAP_NEGATIVE_Z (sáu hằng số này có giá trị liên tiếp nhau nên thường được duyệt bằng vòng lặp). Một lưu ý quan trọng: cubemap thường đặt chế độ wrap là gl.CLAMP_TO_EDGE ở cả ba trục S, T (và R với WebGL2) để tránh viền nối giữa các mặt bị rò màu. Dưới đây là cách tạo và nạp sáu mặt:

create_cubemap.js
function createCubemap(gl, faceImages) {
  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, tex);

  // Sáu hằng số target liên tiếp nhau theo thứ tự +X,-X,+Y,-Y,+Z,-Z
  const targets = [
    gl.TEXTURE_CUBE_MAP_POSITIVE_X, gl.TEXTURE_CUBE_MAP_NEGATIVE_X,
    gl.TEXTURE_CUBE_MAP_POSITIVE_Y, gl.TEXTURE_CUBE_MAP_NEGATIVE_Y,
    gl.TEXTURE_CUBE_MAP_POSITIVE_Z, gl.TEXTURE_CUBE_MAP_NEGATIVE_Z,
  ];

  for (let i = 0; i < 6; i++) {
    // faceImages[i] có thể là HTMLImageElement, <canvas>, hoặc Uint8Array
    gl.texImage2D(
      targets[i], 0, gl.RGBA,           // mip level 0, định dạng nội bộ
      gl.RGBA, gl.UNSIGNED_BYTE,         // định dạng & kiểu dữ liệu nguồn
      faceImages[i]
    );
  }

  // CLAMP_TO_EDGE tránh rò màu ở mép nối giữa các mặt
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  return tex;
}

Khi muốn nạp cubemap bằng Uint8Array sinh thủ tục (procedural), ta truyền thêm tham số width, heightborder = 0 cho biến thể texImage2D nhận con trỏ dữ liệu — đây chính là cách demo bên dưới tạo bầu trời mà không cần tải bất kỳ ảnh nào từ mạng.

2. Vẽ Skybox

Skybox là kỹ thuật tạo cảm giác về một khung cảnh vô tận bao quanh camera. Ý tưởng đơn giản: vẽ một khối lập phương rất lớn (hoặc đơn vị) luôn đặt tâm trùng với camera, và tô màu mỗi điểm trên bề mặt cube bằng cách lấy mẫu cubemap theo chính vị trí của đỉnh đó. Vì cube luôn dịch chuyển cùng camera, người dùng không bao giờ "chạm" tới được mép của bầu trời.

Bí quyết quan trọng nhất nằm ở ma trận view. Nếu dùng nguyên ma trận view, skybox sẽ trôi theo khi ta di chuyển — sai hoàn toàn vì bầu trời phải ở xa vô hạn. Giải pháp là loại bỏ phần tịnh tiến (translation) của ma trận view: chỉ giữ lại phần xoay 3×3, đặt cột tịnh tiến về 0. Như vậy skybox chỉ xoay theo camera chứ không dịch theo. Trong shader đỉnh, ta dùng chính a_position của cube làm vector hướng lấy mẫu cubemap:

skybox.vert.glsl
attribute vec3 a_position;
uniform mat4 u_projection;
uniform mat4 u_viewNoTranslation; // ma trận view ĐÃ bỏ cột tịnh tiến
varying vec3 v_dir;

void main() {
  // Vị trí đỉnh trên cube chính là hướng lấy mẫu cubemap
  v_dir = a_position;

  vec4 pos = u_projection * u_viewNoTranslation * vec4(a_position, 1.0);

  // Thủ thuật depth: ép z = w => sau chia phối cảnh, z/w = 1.0 (xa nhất).
  // Skybox luôn nằm sau mọi vật thể, vẽ sau cùng để tiết kiệm overdraw.
  gl_Position = pos.xyww;
}

Dòng gl_Position = pos.xyww là một thủ thuật độ sâu (depth trick) kinh điển. Sau bước chia phối cảnh (perspective divide), GPU chia mọi thành phần cho w; vì ta ép thành phần z = w, kết quả z/w = 1.0 — giá trị độ sâu lớn nhất (xa nhất) có thể. Kết hợp với hàm so sánh độ sâu gl.depthFunc(gl.LEQUAL), skybox sẽ chỉ được vẽ tại những pixel chưa bị vật thể nào che, mà không cần một cube khổng lồ. Nhờ vậy ta có thể vẽ skybox sau cùng để loại bỏ các pixel sẽ bị ghi đè, tiết kiệm băng thông fragment shader. Fragment shader chỉ việc lấy mẫu cubemap theo hướng đã nội suy:

skybox.frag.glsl
precision highp float;
uniform samplerCube u_envMap;
varying vec3 v_dir;

void main() {
  // textureCube lấy mẫu bằng vector hướng, không cần chuẩn hóa bắt buộc
  vec3 color = textureCube(u_envMap, normalize(v_dir)).rgb;
  // (tuỳ chọn) gamma correction để khớp không gian màu với phần PBR
  color = pow(color, vec3(1.0 / 2.2));
  gl_FragColor = vec4(color, 1.0);
}

3. Reflection Mapping & Refraction

Khi đã có cubemap môi trường, ta có thể làm một vật thể trông như được mạ gương bằng reflection mapping. Nguyên lý dựa thẳng vào định luật phản xạ: tia nhìn từ mắt tới điểm bề mặt sẽ bật ngược lại quanh pháp tuyến, và hướng bật đó cho biết phần nào của môi trường đang được phản chiếu tới mắt. GLSL có sẵn hàm reflect(I, N) tính chính xác điều này, với I là vector tới (incident) và N là pháp tuyến. Ta lấy hướng phản xạ rồi sample cubemap để biết "tia phản xạ này nhìn thấy gì trong môi trường".

R = reflect(I, N) = I − 2 (N · I) N   // phản xạ gương
T = refract(I, N, η)   với   η = n1 / n2   // khúc xạ (Snell)

Một lưu ý về hệ quy chiếu: reflectrefract phải được tính trong cùng một không gian với cubemap — thường là world space. Vì vậy pháp tuyến và vector nhìn cần được biến đổi về world space trước khi dùng. Bên cạnh phản xạ, ta có thể mô phỏng khúc xạ (refraction) — ánh sáng bẻ cong khi đi qua vật liệu trong suốt như thủy tinh hay nước — bằng hàm refract(I, N, eta), trong đó eta là tỉ số chiết suất (index of refraction) giữa hai môi trường (không khí ≈ 1.0, nước ≈ 1.33, thủy tinh ≈ 1.52, kim cương ≈ 2.42). Tỉ số eta càng lệch 1.0, ảnh nhìn xuyên qua càng bị bẻ cong mạnh:

reflect_refract.frag.glsl
precision highp float;
uniform samplerCube u_envMap;
uniform vec3  u_cameraPos;   // vị trí camera trong world space
uniform float u_eta;         // tỉ số chiết suất n1/n2 (vd 1.0/1.52)
uniform float u_mode;        // 0.0 = phản xạ, 1.0 = khúc xạ
varying vec3 v_worldPos;     // vị trí mảnh trong world space
varying vec3 v_worldNormal;  // pháp tuyến trong world space

void main() {
  vec3 N = normalize(v_worldNormal);
  // I: vector từ mắt tới bề mặt (hướng nhìn)
  vec3 I = normalize(v_worldPos - u_cameraPos);

  vec3 R = reflect(I, N);             // tia phản xạ gương
  vec3 T = refract(I, N, u_eta);      // tia khúc xạ theo định luật Snell

  // Chọn giữa phản xạ và khúc xạ theo chế độ
  vec3 dir = mix(R, T, clamp(u_mode, 0.0, 1.0));

  vec3 envColor = textureCube(u_envMap, dir).rgb;
  gl_FragColor = vec4(envColor, 1.0);
}

Trong thực tế, vật liệu hiếm khi phản xạ thuần 100%. Ta thường pha trộn màu phản chiếu với màu nền của vật thể theo một hệ số reflectivity, và để chân thực hơn có thể nhân thêm hệ số Fresnel (đã gặp ở Bài 13): nhìn lướt (grazing angle) thì phản xạ mạnh, nhìn vuông góc thì phản xạ yếu — đúng như mặt hồ phản chiếu rõ ở phía xa nhưng trong suốt ngay dưới chân ta.

4. Demo tương tác: Quả cầu phản chiếu môi trường thời gian thực

Demo dưới đây tạo một cubemap thủ tục ngay trong JavaScript (bầu trời xanh ở đỉnh, nhạt dần ở chân trời, mặt đất nâu ở đáy — không tải bất kỳ ảnh nào) và dựng skybox bao quanh. Quả cầu ở trung tâm phản chiếu chính cubemap đó. Hãy đổi chế độ Reflection / Refraction, kéo thanh trượt độ phản chiếu để pha giữa màu môi trường và màu gốc của quả cầu, và quan sát skybox xoay quanh nhờ ma trận view đã bỏ tịnh tiến:

🎮 Demo tương tác: Reflective Sphere + Procedural Skybox

5. Image-Based Lighting (IBL): mảnh ghép cuối của PBR

Reflection mapping ở mục 3 mới chỉ phản chiếu môi trường như một tấm gương. Để vật liệu PBR ở Bài 13 thực sự "đứng đúng" trong khung cảnh, ta cần coi toàn bộ cubemap môi trường như một nguồn sáng — đó chính là Image-Based Lighting (IBL). Thay vì vài nguồn sáng điểm, mỗi điểm bề mặt nhận ánh sáng tới từ mọi hướng trên bán cầu, lấy mẫu trực tiếp từ cubemap. IBL tách thành hai thành phần tương ứng với hai phần của BRDF Cook-Torrance:

  • Diffuse irradiance (khuếch tán): Với phần khuếch tán, ta cần tích phân toàn bộ ánh sáng tới trên bán cầu quanh pháp tuyến. Tích phân này được tính trước (precompute) một lần và lưu thành một cubemap độ phân giải thấp gọi là irradiance map. Lúc kết xuất, chỉ cần textureCube(irradianceMap, N) là có ngay ánh sáng khuếch tán môi trường.
  • Specular (phản xạ gương): Phần này dùng xấp xỉ tách tổng (split-sum) của Epic Games. Nó gồm hai bảng tra cứu được tính trước: một prefiltered environment map — phiên bản môi trường bị làm mờ dần theo từng mip level tương ứng với độ nhám (roughness) tăng dần — và một BRDF LUT (texture 2D) mã hóa hệ số tỉ lệ và bù của Fresnel theo (NdotV, roughness).

Khi đó công thức ambient của Bài 13 (vec3 ambient = vec3(0.03) * albedo — một hằng số thô) được thay bằng đóng góp IBL thực thụ, giúp kim loại phản chiếu đúng bầu trời và vật điện môi nhận đúng ánh sáng môi trường. Đoạn dưới phác họa bước kết hợp IBL ở mức khái niệm:

ibl_ambient.frag.glsl
// Các texture được tính trước (precomputed) từ cubemap môi trường
uniform samplerCube u_irradianceMap;     // diffuse IBL
uniform samplerCube u_prefilterMap;      // specular IBL (theo mip = roughness)
uniform sampler2D   u_brdfLUT;           // split-sum BRDF lookup

vec3 iblAmbient(vec3 N, vec3 V, vec3 albedo, float metallic, float roughness, vec3 F0) {
  vec3 R = reflect(-V, N);
  float NdotV = max(dot(N, V), 0.0);

  // --- Diffuse: lấy mẫu irradiance theo pháp tuyến ---
  vec3 kS = fresnelSchlick(NdotV, F0);
  vec3 kD = (1.0 - kS) * (1.0 - metallic);
  vec3 irradiance = textureCube(u_irradianceMap, N).rgb;
  vec3 diffuse = irradiance * albedo;

  // --- Specular: split-sum (Epic Games) ---
  const float MAX_LOD = 4.0;
  vec3 prefiltered = textureCubeLodEXT(u_prefilterMap, R, roughness * MAX_LOD).rgb;
  vec2 brdf = texture2D(u_brdfLUT, vec2(NdotV, roughness)).rg;
  vec3 specular = prefiltered * (kS * brdf.x + brdf.y);

  return kD * diffuse + specular;
}

Đây chính là cây cầu nối liền Bài 13 (PBR trực tiếp) với Bài 14: cùng một BRDF Cook-Torrance, nhưng nguồn sáng giờ là cả môi trường được lưu trong cubemap. Khi ghép IBL vào, bạn đã có một pipeline PBR đầy đủ tương đương các engine hiện đại.

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

Trắc nghiệm 1: Cubemap được lấy mẫu thế nào?

Khác với texture 2D thông thường, một samplerCube được lấy mẫu bằng dữ liệu nào trong shader?

Trắc nghiệm 2: Thủ thuật độ sâu của skybox

Trong skybox vertex shader, vì sao ta đặt gl_Position = pos.xyww thay vì pos.xyzw?

Trắc nghiệm 3: Diffuse trong IBL

Trong IBL, vì sao thành phần khuếch tán (diffuse) được tính trước thành một irradiance map độ phân giải thấp?

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

File chứa skybox, cubemap thủ tục và quả cầu phản chiếu/khúc xạ hoàn chỉnh để bạn tự thử nghiệm:

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

Related Articles

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

Lesson 15: Skeletal Animation & Skinning Bài 15: Hoạt Họa Xương & Skinning Lesson 13: Physically-Based Rendering (PBR) & Cook-Torrance BRDF Bài 13: Kết Xuất Dựa Trên Vật Lý (PBR) & Cook-Torrance BRDF Back to WebGL Course Overview Quay lại Lộ trình WebGL Series