Game code can push an ordered stack of screen-space effects onto the renderer each frame. Effects are applied to the 3D scene only — the HUD and UI are always drawn on top, sharp and unaffected.
The renderer owns a few effects of its own (bloom, underwater fog, screen-warp). R.Init() registers them with the same PostProcess that holds the stack's effects, and the renderer turns them on and off itself from its console variables and from where the camera is. They are not stackable, so a stack cannot name them. The scene stage of the stack runs first (PostProcess.resolveGameplayStack()), then the active effects of the renderer resolve to the screen in registration order, and the final stage of the stack comes last (PostProcess.resolve()).
ClientEngineAPI.PostProcess exposes four methods:
| Method | Description |
|---|---|
setStack(stack, options?) |
Replace the stack of a channel with new entries. Applied on the next rendered frame, or cross-faded over options.fade seconds. |
clearStack(channel?) |
Remove the stack of a channel at once, of every channel without a name. Pulses that are running end on their own. |
hasStack(channel?): boolean |
Returns true when a stack is set and meant to stay. Pulses and entries that are fading out do not count. |
pulse(descriptor, options?) |
Start an entry that fades in, holds, fades out and is gone. See "Pulses". |
Import the types from GameInterfaces.ts:
import type { PostProcessStack, PostProcessPulseDescriptor } from '../../../shared/GameInterfaces.ts';A stack is a read-only array of effect descriptors. Each descriptor names an effect by id and carries its settings. Effects are applied in the order they appear in the array.
const stack: PostProcessStack = [
{ id: 'color-grade', settings: { saturation: 0.0 } },
{ id: 'blur', settings: { radius: 6.0 } },
];
this.engine.PostProcess.setStack(stack);To revert to the normal look:
this.engine.PostProcess.clearStack();setStack(stack, { fade }) cross-fades from the previous stack of the channel to the new one over fade seconds, so a menu does not have to snap its blur on:
this.engine.PostProcess.setStack(buymenuStack, { fade: 0.25 });
// later
this.engine.PostProcess.setStack([], { fade: 0.25 });- An entry that is new fades in from nothing, an entry that is gone fades out and is then removed, and an entry that stays moves from its old settings to its new ones.
- An entry stays the same entry when it has the same
key, or, without a key, when it is the n-th entry of the same effect. Give entries akeywhen a stack reorders them or repeats an effect. - What fades are the numbers an effect lists as neutral (
saturation,contrast,exposure,tintStrengthandpulseStrengthofcolor-grade,radiusofblur). A tint color or a pulse period is taken as it is, and the strengths carry how much of it shows. - An entry that is on its way out is applied after the entries of the new stack.
- Fades run on the real clock, which does not stop while the game is paused, so a blur fades in over a paused game.
A pulse is an entry that removes itself: a damage flash, the moment of a pickup, a berserk surge on the grade.
this.engine.PostProcess.pulse({
id: 'color-grade',
key: 'damage',
settings: { saturation: 0.4, tintColor: new Vector(1, 0, 0), tintStrength: 0.5 },
timing: { fadeIn: 0.05, hold: 0.1, fadeOut: 0.6 },
});timingis in seconds, parts that are left out are zero. The pulse is nothing at its start, full strength afterfadeIn, held forhold, and nothing again afterfadeOut, when the engine removes it. A pulse without any duration shows for one frame.- Starting a pulse with a
keythat is already running starts it over instead of stacking a second one. Without a key every call is its own pulse. placement: 'below'puts the pulse in front of the stack entries of its channel, so that the stack is applied over it;'above'(the default) puts it behind them. Pulses of one placement are applied in the order they were started.clearStack()does not end a pulse.
setStack(stack, { channel }) and pulse(descriptor, { channel }) take a channel name, 'default' when left out. A channel has its own stack, which is replaced and cleared on its own, so the HUD can own the powerup tint and the gameplay code the death grade without knowing about each other. Channels are composed in the order they were first used; within a channel the order is the pulses placed below, the stack, then the pulses placed above.
Every entry is applied: two blur entries blur twice, each with its own radius.
Each entry has a stage:
| Stage | Where it is applied |
|---|---|
'scene' |
Before the renderer's own effects (bloom, underwater fog, warp), so they see the result. The default for color-grade and blur. |
'final' |
After the renderer's own effects, as the last pass before the HUD. The default for color-shift. |
Set stage on an entry to override the default of its effect. A final entry costs no extra pass when it is the only thing that is active: it replaces the plain copy of the scene to the screen.
Adjusts color tone. All fields are optional and default to neutral values.
| Field | Type | Default | Description |
|---|---|---|---|
saturation |
number |
1.0 |
Color saturation. 0 = grayscale, 1 = normal, >1 = oversaturated. |
contrast |
number |
1.0 |
Contrast multiplier around mid-grey. <1 = flat, >1 = punchy. |
exposure |
number |
0.0 |
Additive brightness offset applied to all channels. |
tintColor |
Vector |
— | RGB tint color to mix over the scene (e.g. new Vector(1, 0, 0) for red). |
tintStrength |
number |
0.0 |
How strongly the tint is applied. 0 = no tint, 1 = full tint. |
pulseStrength |
number |
0.0 |
Amplitude of a sinusoidal saturation pulse layered on top of the base saturation. |
pulsePeriod |
number |
0.0 |
Period of the saturation pulse in seconds. 0 disables the pulse. |
Two-pass separable Gaussian blur (9-tap, σ = 1.5). All fields are optional.
| Field | Type | Default | Description |
|---|---|---|---|
radius |
number |
4.0 |
Pixel spread of the Gaussian. Higher values produce a wider blur. 0 = no blur. |
Tints the whole view: a damage flash, a pickup, a powerup, the color of a liquid. Entries that follow each other are mixed into one tint. See color-shifts.md.
| Field | Type | Default | Description |
|---|---|---|---|
color |
Vector or [r, g, b] |
black | Red, green and blue, each 0 to 1. |
alpha |
number |
0 |
How much the color covers the view, 0 to 1. Only this fades. |
import type { PostProcessStack } from '../../../shared/GameInterfaces.ts';
import Vector from '../../../shared/Vector.ts';
const gameoverStack: PostProcessStack = [
{ id: 'color-grade', settings: { saturation: 0.0, tintColor: new Vector(1.0, 0.0, 0.0), tintStrength: 1.0 } },
{ id: 'blur', settings: { radius: 3.0 } },
];
// Activate when the game ends
this.engine.PostProcess.setStack(gameoverStack);
// Remove when returning to normal play
this.engine.PostProcess.clearStack();const buymenuStack: PostProcessStack = [
{ id: 'color-grade', settings: { saturation: 0.3 } },
{ id: 'blur', settings: { radius: 8.0 } },
];
onMenuOpen(): void {
this.engine.PostProcess.setStack(buymenuStack);
}
onMenuClose(): void {
this.engine.PostProcess.clearStack();
}const berserkStack: PostProcessStack = [
{
id: 'color-grade',
settings: {
saturation: 1.4,
tintColor: new Vector(1.0, 0.2, 0.2),
tintStrength: 0.25,
pulseStrength: 0.6,
pulsePeriod: 0.8,
},
},
];
onBerserkPickup(): void {
this.engine.PostProcess.setStack(berserkStack);
}
onBerserkExpired(): void {
this.engine.PostProcess.clearStack();
}- Create
source/engine/client/renderer/postprocess/MyEffect.tsextendingPostProcessEffectwiththis.stackable = true. Implementapply(), andinit()/resize()/shutdown()if the effect owns GPU resources. - Say what an entry of the effect is made of:
neutral: the settings with which the effect changes nothing. The numbers listed here are the ones that fade, so list those that carry the intensity and leave out colors and periods.resolveSettings(settings): fills in what an entry left out and repairs numbers that are not finite. It must return settings it can be asked to resolve again.defaultStage, if the effect belongs after the renderer's own effects.combineSettings(settings), if the entries of the effect are layers of one thing (a tint) and not passes of their own: entries that follow each other are handed to it in one list and it returns the one set of settings to apply, ornullfor nothing.
apply(input, x, y, width, height, settings, output)gets the settings of the entry it is applied for, already resolved and faded to where the entry is. Do not look them up onPostProcess: with several entries of one effect there is no single answer.outputsays where the chain bound it to draw (output.target,nullfor the screen, andoutput.viewport): an effect that draws into a target of its own on the way puts it back withRenderTarget.bindOutput(output.target)andgl.viewport(...output.viewport)before the pass that produces the result. Do not ask GL what is bound. An effect that samples a texture besides the one it is given (the scene depth, the emissive buffer) lists it insampledTexturesininit(); the frame checks that it was drawn this frame and is not attached to the output (seer_framegraphbelow).- Add
MyEffectDescriptortoPostProcessEffectDescriptorinsource/shared/GameInterfaces.ts. - Register the GLSL program in
renderer/programs/ShaderPrograms.tsalongside the existingcolor-gradeandblurregistrations. - Call
PostProcess.addEffect(new MyEffect())inR.tsafterPostProcess.init().
The entries themselves (timing, channels, fades, order) live in PostProcessEntries.ts, which knows nothing of GL and is tested on its own.
Frame (renderer/frame/Frame.ts) records the passes of every frame: what each one draws into and which textures it samples, nested the way they run. In development builds it also checks them (a pass that samples a texture attached to its own output, one that samples a target nothing drew into this frame, a pass that ends with another framebuffer bound than its own, a pass left open); each violation is a console.assert and is listed by the command. A production build keeps no record and does none of this, the calls are stripped with their arguments.
r_framegraph prints the passes of the last frame:
frame 412: 9 passes, 0 violations
shadow-topdown -> shadow-topdown reads -
scene -> scene-msaa -> scene reads -
turbulent-boundary -> turbulent-boundary reads -
scene-depth-copy -> scene-depth-copy reads -
scene-depth-copy -> scene-depth-copy reads -
msaa-resolve -> scene reads -
post-process:underwater-fog -> screen reads scene.color0, scene.depth, turbulent-boundary.depth
overlays -> screen reads -
scene -> scene-msaa -> scene is the scene that was drawn multisampled and was resolved at its end. A scene-depth-copy is a pass that needed the depth of the scene while it is drawn (turbulent surfaces, a fog volume): the depth is copied into a texture of its own and the frame goes on drawing into the multisampled target, so what is drawn after it is antialiased as well. reads - there is a multisampled depth renderbuffer, which is not a texture; without multisampling the pass reads scene.depth.