Post-Processing Effects for Web Games
Post-processing is what gives many games their distinctive cinematic feel. The scene is rendered normally to an off-screen texture (a framebuffer object), and then one or more full-screen quad passes apply fragment shader effects to that texture. Each pass reads the previous pass's output and writes a modified version, creating a chain of transformations that builds up the final image. The cost is proportional to the number of passes and the screen resolution, since each pass runs a fragment shader on every pixel.
Set Up the Render-to-Texture Pipeline
In Three.js, post-processing uses the EffectComposer class from the examples/addons. You create a composer, add a RenderPass as the first pass (which renders the scene to an internal texture), and then add additional passes for each effect. The final pass in the chain writes to the screen. The render loop calls composer.render() instead of renderer.render(scene, camera).
In Babylon.js, post-processing effects attach directly to cameras. You create a PostProcess object with a fragment shader, specify the sampling ratio (1.0 for full resolution, 0.5 for half resolution), and assign it to a camera. Multiple post-processes on the same camera execute in the order they were added. Babylon.js also provides the DefaultRenderingPipeline, which bundles common effects (bloom, chromatic aberration, DOF, grain, sharpen, vignette) into a single configurable pipeline.
The key technical concept is that rendering to a texture creates a 2D image of the entire scene that a fragment shader can then read and modify. The shader receives this image as a sampler2D uniform and samples it at each pixel's screen-space UV coordinates. Modifying these UVs before sampling creates distortion effects. Sampling at multiple offset positions creates blur effects. Modifying the sampled color creates color transformation effects.
Resolution matters for performance. Post-processing shaders run on every pixel, and at 1920x1080 that is over two million fragment shader invocations per pass. Some effects (like bloom blur) work at half or quarter resolution to reduce cost, then upscale the result. This is a standard optimization that produces visually acceptable results at a fraction of the computational cost.
Add Bloom for Bright Light Halos
Bloom is the most widely used post-processing effect in games. It simulates the way a camera lens spreads bright light into surrounding areas, making emissive surfaces, bright highlights, and light sources appear to glow.
The standard bloom implementation uses multiple passes. First, a brightness extraction pass isolates pixels above a luminance threshold. Only these bright pixels contribute to the glow, so the threshold controls what glows and what does not. Second, one or more Gaussian blur passes spread the bright pixels outward. Using progressive downsampling (rendering to successively smaller textures: half, quarter, eighth resolution) and then upsampling creates a wide, smooth blur at lower cost than blurring at full resolution. Third, a compositing pass adds the blurred bright pixels back onto the original scene using additive blending.
In Three.js, the UnrealBloomPass implements this pipeline with configurable strength (intensity of the glow), radius (blur spread), and threshold (minimum brightness to contribute). In Babylon.js, the BloomEffect within the DefaultRenderingPipeline provides weight, threshold, and kernel size parameters. Both implementations use multi-resolution blur for efficiency.
Artistic considerations matter as much as technical ones. Subtle bloom (low strength, moderate threshold) adds polish and realism. Aggressive bloom (high strength, low threshold) creates a stylized, dreamy, or overexposed look. The threshold should be set relative to your scene's lighting range, as a threshold that is too low causes the entire scene to bloom, washing out detail.
Apply Color Grading and Tone Mapping
Color grading transforms the colors of the entire rendered scene to establish mood, style, and visual consistency. It is the post-processing equivalent of color correction in film, and it can dramatically change the feeling of a game without altering any models, textures, or lighting.
The most flexible approach uses a LUT (lookup table) texture. A 3D LUT maps every possible input color to an output color, and the post-process shader samples this LUT using the original pixel color as coordinates. By creating different LUT textures (warm golden tones for desert scenes, cool blue-green for underwater, desaturated for flashbacks), you can swap the entire color palette at runtime with a single texture change. LUT textures are typically stored as 2D strip images that encode the 3D color cube as a row of color slices.
Tone mapping compresses the high dynamic range (HDR) of the rendered scene into the low dynamic range (LDR) of the display. Without tone mapping, bright areas clip to white and dark areas crush to black. The Reinhard operator, ACES filmic curve, and AgX are common tone mapping functions that preserve detail in both highlights and shadows. Three.js exposes tone mapping through renderer.toneMapping and Babylon.js through the ImageProcessingPostProcess and the imaging pipeline settings on the scene.
Simple color adjustments (brightness, contrast, saturation, hue shift) can be applied directly in a post-process shader using straightforward math. Saturation uses the luminance formula to compute a grayscale value and mixes between gray and the original color. Contrast scales the color deviation from the midpoint. These are computationally cheap (no texture lookups, just arithmetic) and effective for real-time environmental response, like desaturating the scene when a player takes damage or shifting hues for day-night cycles.
Implement SSAO for Ambient Depth
Screen-space ambient occlusion (SSAO) darkens areas where surfaces are close together or in concavities, simulating how ambient light is blocked in corners, crevices, and contact shadows. It adds a subtle but significant sense of depth and grounding that makes scenes feel more physically plausible.
The algorithm works by sampling the depth buffer at multiple random positions around each pixel. If many of the sampled positions are closer to the camera than the surface at the center pixel (indicating nearby geometry), the pixel is considered occluded and receives a darkening factor. The random sample positions are typically distributed in a hemisphere oriented along the surface normal, which requires a normal buffer or a depth-derived normal reconstruction.
Noise is used to randomize the sample pattern per pixel, which introduces high-frequency grain that a subsequent bilateral blur pass smooths out while preserving edges. The blur step is critical for visual quality, as raw SSAO with visible noise looks harsh and distracting. The bilateral filter blurs the occlusion value but stops at depth discontinuities (edges between objects), preventing dark halos at object boundaries.
SSAO is expensive because it requires many texture samples per pixel (typically 16 to 64 depth buffer reads). Running the SSAO pass at half resolution and upscaling the result halves the cost with minimal visual impact. Three.js provides SSAOPass in its post-processing addons, and Babylon.js includes the SSAO2RenderingPipeline which implements an improved algorithm with better performance characteristics and configurable sample counts.
Build Custom Post-Process Shaders
Custom post-processing shaders follow a simple pattern: receive the rendered scene as a texture, apply a fragment shader transformation, output the modified image. This pattern enables any screen-space effect you can express as a per-pixel operation on the scene image.
Vignette darkens the edges of the screen, drawing the player's eye toward the center. The shader calculates the distance from each pixel to the screen center, and multiplies the color by a falloff curve based on that distance. A smoothstep between an inner radius (where darkening begins) and an outer radius (where it reaches maximum) creates a gentle fade.
Chromatic aberration separates the red, green, and blue channels by sampling them at slightly different UV offsets. The offset increases toward the screen edges, simulating lens distortion. Sampling the red channel at a UV offset of +0.002, the green at 0, and the blue at -0.002 creates a subtle color fringing effect. Larger offsets create a more dramatic, psychedelic distortion useful for damage indicators or disorientation effects.
Pixelation reduces the effective resolution by snapping UV coordinates to a coarse grid before sampling. floor(uv * pixelCount) / pixelCount converts continuous UVs to stepped values, making the image appear blocky. This effect is useful for retro aesthetics, transitions, and revealing hidden content as the pixel count increases over time.
CRT scanlines simulate an old television display by applying horizontal dark lines at regular intervals. A sine wave based on the pixel's vertical position creates alternating light and dark bands. Adding subtle screen curvature (UV distortion that bends the image toward the edges) and color bleeding (slight horizontal blur) completes the retro CRT look. These effects combine multiple simple operations into a recognizable aesthetic.
In Three.js, custom effects use ShaderPass with a shader object containing uniforms, vertexShader, and fragmentShader. In Babylon.js, you create a PostProcess with a custom fragment shader string and access the scene texture through the textureSampler uniform. Both engines make the pattern consistent and straightforward to extend with your own effects.
Post-processing effects operate on the rendered scene image rather than individual objects, enabling bloom, color grading, SSAO, and any custom screen-space effect you can express as a fragment shader. Both Three.js and Babylon.js provide built-in pipelines and straightforward APIs for adding custom effects.