This programming guide is only available in Vietnamese. Switch the language toggle to Vietnamese to read the full article.
Cho tới giờ chúng ta luôn vẽ các vật thể đục (opaque): mỗi pixel chỉ mang đúng một màu của fragment gần camera nhất, và bộ depth buffer tự động loại bỏ những gì bị che khuất. Nhưng thế giới thực đầy rẫy kính, nước, khói, lửa, kính màu, hiệu ứng UI mờ… — những bề mặt cho ánh sáng (và màu của vật phía sau) đi xuyên qua. Để mô phỏng chúng, ta cần alpha blending: pha trộn màu của fragment đang vẽ với màu đã có sẵn trong framebuffer. Bài này sẽ mổ xẻ cơ chế pha trộn của WebGL, lý do vì sao trong suốt lại đòi hỏi sắp xếp thứ tự vẽ, và vì sao đây là một trong những bài toán "dễ làm sai" nhất trong đồ họa thời gian thực.
1. Alpha channel & bài toán trong suốt
Mỗi màu trong WebGL là một vector vec4(r, g, b, a). Ba thành phần đầu là màu; thành phần
thứ tư — alpha — biểu thị độ đục của fragment: a = 1.0 là đục
hoàn toàn, a = 0.0 là trong suốt hoàn toàn (vô hình), còn a = 0.5 nghĩa là
fragment chỉ "góp" một nửa màu của nó, để lộ một nửa màu phía sau. Bản thân alpha không tự
động làm gì cả: nếu bạn không bật blending, GPU sẽ ghi đè màu mới lên màu cũ và alpha bị bỏ qua hoàn
toàn ở bước ghi framebuffer.
Vì sao trong suốt khó hơn hẳn vật đục? Với vật đục, kết quả tại mỗi pixel độc lập với thứ tự vẽ: dù bạn vẽ vật A trước hay vật B trước, depth test luôn giữ lại fragment gần nhất, nên ảnh cuối cùng như nhau. Trong suốt phá vỡ tính chất tuyệt vời đó. Khi pha trộn, màu cuối là một hàm của toàn bộ chuỗi fragment đã đi qua pixel đó, và phép pha trộn nói chung không giao hoán (không đối xứng): vẽ kính đỏ rồi kính xanh cho kết quả khác với vẽ kính xanh rồi kính đỏ. Hệ quả: ta phải kiểm soát chặt chẽ thứ tự vẽ và cách depth buffer tương tác với blending. Đây chính là gốc rễ của mọi rắc rối trong phần còn lại của bài.
// Mặc định blending TẮT: fragment shader ghi đè thẳng lên framebuffer,
// thành phần alpha bị bỏ qua hoàn toàn ở bước ghi.
gl.disable(gl.BLEND);
// Bật blending để GPU pha trộn màu nguồn (source) với màu đích (destination).
gl.enable(gl.BLEND);
// Một fragment shader xuất ra alpha = 0.5 => chỉ góp một nửa màu.
// Nhưng nếu KHÔNG bật gl.BLEND, alpha này vô nghĩa khi ghi ra màn hình.
// gl_FragColor = vec4(color.rgb, 0.5);
2. gl.blendFunc & phương trình pha trộn
Khi blending được bật, với mỗi fragment GPU tính màu cuối theo một
phương trình pha trộn. Gọi src là màu fragment shader vừa xuất ra (color
nguồn) và dst là màu đang có sẵn trong framebuffer (color đích), công thức tổng quát là:
Hàm gl.blendFunc(srcFactor, dstFactor) cho phép bạn chọn hai hệ số nhân này từ một tập
hằng số của WebGL. Preset trong suốt phổ biến nhất là
alpha blending chuẩn ("over" operator của Porter-Duff):
result = src.rgb × src.a + dst.rgb × (1 − src.a)
Diễn giải: fragment đóng góp tỉ lệ src.a của màu nó, và để lộ phần
(1 − src.a)
của màu nền phía sau. Đây là phép nội suy tuyến tính (lerp) giữa nền và màu mới theo alpha — đúng trực
giác về một tấm kính mờ. Vài preset quan trọng khác:
-
Additive (cộng dồn) —
gl.blendFunc(gl.ONE, gl.ONE): màu nguồn được cộng thẳng vào nền. Hoàn hảo cho ánh sáng phát quang: lửa, tia laser, đốm sáng, hệ hạt (particle). Vùng chồng lấp càng sáng — và quan trọng là phép cộng giao hoán nên additive blending không cần sắp xếp thứ tự! -
Multiply (nhân) —
gl.blendFunc(gl.DST_COLOR, gl.ZERO): nhân màu nguồn với màu nền, luôn làm tối đi. Dùng cho bóng đổ giả, kính màu hấp thụ ánh sáng, lớp phủ ám màu. -
Premultiplied alpha —
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA): xem phần dưới.
Ngoài blendFunc, bạn còn có thể đổi cả toán tử bằng
gl.blendEquation(gl.FUNC_ADD) (mặc định là cộng; còn có FUNC_SUBTRACT,
FUNC_REVERSE_SUBTRACT, và với WebGL2 là MIN/MAX). Và
gl.blendFuncSeparate cho phép đặt hệ số riêng cho kênh RGB và kênh alpha:
gl.enable(gl.BLEND);
// 1) Alpha blending chuẩn ("over") — kính mờ, cửa sổ, UI mờ
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// 2) Additive — lửa, laser, particle phát sáng (KHÔNG cần sắp xếp)
gl.blendFunc(gl.ONE, gl.ONE);
// 3) Multiply — ám màu, bóng giả, kính hấp thụ
gl.blendFunc(gl.DST_COLOR, gl.ZERO);
// Toán tử pha trộn (mặc định FUNC_ADD)
gl.blendEquation(gl.FUNC_ADD);
// Hệ số riêng cho RGB và alpha (hữu ích khi render ra texture có alpha)
gl.blendFuncSeparate(
gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, // cho RGB
gl.ONE, gl.ONE_MINUS_SRC_ALPHA // cho alpha
);
Premultiplied alpha là một mẹo quan trọng để tránh các "viền đen/halo" xấu xí quanh
texture có alpha. Ý tưởng: thay vì lưu màu "thẳng" (straight/unassociated) rồi nhân alpha lúc blend,
ta nhân sẵn alpha vào RGB ngay từ đầu (rgb' = rgb × a). Khi đó công thức "over"
trở thành gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA). Lợi ích: lọc texture (mipmap,
bilinear) không còn kéo các pixel "màu rác" có alpha=0 vào vùng nhìn thấy, và bạn có thể trộn lẫn vật
additive với vật trong suốt mà không đổi state — vì màu đã nhân alpha sẵn thì a=0 tự
nhiên cho đóng góp bằng 0.
precision highp float;
uniform sampler2D u_tex;
varying vec2 v_uv;
void main() {
vec4 c = texture2D(u_tex, v_uv); // alpha "thẳng" (straight)
// Nhân sẵn alpha vào RGB => xuất ra màu premultiplied.
// Kết hợp với gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) ở phía JS.
gl_FragColor = vec4(c.rgb * c.a, c.a);
}
3. Depth testing vs Depth writing với vật trong suốt
Đây là phần tinh tế nhất. Cần phân biệt rõ hai việc mà depth buffer làm:
depth testing (so độ sâu của fragment với giá trị đã lưu để quyết định có vẽ hay
không) và depth writing (ghi độ sâu của fragment vừa vẽ vào buffer). Với vật đục, ta
bật cả hai: gl.enable(gl.DEPTH_TEST) và mặc định gl.depthMask(true).
Vấn đề: nếu một vật trong suốt được vẽ và ghi độ sâu của nó vào buffer, thì những vật trong
suốt khác nằm phía sau nó (xa hơn) sẽ trượt depth test và bị loại bỏ — dù lẽ
ra ta phải nhìn thấy chúng xuyên qua tấm kính phía trước! Kết quả là các bề mặt sau "biến mất" một
cách vô lý. Giải pháp chuẩn: với pass vẽ vật trong suốt, hãy tắt ghi độ sâu bằng
gl.depthMask(false) nhưng vẫn giữ depth testing bật — như vậy vật trong suốt vẫn
bị các vật đục ở gần che đúng cách, nhưng không tự che lẫn nhau qua depth buffer.
Tắt depth write giải quyết việc "bị loại oan", nhưng không giải quyết được thứ tự pha trộn. Vì phép "over" không giao hoán, ta phải tự tay vẽ các vật trong suốt theo thứ tự back-to-front (xa trước, gần sau): vật xa nhất vẽ trước để các tấm gần hơn pha trộn chồng lên nó đúng trình tự ánh sáng đi vào mắt. Quy trình điển hình của một frame:
- Vẽ toàn bộ vật đục trước, bật depth test + depth write.
- Sắp xếp các vật trong suốt theo khoảng cách tới camera, xa → gần.
- Bật blending,
gl.depthMask(false), rồi vẽ chúng theo thứ tự đã sắp xếp.
Cách này vẫn có giới hạn cố hữu: sắp xếp theo tâm vật thể sẽ sai khi các vật giao nhau hoặc lồng vào nhau, và không thể sắp xếp đúng từng pixel bên trong một lưới tam giác lõm. Đó là lý do người ta phát minh ra OIT ở phần 5.
// --- PASS 1: vật đục ---
gl.enable(gl.DEPTH_TEST);
gl.depthMask(true); // ghi độ sâu bình thường
gl.disable(gl.BLEND);
drawOpaqueObjects();
// --- PASS 2: vật trong suốt ---
// Sắp xếp BACK-TO-FRONT theo khoảng cách tới camera (xa vẽ trước)
transparent.sort((a, b) => b.distanceToCamera - a.distanceToCamera);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false); // VẪN test độ sâu, nhưng KHÔNG ghi
for (const obj of transparent) {
drawObject(obj); // vật xa hơn sẽ được tấm gần hơn phủ chồng đúng thứ tự
}
gl.depthMask(true); // khôi phục cho frame sau
4. Demo tương tác: pha trộn & sắp xếp độ sâu
Dưới đây là ba tấm phẳng (quad) bán trong suốt nằm ở ba độ sâu khác nhau, xoay chậm. Hãy thử đổi chế độ pha trộn, kéo thanh alpha, và đặc biệt là bật/tắt Depth Write: khi bật ghi độ sâu cho vật trong suốt (cấu hình "sai"), bạn sẽ thấy các tấm phía sau bị cắt cụt một cách vô lý — chính là artifact sắp xếp độ sâu mà phần 3 mô tả. Tắt nó đi để pha trộn trở lại đúng:
5. Order-Independent Transparency (giới thiệu)
Sắp xếp back-to-front nghe đơn giản nhưng trong thực tế rất phiền: bạn phải sắp lại mỗi frame khi camera di chuyển (tốn CPU), việc sắp theo tâm vật thể vẫn sai khi chúng giao cắt hoặc lồng nhau, và không bao giờ xử lý đúng được các tam giác chồng lấp trong cùng một lưới. Order-Independent Transparency (OIT) là họ kỹ thuật cho ra kết quả pha trộn đúng (hoặc gần đúng) mà không cần sắp xếp thủ công.
Kỹ thuật thực dụng nhất cho WebGL là Weighted Blended OIT (McGuire & Bavoil,
2013). Ý tưởng: thay vì pha trộn theo thứ tự, ta tích lũy mọi fragment trong suốt vào hai render
target — một accumulation buffer cộng dồn color × weight và một revealage buffer theo dõi
độ "lộ" còn lại — với blending giao hoán (phép cộng), nên thứ tự không còn quan
trọng. Trọng số weight là một hàm xấp xỉ phụ thuộc độ sâu và alpha, ưu tiên fragment gần
camera. Cuối cùng một pass "resolve" trộn hai buffer lại thành ảnh trong suốt mượt mà.
Hạn chế: Weighted Blended OIT chỉ là xấp xỉ — nó không phân giải chính xác thứ tự nên các cảnh có chênh lệch alpha lớn có thể trông hơi sai. Các phương pháp chính xác tuyệt đối như per-pixel linked lists / A-buffer đòi hỏi ghi tùy ý vào bộ nhớ (cần WebGL2/WebGPU và rất tốn tài nguyên). Vì vậy trong thực tế, lựa chọn thường là: dùng additive cho hiệu ứng phát sáng (không cần sort), sort back-to-front cho số ít vật trong suốt rõ ràng, và để dành OIT cho những cảnh thực sự dày đặc lớp trong suốt.
// Pass tích lũy của Weighted Blended OIT (cần 2 render target, WebGL2 MRT).
// Phía JS: cả hai target dùng blendFunc cộng dồn (giao hoán) => không cần sort.
// accum: gl.blendFunc(gl.ONE, gl.ONE)
// revealage: gl.blendFunc(gl.ZERO, gl.ONE_MINUS_SRC_COLOR)
#version 300 es
precision highp float;
in float v_viewDepth; // độ sâu trong không gian view
in vec4 v_color; // màu + alpha của fragment trong suốt
layout(location = 0) out vec4 accum;
layout(location = 1) out float revealage;
void main() {
float a = v_color.a;
// Trọng số ưu tiên fragment gần camera (một trong các công thức của bài báo gốc)
float w = a * clamp(0.03 / (1e-5 + pow(abs(v_viewDepth) / 200.0, 4.0)), 1e-2, 3e3);
accum = vec4(v_color.rgb * a, a) * w; // cộng dồn, thứ tự không quan trọng
revealage = a; // theo dõi độ "lộ" còn lại
}
6. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: Phương trình pha trộn
Với gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) và fragment có alpha = 0.25, màu
cuối tại pixel được tính như thế nào?
Trắc nghiệm 2: Depth write
Vì sao khi vẽ vật trong suốt người ta thường gọi gl.depthMask(false) nhưng vẫn giữ
DEPTH_TEST bật?
Trắc nghiệm 3: Sắp xếp & OIT
Chế độ pha trộn nào không đòi hỏi sắp xếp back-to-front, và vì sao?
Tải mã nguồn thực hành
File chứa các preset blendFunc, pass vẽ vật trong suốt back-to-front và demo ba tấm kính để bạn tự thử nghiệm:
Tải về mã nguồn Blending mẫu
Comments
Bình luận