Post-Processing
Live Demos: Bloom | SSAO | Depth of Field
Post-processing passes read the HDR scene color, the depth buffer, and the view-space normals and produce the final image. They are added to the graph in configure_render_graph().
Available passes
| Pass | Description | Reads | Writes |
|---|---|---|---|
SsaoPass | Screen-space ambient occlusion | depth, normals | ssao_raw |
SsaoBlurPass | Bilateral blur for SSAO | ssao_raw | ssao |
SsgiPass | Screen-space global illumination (half-res) | scene_color, depth, normals | ssgi_raw |
SsgiBlurPass | Bilateral blur for SSGI | ssgi_raw | ssgi |
SsrPass | Screen-space reflections | scene_color, depth, normals | ssr_raw |
SsrBlurPass | Blur for SSR | ssr_raw | ssr |
BloomPass | HDR bloom with mip chain | scene_color | bloom |
DepthOfFieldPass | Bokeh depth of field | scene_color, depth | scene_color |
PostProcessPass | Final tonemapping and compositing | scene_color, bloom, ssao | output |
EffectsPass | Custom shader effects | scene_color | scene_color |
OutlinePass | Selection outline | selection_mask | scene_color |
BlitPass | Simple texture copy | input | output |
Enabling effects
Post-processing is controlled through world.resources.graphics.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.graphics.bloom_enabled = true; world.resources.graphics.bloom_intensity = 0.3; world.resources.graphics.ssao_enabled = true; world.resources.graphics.ssao_radius = 0.5; world.resources.graphics.ssao_intensity = 1.0; world.resources.graphics.color_grading.tonemap_algorithm = TonemapAlgorithm::Aces; } }
SSAO (Screen-Space Ambient Occlusion)
Corners, crevices, and enclosed spaces receive less ambient light in the real world because surrounding geometry occludes incoming light from many directions. SSAO approximates that effect in screen space by reading the depth buffer.
How SSAO works
For each pixel, the shader reconstructs the 3D position from the depth buffer, then samples several random points in a hemisphere oriented along the surface normal. Each sample point is projected back into screen space and checked against the depth buffer. If the stored depth is closer than the sample point's depth, that direction is considered occluded. The ratio of occluded samples to total samples is the occlusion factor.
The three inputs are the depth buffer for the 3D position of each pixel, the view-space normals for the hemisphere orientation, and a random noise texture that rotates the sample kernel per-pixel to avoid banding patterns.
The raw output is noisy because the sample count is small. Typical SSAO uses 16 to 64 samples per pixel. A bilateral blur pass smooths the result while preserving edges, which means it avoids blurring across depth discontinuities. Without the bilateral guard the blur would produce halos around silhouettes.
#![allow(unused)] fn main() { world.resources.graphics.ssao_enabled = true; world.resources.graphics.ssao_radius = 0.5; world.resources.graphics.ssao_intensity = 1.0; world.resources.graphics.ssao_bias = 0.025; }
ssao_radius is the hemisphere radius in world units. Larger values detect occlusion from farther geometry but can over-darken broad regions. ssao_bias is a small depth offset that prevents self-occlusion artifacts on flat surfaces. ssao_intensity is the multiplier on the final occlusion factor.
SSGI (Screen-Space Global Illumination)
Light bounces between surfaces in the real world. A red wall next to a white floor tints the floor red. Traditional rasterization computes direct lighting only, which is light source to surface to camera. Global illumination adds the indirect bounces.
SSGI approximates one bounce of indirect light using only screen-space data. For each pixel, the shader traces short rays through the depth buffer to find nearby surfaces, then reads the color at those hit points as incoming indirect light. It is conceptually the same trick as SSAO, except it samples color instead of just occlusion.
The pass runs at half resolution. The indirect illumination is low-frequency, the bilateral blur cleans up the noise, and the upsample reintroduces full resolution at the composite step.
SSR (Screen-Space Reflections)
SSR adds dynamic reflections by ray-marching through the depth buffer. For each reflective pixel, the shader computes the reflection vector from the camera direction and the surface normal, then steps along that vector in screen space, checking the depth buffer at each step. When the ray's depth exceeds the depth buffer value, the ray has hit a surface. The color at that screen position is used as the reflection.
The technique works well for reflections of on-screen geometry. The limits are inherent. Off-screen objects cannot be reflected because there is no data for them. Reflections at grazing angles stretch across large screen areas. The blur pass hides the worst of the artifacts, and a fallback to environment maps or IBL fills in where SSR has no data at all.
Bloom
Bloom simulates the light scattering that happens in real cameras and in the human eye when bright light sources bleed into surrounding pixels. In HDR rendering, pixels can hold values above 1.0, which is the displayable range. Bloom extracts those bright pixels and spreads their light outward.
How bloom works
The bloom pipeline uses a progressive downsample-then-upsample chain, the same approach described in the Call of Duty Advanced Warfare presentation.
The first step thresholds the HDR scene color, keeping only pixels above a configurable cutoff. The second step is the downsample chain. Resolution is progressively halved through multiple mip levels, for example 1920x1080 to 960x540 to 480x270 and so on, with a blur applied at each step. Blurring at low mip levels is far cheaper than blurring at full resolution because each level has a quarter of the pixels of the level above. The third step is the upsample chain. The renderer walks back up the mip chain, additively blending each level with the one above it. The result is a smooth, wide blur that spans many pixels of the final image without ever using a large blur kernel directly. The fourth step is the composite. The bloom result is added to the scene color during the final post-process pass.
The mip-chain approach produces natural-looking bloom because tight glow comes out of the high-resolution mips while wide glow comes out of the low-resolution mips, both at once.
#![allow(unused)] fn main() { world.resources.graphics.bloom_enabled = true; world.resources.graphics.bloom_intensity = 0.5; }
Materials with high emissive values produce the strongest bloom.
#![allow(unused)] fn main() { let glowing = Material { base_color: [0.2, 0.8, 1.0, 1.0], emissive_factor: [2.0, 8.0, 10.0], ..Default::default() }; }
Depth of field
Depth of field simulates the optical behavior of a physical camera lens. A real lens can only focus at one distance, and objects nearer or farther than the focal plane appear blurred. The amount of blur is called the circle of confusion (CoC), and it grows with distance from the focal plane. Aperture size sets the maximum.
How DoF works
The first step is CoC computation. For each pixel, the shader pulls the depth value out of the depth buffer, combines it with the focus distance and aperture, and produces a CoC diameter in pixels.
The second step is the blur. A variable-radius blur is applied where the kernel size scales with the CoC. Pixels far from focus get heavy blur. Pixels near the focal plane stay sharp.
The third step is the bokeh accent. Bright out-of-focus highlights form the characteristic disc or hexagon shapes called bokeh. The shader emphasizes bright pixels during the blur to reproduce the optical effect.
#![allow(unused)] fn main() { world.resources.graphics.depth_of_field.enabled = true; world.resources.graphics.depth_of_field.focus_distance = 10.0; world.resources.graphics.depth_of_field.focus_range = 5.0; world.resources.graphics.depth_of_field.max_blur_radius = 10.0; world.resources.graphics.depth_of_field.bokeh_threshold = 1.0; world.resources.graphics.depth_of_field.bokeh_intensity = 1.0; }
Tonemapping
HDR rendering computes lighting in a physically linear color space where values can range from 0 to thousands. Displays can only show values between 0 and 1. Tonemapping compresses the HDR range into the displayable LDR range while preserving the perception of brightness differences and color relationships.
Different curves make different tradeoffs. Reinhard is the simple color / (color + 1) mapping. It preserves highlights but tends to look washed out. ACES is the film industry curve. It produces good contrast and a slight warm tint and is what most games ship with. AgX is a more recent curve designed to handle highly saturated colors better than ACES, which can produce hue shifts in bright saturated regions. Neutral does minimal color manipulation and is useful when color grading is handled externally.
The PostProcessPass does the HDR-to-LDR conversion.
#![allow(unused)] fn main() { pub enum TonemapAlgorithm { Reinhard, Aces, ReinhardExtended, Uncharted2, AgX, Neutral, None, } world.resources.graphics.color_grading.tonemap_algorithm = TonemapAlgorithm::Aces; }
Color grading
#![allow(unused)] fn main() { world.resources.graphics.color_grading.saturation = 1.0; world.resources.graphics.color_grading.contrast = 1.0; world.resources.graphics.color_grading.brightness = 0.0; }
Effects pass
The EffectsPass runs custom WGSL shader effects for specialized visual treatments. The standard set includes color grading presets, chromatic aberration, film grain, and any custom shader the application wants to inject.
See Effects Pass for details.
Custom post-processing
Custom passes are added through the render graph. See Custom Passes for the implementation pattern.
Performance
| Effect | Cost | Notes |
|---|---|---|
| Bloom | Medium | Multiple blur passes at half resolution |
| SSAO | High | Many depth samples per pixel |
| SSGI | High | Half resolution helps, but still expensive |
| SSR | High | Ray tracing through depth buffer |
| DoF | Medium | Gaussian blur |
| Tonemapping | Low | Per-pixel math |
| Color Grading | Low | Per-pixel math |
Disable expensive effects for lower-end hardware.
#![allow(unused)] fn main() { fn set_quality_low(world: &mut World) { world.resources.graphics.ssao_enabled = false; world.resources.graphics.bloom_enabled = false; } fn set_quality_high(world: &mut World) { world.resources.graphics.ssao_enabled = true; world.resources.graphics.bloom_enabled = true; } }