BLIT386BLIT386

Post-Process Effects

The two-tier post-process effect system, built-in effects, and CRT presets.

BLIT386 ships a two-tier post-process system that runs between the scene render and the swap-chain present. It is opt-in and adds zero cost while no effect is registered. Effects are organized into two chains by what they operate on:

  • Pixel tier - runs at the logical render resolution (e.g. 320×240) on an r8uint index framebuffer (one byte per pixel: which palette slot each logical pixel uses). Hosts effects that stay palette-native: glitch shifts, mosaic blocks, index manipulation. Sampling uses integer textureLoad (no RGB averaging drift).
  • Display tier - runs at the canvas output resolution (e.g. 1280×960) on the upscaled image. Hosts effects that simulate the physical display: CRT scanlines, barrel curvature, RGB shadow mask, vignette, chromatic aberration, bloom, and so on. Operating at output resolution lets curved sampling (barrel) express smoothly without quantizing onto the source pixel grid.

This guide covers the public API, the two-tier architecture, every built-in effect, the Effect interface for writing your own, the bundled presets, and the upstream attribution.

For how logical, drawing buffer, CSS cap, and effect tier map to HardwareSettings and BT getters, see Resolution model in the core API docs.

Quick start

class  {
  :  | null = null;

  () {
    return {
      : new (320, 240), // logical pixel-art resolution
      : new (1280, 960), // output drawing-buffer size
      : 'nearest' as , // 'nearest' | 'linear'
      : 60,
    };
  }

  async () {
    // ... palette, sprites ...

    // Pixel tier: chunky glitch on the 320×240 framebuffer
    this. = new ();
    this.. = 4;
    .(this.);

    // Display tier: a CRT look on the 1280×960 output
    for (const  of ..()) {
      .(); // each effect declares its tier; engine routes automatically
    }

    return true;
  }
}

The chain is stable across frames: effects retain their state until removed. Mutate fields directly on the instance each frame; the chain re-uploads the uniform block before its encodePass.

Architecture

[Demo render() draws palette indices into logical r8uint targets @ 320×240]
        ↓
[Pixel chain @ 320×240]      ← index-space: PixelGlitch, PixelMosaic, ...
        ↓
[PaletteResolveUpscalePass @ outputSize] ← LUT resolve + 'nearest' | 'linear' upscale to RGBA
        ↓
[Display chain @ outputSize] ← screen sim: BarrelDistortion, Scanlines, RGBMask, ...
        ↓
[Swap chain]

Invariants:

  • WebGPU always keeps the logical stage index-native (r8uint); RGBA exists only after PaletteResolveUpscalePass.
  • Both effect chains empty: scene → logical composite → palette resolve/upscale → swap (two render passes minimum for scene + resolve when no effects are registered).
  • Only the pixel chain has effects: scene → pixel ping-pong → logical composite texture → resolve → swap (or display input when display tier is active).
  • Only the display chain has effects: scene → logical composite → resolve → display chain → swap chain.
  • The last display-tier effect writes to the swap chain when display effects are active; otherwise palette resolve writes directly to the swap chain.
  • Adding a tier='display' effect when drawingBufferSize is unset throws with a clear message - display effects need an output buffer larger than the logical framebuffer to operate.

API

BT.effectAdd(effect: Effect): void

Appends an effect to the chain matching its declared tier. Effects can be added at any time; the first add allocates the chain's offscreen render targets, the second add allocates a second target for ping-pong. Throws if the engine has not been initialized, if a tier='display' effect is added without drawingBufferSize, or if the active backend is 'software' (Canvas 2D does not support post-process effects). At runtime, gate registration with BT.activeBackend === 'webgpu' (not BT.requestedBackend - WebGPU may have been requested but fallen back). See api-core.md.

BT.effectRemove(effect: Effect): void

Removes a previously registered effect. Searches both tiers and disposes the effect from whichever chain holds it. Removing an effect that was never added is a no-op. When the last effect in either chain is removed, that chain's offscreen textures are destroyed. Throws in 'software' mode.

BT.effectClear(): void

Removes every effect in both tiers and destroys all offscreen GPU resources. Throws in 'software' mode.

Effect interface

Effect Since 1.0.3 EffectTier Since 1.0.3
import type { Effect, ,  } from 'blit386';

export class  implements Effect {
  public readonly :  = 'display'; // or 'pixel'

  (: GPUDevice, : , : ): void {
    // Create pipeline, uniform buffer, sampler.
  }

  (: number, : ): void {
    // Write per-frame uniform data to the GPU.
  }

  (: GPUCommandEncoder, : GPUTextureView, : GPUTextureView): void {
    // Begin a render pass against destView, sample sourceView, draw fullscreen triangle.
  }

  ?(): void {
    // Optional: destroy GPU buffers.
  }
}

The chain calls init once when the effect is added, updateUniforms + encodePass once per frame, and dispose once when removed.

The base class FullscreenEffect handles most display-tier boilerplate (pipeline, sampler, uniform buffer, bind-group cache). Pixel-tier effects extend FullscreenPixelEffect, which compiles separate WGSL for r8uint (texture_2d<u32>) vs. RGBA paths - see Writing a custom effect below.

HardwareSettings

interface HardwareSettings {
  : ;
  ?: ; // drives drawing buffer + CSS, enables display tier
  ?: 'nearest' | 'linear'; // default 'nearest'
  : number;
  ?: boolean;
  ?: 'webgpu' | 'software'; // default 'webgpu'; 'software' disables all post-process effects
}

When drawingBufferSize is omitted from a configure() return value that includes displaySize, the WebGPU drawing buffer matches displaySize. Palette resolve still runs (logical indices to RGBA at that size). The display tier remains unavailable (adding a display effect throws) because no explicit output buffer was configured.

If the demo does not implement configure(), or returns a partial object without displaySize (for example only { targetFPS: 30 }), the engine merges with defaultConfig(), which does set drawingBufferSize (640×480 for 320×240 logical), so the display tier remains available unless you set a custom displaySize and omit output sizing.

Pixel-tier effects

PixelGlitch - chunky band shift

PixelGlitch Since 1.0.3

Per-row horizontal glitch: every Nth row of source palette indices gets a random horizontal shift in index space (integer texel steps), so output stays on valid palette slots without RGB remapping.

Prop

Type

PixelMosaic - block down-quantize

PixelMosaic Since 1.0.3

Replaces each blockSize x blockSize group of source pixels with a single sample. Useful for transitions, dream sequences, and "low-res mode" effects.

Prop

Type

Display-tier effects

BarrelDistortion - pincushion curve

BarrelDistortion Since 1.0.3

warp(uv) = uv + delta * d2 * curvature. Operates at output resolution so the curve has enough pixels to express smoothly - no stepping artifacts on diagonals.

Prop

Type

Scanlines - bright/dark horizontal bands

Scanlines Since 1.0.3

Gaussian-weighted scanline pattern matched to source pixel rows.

Prop

Type

RGBMask - CRT shadow mask

RGBMask Since 1.0.3

R/G/B vertical-stripe pattern with darkened cell borders, simulating an aperture-grille CRT.

Prop

Type

Vignette - edge darkening

Vignette Since 1.0.3

Smooth radial fade. pow(edge.x * edge.y, amount).

Prop

Type

ChromaticAberration - RGB channel offset

ChromaticAberration Since 1.0.3

Red samples left of the fragment, blue samples right. Cheap CRT optics produce a tiny version of this naturally.

Prop

Type

Flicker - brightness multiplier

Flicker Since 1.0.3

The simplest CRT animation knob. color *= amount. Demo drives it per-frame.

Prop

Type

RollLine - scrolling interference band

RollLine Since 1.0.3

A horizontal bright stripe slowly scrolls down the screen.

Prop

Type

Interference - per-row analog jitter

Interference Since 1.0.3

Each row gets a random horizontal offset reseeded each frame.

Prop

Type

Noise - additive pseudo-random noise

Noise Since 1.0.3

Per-pixel film grain. Reseeds each frame from time.

Prop

Type

Bloom - soft phosphor glow

Bloom Since 1.0.3

Single-pass 5×5 box blur (25 taps) mixed with the original color.

Prop

Type

A future optimization would be a two-pass separable Gaussian (5+5 = 10 taps); we will revisit when GPU perf tests demand it.

Presets

Each preset is a function that returns a fresh array of pre-configured effects. Add them in order to the engine via BT.effectAdd.

BT.preset.crtPipBoy()

Recreates the original PipBoy CRT look: BarrelDistortion + ChromaticAberration + Interference + RollLine + Scanlines + RGBMask + Noise + Flicker + Bloom. Demos that want the full kitchen-sink effect should use this.

BT.preset.amber()

Amber monochrome PC monitor (think IBM 5151 / Hercules).

BT.preset.green()

Green monochrome PC monitor (think IBM monochrome / VT100).

Monochrome presets are incomplete

amber() and green() currently ship as parameter-only sets - the actual amber/green tint quantization is planned but not yet implemented.

Writing a custom effect

FullscreenEffect Since 1.0.3

Extend FullscreenEffect when sampling RGBA at output resolution. Subclasses provide one WGSL fragment shader (src + sampler), declare tier = 'display', set uniformBytes, and implement writeUniforms.

import { , type  } from 'blit386';

export class  extends  {
  public readonly  = 'display' as ;
  public  = 1.2;

  protected readonly  = 'GammaEffect';
  protected readonly  = 16;
  protected readonly  = `
struct Params {
    intensity: f32,
    _pad0: f32,
    _pad1: f32,
    _pad2: f32,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var samp: sampler;

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let c = textureSample(src, samp, in.uv);
    return vec4<f32>(pow(c.rgb, vec3<f32>(params.intensity)), c.a);
}`;

  protected (: number, : ): void {
    const  = this.;
    if (!) return;
    [0] = this.;
    [1] = [2] = [3] = 0;
  }
}

Notes

  • FullscreenEffect subclasses provide fragmentShader, label, and uniformBytes (multiple of 16). Override samplerFilter ('linear' default; many effects keep 'linear' at display resolution).
  • The fragment shader sees Params, src, samp at @group(0) @binding(0..2). The shared vertex stage in src/render/effects/fullscreenVS.ts is concatenated automatically, giving you VsOut { pos: vec4<f32>, uv: vec2<f32> }.

How the chain works internally

no effects:       scene (r8uint) -> paletteResolve (RGBA @ outputSize) -> swap-chain
pixel only:       scene -> texA -> [px...] -> sceneTex (r8uint) -> paletteResolve -> swap-chain
display only:     scene -> sceneTex -> paletteResolve -> texA' (RGBA) -> [dsp...] -> swap-chain
pixel + display:  scene -> texA -> [px...] -> sceneTex -> paletteResolve -> texA' -> [dsp...] -> swap-chain
  • Each chain (pixel and display) lazily allocates its own ping-pong textures texA and texB only when it has effects (pixel targets stay r8uint; display targets match the swap-chain RGBA format).
  • PaletteResolveUpscalePass runs every frame on WebGPU: it resolves indices through the active palette and magnifies to drawingBufferSize using outputUpscaleFilter ('nearest' or 'linear'), even when no display-tier effects are registered.
  • Frame capture (BT.captureFrame()) reads the swap-chain texture, so screenshots reflect the post-processed output.

Full demos

Two demos put the effect chain to work in a complete scene: a hand-built CRT stack layered onto the basics demo, and a full CRT-post-processed game.

Attribution

API history

SymbolSinceLast changedStatus
BarrelDistortion1.0.3–stable
Bloom1.0.3–stable
ChromaticAberration1.0.3–stable
Effect1.0.3–stable
EffectTier1.0.3–stable
Flicker1.0.3–stable
FullscreenEffect1.0.3–stable
FullscreenPixelEffect1.0.3–stable
Interference1.0.3–stable
Noise1.0.3–stable
PixelGlitch1.0.3–stable
PixelMosaic1.0.3–stable
RGBMask1.0.3–stable
RollLine1.0.3–stable
Scanlines1.0.3–stable
Vignette1.0.3–stable

1.0.3 - Sat May 16 2026

  • AddedBarrelDistortion
  • AddedBloom
  • AddedChromaticAberration
  • AddedEffect
  • AddedEffectTier
  • AddedFlicker
  • AddedFullscreenEffect
  • AddedFullscreenPixelEffect
  • AddedInterference
  • AddedNoise
  • AddedPixelGlitch
  • AddedPixelMosaic
  • AddedRGBMask
  • AddedRollLine
  • AddedScanlines
  • AddedVignette

See also

Last updated on October 18, 2026

On this page