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) và 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:
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, height và border = 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:
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:
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".
T = refract(I, N, η) với η = n1 / n2 // khúc xạ (Snell)
Một lưu ý về hệ quy chiếu: reflect và refract 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:
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:
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:
// 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
Comments
Bình luận