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 anr8uintindex 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 integertextureLoad(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 afterPaletteResolveUpscalePass. - 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 whendrawingBufferSizeis 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.3Extend 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
FullscreenEffectsubclasses providefragmentShader,label, anduniformBytes(multiple of 16). OverridesamplerFilter('linear'default; many effects keep'linear'at display resolution).- The fragment shader sees
Params,src,sampat@group(0) @binding(0..2). The shared vertex stage insrc/render/effects/fullscreenVS.tsis concatenated automatically, giving youVsOut { 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 (
pixelanddisplay) lazily allocates its own ping-pong texturestexAandtexBonly when it has effects (pixel targets stayr8uint; display targets match the swap-chain RGBA format). PaletteResolveUpscalePassruns every frame on WebGPU: it resolves indices through the active palette and magnifies todrawingBufferSizeusingoutputUpscaleFilter('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
| Symbol | Since | Last changed | Status |
|---|---|---|---|
BarrelDistortion | 1.0.3 | – | stable |
Bloom | 1.0.3 | – | stable |
ChromaticAberration | 1.0.3 | – | stable |
Effect | 1.0.3 | – | stable |
EffectTier | 1.0.3 | – | stable |
Flicker | 1.0.3 | – | stable |
FullscreenEffect | 1.0.3 | – | stable |
FullscreenPixelEffect | 1.0.3 | – | stable |
Interference | 1.0.3 | – | stable |
Noise | 1.0.3 | – | stable |
PixelGlitch | 1.0.3 | – | stable |
PixelMosaic | 1.0.3 | – | stable |
RGBMask | 1.0.3 | – | stable |
RollLine | 1.0.3 | – | stable |
Scanlines | 1.0.3 | – | stable |
Vignette | 1.0.3 | – | stable |
1.0.3 - Sat May 16 2026
- Added
BarrelDistortion - Added
Bloom - Added
ChromaticAberration - Added
Effect - Added
EffectTier - Added
Flicker - Added
FullscreenEffect - Added
FullscreenPixelEffect - Added
Interference - Added
Noise - Added
PixelGlitch - Added
PixelMosaic - Added
RGBMask - Added
RollLine - Added
Scanlines - Added
Vignette
See also
API: Rendering
API: Core
API: Palette
Testing
Software Fallback Smoke Matrix
Last updated on October 18, 2026