| Header | Link |
|---|---|
| Purpose | Purpose |
| Use Cases | Use Cases |
| Example | Example |
| Reference | Reference |
Post-processing runs an ordered chain of full-screen effects after the scene renders, letting you restyle and react to the frame without touching individual materials. Configure it per camera on Camera2D/Camera3D or globally through ResourceWindow. Because effects are named and addressable, gameplay can turn them on, off, or animate their strength, which is how you build hit-flashes, low-health tunnel vision, and cinematic color grades.
- Low-health feedback: a
vignettethat tightens plussaturatetoward grayscale as HP drops, driven by mutating the named effects withpost_processing.get_mut(...). - Hit flash / damage tint: a
color_filterwhosestrengthyou ramp for a frame or two on impact. - Retro or dream looks:
crtscanlines,pixelate, orwarpfor flashbacks and CRT screens. - Cinematic grade:
color_grade(contrast,temperature,vibrance,lift/gain) plus a filmlut3dfor a consistent mood. - HDR glow and auto-exposure:
bloomon bright emitters with anexposureeffect (auto_exposure = true) so dark caves brighten and sunlit exteriors settle. - Custom screen effects: a
customWGSL pass implementingpost_process(uv, color, depth), e.g. depth-based fog or edge outlines.
Use named built-in effects for common grades and feedback because scripts can tune them without owning shader code. Use a custom pass for image logic the built-ins cannot compose. Keep damage/health truth in gameplay state and drive effect strength from it; post-processing remains presentation.
Add a bloom + vignette chain to a 3D camera in a scene:
[MainCamera]
[Camera3D]
active = true
post_processing = [
{ type = "bloom", strength = 0.7, threshold = 0.75, radius = 1.5 },
{ type = "vignette", strength = 0.6, radius = 0.55, softness = 0.25 }
]
[/Camera3D]
[/MainCamera]
Add a named damage-tint effect from a script and pulse it on a hit:
with_node_mut!(ctx.run, Camera3D, cam_id, |cam| {
cam.post_processing.add(
"damage",
PostProcessEffect::ColorFilter { color: [1.0, 0.2, 0.2], strength: 0.0 },
);
});
// On taking damage, raise the strength for a brief flash:
with_node_mut!(ctx.run, Camera3D, cam_id, |cam| {
if let Some(PostProcessEffect::ColorFilter { strength, .. }) =
cam.post_processing.get_mut("damage")
{
*strength = 0.6;
}
});Post-processing can be configured as:
- Per camera using
post_processingonCamera2D/Camera3D. - Global using
ResourceWindowpost-processing methods/macros.
Each chain is ordered: effects are applied in sequence (stacked) and run after 3D + particles + 2D. Scene effects and bloom stay in scene-referred linear light until the dedicated final tonemap.
If multiple cameras are active, the post chain used is the active 3D camera if present, otherwise the active 2D camera.
Visual accessibility settings are separate from post-processing and run after camera + global post-processing, before final tonemap. See Visual Accessibility.
blur(strength)
3x3 blur; higher strength increases sample offset.pixelate(size)
Pixel size in screen pixels.pixel_art(virtual_height,color_levels,dither_strength) Samples the whole frame on an aspect-correct virtual grid, applies Bayer dithering, then quantizes each RGB channel. Usevirtual_height = 180for a 320x180 grid on a 16:9 viewport.warp(waves,strength)
Horizontal sine‑wave distortion.vignette(strength,radius,softness)
Darkens edges;radiusis the start of falloff.crt(scanlines,curvature,chromatic,vignette)
Scanlines, mild screen curvature, chromatic offset, and a vignette.color_filter(color,strength)
Multiplies the scene bycolor, mixed bystrength.reverse_filter(color,strength,softness)
Keeps colors close tocolorwhile others wash toward grayscale.chroma_key(color,tolerance,softness) Makes colors near the key transparent.coloraccepts an RGB tuple or hex text.bloom(strength,threshold,radius)
Bright‑only blur added back into the image.- Bloom runs in scene-linear HDR before final tonemap.
thresholdmay exceed1.0. exposure(exposure,auto_exposure,min_exposure,max_exposure,speed_up,speed_down,target_luminance) Configures final ACES tonemap exposure. It does not add an ordered image pass.saturate(amount)
0 = grayscale, 1 = original, >1 boosts saturation.black_white(amount)
0 = original, 1 = full black & white.color_grade
Manual grade controls:exposure,contrast,brightness,saturation,gamma,temperature,tint,hue_shift,vibrance,lift,gain, andoffset.lut2d(texture, optionallut_size,strength)
Uses a flattened 2D LUT texture, usuallyN*Nwide byNhigh.lut3d(texture, optionallut_size,strength)
Uploads a flattened LUT texture as a GPU 3D texture before sampling.custom(shader/shader_path, optionalparams)
Runtime note:
- Built-in effects preserve input alpha unless the effect explicitly changes it.
chroma_keymultiplies input alpha by its soft color-distance mask.- Camera streams route webcam frames through the post chain when an image effect is present.
- Other built-in effects do not read depth.
customeffects receive depth fromdepth_texand can use it inpost_process.
Rust chroma-key example:
PostProcessEffect::ChromaKey {
color: color!("#00FF00"),
tolerance: 0.1,
softness: 0.05,
}post_processing can be an array or an object. Arrays keep order. Objects support two forms:
- Indexed keys (
0,1,2, orp0,p1, ...) to preserve explicit order. - Named keys (
bloom,blur2, ...) to make effects addressable by name in scripts.
Each entry is an effect object. You can also provide a name field inside the effect object.
[MainCamera]
[Camera3D]
active = true
post_processing = [
{ type = "blur", strength = 2.0 },
{ type = "pixelate", size = 6.0 },
{ type = "pixel_art", virtual_height = 180, color_levels = 8, dither_strength = 0.08 },
{ type = "warp", waves = 8.0, strength = 3.0 },
{ type = "vignette", strength = 0.6, radius = 0.55, softness = 0.25 },
{ type = "crt", scanlines = 0.35, curvature = 0.15, chromatic = 1.0, vignette = 0.25 },
{ type = "color_filter", color = (1.0, 0.8, 0.6), strength = 0.8 },
{ type = "reverse_filter", color = (0.1, 0.8, 0.2), strength = 0.9, softness = 0.2 },
{ type = "chroma_key", color = "#00FF00", tolerance = 0.1, softness = 0.05 },
{ type = "exposure", auto_exposure = true, exposure = 0.0, min_exposure = -4.0, max_exposure = 4.0, speed_up = 3.0, speed_down = 1.0, target_luminance = 0.18 },
{ type = "bloom", strength = 0.7, threshold = 0.75, radius = 1.5 },
{ type = "saturate", amount = 1.2 },
{ type = "black_white", amount = 1.0 },
{
type = "color_grade",
exposure = 0.15,
contrast = 1.1,
brightness = 0.02,
saturation = 1.15,
gamma = 1.0,
temperature = 0.1,
tint = -0.02,
hue_shift = 0.0,
vibrance = 0.25,
lift = (0.0, 0.0, 0.0),
gain = (1.0, 1.0, 1.0),
offset = (0.0, 0.0, 0.0)
},
{ type = "lut2d", texture = "res://luts/film_32.png", lut_size = 32, strength = 0.85 },
{ type = "lut3d", texture = "res://luts/print_32.png", lut_size = 32, strength = 1.0 },
{
type = "custom",
shader = "res://shaders/post_edge.wgsl",
params = [0.75, 2.0]
}
]
[/Camera3D]
[/MainCamera]
Named object form (keys become effect names):
[MainCamera]
[Camera3D]
active = true
post_processing = {
bloom = { type = "bloom", strength = 0.7, threshold = 0.75, radius = 1.5 },
blur2 = { type = "blur", strength = 2.0 },
vignette = { type = "vignette", strength = 0.6, radius = 0.55, softness = 0.25 }
}
[/Camera3D]
[/MainCamera]
post_processing is a PostProcessSet that stores a Vec<PostProcessEntry>.
Each entry ties one optional name to one effect. You can add, remove, rename, and query by name.
Build from owned effects:
let fx = vec![
PostProcessEffect::Blur { strength: 2.0 },
PostProcessEffect::Pixelate { size: 5.0 },
PostProcessEffect::Vignette {
strength: 0.6,
radius: 0.55,
softness: 0.25,
},
PostProcessEffect::ColorFilter {
color: [1.0, 0.8, 0.6],
strength: 0.8,
},
PostProcessEffect::BlackWhite { amount: 1.0 },
];
with_node_mut!(ctx.run, Camera3D, cam_id, |cam| {
cam.post_processing = PostProcessSet::from_effects(fx);
});Add a named effect:
with_node_mut!(ctx.run, Camera3D, cam_id, |cam| {
cam.post_processing.add(
"warp",
PostProcessEffect::Warp { waves: 6.0, strength: 2.0 },
);
});Get or mutate by name (Camera3D or Camera2D):
with_node_mut!(ctx.run, Camera3D, cam_id, |cam| {
if let Some(PostProcessEffect::Bloom { strength, .. }) =
cam.post_processing.get_mut("bloom")
{
*strength = 2.0;
}
});Read-only access with with_node!:
let bloom_strength = with_node!(ctx.run, Camera3D, cam_id, |cam| {
cam.post_processing
.get("bloom")
.and_then(|fx| match fx {
PostProcessEffect::Bloom { strength, .. } => Some(*strength),
_ => None,
})
}).unwrap_or_default();Enumerate names:
with_node!(ctx.run, Camera3D, cam_id, |cam| {
for name in cam.post_processing.names() {
if let Some(name) = name {
log::info!("post fx: {name}");
}
}
}).unwrap_or_default();Read entries:
with_node!(ctx.run, Camera3D, cam_id, |cam| {
for entry in cam.post_processing.entries() {
let name = entry.name.as_deref().unwrap_or("<unnamed>");
log::info!("post fx: {name}");
}
}).unwrap_or_default();color_grade is the manual color-grading pass. Defaults are neutral:
exposure = 0.0: EV stops.1.0doubles light,-1.0halves it.contrast = 1.0: contrast around mid gray.brightness = 0.0: additive brightness.saturation = 1.0:0.0grayscale,1.0original, higher boosts.gamma = 1.0: output gamma curve.temperature = 0.0: positive warms, negative cools.tint = 0.0: positive magenta/green shift.hue_shift = 0.0: full hue rotation units;1.0wraps once.vibrance = 0.0: saturation boost biased toward low-chroma colors.lift = (0.0, 0.0, 0.0): shadow offset per channel.gain = (1.0, 1.0, 1.0): highlight gain per channel.offset = (0.0, 0.0, 0.0): final additive channel offset.
Color grade runs in scene-linear HDR before final tonemap. Its exposure field remains a grade
operation for API compatibility. Use exposure effect to control final manual or auto exposure.
Scene example:
post_processing = [
{
type = "color_grade",
exposure = 0.25,
contrast = 1.12,
saturation = 1.08,
temperature = 0.08,
vibrance = 0.2,
lift = (-0.01, -0.01, 0.0),
gain = (1.04, 1.02, 0.98)
}
]Script example:
cam.post_processing.add(
"grade",
PostProcessEffect::ColorGrade {
exposure: 0.25,
contrast: 1.12,
brightness: 0.0,
saturation: 1.08,
gamma: 1.0,
temperature: 0.08,
tint: 0.0,
hue_shift: 0.0,
vibrance: 0.2,
lift: [-0.01, -0.01, 0.0],
gain: [1.04, 1.02, 0.98],
offset: [0.0, 0.0, 0.0],
},
);Frame color stays in Rgba16Float through scene rendering, color grade, LUT, and bloom when the
adapter supports required texture usages. Limited web adapters use a linear 8-bit fallback. Bloom
extracts and composites scene-referred light before one dedicated final pass converts the frame
to the surface. SDR output clamps the ACES curve to 0..1. Native extended-linear HDR output
uses display headroom and keeps smooth values above 1.0 for brighter-than-white highlights.
Material shaders do not tonemap their own output.
Real display HDR defaults to auto. Use hdr_set!(ctx.res, HdrMode::Off | Auto | On) and query
hdr_status!(ctx.res) to distinguish requested, supported, and active state. Unsupported requests
fall back to SDR instead of failing surface setup. See Display HDR.
Without an exposure effect, final exposure stays fixed at 0.0 EV. Manual override:
post_processing = [
{ type = "exposure", auto_exposure = false, exposure = 1.0 }
]Auto exposure uses average log luminance, EV clamps, and time-based adaptation:
post_processing = [
{
type = "exposure",
auto_exposure = true,
exposure = 0.0,
min_exposure = -4.0,
max_exposure = 4.0,
speed_up = 3.0,
speed_down = 1.0,
target_luminance = 0.18
},
{ type = "bloom", strength = 0.7, threshold = 1.0, radius = 1.5 }
]exposure: EV compensation in auto mode; fixed EV in manual mode.speed_up: adaptation rate when exposure rises for a dark scene.speed_down: adaptation rate when exposure falls for a bright scene.target_luminance: scene key value;0.18is middle gray.- Camera config applies first; last global
exposureentry overrides it. - Platforms without compute/storage support fall back to fixed
exposureEV.
Script config:
cam.post_processing.add(
"exposure",
PostProcessEffect::Exposure {
exposure: 0.0,
auto_exposure: true,
min_exposure: -4.0,
max_exposure: 4.0,
speed_up: 3.0,
speed_down: 1.0,
target_luminance: 0.18,
},
);LUT textures use normal texture asset paths. In static builds, res:// LUT images are packed
through the same static texture pipeline as sprites and materials.
lut2d expects a flattened 2D LUT image:
- horizontal layout: width =
N * N, height =N - red = x within one tile
- green = y
- blue = tile index across x
lut3d accepts the same horizontal layout and uploads it as a GPU 3D texture. It also accepts a
vertical layout: width = N, height = N * N.
If lut_size is omitted or 0, the renderer infers it from image dimensions when possible. Use
lut_size when dimensions are ambiguous.
Scene examples:
post_processing = [
{ type = "lut2d", texture = "res://luts/film_32.png", lut_size = 32, strength = 0.75 },
{ type = "lut3d", texture = "res://luts/print_32.png", lut_size = 32, strength = 1.0 }
]Script examples:
let film_lut = ResPath::new("res://luts/film_32.png");
let print_lut = ResPath::new("res://luts/print_32.png");
cam.post_processing.add(
"film_lut",
PostProcessEffect::Lut2D {
texture_path: film_lut.as_str().into(),
size: 32,
strength: 0.75,
},
);
cam.post_processing.add(
"print_lut",
PostProcessEffect::Lut3D {
texture_path: print_lut.as_str().into(),
size: 32,
strength: 1.0,
},
);Custom post effects mirror the custom material workflow and are defined by a .wgsl file. Your
shader must implement:
fn post_process(uv: vec2<f32>, color: vec4<f32>, depth: f32) -> vec4<f32>
The engine provides a prelude with:
input_tex+input_sampler(color)depth_tex(depth, if your shader uses it)lut_2d_tex/lut_3d_tex(available for custom shaders, normally unused)postuniform (resolution, inv_resolution, near, far, projection_mode, time)custom_params(optionalvec4<f32>array fromparams)
Use type = "custom" with shader = "res://path/to/shader.wgsl" in scenes, or
PostProcessEffect::Custom { shader_path, params } in code. params is a Vec<CustomPostParam>.
Performance notes:
- Post uniforms are written with per-pass dynamic offsets (aligned uniform slots), so each pass reads its own params without uniform overwrite hazards.
- LUT textures are cached by path and size after first use.
This example does a simple edge highlight using color differences and a user-controlled strength
(params[0].x).
fn post_process(uv: vec2<f32>, color: vec4<f32>, depth: f32) -> vec4<f32> {
let strength = max(custom_params[0].x, 0.0);
let off = post.inv_resolution;
let c0 = textureSample(input_tex, input_sampler, uv + vec2<f32>(off.x, 0.0)).rgb;
let c1 = textureSample(input_tex, input_sampler, uv + vec2<f32>(-off.x, 0.0)).rgb;
let c2 = textureSample(input_tex, input_sampler, uv + vec2<f32>(0.0, off.y)).rgb;
let c3 = textureSample(input_tex, input_sampler, uv + vec2<f32>(0.0, -off.y)).rgb;
let edge = length(c0 - c1) + length(c2 - c3);
return vec4<f32>(color.rgb + edge * strength, color.a);
}