Advanced Rendering Techniques and Post-Processing
This note is based on DISCOVER three.js, mostly excerpts with some personal understanding
Table of Contents
- Introduction to Post-Processing
- Understanding the Rendering Pipeline
- Setting Up EffectComposer
- Common Post-Processing Effects
- Custom Shader Effects
- Performance Optimization
- Project Structure
- Implementation Examples
Introduction to Post-Processing
Post-processing is a crucial technique in modern 3D graphics that allows us to apply effects to the rendered scene after the initial rendering pass. Unlike real-time effects that are calculated during the rendering process, post-processing effects are applied to the final rendered image as a 2D texture.
Why Use Post-Processing?
Post-processing provides several advantages:
- Visual Enhancement: Add cinematic effects like bloom, depth of field, motion blur
- Performance Optimization: Some effects are more efficient in screen space
- Artistic Control: Fine-tune the final appearance without modifying scene geometry
- Realism: Simulate camera artifacts and real-world optical effects
Common Use Cases
- Game Development: Creating atmospheric effects, highlighting objects
- Architectural Visualization: Ambient occlusion, realistic lighting
- Film and Animation: Color grading, vignetting, lens distortion
- Data Visualization: Edge detection, highlighting specific data points
Understanding the Rendering Pipeline
Before implementing post-processing, it's essential to understand how the rendering pipeline works in three.js.
Standard Rendering Flow
Without post-processing, the rendering flow is straightforward:
Scene + Camera → WebGLRenderer → Canvas DisplayPost-Processing Flow
With post-processing, the flow becomes:
Scene + Camera → RenderTarget → EffectComposer → Multiple Passes → Final OutputKey Concepts
Render Targets: Off-screen buffers that store the rendered scene
import { WebGLRenderTarget } from 'three';
const renderTarget = new WebGLRenderTarget(
window.innerWidth,
window.innerHeight,
{
minFilter: LinearFilter,
magFilter: LinearFilter,
format: RGBAFormat,
stencilBuffer: false,
}
);Passes: Individual processing steps applied to the render target
// A pass represents a single effect or rendering operation
class Pass {
constructor() {
this.enabled = true;
this.needsSwap = true;
this.clear = false;
this.renderToScreen = false;
}
render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
// Implementation of the effect
}
}Setting Up EffectComposer
The EffectComposer is the core of the post-processing system. It manages the rendering pipeline and applies effects in sequence.
Directory Structure
src/
├── World/
│ ├── components/
│ │ ├── camera.js
│ │ ├── scene.js
│ │ ├── lights.js
│ │ └── mesh.js
│ ├── systems/
│ │ ├── renderer.js
│ │ ├── controls.js
│ │ ├── Loop.js
│ │ ├── Resizer.js
│ │ └── composer.js // New: Post-processing setup
│ └── World.js
└── main.jsCreating composer.js
Create systems/composer.js:
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
import { SMAAPass } from 'three/examples/jsm/postprocessing/SMAAPass.js';
function createComposer(renderer, scene, camera) {
// Create the composer
const composer = new EffectComposer(renderer);
// Set the pixel ratio for high-DPI displays
composer.setPixelRatio(window.devicePixelRatio);
// Set the size to match the renderer
composer.setSize(window.innerWidth, window.innerHeight);
// Add the base render pass
// This renders the scene normally
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
// Add bloom effect
const bloomPass = new UnrealBloomPass(
{ x: window.innerWidth, y: window.innerHeight },
1.5, // strength
0.4, // radius
0.85 // threshold
);
composer.addPass(bloomPass);
// Add anti-aliasing as the final pass
const smaaPass = new SMAAPass(
window.innerWidth,
window.innerHeight
);
composer.addPass(smaaPass);
// Return the composer for use in the render loop
return composer;
}
export { createComposer };Key Properties Explained
Composer Configuration:
setPixelRatio(): Matches device pixel ratio for sharp renderingsetSize(): Sets render target dimensionsaddPass(): Adds effects to the pipeline in order
Pass Order Matters: The order in which you add passes is crucial:
- RenderPass: Always first - renders the base scene
- Effect Passes: Applied in sequence
- Anti-aliasing: Usually last for clean edges
Common Post-Processing Effects
Let's explore the most commonly used post-processing effects and their practical applications.
1. Bloom Effect
Bloom simulates the way bright light bleeds around objects, creating a glowing effect.
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
function addBloomEffect(composer, width, height) {
const bloomPass = new UnrealBloomPass(
{ x: width, y: height },
1.5, // strength: how intense the glow is
0.4, // radius: how far the glow spreads
0.85 // threshold: minimum brightness to bloom
);
// Adjust bloom properties dynamically
bloomPass.strength = 1.5;
bloomPass.radius = 0.4;
bloomPass.threshold = 0.85;
composer.addPass(bloomPass);
return bloomPass;
}Use Cases:
- Glowing UI elements
- Neon lights
- Energy effects
- Sun/light sources
Best Practices:
- Keep strength between 0.5 - 2.0 for subtlety
- Adjust threshold to control which objects glow
- Higher radius = softer, more diffused glow
2. Depth of Field
Simulates camera focus, blurring objects based on distance from focal point.
import { BokehPass } from 'three/examples/jsm/postprocessing/BokehPass.js';
function addDepthOfField(composer, scene, camera, width, height) {
const bokehPass = new BokehPass(scene, camera, {
focus: 10.0, // focal distance
aperture: 0.025, // aperture size (larger = more blur)
maxblur: 0.01 // maximum blur amount
});
bokehPass.uniforms.focus.value = 10.0;
bokehPass.uniforms.aperture.value = 0.025;
bokehPass.uniforms.maxblur.value = 0.01;
composer.addPass(bokehPass);
return bokehPass;
}Parameters Explained:
focus: Distance to the focal plane (objects here are sharp)aperture: Simulates camera aperture (f-stop)maxblur: Limits maximum blur intensity
Practical Tips:
- Animate focus to create rack focus effects
- Lower aperture for subtle background blur
- Combine with cinematography principles
3. Screen Space Ambient Occlusion (SSAO)
Adds realistic shadows in crevices and contact points.
import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js';
function addSSAO(composer, scene, camera, width, height) {
const ssaoPass = new SSAOPass(
scene,
camera,
width,
height
);
// Configure SSAO parameters
ssaoPass.kernelRadius = 16; // sampling radius
ssaoPass.minDistance = 0.005; // minimum occlusion distance
ssaoPass.maxDistance = 0.1; // maximum occlusion distance
composer.addPass(ssaoPass);
return ssaoPass;
}Benefits:
- Adds depth and dimension
- Makes contact shadows more realistic
- Enhances surface detail
4. Color Correction and Grading
Control the overall color tone and mood of your scene.
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { ColorCorrectionShader } from 'three/examples/jsm/shaders/ColorCorrectionShader.js';
function addColorCorrection(composer) {
const colorCorrectionPass = new ShaderPass(ColorCorrectionShader);
// Adjust individual color channels
colorCorrectionPass.uniforms.powRGB.value.set(2.0, 2.0, 2.0);
colorCorrectionPass.uniforms.mulRGB.value.set(1.0, 1.0, 1.0);
colorCorrectionPass.uniforms.addRGB.value.set(0.0, 0.0, 0.0);
composer.addPass(colorCorrectionPass);
return colorCorrectionPass;
}5. Film Grain and Noise
Adds vintage or cinematic quality to renders.
import { FilmPass } from 'three/examples/jsm/postprocessing/FilmPass.js';
function addFilmGrain(composer) {
const filmPass = new FilmPass(
0.35, // noise intensity
0.5, // scanline intensity
648, // scanline count
false // grayscale mode
);
composer.addPass(filmPass);
return filmPass;
}Custom Shader Effects
Creating custom post-processing effects requires understanding GLSL shaders.
Creating a Custom Pass
// customPass.js
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
const CustomShader = {
uniforms: {
tDiffuse: { value: null }, // Input texture
time: { value: 0.0 }, // Time for animation
intensity: { value: 1.0 }, // Effect intensity
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float time;
uniform float intensity;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
// Create a wave distortion effect
uv.x += sin(uv.y * 10.0 + time) * 0.01 * intensity;
uv.y += cos(uv.x * 10.0 + time) * 0.01 * intensity;
vec4 color = texture2D(tDiffuse, uv);
gl_FragColor = color;
}
`
};
function createCustomPass() {
const pass = new ShaderPass(CustomShader);
return pass;
}
export { createCustomPass };Using the Custom Pass
import { createCustomPass } from './customPass.js';
// In your composer setup
const customPass = createCustomPass();
composer.addPass(customPass);
// Update in animation loop
customPass.uniforms.time.value += delta;Common Shader Patterns
Vignette Effect:
float vignette(vec2 uv, float intensity) {
float dist = distance(uv, vec2(0.5));
return 1.0 - smoothstep(0.3, 0.7, dist) * intensity;
}Chromatic Aberration:
vec4 chromaticAberration(sampler2D tex, vec2 uv, float amount) {
vec2 direction = uv - vec2(0.5);
vec3 color;
color.r = texture2D(tex, uv + direction * amount).r;
color.g = texture2D(tex, uv).g;
color.b = texture2D(tex, uv - direction * amount).b;
return vec4(color, 1.0);
}Performance Optimization
Post-processing can be expensive. Here are strategies to maintain good performance.
1. Resolution Management
function createComposer(renderer, scene, camera) {
const composer = new EffectComposer(renderer);
// Use lower resolution for expensive effects
const lowResScale = 0.5;
const width = window.innerWidth * lowResScale;
const height = window.innerHeight * lowResScale;
const renderTarget = new WebGLRenderTarget(width, height);
composer = new EffectComposer(renderer, renderTarget);
return composer;
}2. Conditional Effects
class AdaptiveComposer {
constructor(renderer, scene, camera) {
this.composer = createComposer(renderer, scene, camera);
this.highQualityMode = true;
this.expensivePass = null;
}
toggleQuality() {
this.highQualityMode = !this.highQualityMode;
if (this.highQualityMode && !this.expensivePass) {
// Add expensive effects
this.expensivePass = new SSAOPass(/*...*/);
this.composer.addPass(this.expensivePass);
} else if (!this.highQualityMode && this.expensivePass) {
// Remove expensive effects
this.composer.removePass(this.expensivePass);
this.expensivePass = null;
}
}
}3. Effect Pooling
class EffectManager {
constructor() {
this.effectPool = new Map();
}
getEffect(name, createFn) {
if (!this.effectPool.has(name)) {
this.effectPool.set(name, createFn());
}
return this.effectPool.get(name);
}
disposeEffect(name) {
const effect = this.effectPool.get(name);
if (effect && effect.dispose) {
effect.dispose();
}
this.effectPool.delete(name);
}
}4. Performance Monitoring
class PerformanceMonitor {
constructor() {
this.frameTime = 0;
this.frameCount = 0;
this.fps = 60;
}
update(delta) {
this.frameTime += delta;
this.frameCount++;
if (this.frameTime >= 1.0) {
this.fps = this.frameCount / this.frameTime;
this.frameTime = 0;
this.frameCount = 0;
// Adjust quality based on FPS
if (this.fps < 30) {
this.reduceQuality();
} else if (this.fps > 55) {
this.increaseQuality();
}
}
}
reduceQuality() {
console.log('Reducing post-processing quality');
// Implement quality reduction
}
increaseQuality() {
console.log('Increasing post-processing quality');
// Implement quality increase
}
}Project Structure
Here's a complete project structure for a post-processing application:
project/
├── src/
│ ├── World/
│ │ ├── components/
│ │ │ ├── camera.js
│ │ │ ├── scene.js
│ │ │ ├── lights.js
│ │ │ ├── models/
│ │ │ │ └── model.js
│ │ │ └── materials/
│ │ │ └── material.js
│ │ ├── systems/
│ │ │ ├── renderer.js
│ │ │ ├── composer.js
│ │ │ ├── passes/
│ │ │ │ ├── bloomPass.js
│ │ │ │ ├── ssaoPass.js
│ │ │ │ └── customPass.js
│ │ │ ├── controls.js
│ │ │ ├── Loop.js
│ │ │ └── Resizer.js
│ │ └── World.js
│ ├── utils/
│ │ ├── EffectManager.js
│ │ └── PerformanceMonitor.js
│ └── main.js
├── assets/
│ ├── textures/
│ ├── models/
│ └── shaders/
└── index.htmlImplementation Examples
Complete World.js with Post-Processing
import { createCamera } from './components/camera.js';
import { createScene } from './components/scene.js';
import { createLights } from './components/lights.js';
import { loadModel } from './components/models/model.js';
import { createRenderer } from './systems/renderer.js';
import { createComposer } from './systems/composer.js';
import { createControls } from './systems/controls.js';
import { Loop } from './systems/Loop.js';
import { Resizer } from './systems/Resizer.js';
class World {
constructor(container) {
// Create core components
this.camera = createCamera();
this.scene = createScene();
this.renderer = createRenderer();
// Create post-processing composer
this.composer = createComposer(
this.renderer,
this.scene,
this.camera
);
// Append renderer to container
container.append(this.renderer.domElement);
// Create controls
this.controls = createControls(
this.camera,
this.renderer.domElement
);
// Create lights
const { ambientLight, mainLight } = createLights();
this.scene.add(ambientLight, mainLight);
// Create animation loop
this.loop = new Loop(
this.camera,
this.scene,
this.composer // Use composer instead of renderer
);
// Add controls to updatables
this.loop.updatables.push(this.controls);
// Handle window resize
const resizer = new Resizer(
container,
this.camera,
this.renderer,
this.composer // Also resize composer
);
}
async init() {
const model = await loadModel();
// Add model to scene
this.scene.add(model);
// Add model to updatables if it has animations
if (model.tick) {
this.loop.updatables.push(model);
}
}
render() {
// Render using composer for post-processing
this.composer.render();
}
start() {
this.loop.start();
}
stop() {
this.loop.stop();
}
// Method to toggle effects
toggleEffect(effectName) {
// Implementation depends on your effect management
console.log(`Toggling ${effectName}`);
}
}
export { World };Updated Loop.js for Post-Processing
import { Clock } from 'three';
const clock = new Clock();
class Loop {
constructor(camera, scene, composer) {
this.camera = camera;
this.scene = scene;
this.composer = composer; // Use composer instead of renderer
this.updatables = [];
}
start() {
this.composer.renderer.setAnimationLoop(() => {
this.tick();
// Render using composer (includes all post-processing)
this.composer.render();
});
}
stop() {
this.composer.renderer.setAnimationLoop(null);
}
tick() {
const delta = clock.getDelta();
for (const object of this.updatables) {
object.tick(delta);
}
}
}
export { Loop };Updated Resizer.js
const setSize = (container, camera, renderer, composer) => {
const width = container.clientWidth;
const height = container.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
// Also resize the composer
if (composer) {
composer.setSize(width, height);
composer.setPixelRatio(window.devicePixelRatio);
}
};
class Resizer {
constructor(container, camera, renderer, composer) {
// Set initial size
setSize(container, camera, renderer, composer);
// Handle window resize
window.addEventListener('resize', () => {
setSize(container, camera, renderer, composer);
this.onResize();
});
}
onResize() {
// Hook for additional resize logic
}
}
export { Resizer };Advanced Techniques
1. Selective Bloom
Only bloom specific objects:
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
class SelectiveBloom {
constructor(scene, camera, renderer) {
this.scene = scene;
this.camera = camera;
this.renderer = renderer;
// Create separate layers
this.bloomLayer = new Layers();
this.bloomLayer.set(1);
this.setupComposer();
}
setupComposer() {
// Main composer
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(new RenderPass(this.scene, this.camera));
// Bloom composer (only renders bloom layer)
this.bloomComposer = new EffectComposer(this.renderer);
const bloomPass = new UnrealBloomPass(/*...*/);
this.bloomComposer.addPass(new RenderPass(this.scene, this.camera));
this.bloomComposer.addPass(bloomPass);
}
markForBloom(object) {
object.layers.enable(1);
}
render() {
// Render bloom objects
this.camera.layers.set(1);
this.bloomComposer.render();
// Render normal scene
this.camera.layers.set(0);
this.composer.render();
// Combine results
// ... implementation
}
}2. Multi-Pass Effects
Chain multiple effects creatively:
function createAdvancedComposer(renderer, scene, camera) {
const composer = new EffectComposer(renderer);
// Pass 1: Base render
composer.addPass(new RenderPass(scene, camera));
// Pass 2: SSAO for depth
const ssaoPass = new SSAOPass(scene, camera);
composer.addPass(ssaoPass);
// Pass 3: Bloom for glow
const bloomPass = new UnrealBloomPass(/*...*/);
composer.addPass(bloomPass);
// Pass 4: Color correction
const colorPass = new ShaderPass(ColorCorrectionShader);
composer.addPass(colorPass);
// Pass 5: Final anti-aliasing
const smaaPass = new SMAAPass();
composer.addPass(smaaPass);
return composer;
}3. Dynamic Effect Switching
Switch effects based on scene state:
class DynamicEffects {
constructor(composer) {
this.composer = composer;
this.effects = {
day: [],
night: [],
underwater: []
};
this.currentMode = 'day';
}
switchMode(mode) {
if (mode === this.currentMode) return;
// Remove current effects
this.effects[this.currentMode].forEach(pass => {
this.composer.removePass(pass);
});
// Add new effects
this.effects[mode].forEach(pass => {
this.composer.addPass(pass);
});
this.currentMode = mode;
}
registerEffect(mode, pass) {
if (!this.effects[mode]) {
this.effects[mode] = [];
}
this.effects[mode].push(pass);
}
}Best Practices and Tips
1. Order of Operations
// Recommended order:
// 1. Render scene
const renderPass = new RenderPass(scene, camera);
// 2. Add depth-based effects (SSAO, DOF)
const ssaoPass = new SSAOPass(/*...*/);
// 3. Add lighting effects (bloom)
const bloomPass = new UnrealBloomPass(/*...*/);
// 4. Add color grading
const colorPass = new ShaderPass(/*...*/);
// 5. Add screen-space effects (film grain, vignette)
const filmPass = new FilmPass(/*...*/);
// 6. Final anti-aliasing
const smaaPass = new SMAAPass(/*...*/);2. Memory Management
class EffectLifecycle {
constructor() {
this.activePasses = [];
}
addPass(composer, pass) {
composer.addPass(pass);
this.activePasses.push(pass);
}
removePass(composer, pass) {
composer.removePass(pass);
const index = this.activePasses.indexOf(pass);
if (index > -1) {
this.activePasses.splice(index, 1);
}
}
dispose() {
this.activePasses.forEach(pass => {
if (pass.dispose) {
pass.dispose();
}
});
this.activePasses = [];
}
}3. Debug Mode
class ComposerDebugger {
constructor(composer) {
this.composer = composer;
this.debugMode = false;
}
toggleDebug() {
this.debugMode = !this.debugMode;
if (this.debugMode) {
// Show each pass output separately
this.composer.passes.forEach((pass, index) => {
pass.renderToScreen = true;
// Render and capture
this.composer.render();
this.captureOutput(`Pass_${index}`);
pass.renderToScreen = false;
});
}
}
captureOutput(name) {
const canvas = this.composer.renderer.domElement;
const dataURL = canvas.toDataURL('image/png');
console.log(`${name}:`, dataURL);
}
}Troubleshooting Common Issues
Issue 1: Black Screen
Problem: Screen appears black after adding post-processing
Solution:
// Ensure at least one pass renders to screen
const passes = composer.passes;
passes[passes.length - 1].renderToScreen = true;
// Check render target format
const renderTarget = new WebGLRenderTarget(width, height, {
format: RGBAFormat, // Ensure correct format
type: FloatType, // Use FloatType for HDR
});Issue 2: Poor Performance
Problem: FPS drops significantly with post-processing
Solutions:
// 1. Reduce resolution
composer.setSize(width * 0.5, height * 0.5);
// 2. Limit expensive effects
const ssaoPass = new SSAOPass(scene, camera);
ssaoPass.kernelSize = 8; // Reduce from default 32
// 3. Use simpler alternatives
// Instead of UnrealBloomPass, use simpler bloom
const bloomPass = new BloomPass(1.0, 25, 4.0, 256);Issue 3: Artifacts or Banding
Problem: Visible banding or artifacts in gradients
Solution:
// Use higher precision render targets
const renderTarget = new WebGLRenderTarget(width, height, {
type: FloatType, // or HalfFloatType
format: RGBAFormat,
encoding: sRGBEncoding,
});
// Add dithering
const ditherPass = new ShaderPass(DitherShader);
composer.addPass(ditherPass);Conclusion
Post-processing is a powerful tool in three.js that can dramatically enhance visual quality. Key takeaways:
- Start Simple: Begin with basic effects before adding complexity
- Profile Performance: Always monitor FPS and optimize accordingly
- Combine Wisely: Not all effects need to run simultaneously
- Mobile Considerations: Reduce quality on lower-end devices
- Artistic Vision: Use effects to support your visual goals, not just because they're available
Further Resources
- Official three.js Post-Processing Examples
- EffectComposer Documentation
- Custom Shader Writing Guide
- Performance Optimization Techniques
Practice Exercises
- Create a day/night cycle that switches between different post-processing effects
- Implement a photo mode with adjustable bloom, DOF, and color grading
- Build a performance-adaptive system that adjusts effects based on FPS
- Create a custom glitch effect shader
- Implement selective bloom that only affects emissive materials
Note: Always test post-processing on target devices. What works on desktop may perform poorly on mobile. Consider implementing a quality preset system for different device capabilities.