verve.gl post-processing

Guide: Image quality & post-processing · WebGL · Advanced WebGL.

The post-processing API is part of the low-level gl core (src/core/gl/command.zig). It is driven from island code via the encoder — beginPostProcess / endPostProcess and the Encoder.run* passes — not through the declarative ctx.glScene builder. All shaders ship as WGSL + GLSL twins (WebGPU / WebGL2).

PostProcess struct

Configuration for beginPostProcess / endPostProcess.

pub const PostProcess = struct {
    bloom:     ?Bloom    = .{},     // null → skip bright-pass + blur
    fxaa:      bool      = true,    // FXAA on the final blit
    webgpu:    bool      = false,   // emit WGSL modules vs GLSL
    tonemap:   ToneMap   = .aces,   // composite tone-mapping operator
    vignette:  ?Vignette = null,    // null → off
    ao_tex:    u32       = 0,       // SSAO blurred-AO handle (composite tex2); 0 → 1×1 white (AO=1)
    scene_src: u32       = 0,       // scene-source override; 0 → h_scene_hdr (240)
};
FieldDefaultNotes
bloom.{}null skips the bright-pass + blur chain
fxaatrueFXAA anti-aliasing on the final canvas blit
webgpufalsetrue emits the WGSL post modules; default emits GLSL
tonemap.acesComposite tone-mapping operator (see ToneMap)
vignettenullOptional corner darkening after tone-mapping
ao_tex0SSAO blurred-AO render target bound at composite tex2; 0 → 1×1 white dummy (no-op)
scene_src0Render target the bright-pass + composite read as the scene; 0h_scene_hdr. SSR/DOF/OIT set it to their resolved output

ToneMap enum

pub const ToneMap = enum(u8) {
    linear       = 0,  // pow(clamp(hdr,0,1), 1/2.2) — clips highlights
    reinhard     = 1,  // hdr/(1+hdr), gamma 2.2
    reinhard_ext = 2,  // hdr*(1+hdr/W²)/(1+hdr), W=4, gamma 2.2
    aces         = 3,  // Hill ACES fit, NO gamma (default; pre-slice-2 output)
    agx          = 4,  // minimal AgX (Sobotka/Filament); basis for three.js AgXToneMapping
    uncharted2   = 5,  // Hable filmic, bias 2.0, W=11.2, gamma 2.2
};

The integer value is the wire contract with the composite shader (op = i32(P.tonemap + 0.5) selects the branch).

Vignette struct

pub const Vignette = struct {
    intensity: f32 = 0.0,   // 0 = off, 1 = full effect
    radius:    f32 = 0.75,  // distance from center where falloff begins (0.5–0.8)
};

Applied after tone-mapping via smoothstep(radius, radius − 0.45, d).

Bloom struct

pub const Bloom = struct {
    threshold: f32 = 1.0,  // luminance above which pixels bloom
    intensity: f32 = 0.6,  // bloom blended into the composite
};

Composite params packing

The composite stage packs into one 16-byte slot — no separate buffer:

p_comp = [intensity, tonemap, vig_intensity, vig_radius]

Render-target & shader handles

Each effect reserves a contiguous handle block (h_ = render target, sh_ = shader). Handles 240–266 are reserved for the post path so they never clash with app handles.

CtxHandleIDRole
PostCtxh_scene_hdr240Offscreen scene HDR target
h_bloom_a / h_bloom_b241 / 242Bloom blur ping-pong
h_ldr243LDR composite output
sh_bright244Bright-pass shader
sh_blur245Separable Gaussian (shared by DOF)
sh_composite246Tone-map + vignette composite
sh_fxaa247FXAA
PrepassCtxh_gbuffer248rgba16f depth + view-normal G-buffer (public consume handle)
sh_prepass249variant_prepass shader
sh_gdebug250G-buffer debug-viz shader
SsaoCtxh_ao_raw / h_ao_blur251 / 252AO accumulation / blurred AO (composite tex2)
sh_ssao / sh_ssao_blur253 / 254SSAO pass / 4×4 box blur
SsrCtxh_scene_ssr255Scene + reflections RT (drop-in scene_src)
sh_ssr256SSR fullscreen shader
DofCtxh_dof_a / h_dof_b257 / 258Blur ping-pong (H) / fully blurred (V)
h_scene_dof259Sharp + blur CoC composite (drop-in scene_src)
sh_dof260CoC combine shader (vec4 params → 32B)
OitCtxh_accum / h_reveal261 / 262Additive accum / multiplicative revealage
h_scene_oit263Resolved scene + transparency (drop-in scene_src)
sh_oit264Transparent-geometry shader (WGSL MRT / GLSL accum-out)
sh_oit_reveal265GLSL revealage-out shader (WebGL2 only)
sh_oit_resolve266Fullscreen resolve (accum + reveal + opaque)

Post Params layout (SSAO / SSR)

SSAO and SSR share one 144-byte Params block so the bridge's binding-size path is reused:

[0..4)   = params (vec4)        // SSAO: (radius, bias, intensity, _)
                                // SSR:  (strength, max_distance, thickness, fresnel_power)
[4..20)  = inv_proj (mat4, column-major)
[20..36) = proj     (mat4, column-major)

DOF needs no matrices — its combine params are a single vec4 (focus_distance, focal_range, max_blur, _) → 32 bytes.

Encoder passes

MethodEffect
beginPostProcess(PostProcess)Redirect scene rendering into h_scene_hdr
endPostProcess(...)Bright-pass → blur → composite (tone-map + vignette) → FXAA → canvas
Encoder.runSsao(...)SSAO pass + 4×4 box blur → h_ao_blur
Encoder.runSsr(...)32-step screen-space reflection pass → h_scene_ssr
Encoder.runDof(...)2 blur passes + CoC combine → h_scene_dof
Encoder.runOit(ctx, webgpu, w, h, draws)Weighted-blended OIT; draws = per-object OitDraw list → h_scene_oit

OIT wire tags & blend state

runOit emits backend-specific structure; the resolve and weight/blend math are identical, so both backends produce the same image.

ConstantValueRole
variant_oit1 << 17Standalone WBOIT geometry shader (mvp + mv + color UBO; not a PBR add-on)
begin_mrt_pass40WebGPU MRT pass open {accum, reveal, depth_src}
draw_oit41Transparent draw {vbuf, ibuf, idx_off, idx_count, mvp_ptr, mv_ptr, color_ptr}
state_blend_add1 << 4WebGL2 additive accum blend (ONE/ONE)
state_blend_mult1 << 5WebGL2 revealage blend (ZERO/ONE_MINUS_SRC_COLOR)

On WebGPU per-target blend is baked into the MRT pipeline, so the WebGL2 blend bits only steer the fallback (two single-target passes).

Backend divergence

BackendTransparent accum + reveal
WebGPUOne MRT pass — 2-target pipeline, per-target blend, depth-write off, opaque depth read-only
WebGL2Two single-target passes over the same geometry (accum ONE/ONE, reveal ZERO/ONE_MINUS_SRC_COLOR)