Post-processing
The look of a 3D scene as one object, scene.postProcessing: tone mapping, colour grading, a LUT,
bloom, vignette, ambient occlusion, screen-space reflections and depth of field (what Unity's and
Unreal's post-process volumes hold). Set it at construction (SceneOptions.postProcessing), change
it live afterwards. The scene's own light (ibl, environmentIntensity, exposureCompensation,
fog) and render quality (antialias, renderScale) stay on Scene.
- make the scene look warmer, colder, punchier or more washed out
- apply a colour LUT exported from DaVinci Resolve or Photoshop
- make bright lights and emissive surfaces glow
- darken the edges of the screen, a damage pulse
- add contact shadows in creases and under objects
- blur the background and keep a character in focus
- reflect the lamps in a wet, glossy floor
- keep bright colours from washing out to white
Platforms: iOS, Android, desktop, the web player; on the web (Web Lite) and in headless tests every field is accepted and does nothing.
At a glance
const scene = new Scene({
skybox: '#3a78d8',
postProcessing: {
toneMapping: 'neutral',
whiteBalance: { temperature: 0.15 },
contrast: 1.15,
saturation: 1.1,
lut: asset('./look.cube'),
bloom: { intensity: 0.3 },
vignette: { midPoint: 0.4, feather: 0.6 },
ambientOcclusion: { radius: 0.6 },
depthOfField: { focusDistance: 4, aperture: 2, strength: 3 },
},
})
scene.add(Light.sun(), Mesh.box({ material: Material.lit({ color: '#ff5555' }) }))
scene.open()
scene.postProcessing.saturation = 0.6 // one field, live
scene.postProcessing.apply({ toneMapping: 'filmic', bloom: { intensity: 0.1 } }) // several: the named ones only
scene.postProcessing.depthOfField = { focusDistance: 2 } // a focus pull: write it every frame
scene.postProcessing.reset() // back to the engine's own look, every effect offChange the look at runtime: field writes, apply, reset
scene.postProcessing // the object (read-only property: assigning to it throws)
scene.postProcessing.<field> = value // one field
scene.postProcessing.apply(options): this // the fields named in `options`, nothing else
scene.postProcessing.reset(): this // every field to its default
scene.postProcessing.<field> // the current value (a feature that is off reads `false`)applyis a merge at every level.apply({ bloom: { intensity: 0.4 } })changes one bloom knob and keeps the others;apply({ whiteBalance: { tint: 0.1 } })keeps the temperature. A field left out is left alone.SceneOptions.postProcessingis the sameapplyover the defaults.- A feature is
true,falseor an object.trueturns it on with its defaults (or the knobs it already had);falseturns it off; an object turns it on with those knobs over the current ones. Reading a feature gives its full knob set, orfalse. - There is nothing to assign.
scene.postProcessing = { ... }throws — a whole-object write would have to decide what happens to the fields it does not name, and the two readings ("reset them", "keep them") are both reasonable.applykeeps,resetresets, and you say which.
Which effects are cheap to animate: grading vs live band
The engine treats the fields in two ways, and it decides what you may animate every frame:
- The grading band —
toneMapping,exposure,whiteBalance,contrast,saturation,vibrance,shadows/midtones/highlights/ranges,slope/offset/power,curves,lut— is baked into ONE 3D lookup table that the frame samples anyway. Per frame it costs nothing; a change re-bakes a 32³ table on the CPU, a few milliseconds. So field writes are coalesced: writing three fields in one handler bakes once, at the end of the handler, and the values are quantised to 1/1000 so a tween that barely moves bakes nothing. A blend between two looks over a second (a few dozen bakes) is fine; a grading field driven every frame for a minute is not what this is for. - The live band —
bloom,vignette,ambientOcclusion,screenSpaceReflections,depthOfField— is a set of view options: written to the engine at once, free to animate.
apply sends the grading band immediately (one bake), not at the end of the handler.
Tone mapping and exposure: toneMapping, exposure
toneMapping?: 'aces' | 'neutral' | 'linear' | 'filmic' // default 'aces'
exposure?: number // post-exposure, stops; default 0The scene is lit in physical units (a sun of 100 000 lux, a lamp of 800 lumens). The tone mapping operator is what folds that range into the display's: everything before it works with light, everything after it with the pixels you see.
'aces'— filament's ACES legacy, the default. The film look: bright colours desaturate towards white, so HDR fire reads pale.'neutral'— Khronos PBR Neutral. Keeps hue and saturation until very bright; the choice when colours must stay what the material says (product shots, flat-colour art).'linear'— clips each channel. The look of an engine without a tonemapper: saturated, and everything above white is lost.'filmic'— the Uncharted 2 curve.
There are two exposures, and they do different things. The camera's
exposureCompensation scales the light — bloom, fog and the sun's
highlight all see it — and is the one to expose a dark interior with. postProcessing.exposure
scales the finished picture, after bloom: a fade to white, a flash, a day-to-dusk grade.
Colour grading: warmer, colder, contrast, saturation
whiteBalance?: { temperature?: number; tint?: number } // -1..1 each; default 0
contrast?: number // 0..2; default 1
saturation?: number // 0..2; default 1
vibrance?: number // 0..2; default 1
shadows?: [r, g, b]; midtones?: [r, g, b]; highlights?: [r, g, b] // default [1, 1, 1]
ranges?: { shadows?: [start, end]; highlights?: [start, end] } // default [0, 0.333], [0.55, 1]
slope?: [r, g, b]; offset?: [r, g, b]; power?: [r, g, b] // default [1,1,1], [0,0,0], [1,1,1]
curves?: { shadowGamma?: [r, g, b]; midPoint?: [r, g, b]; highlightScale?: [r, g, b] } // default [1, 1, 1] eachThe adjustments of a grading suite, applied in this order before the operator:
whiteBalance—temperatureon the blue / yellow axis (-1 is 50 000 K, cool; 1 is 2 000 K, warm),tinton the green / magenta axis.shadows/midtones/highlights— a linear RGB multiplier per tonal zone:[1, 1, 1.05]on the shadows and[1.05, 1, 0.95]on the highlights is the classic cool-shadows / warm-lights split.rangessays where the zones fade into each other, in 0..1 of the luminance.slope/offset/power— the ASC CDL, which is the lift / gamma / gain of a grading tool:slopemultiplies (gain),offsetadds (lift),poweris the exponent (gamma). In log space.contrast— in log space, around middle grey.vibrance, thensaturation— vibrance spares what is already saturated, saturation does not.curves— a per-channel curve in three numbers: a gamma on the shadows, the point where the shadows stop, a scale on the highlights.
Every default is the identity, so a scene that sets none of them is the engine's own look and costs no table at all.
Apply a LUT from a grading tool: lut
lut?: string | null // asset('./look.cube'), or a bare staged filename; null removes itA 3D LUT exported by DaVinci Resolve, Photoshop or any tool that writes the .cube format
(LUT_3D_SIZE n, then n³ lines of r g b). It is applied last, after the operator, in
display-referred sRGB — the same convention as Unity's Color Lookup, so a LUT built against a
screenshot of your scene applies as designed. DOMAIN_MIN / DOMAIN_MAX are honoured; a 1D LUT
(LUT_1D_SIZE) is rejected, and a file the engine cannot read logs [grading] … and leaves the
look as it was. The file is parsed once; swapping it is a bake like any other grading change.
Make bright lights glow: bloom
Bright lights, emissive surfaces and sunlit highlights glow past their edges.
bloom?: boolean | {
intensity?: number // how much of the blurred highlights is added back, 0..1; default 0.2
threshold?: boolean // only what is brighter than 1.0 after exposure blooms; default true
highlight?: number // clamp of one pixel's contribution; default 1000
levels?: number // depth of the blur chain, 1..12: how far the glow spreads; default 6
quality?: 'low' | 'medium' | 'high' | 'ultra' // the blur's sample count; default 'medium'
lensFlare?: boolean // ghosts + halo around the brightest spots; default false
dirt?: Texture | null // a "dirty lens": this texture multiplied into the glow; default none
dirtStrength?: number // 0..1; default 0.2
}Bloom reads the HDR picture before tone mapping, which is why it knows that the sun is a thousand
times brighter than a lamp. threshold: false lets everything glow a little — the soft, dreamy
look; highlight is the fix when a few emissive pixels flicker; dirt is the smudged-lens look of
a Texture.load(...).
Darken the screen edges: vignette
vignette?: boolean | {
midPoint?: number // how far from the centre the darkening starts, 0..1; default 0.5
roundness?: number // 0 follows the aspect ratio, 1 is a circle; default 0.5
feather?: number // softness of the edge, 0..1; default 0.5
color?: ColorInput // the colour at the edge; default black
}A uniform of the grading pass, outside the table: free to animate (a heartbeat, a damage pulse).
Contact shadows in creases: ambientOcclusion
ambientOcclusion?: boolean | {
intensity?: number // strength of the darkening; default 1
radius?: number // how far the occlusion reaches, metres; default 0.3
power?: number // falloff contrast; > 1 tightens it into the crease; default 1
quality?: 'low' | 'medium' | 'high' | 'ultra' // sample count; default 'medium'
}The contact darkening in creases and where props meet the ground. Without it an image-based light
lights a crease exactly as brightly as an open face, so everything reads as pasted onto the floor
rather than standing on it. radius is in world metres and is the one knob that must follow the
scene's scale: ~0.3 for objects on a table, ~0.6–1 for a yard of crates and containers. It earns
its place in interiors and dense prop clusters; on an open sunlit yard it is marginal (it only
attenuates the ambient term, and sunlight is not ambient).
Reflections on a glossy floor: screenSpaceReflections {#screen-space-reflections}
screenSpaceReflections?: boolean | {
maxDistance?: number // metres a ray travels before giving up; default 3
thickness?: number // metres a surface is assumed to extend behind its depth; default 0.1
stride?: number // pixels per ray step: 1 exact and slow, 2..4 the usual; default 2
}Glossy surfaces reflect what is on screen, by a ray march through the depth buffer — a wet floor
reflecting the lamps above it. The limits are the classic ones: nothing off screen reflects, a
reflection ends at the frame's edge, and a low-roughness surface shows the ray's grain (a larger
stride is cheaper and grainier). Where a ray misses, the image-based reflection stays.
Which materials reflect. The engine traces reflections only for materials whose definition says
reflections : screenspace(a Filament material property; the default isdefault= image-based reflections only). The built-in materials (Material.lit()and friends) do not say it yet, so with them the option lands on the view and nothing reflects. An app's own shader (Material.load) can: add the line to its.mat, and that material reflects while the option is on, and falls back to the image-based reflection while it is off. A per-pixel ray is a real cost on a phone, not measured yet.
Blur the background, focus on a subject: depthOfField
depthOfField?: boolean | {
focusDistance?: number // metres from the camera to the plane in focus; default 10
aperture?: number // the f-number the blur should look like; default 2.8, smaller = shallower
strength?: number // multiplier on the blur; default 1 = the physics of the camera's lens
}The camera focuses at focusDistance; everything in front of and behind that plane blurs as a
lens would, bokeh keeping its light (the pass runs on the HDR picture). aperture is the f-number
the blur should look like: the engine scales the blur instead of opening the camera's real
aperture, so the exposure does not move.
The physics are honest and therefore mild. A game camera is a wide lens — a 60° field of view is a
21 mm lens — and a wide lens blurs little: at f/1.4 and a focus of 2.5 m, a prop 9 m away blurs by
about 2 px of a 600 px frame. strength is the artistic multiplier for the look a 50 mm lens would
give; 3–4 at fov 60° reads as cinematic. All three are live: a focus pull is focusDistance
written every frame, from the distance to whatever the camera is looking at.
class FocusOnTarget extends System<'focus', Scene> {
target!: Node
update() {
const d = this.target.position.sub(this.scene.camera.position).length()
this.scene.postProcessing.depthOfField = { focusDistance: d }
}
}
scene.system(FocusOnTarget, { target: hero })Overlay scenes, HDR displays, the web
- An overlay scene (
scene.createOverlay()) takes its parent's grading — one look per picture; its bloom, vignette and the rest of the live band are its own. - The web player runs the full engine: every effect works there (ambient occlusion is the engine's SAO). Web Lite draws on the lite 3D engine, which has no post-processing stack: every field is accepted, reads back, and changes nothing on screen.
- On an HDR display the operator is stretched to the screen's headroom; that is the host's side,
configured by
SceneOptions.hdr, not a field here.