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.
The difference between a tech demo and something that looks shipped is often the last 5% — the glow on a neon sign, the soft falloff at the screen edge, the filmic contrast. {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.} That layer is post-processing: image effects applied after the 3D render, to the rendered picture itself. {Lớp đó là post-processing: hiệu ứng ảnh áp dụng sau khi render 3D, lên chính bức ảnh đã render.}
Toggle bloom in the demo and push the threshold around — only the bright emissive core glows; the dim cubes stay crisp. {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.}
Open the full demo {Mở demo đầy đủ}: /tools/threejs-postprocessing-demo/.
The mental model: render to a texture, then rework it {Mô hình tư duy: render ra texture, rồi xử lý lại}
Normally renderer.render(scene, camera) draws straight to the screen. {Bình thường renderer.render(scene, camera) vẽ thẳng ra màn hình.} Post-processing inserts a middle step: {Post-processing chèn một bước giữa:}
- Render the 3D scene into an off-screen render target (a texture). {Render scene 3D vào một render target ngoài màn hình (một texture).}
- Run that texture through a chain of full-screen passes — each pass reads the previous result and writes a new one. {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.}
- The final pass writes to the screen. {Pass cuối ghi ra màn hình.}
The orchestrator for all this is the EffectComposer. {Bộ điều phối cho tất cả là EffectComposer.}
Wiring an 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());
Then, in your loop, render through the composer instead of the renderer: {Rồi trong loop, render qua composer thay vì renderer:}
function animate() {
requestAnimationFrame(animate);
controls.update();
composer.render(); // ← not renderer.render(...)
}
That single swap — composer.render() for renderer.render() — is the whole hook. {Cú đổi duy nhất đó — composer.render() thay cho renderer.render() — chính là toàn bộ điểm móc nối.}
Pass order is the program {Thứ tự pass chính là chương trình}
Passes run top to bottom, each consuming the last one’s output. {Pass chạy từ trên xuống, mỗi cái tiêu thụ output của cái trước.} Order changes the result: {Thứ tự đổi kết quả:}
RenderPassis almost always first — there’s nothing to process until the scene exists as a texture. {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.}- Color/tone effects like
OutputPassgo last, so they grade the final composited image, not an intermediate one. {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.} - A vignette before bloom darkens before the glow spreads; after bloom it frames the finished image. Neither is “wrong” — they’re different looks. {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 and the color-space trap {OutputPass và cái bẫy không gian màu}
Here’s a bug that bites everyone the first time: add bloom and suddenly the whole scene looks washed out or too dark. {Đâ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.} The composer’s render target is linear, but the screen expects sRGB. {Render target của composer là linear, nhưng màn hình mong đợi sRGB.} When you render directly, Three.js converts for you; inside a composer chain it doesn’t, until you add an OutputPass at the end to do tone mapping + sRGB conversion. {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.}
Rule: the moment you switch to an
EffectComposer, add anOutputPassas the final pass. {Quy tắc: ngay khi chuyển sangEffectComposer, thêmOutputPasslàm pass cuối.}
Tuning bloom — strength, radius, threshold {Tinh chỉnh bloom — strength, radius, threshold}
UnrealBloomPass has three knobs, and threshold is the one that separates tasteful from cartoonish: {UnrealBloomPass có ba núm, và threshold là cái phân biệt tinh tế với màu mè:}
threshold— only pixels brighter than this bloom. Low threshold = everything glows (hazy, overdone). High threshold = only the brightest highlights glow (controlled, premium). {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— how intense the added glow is. {strength— quầng thêm vào mạnh cỡ nào.}radius— how far the glow bleeds outward. {radius— quầng loang ra xa cỡ nào.}
The senior trick: drive bloom from emissive materials and HDR-range values above 1.0, so the bright parts of your scene blow out naturally and the threshold has something to catch. {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
Don’t forget resize {Đừng quên resize}
The composer holds its own render targets sized to the canvas. {Composer giữ render target riêng có kích thước theo canvas.} On resize you must update both the renderer and the composer, or the post-processed image will stretch or alias: {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();
}
The performance budget {Ngân sách hiệu năng}
Every pass is at least one full-screen draw over every pixel. {Mỗi pass ít nhất là một lần vẽ toàn màn hình lên mọi pixel.} Bloom is several passes (downsample → blur → upsample), which is why it’s the heaviest common effect. {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.} On a high-DPI phone at full resolution this adds up fast. {Trên điện thoại DPI cao ở độ phân giải tối đa, nó cộng dồn nhanh.} Senior habits: {Thói quen senior:}
- Cap
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))— post-processing cost scales with pixel count. {Giới hạnsetPixelRatio— chi phí hậu kỳ tỉ lệ với số pixel.} - Use as few passes as the look needs; combine effects into a single custom
ShaderPasswhen you can. {Dùng ít pass nhất mà vẻ cần; gộp hiệu ứng vào mộtShaderPasstuỳ biến khi có thể.} - Consider the newer
postprocessinglibrary (pmndrs) which merges effects into one pass for big savings. {Cân nhắc thư việnpostprocessing(pmndrs) mới hơn, gộp hiệu ứng vào một pass để tiết kiệm lớn.}
A custom pass, in one shader {Một pass tuỳ biến, trong một shader}
When you outgrow the built-ins, a ShaderPass runs your own GLSL over the image. {Khi bạn vượt qua các pass dựng sẵn, ShaderPass chạy GLSL của bạn lên ảnh.} The shape is always the same — tDiffuse is the incoming image: {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));
The master’s warnings {Lời cảnh báo của sư phụ}
- Washed-out / dark after adding the composer? Add an
OutputPassas the final pass. {Nhợt/tối sau khi thêm composer? ThêmOutputPasslàm pass cuối.} - Effects not showing? You’re still calling
renderer.render()— switch tocomposer.render(). {Hiệu ứng không hiện? Bạn vẫn gọirenderer.render()— đổi sangcomposer.render().} - Image stretches on resize? You forgot
composer.setSize(). {Ảnh kéo khi resize? Bạn quêncomposer.setSize().} - Everything glows / looks hazy? Threshold too low, or emissive values too high. {Mọi thứ rực/mờ ảo? Threshold quá thấp, hoặc emissive quá cao.}
- Mobile frame rate dies? Drop pixel ratio and pass count before anything else. {FPS mobile chết? Giảm pixel ratio và số pass trước tiên.}
Practice, or it didn’t happen {Luyện tập, không thì coi như chưa học}
- Threshold sweep {Quét threshold}: in the demo, move threshold from 0 to 0.8 and name where it stops looking like fog and starts looking premium. {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.}
- Order experiment {Thử thứ tự}: add a vignette
ShaderPassbefore vs. after bloom and compare. {thêm mộtShaderPassvignette trước và sau bloom rồi so sánh.} - Emissive driving {Điều khiển bằng emissive}: drop
emissiveIntensityto 0 and confirm the bloom disappears even with strength high. {hạemissiveIntensityvề 0 và xác nhận bloom biến mất dù strength cao.}
What’s next {Phần tiếp theo}
Your scene now looks polished — but polish costs frames, and so does everything else as scenes grow. {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.} In Part 8 we go full senior on performance: draw calls, InstancedMesh for thousands of objects, geometry merging, frustum culling, level-of-detail, and how to read renderer.info like a profiler — with a live demo that pits one instanced mesh against thousands of separate ones. {Ở 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ẻ.}