Skip to content

Post-processing

Estella runs full-screen post-processing through ping-pong framebuffers. Add a PostProcessVolume component, enable the effects you want, and tune their parameters — in the editor or in code. Effects share the ping-pong buffers, so several can stack in one frame at no extra target cost beyond the passes.

Effect What it does Key uniforms
bloom Glow on bright areas threshold, intensity, radius
tonemap ACES filmic tonemapping exposure
colorGrade Exposure / contrast / saturation / white balance exposure, contrast, saturation, temperature, tint
lutGrade Color grading through a lookup-table texture u_lut (texture), intensity
vignette Darkened screen edges intensity, softness
chromaticAberration RGB fringing intensity
blur Gaussian blur intensity
fxaa Fast anti-aliasing intensity
lensDistortion Barrel / pincushion warp strength, zoom
pixelate Block quantization pixelSize
outline Ink-edge outlines (Sobel edge darkening) intensity, threshold, thickness
grayscale Desaturate intensity

The uniform names above are the friendly labels; in code they are u_-prefixed (u_intensity, u_exposure, …). Most effects are identity at their defaults (colorGrade, blur, chromaticAberration, fxaa, lensDistortion, lutGrade), so adding them changes nothing until you dial them in. A few are visible the moment you add them — grayscale (full desaturation), vignette, bloom, outline, and tonemap (the ACES curve always applies) — so enable those only when you want them.

When the project renders in the linear color space (see Lighting), the effect chain runs in half-float HDR wherever float render targets are available — always on WebGPU, and on WebGL2 with the near-universal EXT_color_buffer_float. Light accumulation past 1.0 (a Light2D with intensity above 1) then survives into the chain instead of clamping at the 8-bit store:

  • bloom with threshold above 1 blooms only over-range energy — bright lights glow while ordinary LDR art stays crisp. This is the classic “emissive glow” setup: crank a light’s intensity, set threshold ≈ 1.2–1.5.
  • tonemap receives true HDR input, so the ACES curve rolls off highlights instead of shaping already-clamped values.
  • Without a tonemap pass, over-range values simply clip at the final linear→sRGB encode — fine for subtle glow, harsh for large over-exposure.

In gamma mode (or without float-target support) the chain is LDR and behaves as before; a threshold above ~0.75 then never fires.

Add a PostProcessVolume, enable effects, and set their parameters. Volumes blend where they overlap, so you can drive effects by area (a “underwater” volume) or fade them in over a transition.

Property Type Default Description
effects list [] { type, enabled, uniforms } per effect.
isGlobal boolean true Apply everywhere vs. only inside the volume shape.
shape 'box' | 'sphere' 'box' Bounds shape for a local volume.
size Vec2 {5, 5} Volume extents (local volumes).
priority number 0 Higher-priority volumes win where they overlap.
weight number 1 Blend weight, 0..1 (fade the whole volume).
blendDistance number 0 Falloff distance at the volume edge.

For a fixed camera pipeline in code, use the PostProcess resource. A stack starts empty — add one pass per effect. Get the effect’s shader from the registry with getEffectDef(type).factory(), then set its u_-prefixed uniforms and bind the stack to a camera:

import { defineSystem, Query, Res, Camera, PostProcess, getEffectDef } from 'esengine';
const setup = defineSystem([Query(Camera), Res(PostProcess)], (q, pp) => {
const grade = getEffectDef('colorGrade')!;
for (const [camera] of q) {
const stack = pp.createStack();
stack.addPass('colorGrade', grade.factory())
.setUniform('colorGrade', 'u_saturation', 1.3)
.setUniform('colorGrade', 'u_contrast', 1.1);
pp.bind(camera, stack); // pp.unbind(camera) to remove
}
});

A multi-pass effect like bloom exposes def.multiPass; add each sub-pass (for (const p of grade.multiPass!) stack.addPass(p.name, p.factory())). For anything beyond a fixed pipeline, a PostProcessVolume is simpler — it resolves multi-pass and texture params for you.

Method Description
PostProcess.createStack() Create an empty effect stack.
stack.addPass(name, shader) Add an effect pass; shader from getEffectDef(type).factory() (chainable).
stack.setUniform(pass, uniform, value) Set a u_-prefixed parameter (chainable).
stack.setTexture(pass, uniform, handle) Bind a texture parameter, e.g. a LUT (chainable).
stack.setEnabled(name, on) Toggle an already-added pass (chainable).
PostProcess.bind(camera, stack) / unbind(camera) Attach / remove a camera’s stack.
getEffectDef(type) / getAllEffectDefs() / getEffectTypes() Look up effect definitions (shader factory + uniforms).

Some effects take a texture, not just scalars. lutGrade samples a color lookup table — a 2D-packed 1024×32 PNG (32 slices of a 32³ cube) — so you can author a whole grade in an image editor and drop it in. Add the pass, then bind the texture with setTexture (or, on a PostProcessVolume, list it under the effect’s textures: { u_lut: <ref> }):

stack.addPass('lutGrade', getEffectDef('lutGrade')!.factory())
.setTexture('lutGrade', 'u_lut', lutHandle)
.setUniform('lutGrade', 'u_intensity', 1);
  • Order matters — passes run in the order you add them (or, on a volume, the order of its effects[]) and share the buffers, so a sensible chain is bloom → tonemap → color grade → vignette.
  • Use volumes for locality (an area’s mood) and the PostProcess API for a fixed camera pipeline.
  • Fade with weight on a volume for smooth transitions instead of toggling.
  • Keep the stack short on low-end/mobile — each pass is a full-screen draw.