This programming guide is only available in Vietnamese. Switch the language toggle to Vietnamese to read the full article.
Bóng đổ là một trong những tín hiệu thị giác quan trọng nhất giúp não bộ định vị vật thể trong không gian ba chiều: nó cho biết một vật đang chạm sàn hay lơ lửng, gần hay xa nguồn sáng. Thế nhưng các mô hình chiếu sáng cục bộ (Phong, Blinn-Phong, thậm chí PBR) đều không biết gì về bóng đổ — mỗi fragment chỉ tính ánh sáng dựa trên pháp tuyến của chính nó mà hoàn toàn "mù" trước việc liệu có vật thể nào khác chắn đường tia sáng hay không. Shadow Mapping là kỹ thuật phổ biến nhất để bù đắp lỗ hổng đó trong kết xuất thời gian thực, và nó được dựng hoàn toàn trên nền tảng render-to-texture mà ta đã học ở bài Framebuffer.
1. Nguyên lý Shadow Mapping
Ý tưởng cốt lõi của Shadow Mapping đẹp đến mức gây bất ngờ: một điểm nằm trong bóng tối khi và chỉ khi nó không phải là điểm gần nguồn sáng nhất theo hướng tia sáng đó. Nói cách khác, nếu đứng tại vị trí nguồn sáng và "nhìn" về phía một fragment, mà fragment đó bị một vật thể khác che khuất, thì nó đang ở trong bóng.
Để hiện thực hóa trực giác này, ta thực hiện kết xuất theo hai lượt (two-pass rendering):
- Pass 1 — Depth Pass (từ góc nhìn nguồn sáng): Ta đặt một "camera ảo" ngay tại nguồn sáng, hướng về khung cảnh, rồi render toàn bộ scene vào một texture đặc biệt. Ta không quan tâm tới màu sắc; thứ duy nhất được lưu lại là khoảng cách (độ sâu) từ nguồn sáng tới bề mặt gần nhất theo mỗi tia. Texture kết quả này gọi là Depth Map (hay Shadow Map).
-
Pass 2 — Render Pass (từ góc nhìn camera thật): Ta render scene như bình thường.
Với mỗi fragment, ta tính xem nó cách nguồn sáng bao xa (gọi là
currentDepth), rồi tra cứu trong Depth Map để biết bề mặt gần nhất theo hướng đó cách nguồn sáng bao xa (closestDepth). NếucurrentDepth > closestDepth, nghĩa là có thứ gì đó đứng chắn phía trước fragment này → nó nằm trong bóng.
Toàn bộ phép so sánh phụ thuộc vào việc cả hai pass cùng dùng chung một
ma trận View-Projection của nguồn sáng (lightSpaceMatrix). Với nguồn
sáng có hướng (directional / mặt trời) ta dùng phép chiếu trực giao (orthographic); với đèn spotlight
ta dùng phép chiếu phối cảnh (perspective). Ma trận này biến một điểm trong thế giới thành tọa độ
light space, nơi thành phần Z chính là độ sâu cần so sánh.
// Dựng ma trận View-Projection cho "camera tại nguồn sáng".
// Với directional light, dùng orthographic để các tia sáng song song.
const lightProjection = ortho(-4, 4, -4, 4, 1.0, 12.0); // left,right,bottom,top,near,far
const lightView = lookAt(lightPos, [0, 0, 0], [0, 1, 0]); // nhìn về tâm scene
// Mọi điểm world * lightSpaceMatrix -> tọa độ clip trong không gian nguồn sáng
const lightSpaceMatrix = multiply(lightProjection, lightView);
// Pass 1 dùng lightSpaceMatrix để render depth map.
// Pass 2 truyền chính lightSpaceMatrix này vào shader để so sánh độ sâu.
2. Pass 1: Render Depth Map vào FBO
Pass đầu tiên tái sử dụng đúng kỹ thuật render-to-texture qua Framebuffer Object (FBO): thay vì vẽ ra màn hình, ta vẽ vào một texture riêng. Điểm khác biệt là lần này ta chỉ cần thông tin độ sâu. Có hai cách lưu trữ phổ biến:
-
Depth Texture thật: Trong WebGL2 (hoặc WebGL1 kèm tiện ích
WEBGL_depth_texture), ta gắn thẳng một texture định dạngDEPTH_COMPONENTvàoDEPTH_ATTACHMENT. GPU tự ghi giá trị Z phần cứng vào đó. Đây là cách tối ưu nhất. - Mã hóa độ sâu vào kênh màu: Với phần cứng cũ, ta render độ sâu tuyến tính ra một texture màu RGBA thông thường (đôi khi đóng gói vào nhiều kênh để tăng độ chính xác). Dễ tương thích hơn nhưng tốn băng thông.
Dưới đây là quy trình thiết lập một FBO với depth texture trong WebGL2. Lưu ý ta vẫn
cần khai báo COLOR_ATTACHMENT0 (hoặc gọi gl.drawBuffers([gl.NONE])) để FBO
hợp lệ, nhưng trọng tâm là cái depth texture sẽ được dùng như một sampler ở Pass 2:
const SHADOW_SIZE = 1024; // độ phân giải shadow map (càng cao viền bóng càng mịn)
// 1. Tạo depth texture
const depthTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, depthTex);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24,
SHADOW_SIZE, SHADOW_SIZE, 0,
gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
// CLAMP_TO_EDGE: ngoài vùng map coi như "đầy sáng", tránh bóng giả ở rìa
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// 2. Tạo FBO và gắn depth texture vào DEPTH_ATTACHMENT
const depthFBO = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, depthFBO);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTex, 0);
// Không vẽ màu trong pass này
gl.drawBuffers([gl.NONE]);
gl.readBuffer(gl.NONE);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
console.error('Depth FBO chưa hoàn tất!');
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
Khi vẽ Pass 1, ta đặt viewport bằng kích thước shadow map, dùng một shader cực kỳ tối
giản (vertex shader chỉ nhân vị trí với lightSpaceMatrix, fragment shader gần như rỗng vì
GPU tự ghi depth), và xóa depth buffer trước khi vẽ. Một mẹo tối ưu hiệu năng là bật
front-face culling trong pass này để giảm hiện tượng "shadow acne" (sẽ bàn ở phần 5):
function renderDepthPass() {
gl.bindFramebuffer(gl.FRAMEBUFFER, depthFBO);
gl.viewport(0, 0, SHADOW_SIZE, SHADOW_SIZE);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
gl.useProgram(depthProgram);
gl.uniformMatrix4fv(uLightSpaceLoc, false, lightSpaceMatrix);
// (Tùy chọn) cull mặt trước để giảm shadow acne
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.FRONT);
drawScene(depthProgram); // vẽ sàn + vật thể, chỉ ghi depth
gl.cullFace(gl.BACK); // khôi phục cho pass sau
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
3. Pass 2: So sánh độ sâu & tô bóng
Ở Pass 2, ta render scene bằng camera thật và shader chiếu sáng đầy đủ. Điểm mới nằm ở chỗ: vertex
shader tính thêm tọa độ của fragment trong light space bằng cách nhân vị trí world với
lightSpaceMatrix, rồi chuyển cho fragment shader qua biến
v_fragPosLightSpace.
Trong fragment shader, hàm shadowCalculation thực hiện ba bước:
-
Perspective divide & remap: Chia tọa độ light-space cho
wđể về Normalized Device Coordinates ([-1, 1]), rồi remap về[0, 1]để dùng làm tọa độ texture và làm giá trị độ sâu so sánh. -
Tra cứu depth map: Lấy
closestDepthtừ shadow map tại tọa độ đã tính, vàcurrentDepthchính là thành phần Z của fragment. -
So sánh có bias: Nếu
currentDepth - bias > closestDepththì fragment nằm trong bóng (trả về1.0), ngược lại được chiếu sáng (0.0). Hệ sốbiasnhỏ giúp khử lỗi self-shadowing.
Kết quả shadow (0 hoặc 1) được dùng để
tắt thành phần diffuse & specular nhưng vẫn giữ ambient — nhờ vậy vùng bóng tối
lại mà không đen tuyệt đối, đúng như ngoài đời:
uniform sampler2D u_shadowMap;
in vec4 v_fragPosLightSpace;
in vec3 v_normal;
in vec3 v_fragPos;
float shadowCalculation(vec4 fragPosLightSpace, float bias) {
// 1. Perspective divide -> NDC [-1, 1], rồi remap về [0, 1]
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
projCoords = projCoords * 0.5 + 0.5;
// Ngoài vùng far plane của shadow map: coi như được chiếu sáng
if (projCoords.z > 1.0) return 0.0;
// 2. Độ sâu gần nhất theo hướng nguồn sáng (đã lưu ở Pass 1)
float closestDepth = texture(u_shadowMap, projCoords.xy).r;
// Độ sâu thực của fragment hiện tại
float currentDepth = projCoords.z;
// 3. So sánh có bias để khử shadow acne
return (currentDepth - bias > closestDepth) ? 1.0 : 0.0;
}
void main() {
vec3 N = normalize(v_normal);
vec3 L = normalize(u_lightPos - v_fragPos);
// bias phụ thuộc góc giữa pháp tuyến và tia sáng
float bias = max(u_bias * (1.0 - dot(N, L)), u_bias * 0.1);
float shadow = u_shadowOn * shadowCalculation(v_fragPosLightSpace, bias);
float diff = max(dot(N, L), 0.0);
vec3 ambient = 0.25 * u_color;
vec3 diffuse = diff * u_color;
// Vùng trong bóng chỉ còn ambient
vec3 result = ambient + (1.0 - shadow) * diffuse;
fragColor = vec4(result, 1.0);
}
4. Demo tương tác: Shadow Mapping hai lượt thời gian thực
Demo dưới đây hiện thực hóa đầy đủ quy trình hai lượt: một quả cầu lơ lửng đổ bóng xuống mặt sàn. Toàn bộ bóng đổ được tính bằng depth map render từ góc nhìn nguồn sáng. Hãy kéo thanh Góc nguồn sáng để thấy bóng dịch chuyển theo mặt trời, dùng nút Bật/Tắt bóng để so sánh trực tiếp scene có và không có shadow mapping, và quan trọng nhất — kéo thanh Depth Bias về 0 để tận mắt chứng kiến hiện tượng Shadow Acne (các vệt sọc tối lốm đốm) xuất hiện, rồi tăng bias để khử chúng:
với closestDepth = texture(shadowMap, projCoords.xy).r // depth gần nhất từ nguồn sáng
5. Khử lỗi: Shadow Acne, Peter Panning & PCF
Shadow Mapping cơ bản hiếm khi cho kết quả sạch ngay lần đầu. Có ba "bệnh kinh điển" mà mọi lập trình viên đồ họa đều phải đối mặt:
-
Shadow Acne (mụn bóng): Những vệt sọc tối lốm đốm trên bề mặt được chiếu sáng.
Nguyên nhân là độ phân giải hữu hạn của shadow map: nhiều fragment cùng ánh xạ vào một texel depth,
khiến phép so sánh
currentDepth > closestDepthdao động quanh ranh giới. Cách chữa phổ biến nhất là cộng một depth bias nhỏ — đẩy độ sâu fragment ra xa nguồn sáng một chút để nó không tự che bóng chính mình. Bias nên tỉ lệ với góc nghiêng bề mặt (slope-scaled bias):bias = max(k * (1 - dot(N, L)), kMin). - Peter Panning (bóng bị tách rời): Nếu bias quá lớn, bóng bị đẩy lệch khỏi chân vật thể, khiến vật trông như đang "bay" lơ lửng — đặt theo nhân vật Peter Pan. Đây là sự đánh đổi trực tiếp với acne: bias đủ để khử acne nhưng không quá tay. Một giải pháp triệt để hơn là dùng front-face culling ở Pass 1 (chỉ ghi mặt sau vào depth map), giúp giảm acne mà không cần bias lớn.
- Viền bóng răng cưa (aliasing): Vì shadow map là texture rời rạc, viền bóng bị bậc thang. Kỹ thuật PCF (Percentage-Closer Filtering) lấy mẫu nhiều texel lân cận quanh điểm tra cứu, so sánh từng cái rồi lấy trung bình tỉ lệ "trong bóng". Kết quả là viền bóng được làm mềm, chuyển dần từ sáng sang tối thay vì cắt phựt.
Demo phía trên đã tích hợp sẵn PCF 3×3; dưới đây là phần lõi của nó:
// PCF 3x3: lấy trung bình kết quả so sánh trên 9 texel lân cận
float shadow = 0.0;
vec2 texel = 1.0 / vec2(textureSize(u_shadowMap, 0)); // kích thước 1 texel
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
float closest = texture(u_shadowMap, projCoords.xy + vec2(x, y) * texel).r;
shadow += (currentDepth - bias > closest) ? 1.0 : 0.0;
}
}
shadow /= 9.0; // tỉ lệ trong bóng -> viền mềm mượt
6. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: Bản chất của Depth Map
Trong Pass 1 của Shadow Mapping, thông tin gì thực sự được lưu vào Depth Map (Shadow Map)?
Trắc nghiệm 2: Đánh đổi Bias
Hiện tượng Peter Panning (bóng tách rời khỏi chân vật thể, làm vật trông như đang bay)
là hệ quả trực tiếp của điều gì?
Trắc nghiệm 3: Vai trò của PCF
Kỹ thuật Percentage-Closer Filtering (PCF) được thêm vào hàm tính bóng nhằm mục đích chính nào?
Tải mã nguồn thực hành
File chứa quy trình two-pass shadow mapping hoàn chỉnh (depth FBO, PCF, depth bias) để bạn tự thử nghiệm:
Tải về mã nguồn Shadow Mapping mẫu
Comments
Bình luận