jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

5 MIN READ Updated JUL 12, 2026

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ở demo toàn màn hình

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:

  1. Render scene 3D vào một render target ngoài màn hình (một texture).
  2. 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.
  3. 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ả:

  • RenderPass gầ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êm OutputPass là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 emissivegiá 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 ShaderPass tuỳ 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 OutputPass làm pass cuối.
  • Hiệu ứng không hiện? Bạn vẫn gọi renderer.render() — đổi sang composer.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

  1. 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.
  2. Thử thứ tự: thêm một ShaderPass vignette trước và sau bloom rồi so sánh.
  3. Điều khiển bằng emissive: hạ emissiveIntensity về 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ẻ.