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 gl core feature today. It is driven from island code through the gl encoder (beginPostProcess / endPostProcess and the Encoder.run* passes), not through the declarative ctx.glScene builder — there is no data-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.

ValueToneMapCurveNotes
0.linearpow(clamp(hdr,0,1), 1/2.2)Clips highlights to white
1.reinhardhdr/(1+hdr), then gamma 2.2Simple, desaturates highlights
2.reinhard_exthdr*(1+hdr/W²)/(1+hdr), W=4, gamma 2.2Preserves bright detail
3.aces (default)Hill ACES fit, no gammaByte-identical to pre-slice-2 output
4.agxMinimal AgX (Sobotka/Filament)Neutral, no highlight hue-shift
5.uncharted2Hable filmic, bias 2.0, W=11.2, gamma 2.2Classic 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
FieldDefaultNotes
intensity0.00 = off, 1 = full darkening
radius0.75Where 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·reveal

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

BackendHow accum + reveal are filled
WebGPUOne 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 → canvas

For the exact struct fields, handle IDs (240–266), and wire tags, see the verve.gl post-processing reference.

Next: Advanced animation.