Fix Color Banding in Three.js Post-Processing

Color banding is a common visual artifact in Three.js web applications, characterized by visible steps or “stripes” in smooth color gradients, especially when utilizing post-processing pipelines. This article explains how to eliminate color banding by configuring the precision, format, and color space properties of a WebGLRenderTarget. You will learn the optimal settings for texture types and color spaces to ensure smooth, high-fidelity gradients in your 3D scenes.

Why Color Banding Occurs in Post-Processing

By default, a WebGLRenderTarget in Three.js uses an 8-bit unsigned byte texture format (THREE.UnsignedByteType). This allocates only 256 distinct values per color channel (Red, Green, Blue). When post-processing shaders (such as vignette, depth of field, or color grading) manipulate these colors, the mathematical operations quickly exhaust the limited 8-bit range. This lack of precision causes rounding errors, resulting in visible stair-step patterns instead of smooth gradients.

1. Increase Bit Depth using type

The most effective way to eliminate color banding is to increase the bit depth of the render target. Instead of 8-bit integers, use 16-bit float values (Half-Float precision), which provide significantly more color steps and high-dynamic-range (HDR) capabilities.

Configure the type property of your render target to THREE.HalfFloatType:

const renderTarget = new THREE.WebGLRenderTarget(width, height, {
  type: THREE.HalfFloatType
});

Using THREE.HalfFloatType is the industry standard for post-processing. It offers a massive precision upgrade over UnsignedByteType while maintaining excellent performance and compatibility across desktop and mobile devices. Avoid THREE.FloatType (32-bit) unless absolutely necessary, as it doubles memory usage and bandwidth with minimal visual improvement over half-float.

2. Set the Correct Color Format

To ensure maximum compatibility and accuracy during post-processing blend operations, define the format property explicitly. Use THREE.RGBAFormat to store red, green, blue, and alpha channels:

const renderTarget = new THREE.WebGLRenderTarget(width, height, {
  format: THREE.RGBAFormat,
  type: THREE.HalfFloatType
});

Using RGBAFormat ensures proper alpha channel alignment, which is critical for blending multiple post-processing passes.

3. Configure the Color Space

Modern versions of Three.js (r152+) have deprecated the old encoding property in favor of colorSpace. Managing where color conversions occur in your post-processing pipeline is essential for preventing color degradation.

// For newer Three.js versions (r152+)
renderTarget.texture.colorSpace = THREE.SRGBColorSpace;

// For older Three.js versions
renderTarget.texture.encoding = THREE.sRGBEncoding;

If you use EffectComposer, the composer’s final pass (like OutputPass or ShaderPass with gamma correction) generally handles this conversion automatically.

Complete WebGLRenderTarget Configuration Example

When manually instantiating a WebGLRenderTarget for a custom post-processing setup, use the following optimized configuration:

const width = window.innerWidth;
const height = window.innerHeight;

const targetOptions = {
  minFilter: THREE.LinearFilter,
  magFilter: THREE.LinearFilter,
  format: THREE.RGBAFormat,
  type: THREE.HalfFloatType, // Prevents banding by providing 16-bit precision
  depthBuffer: true,
  stencilBuffer: false
};

const myRenderTarget = new THREE.WebGLRenderTarget(width, height, targetOptions);

Alternative Solution: Enable Dithering

If your target hardware does not support HalfFloatType, or if you still notice minor banding in dark gradients, enable dithering on your materials. Dithering introduces a tiny, imperceptible amount of noise that breaks up the visible lines of color banding.

You can enable dithering globally on materials that cover large areas of your scene, such as skies or background walls:

const material = new THREE.MeshStandardMaterial({
  color: 0x333333,
  dithering: true // Smooths out gradients at 8-bit precision
});