Image quality & post-processing
The WebGL guide and Advanced WebGL cover the scene path — geometry, materials, lights, shadows. This page covers the post-processing pipeline: the offscreen passes that run after the scene is shaded and turn linear HDR into the final pixels — bloom, tone-mapping, vignette, SSAO, SSR, depth of field, and order-independent transparency.
Like the rest of verve.gl, the whole pipeline is a binary command stream that drives WebGPU when the browser supports it, with a WebGL2 fallback — the WGSL and GLSL shader sets are byte-for-byte twins, so an image is identical on either backend.
See it live: tone-mapping · SSAO · SSR · DOF · OIT.
Altitude: post-processing is a low-level
glcore feature today. It is driven from island code through theglencoder (beginPostProcess/endPostProcessand theEncoder.run*passes), not through the declarativectx.glScenebuilder — there is nodata-gl*attribute for it. Each effect is a hand-written island chunk; see the live demos linked above. The full struct/handle/encoder surface is in the verve.gl post-processing reference.
The post-process pass
beginPostProcess(PostProcess) redirects scene rendering into an offscreen HDR target (h_scene_hdr); endPostProcess(...) runs the bright-pass → blur → composite → FXAA chain and blits to the canvas. Everything is configured through one struct:
const post: verve.gl.PostProcess = .{
.bloom = .{ .threshold = 1.0, .intensity = 0.6 }, // or null to skip
.fxaa = true,
.tonemap = .aces, // image-quality slice 2
.vignette = null, // or .{ .intensity = 0.4, .radius = 0.7 }
.webgpu = use_webgpu, // emit WGSL modules vs GLSL
// wiring for the depth-buffer effects (below):
.ao_tex = 0, // SSAO blurred-AO handle, 0 = no-op white
.scene_src = 0, // scene source override, 0 = h_scene_hdr
};The composite stage packs its parameters into a single 16-byte slot (p_comp = [intensity, tonemap, vig_intensity, vig_radius]), so tone-mapping and vignette cost no extra buffer and no new wire tags.
Tone-mapping operators
The composite stage maps linear HDR to display range with one of six operators, selected by PostProcess.tonemap. The enum value is the shader branch selector (op = i32(tonemap + 0.5)); the math is identical on both backends.
| Value | ToneMap | Curve | Notes |
|---|---|---|---|
| 0 | .linear | pow(clamp(hdr,0,1), 1/2.2) | Clips highlights to white |
| 1 | .reinhard | hdr/(1+hdr), then gamma 2.2 | Simple, desaturates highlights |
| 2 | .reinhard_ext | hdr*(1+hdr/W²)/(1+hdr), W=4, gamma 2.2 | Preserves bright detail |
| 3 | .aces (default) | Hill ACES fit, no gamma | Byte-identical to pre-slice-2 output |
| 4 | .agx | Minimal AgX (Sobotka/Filament) | Neutral, no highlight hue-shift |
| 5 | .uncharted2 | Hable filmic, bias 2.0, W=11.2, gamma 2.2 | Classic filmic shoulder |
.aces is the default and reproduces the historical look exactly. AgX (.agx) is the same approximation that underlies three.js AgXToneMapping (r160+).
var post: verve.gl.PostProcess = .{ .tonemap = .agx };Vignette
An optional vignette darkens the frame corners after tone-mapping, via smoothstep(radius, radius − 0.45, d) where d is distance from center:
post.vignette = .{ .intensity = 0.4, .radius = 0.7 }; // null = off| Field | Default | Notes |
|---|---|---|
intensity | 0.0 | 0 = off, 1 = full darkening |
radius | 0.75 | Where the falloff begins; typical 0.5–0.8 |
The depth + normal G-buffer
The depth-aware effects below all read a shared depth + view-space normal G-buffer (h_gbuffer, rgba16f with depth) laid down by an automatic prepass. It has no public builder surface — it runs transparently. SSAO, SSR, and DOF are its three consumers; each reconstructs view-space position from the G-buffer using the camera's inv_proj/proj matrices.
SSAO — screen-space ambient occlusion
A two-pass chain (Encoder.runSsao): an SSAO pass reconstructs position from the G-buffer, samples a hardcoded 16-point hemisphere kernel with a per-pixel hash rotation, accumulates occlusion, then a 4×4 box blur smooths it. The composite multiplies the blurred AO into the scene term before bloom + tone-mapping.
Wiring: the blurred-AO handle (SsaoCtx.h_ao_blur) is passed to the composite as PostProcess.ao_tex; when ao_tex = 0 the bridge binds a 1×1 white dummy (AO = 1.0), so non-AO scenes are unchanged. The 144-byte post Params carry (radius, bias, intensity, _) + inv_proj + proj. Self-contained — no noise texture, no kernel UBO, no extra asset files.
SSR — screen-space reflections
A single fullscreen pass (Encoder.runSsr) reflects the view vector around the surface normal and ray-marches a fixed 32-step loop in screen space, re-projecting each step via proj and depth-comparing against the G-buffer within a thickness tolerance. On a hit it adds the scene color scaled by reflection_strength, a Schlick Fresnel term, and a screen-edge fade. Output is scene color + reflections in h_scene_ssr.
SSR rides the same 144-byte post Params as SSAO, where params = (strength, max_distance, thickness, fresnel_power). The island redirects the composite to read reflections by setting PostProcess.scene_src = h_scene_ssr.
SSR is global (uniform reflectivity × Fresnel), not material-aware: the G-buffer stores only normal + depth, so per-material roughness-weighted reflections are deferred (they would need an extra material channel, i.e. MRT).
DOF — depth of field
The simplest depth consumer (Encoder.runDof) — it needs no matrices, reading linear view depth straight from the G-buffer alpha. Three passes: two separable Gaussian blurs (reusing the bloom blur shader sh_blur — no new blur shader) produce a fully blurred scene, then a CoC combine shader (sh_dof) picks sharp vs blurred per pixel:
coc = clamp(|depth − focus_distance| / focal_range, 0, 1) × max_blur
out = mix(sharp, blurred, coc)Combine params are a single vec4 (focus_distance, focal_range, max_blur, _) → a 32-byte uniform. Output h_scene_dof is fed to the composite via PostProcess.scene_src.
OIT — weighted-blended order-independent transparency
Encoder.runOit(ctx, webgpu, w, h, draws) renders transparent geometry once, with no depth sort, into two rgba16f buffers — an additive accumulation buffer and a multiplicative revealage buffer — then a fullscreen resolve composites them over the opaque scene:
avg = accum.rgb / max(accum.a, 1e-5)
out = avg·(1 − reveal) + opaque·revealThe result is genuinely order-independent — rotating the camera doesn't change the blend. The depth-based weight (McGuire eq. 10 variant) is identical on both backends. The island sets PostProcess.scene_src = h_scene_oit to feed the result through the composite. draws is a per-object OitDraw list (mvp + mv + rgba color pointers — a single shared static would alias to the last draw).
This is the engine's first multi-target (MRT) path, and the one place the two backends genuinely diverge under the hood — handled transparently:
| Backend | How accum + reveal are filled |
|---|---|
| WebGPU | One MRT pass — a 2-target pipeline with per-target blend, depth-write off, sharing opaque depth read-only (begin_mrt_pass) |
| WebGL2 (GLES 3.0) | No per-attachment blend → two single-target passes over the same geometry: accum (ONE/ONE) then reveal (ZERO/ONE_MINUS_SRC_COLOR) |
The resolve and the weight/blend math are identical, so both backends produce the same image.
Pipeline order
scene → [G-buffer prepass] → h_scene_hdr
→ SSR / DOF / OIT (optional, write a new scene_src)
→ SSAO (multiplied into scene term)
→ bloom bright-pass → blur
→ composite (tone-map + vignette)
→ FXAA → canvasFor the exact struct fields, handle IDs (240–266), and wire tags, see the verve.gl post-processing reference.
Next: Advanced animation.