This programming guide is only available in Vietnamese. Switch the language toggle to Vietnamese to read the full article.
Ở những bài trước, mỗi đỉnh (vertex) mang một pháp tuyến (normal) duy nhất và phép chiếu sáng nội suy pháp tuyến đó trên toàn bề mặt tam giác. Kết quả là các mặt phẳng trông mượt nhưng phẳng lì, thiếu hoàn toàn những chi tiết nhỏ như vết nứt gạch, vân gỗ hay rãnh kim loại. Normal Mapping là kỹ thuật làm thay đổi điều đó: nó cho phép mỗi điểm ảnh (fragment) sử dụng một pháp tuyến riêng đọc từ texture, tạo ảo giác bề mặt gồ ghề chi tiết mà không thêm một tam giác nào. Đây là một trong những kỹ thuật có tỉ lệ "đẹp trên chi phí" cao nhất trong đồ họa thời gian thực.
1. Vì sao cần Normal Mapping?
Hãy tưởng tượng bạn cần kết xuất một bức tường gạch chi tiết với từng viên gạch lồi lõm, từng đường vữa lõm xuống. Cách "thật" nhất là mô hình hóa toàn bộ chi tiết đó bằng hình học — high-poly — có thể tốn hàng triệu tam giác cho riêng một bức tường. GPU sẽ nghẹt thở khi cảnh có hàng chục bức tường như vậy. Normal Mapping giải bài toán này bằng một mẹo về ánh sáng.
Mắt người cảm nhận hình khối chủ yếu qua cách ánh sáng phản chiếu trên bề mặt, mà ánh
sáng lại phụ thuộc trực tiếp vào pháp tuyến tại mỗi điểm (qua tích vô hướng
N · L). Ý tưởng cốt lõi: thay vì thay đổi hình học thật, ta chỉ cần thay đổi
pháp tuyến tại từng điểm ảnh. Một bề mặt phẳng (low-poly, chỉ 2 tam giác) nhưng được
cấp một bản đồ pháp tuyến chi tiết sẽ phản chiếu ánh sáng y như thể nó gồ ghề thật.
- High-poly: chi tiết là thật, đổ bóng và viền (silhouette) chính xác, nhưng cực kỳ tốn tam giác và bộ nhớ.
- Low-poly + Normal Map: hình học gần như phẳng, nhưng ánh sáng "tin" rằng bề mặt gồ ghề. Chi phí chỉ là một lần lấy mẫu texture cho mỗi pixel.
Normal map là một texture đặc biệt: thay vì lưu màu RGB, mỗi texel lưu một
vector pháp tuyến được mã hóa thành ba kênh màu. Đó là lý do normal map thường có sắc xanh
tím đặc trưng — vì phần lớn pháp tuyến hướng ra ngoài theo trục Z ((0,0,1)), mã hóa thành
màu (128,128,255). Quy trình chuẩn của ngành (gọi là "baking") là dựng một bản high-poly,
rồi nướng chi tiết pháp tuyến của nó vào một normal map dán lên bản low-poly.
// Mỗi texel của normal map lưu một vector pháp tuyến đã mã hóa.
// Kênh màu [0,1] -> thành phần vector [-1,1].
// R -> trục Tangent (X tiếp tuyến)
// G -> trục Bitangent (Y tiếp tuyến)
// B -> trục Normal (Z, hướng ra ngoài bề mặt)
//
// Màu "phẳng" mặc định (128,128,255) => vector (0,0,1):
// tức "không lệch khỏi pháp tuyến gốc của bề mặt".
//
vec3 sampledNormal = texture2D(u_normalMap, v_uv).rgb; // dải [0,1]
vec3 tangentNormal = sampledNormal * 2.0 - 1.0; // -> dải [-1,1]
// tangentNormal lúc này nằm trong KHÔNG GIAN TIẾP TUYẾN,
// chưa thể dùng trực tiếp để chiếu sáng (xem mục 2 & 3).
2. Tangent Space & ma trận TBN
Câu hỏi mấu chốt: normal đọc từ texture nằm trong không gian nào? Câu trả lời là không gian tiếp tuyến (tangent space) — một hệ trục cục bộ "dán" lên bề mặt tại từng điểm. Trong hệ này, trục Z luôn hướng ra ngoài bề mặt (chính là pháp tuyến hình học), còn hai trục còn lại nằm phẳng trên bề mặt, gióng theo hướng tăng của tọa độ UV. Nhờ vậy, cùng một normal map dùng được cho mặt phẳng, mặt cong hay bất kỳ hình dạng nào — vì nó luôn được diễn giải tương đối so với bề mặt cục bộ.
Ba trục của không gian tiếp tuyến có tên riêng, ghép lại thành ma trận TBN:
- T — Tangent: hướng theo trục U (chiều ngang) của texture, nằm trên bề mặt.
- B — Bitangent: hướng theo trục V (chiều dọc) của texture, vuông góc với T.
- N — Normal: pháp tuyến hình học của đỉnh, vuông góc với cả T và B.
Ma trận TBN = [T | B | N] (mỗi vector là một cột) là phép biến đổi từ
không gian tiếp tuyến sang không gian thế giới (world). Để tính được T và B, ta khai
thác mối liên hệ giữa thay đổi vị trí (edge) và thay đổi tọa độ UV (deltaUV)
trên một tam giác. Với hai cạnh E1, E2 và biến thiên UV tương ứng, ta có hệ:
E2 = ΔU2·T + ΔV2·B
⇒ [T B] = (1 / (ΔU1·ΔV2 − ΔU2·ΔV1)) × [ ΔV2·E1 − ΔV1·E2 , −ΔU2·E1 + ΔU1·E2 ]
Hệ số 1 / (ΔU1·ΔV2 − ΔU2·ΔV1) chính là nghịch đảo định thức của ma trận UV — đảo ngược
lại phép ánh xạ từ không gian UV về không gian tiếp tuyến. Dưới đây là cách tính T cho mỗi tam giác
trong JavaScript khi xây dựng buffer:
// Tính tangent cho một tam giác (p0,p1,p2) với UV (uv0,uv1,uv2).
// Trả về vector tangent T trong cùng không gian với vị trí đỉnh.
function computeTangent(p0, p1, p2, uv0, uv1, uv2) {
// Hai cạnh tam giác
const e1 = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
const e2 = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
// Biến thiên UV tương ứng
const du1 = uv1[0] - uv0[0], dv1 = uv1[1] - uv0[1];
const du2 = uv2[0] - uv0[0], dv2 = uv2[1] - uv0[1];
const det = du1 * dv2 - du2 * dv1;
const f = det === 0 ? 0 : 1.0 / det; // tránh chia cho 0 (UV suy biến)
// T = f * (dv2 * E1 - dv1 * E2)
const T = [
f * (dv2 * e1[0] - dv1 * e2[0]),
f * (dv2 * e1[1] - dv1 * e2[1]),
f * (dv2 * e1[2] - dv1 * e2[2]),
];
// Chuẩn hóa; trong thực tế ta cộng dồn T của các tam giác chung
// một đỉnh rồi mới chuẩn hóa (averaging) để bề mặt liền mạch.
const len = Math.hypot(T[0], T[1], T[2]) || 1;
return [T[0] / len, T[1] / len, T[2] / len];
}
Sau khi có T tại mỗi đỉnh (gửi vào shader như một attribute), vertex shader sẽ dựng ma trận TBN trong
không gian thế giới. Một mẹo quan trọng: ta tái trực giao hóa (re-orthogonalize) T so
với N bằng phép Gram-Schmidt, vì sau khi nội suy giữa các đỉnh, T và N có thể không còn vuông góc
tuyệt đối. Bitangent được suy ra bằng tích có hướng cross(N, T):
attribute vec3 a_position;
attribute vec3 a_normal;
attribute vec3 a_tangent;
attribute vec2 a_uv;
uniform mat4 u_MVP;
uniform mat4 u_Model;
uniform mat3 u_NormalMatrix;
varying vec2 v_uv;
varying vec3 v_fragPos;
varying mat3 v_TBN; // truyền nguyên ma trận TBN sang fragment shader
void main() {
v_uv = a_uv;
v_fragPos = vec3(u_Model * vec4(a_position, 1.0));
// Đưa N và T sang không gian thế giới
vec3 N = normalize(u_NormalMatrix * a_normal);
vec3 T = normalize(u_NormalMatrix * a_tangent);
// Gram-Schmidt: bỏ thành phần của T trùng phương N -> T vuông góc N
T = normalize(T - dot(T, N) * N);
vec3 B = cross(N, T); // bitangent suy ra từ N và T
v_TBN = mat3(T, B, N); // ma trận đổi cơ sở: tiếp tuyến -> thế giới
gl_Position = u_MVP * vec4(a_position, 1.0);
}
3. Lấy mẫu Normal Map trong Fragment Shader
Trong fragment shader, quy trình gồm ba bước rõ ràng. Bước một — giải mã: đọc texel
từ normal map (dải [0,1]) và ánh xạ về dải [-1,1] bằng
texel * 2.0 - 1.0. Vì sao cần phép này? Vì texture chỉ lưu được số dương không dấu, nên
thành phần âm của vector (ví dụ nghiêng sang trái, X < 0) phải được "dồn" về nửa trên của dải
[0,1] khi nướng. Phép ×2−1 đảo ngược chính xác phép nén đó.
Bước hai — biến đổi không gian: vector vừa giải mã nằm trong không gian tiếp tuyến.
Để chiếu sáng cùng với vị trí nguồn sáng (vốn ở không gian thế giới), ta nhân nó với ma trận TBN:
worldNormal = normalize(TBN * tangentNormal). Lúc này pháp tuyến mới đã ở đúng không gian
với lightPos và viewPos.
Bước ba — chiếu sáng như thường lệ: dùng pháp tuyến nhiễu loạn (perturbed)
này trong công thức Lambert/Blinn-Phong thay cho pháp tuyến hình học. Vì pháp tuyến giờ thay đổi theo
từng pixel theo normal map, ánh sáng sẽ lộ ra mọi chi tiết gồ ghề. Ta còn có thể thêm tham số
u_strength để khuếch đại hoặc làm dịu độ lồi lõm bằng cách co/giãn thành phần XY của
normal trước khi chuẩn hóa.
precision highp float;
varying vec2 v_uv;
varying vec3 v_fragPos;
varying mat3 v_TBN;
uniform sampler2D u_normalMap;
uniform vec3 u_lightPos;
uniform vec3 u_viewPos;
uniform vec3 u_baseColor;
uniform float u_strength; // cường độ gồ ghề
uniform bool u_useNormalMap;
void main() {
vec3 N;
if (u_useNormalMap) {
// 1) Giải mã từ [0,1] về [-1,1]
vec3 tN = texture2D(u_normalMap, v_uv).rgb * 2.0 - 1.0;
// Khuếch đại độ lệch XY theo cường độ (giữ Z dương)
tN.xy *= u_strength;
tN = normalize(tN);
// 2) Tiếp tuyến -> thế giới
N = normalize(v_TBN * tN);
} else {
// Không dùng map: lấy thẳng cột N của TBN (pháp tuyến hình học)
N = normalize(v_TBN[2]);
}
// 3) Chiếu sáng Blinn-Phong với pháp tuyến đã nhiễu loạn
vec3 L = normalize(u_lightPos - v_fragPos);
vec3 V = normalize(u_viewPos - v_fragPos);
vec3 H = normalize(L + V);
float diff = max(dot(N, L), 0.0);
float spec = pow(max(dot(N, H), 0.0), 48.0);
vec3 ambient = 0.12 * u_baseColor;
vec3 diffuse = diff * u_baseColor;
vec3 specular = spec * vec3(0.35);
gl_FragColor = vec4(ambient + diffuse + specular, 1.0);
}
4. Demo tương tác: Mặt phẳng gạch có Normal Map thời gian thực
Dưới đây là một mặt phẳng đơn giản (chỉ vài tam giác) được cấp một normal map gạch sinh tự động ngay trong trình duyệt (không tải ảnh ngoài). Hãy bật/tắt ô Normal Map để thấy cùng một hình học chuyển từ phẳng lì sang gồ ghề chi tiết. Kéo thanh trượt vị trí ánh sáng để quan sát cách các viên gạch "lộ khối" khi ánh sáng quét qua, và cường độ để điều chỉnh độ sâu của rãnh vữa:
5. Bump Mapping & Parallax Mapping
Normal Mapping không phải kỹ thuật duy nhất tạo ảo giác bề mặt gồ ghề. Có ba kỹ thuật cùng họ, khác nhau về dữ liệu đầu vào và mức độ chân thực:
- Bump Mapping (gốc, Blinn 1978): dùng height map — một texture thang xám (grayscale) lưu độ cao. Pháp tuyến nhiễu loạn được tính từ độ dốc của height map (đạo hàm theo U và V). Rẻ về bộ nhớ (1 kênh) nhưng phải tính sai phân trong shader.
- Normal Mapping: lưu sẵn vector pháp tuyến trong 3 kênh — bỏ qua bước tính đạo hàm, nhanh và chính xác hơn. Đây là chuẩn phổ biến hiện nay.
- Parallax Mapping: tiến xa hơn — không chỉ làm nhiễu pháp tuyến mà còn dịch chuyển tọa độ UV theo hướng nhìn để mô phỏng độ sâu thật, tạo cảm giác các viên gạch thực sự nhô lên và che khuất nhau khi nhìn nghiêng.
Ý tưởng của Parallax: khi nhìn một bề mặt gồ ghề từ một góc, các phần lồi che mất các
phần lõm phía sau chúng — đó là hiệu ứng thị sai (parallax). Ta dùng height map để ước lượng độ sâu
h tại điểm đang xét, rồi dịch UV một đoạn dọc theo hướng nhìn (đã đưa về không gian tiếp
tuyến). Bản đơn giản nhất gọi là Parallax Mapping with Offset Limiting:
// viewDirTS: hướng nhìn ĐÃ đưa về không gian tiếp tuyến (tangent space)
// u_heightScale: độ sâu giả lập (vd 0.05)
vec2 parallaxUV(vec2 uv, vec3 viewDirTS) {
float height = texture2D(u_heightMap, uv).r; // độ cao [0,1]
// Dịch UV ngược theo hướng nhìn, tỉ lệ với độ sâu.
// Chia cho viewDirTS.z để "offset limiting" tránh artefact ở góc lướt.
vec2 p = (viewDirTS.xy / viewDirTS.z) * (height * u_heightScale);
return uv - p;
}
void main() {
// Đưa view-dir về tangent space rồi tính UV mới có thị sai
vec3 viewDirTS = normalize(u_viewPos_TS - v_fragPos_TS);
vec2 uv = parallaxUV(v_uv, viewDirTS);
// Sau đó lấy mẫu normal map TẠI uv đã dịch -> vừa có thị sai vừa có chi tiết
vec3 tN = texture2D(u_normalMap, uv).rgb * 2.0 - 1.0;
// ... chiếu sáng như mục 3 ...
}
Tóm lại thứ tự độ chân thực & chi phí tăng dần: Bump < Normal < Parallax < Parallax Occlusion Mapping (POM, dùng ray-marching qua height map cho hiệu ứng che khuất hoàn chỉnh). Trong thực tế, Normal Mapping là điểm cân bằng phổ biến nhất; Parallax được thêm vào cho các bề mặt cận cảnh quan trọng (sàn đá, tường gạch nhân vật đứng sát).
6. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: Giải mã normal map
Vì sao trong fragment shader ta phải thực hiện phép texel * 2.0 - 1.0 sau khi lấy mẫu
normal map?
Trắc nghiệm 2: Ma trận TBN
Ma trận TBN trong normal mapping thực hiện phép biến đổi nào?
Trắc nghiệm 3: So sánh kỹ thuật
Điểm khác biệt cốt lõi khiến Parallax Mapping chân thực hơn Normal Mapping là gì?
Tải mã nguồn thực hành
File chứa shader normal mapping hoàn chỉnh, hàm tính tangent và demo mặt phẳng gạch sinh thủ tục để bạn tự thử nghiệm:
Tải về mã nguồn Normal Mapping mẫu
Comments
Bình luận