Skip to content

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

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:

  1. Visual Enhancement: Add cinematic effects like bloom, depth of field, motion blur
  2. Performance Optimization: Some effects are more efficient in screen space
  3. Artistic Control: Fine-tune the final appearance without modifying scene geometry
  4. 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 Display

Post-Processing Flow

With post-processing, the flow becomes:

Scene + Camera → RenderTarget → EffectComposer → Multiple Passes → Final Output

Key Concepts

Render Targets: Off-screen buffers that store the rendered scene

js
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

js
// 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.js

Creating composer.js

Create systems/composer.js:

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 rendering
  • setSize(): Sets render target dimensions
  • addPass(): Adds effects to the pipeline in order

Pass Order Matters: The order in which you add passes is crucial:

  1. RenderPass: Always first - renders the base scene
  2. Effect Passes: Applied in sequence
  3. 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.

js
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.

js
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.

js
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.

js
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.

js
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

js
// 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

js
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:

glsl
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:

glsl
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

js
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

js
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

js
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

js
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.html

Implementation Examples

Complete World.js with Post-Processing

js
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

js
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

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:

js
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:

js
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:

js
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

js
// 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

js
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

js
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:

js
// 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:

js
// 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:

js
// 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:

  1. Start Simple: Begin with basic effects before adding complexity
  2. Profile Performance: Always monitor FPS and optimize accordingly
  3. Combine Wisely: Not all effects need to run simultaneously
  4. Mobile Considerations: Reduce quality on lower-end devices
  5. Artistic Vision: Use effects to support your visual goals, not just because they're available

Further Resources

Practice Exercises

  1. Create a day/night cycle that switches between different post-processing effects
  2. Implement a photo mode with adjustable bloom, DOF, and color grading
  3. Build a performance-adaptive system that adjusts effects based on FPS
  4. Create a custom glitch effect shader
  5. 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.