Three.js from Zero to Senior · Part 7 — Post-Processing & the EffectComposer
Add cinematic polish: how the EffectComposer renders your scene to a texture and runs full-screen passes (bloom, vignette, custom shaders), why pass order and OutputPass matter, and the performance budget — with a live bloom rig.
Khác biệt giữa một demo kỹ thuật và thứ trông đã hoàn thiện thường nằm ở 5% cuối — quầng sáng trên biển neon, độ tắt mềm ở mép màn hình, độ tương phản điện ảnh. Lớp đó là post-processing: hiệu ứng ảnh áp dụng sau khi render 3D, lên chính bức ảnh đã render.
Bật bloom trong demo và đẩy threshold — chỉ lõi phát sáng mới rực; các khối mờ vẫn sắc.
Mô hình tư duy: render ra texture, rồi xử lý lại
Bình thường renderer.render(scene, camera) vẽ thẳng ra màn hình. Post-processing chèn một bước giữa:
- Render scene 3D vào một render target ngoài màn hình (một texture).
- Cho texture đó chạy qua một chuỗi pass toàn màn hình — mỗi pass đọc kết quả trước và ghi ra kết quả mới.
- Pass cuối ghi ra màn hình.
Bộ điều phối cho tất cả là EffectComposer.
Nối một EffectComposer
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const composer = new EffectComposer(renderer);
// 1. RenderPass draws the scene into the composer's render target. Almost always first.
composer.addPass(new RenderPass(scene, camera));
// 2. Bloom: extract bright pixels, blur them, add back.
const bloom = new UnrealBloomPass(new THREE.Vector2(w, h), 1.1, 0.4, 0.2);
// resolution strength radius threshold
composer.addPass(bloom);
// 3. OutputPass: tone mapping + correct sRGB conversion. Put it LAST.
composer.addPass(new OutputPass());
Rồi trong loop, render qua composer thay vì renderer:
function animate() {
requestAnimationFrame(animate);
controls.update();
composer.render(); // ← not renderer.render(...)
}
Cú đổi duy nhất đó — composer.render() thay cho renderer.render() — chính là toàn bộ điểm móc nối.
Thứ tự pass chính là chương trình
Pass chạy từ trên xuống, mỗi cái tiêu thụ output của cái trước. Thứ tự đổi kết quả:
RenderPassgần như luôn đầu tiên — chẳng có gì để xử lý cho tới khi scene tồn tại dạng texture.- Hiệu ứng màu/tone như
OutputPassđi cuối, để chỉnh trên ảnh hợp thành cuối cùng, không phải ảnh trung gian. - Vignette trước bloom làm tối trước khi quầng lan; sau bloom thì nó đóng khung ảnh hoàn thiện. Không cái nào “sai” — chúng là hai vẻ khác nhau.
OutputPass và cái bẫy không gian màu
Đây là lỗi cắn ai cũng dính lần đầu: thêm bloom và đột nhiên cả scene trông nhợt nhạt hoặc quá tối. Render target của composer là linear, nhưng màn hình mong đợi sRGB. Khi render trực tiếp, Three.js tự chuyển cho bạn; trong chuỗi composer thì không, cho tới khi bạn thêm OutputPass ở cuối để làm tone mapping + chuyển sRGB.
Quy tắc: ngay khi chuyển sang
EffectComposer, thêmOutputPasslàm pass cuối.
Tinh chỉnh bloom — strength, radius, threshold
UnrealBloomPass có ba núm, và threshold là cái phân biệt tinh tế với màu mè:
threshold— chỉ pixel sáng hơn ngưỡng này mới bloom. Ngưỡng thấp = mọi thứ rực (mờ ảo, lố). Ngưỡng cao = chỉ điểm sáng nhất rực (kiểm soát, cao cấp).strength— quầng thêm vào mạnh cỡ nào.radius— quầng loang ra xa cỡ nào.
Mẹo senior: cấp bloom từ vật liệu emissive và giá trị dải HDR trên 1.0, để phần sáng của scene cháy ra tự nhiên và threshold có cái để bắt.
material.emissive = new THREE.Color(0xc8ff00);
material.emissiveIntensity = 1.6; // > 1 pushes it into bloom range
Đừng quên resize
Composer giữ render target riêng có kích thước theo canvas. Khi resize bạn phải cập nhật cả renderer lẫn composer, nếu không ảnh hậu kỳ sẽ bị kéo hoặc răng cưa:
function onResize(w, h) {
renderer.setSize(w, h);
composer.setSize(w, h); // ← easy to forget
camera.aspect = w / h; camera.updateProjectionMatrix();
}
Ngân sách hiệu năng
Mỗi pass ít nhất là một lần vẽ toàn màn hình lên mọi pixel. Bloom là vài pass (giảm mẫu → làm mờ → tăng mẫu), nên nó là hiệu ứng phổ biến nặng nhất. Trên điện thoại DPI cao ở độ phân giải tối đa, nó cộng dồn nhanh. Thói quen senior:
- Giới hạn
setPixelRatio— chi phí hậu kỳ tỉ lệ với số pixel. - Dùng ít pass nhất mà vẻ cần; gộp hiệu ứng vào một
ShaderPasstuỳ biến khi có thể. - Cân nhắc thư viện
postprocessing(pmndrs) mới hơn, gộp hiệu ứng vào một pass để tiết kiệm lớn.
Một pass tuỳ biến, trong một shader
Khi bạn vượt qua các pass dựng sẵn, ShaderPass chạy GLSL của bạn lên ảnh. Hình dạng luôn giống nhau — tDiffuse là ảnh đầu vào:
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
const Vignette = {
uniforms: { tDiffuse: { value: null }, strength: { value: 1.0 } },
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
uniform sampler2D tDiffuse; uniform float strength; varying vec2 vUv;
void main(){
vec4 c = texture2D(tDiffuse, vUv);
float d = distance(vUv, vec2(0.5)); // 0 center → ~0.7 corner
c.rgb *= smoothstep(0.8, 0.2, d * strength);
gl_FragColor = c;
}`,
};
composer.addPass(new ShaderPass(Vignette));
Lời cảnh báo của sư phụ
- Nhợt/tối sau khi thêm composer? Thêm
OutputPasslàm pass cuối. - Hiệu ứng không hiện? Bạn vẫn gọi
renderer.render()— đổi sangcomposer.render(). - Ảnh kéo khi resize? Bạn quên
composer.setSize(). - Mọi thứ rực/mờ ảo? Threshold quá thấp, hoặc emissive quá cao.
- FPS mobile chết? Giảm pixel ratio và số pass trước tiên.
Luyện tập, không thì coi như chưa học
- Quét threshold: trong demo, kéo threshold từ 0 tới 0.8 và gọi tên chỗ nó hết trông như sương và bắt đầu trông cao cấp.
- Thử thứ tự: thêm một
ShaderPassvignette trước và sau bloom rồi so sánh. - Điều khiển bằng emissive: hạ
emissiveIntensityvề 0 và xác nhận bloom biến mất dù strength cao.
Phần tiếp theo
Scene của bạn giờ trông bóng bẩy — nhưng bóng bẩy tốn frame, và mọi thứ khác cũng vậy khi scene lớn lên. Ở Phần 8 ta vào chế độ senior về hiệu năng: draw call, InstancedMesh cho hàng nghìn vật thể, gộp geometry, frustum culling, mức chi tiết, và đọc renderer.info như một profiler — với demo đối đầu một instanced mesh với hàng nghìn mesh riêng lẻ.