Post-Process: Bloom Intensity Stack
A bright emissive sphere lit by a back-rim spot, sitting in front of the
live camera feed. A PostProcess component on the Post-Effect-group
Camera carries a single Bloom sub-effect. Tap anywhere to step through
five intensity presets — Off, Subtle, Medium, Strong,
and Cinematic — each modifies bloom.intensity and bloom.threshold
to push the glow from invisible to almost full whiteout. A sin-wave
pulse modulates the active intensity each frame so the live preview
breathes between taps.

The canonical scene structure
The single most important thing to know about Effect House post-processing:
PostProcess is composited only when its host Camera lives in the
dedicated Post Effect render group. Two Cameras work together:
| Render group | Camera | Job |
|---|---|---|
| General | Default scene Camera | Renders the 3D scene + the live camera feed onto the Final Render Output texture. |
| Post Effect | Bloom-spawned Camera | Reads the Final Render Output, applies the configured PostProcess chain, writes the result back to the screen. |
Effect House sets this split up automatically when you create any of the
post-process built-in objects (add_builtin_object("Bloom"),
add_builtin_object("Vignette"), etc.) — the General-group Camera is the
default that already exists in every project, and the new built-in
becomes the Post-Effect-group Camera. Don't try to wire a single Camera
with PostProcess as a component in the General group — the script-side
API works (you can flip pp.bloom.enabled = true) but the engine never
composites the pass onto the screen.
One sub-effect per builtin — the postProcessList constraint
The PostProcess component exposes typed accessors for every effect
(pp.bloom, pp.vignette, pp.chromaticAberration, pp.distort,
pp.bokehBlur, pp.grain, pp.lensFlare, pp.motionBlur,
pp.fxaa, pp.custom). At runtime each accessor is either a live
instance or null — null means the engine never instantiated that
sub-effect, and any property assignment silently no-ops.
The postProcessList: [...] array on PostProcess looks like the
control point for which accessors are non-null, but in practice only
the editor-time values determined when the SceneObject was created via
add_builtin_object("<EffectName>") produce live accessors. Mutating
postProcessList after creation does not retro-instantiate the new
sub-effects — pp.vignette stays null even after you append "Vignette"
to the list.
The practical consequence: one PostProcess builtin per effect.
This tutorial covers the Bloom flow; replace the builtin name to demo
any other effect. The "Customize" section below lists all 8 supported
builtins and their typed accessor + property surface.
What you'll build
- The default Camera in the General render group at
(0, 0, 14)— renders the live camera feed plus the 3D content below. - A BrightSubject sphere at the origin, scale
0.5, wearing a Standard PBR material with strong emissive (orange,_EmissiveIntensity = 1.5). The bright pixels above the bloom threshold drive the glow. - A RimLight (
SpotLight) at(0, 5, -6)reversed — adds a silhouette pop. - A PostProcessCamera SceneObject in the Post Effect render group,
carrying
Camera+PostProcess+FilterStackscript. Created viaadd_builtin_object("Bloom"), which guaranteespp.bloomis alive. - A
TitleTextandStatusText2D HUD that mirrors the active state (e.g.,3 of 5 / Medium). - The
FilterStackscript attached directly to the PostProcessCamera SceneObject — same SceneObject as thePostProcesscomponent, so a singlegetSceneObject().getComponent("PostProcess")resolves the chain.
Open the demo
Unzip and open in Effect House (5.9.0+). The opening scene contains:
- Camera (General group) — the scene Camera at
z = 14. - BrightSubject — the orange emissive sphere.
- Directional Light (auto-created), Environment Light, RimLight — a basic 3-light rig.
- 2D Camera — auto-created by the first 2D Text.
- TitleText + StatusText — the HUD.
- PostProcessCamera (Post Effect group) — the Bloom-spawned Camera +
PostProcess SceneObject. Hosts the
FilterStackscript.
Read the script
FilterStack.ts
@component()
export class FilterStack extends APJS.BasicScriptComponent {
// The status label that names the active state. Wired in the inspector.
@serializeProperty statusText!: APJS.SceneObject;
// Pulse rate of bloom intensity oscillation, cycles per second.
@serializeProperty pulseHz: number = 0.4;
private pp!: APJS.PostProcess;
private bloom!: APJS.Bloom;
private statusComp!: APJS.Text;
private touchCallback!: (e: APJS.IEvent) => void;
private cycleIndex: number = -1;
private elapsed: number = 0;
private inited: boolean = false;
// Five intensity preset states. Bloom is the only effect on this
// PostProcess (postProcessList: ["Bloom"]) because that's the configuration
// the canonical add_builtin_object("Bloom") flow guarantees — pp.bloom is
// alive and addressable. Mutating postProcessList via DSL after creation
// does NOT instantiate additional sub-effects; pp.vignette / pp.distort /
// etc. stay null. To demo a different effect, swap the builtin to
// add_builtin_object("Vignette") (or any of the 8 supported types) and
// address that effect via pp.vignette / pp.distort / etc.
private static readonly STATES: { name: string; intensity: number; threshold: number }[] = [
{ name: "Off", intensity: 0, threshold: 1.0 },
{ name: "Subtle", intensity: 1.5, threshold: 0.8 },
{ name: "Medium", intensity: 5, threshold: 0.4 },
{ name: "Strong", intensity: 10, threshold: 0.2 },
{ name: "Cinematic", intensity: 18, threshold: 0.05 },
];
// Pulse amplitude (added to base intensity each frame). Set to 0 for
// static comparison screenshots; ~30% of base intensity for a breathing
// effect.
private static readonly PULSE_AMP_FACTOR = 0.3;
onUpdate(dt: number): void {
if (!this.inited) {
// PostProcess and Bloom both live on the same SceneObject as this
// script (the Bloom-spawned camera in the Post Effect render group).
this.pp = this.getSceneObject().getComponent("PostProcess") as APJS.PostProcess;
if (!this.pp) return;
if (!this.statusText) return;
this.bloom = this.pp.bloom as APJS.Bloom;
if (!this.bloom) {
console.log("[FilterStack] pp.bloom is null — PostProcess sub-effect not instantiated. " +
"Confirm this SceneObject was created via add_builtin_object('Bloom') in a fresh project, " +
"and that postProcessList was NOT subsequently mutated.");
return;
}
this.statusComp = this.statusText.getComponent("Text") as APJS.Text;
// Tune the always-on bloom params once. STATES drives intensity + threshold per tap.
this.bloom.diffuse = 6.0;
this.bloom.softKnee = 0.5;
this.bloom.color = new APJS.Color(1.0, 0.9, 0.7, 1.0);
this.bloom.fastMode = true;
this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
this.advance();
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.Touch, this.touchCallback, this);
this.advance(); // start at index 0 (Off)
this.inited = true;
console.log("[FilterStack] ready — " + FilterStack.STATES.length + " intensity presets wired");
return;
}
// Pulse the bloom intensity around the active state's base value so the
// live preview animates between taps.
if (this.bloom.enabled) {
this.elapsed += dt;
const phase = Math.sin(this.elapsed * this.pulseHz * 2 * Math.PI);
const base = FilterStack.STATES[this.cycleIndex].intensity;
const amp = base * FilterStack.PULSE_AMP_FACTOR;
this.bloom.intensity = base + amp * phase;
}
}
onDestroy(): void {
if (this.touchCallback) {
APJS.EventManager.getGlobalEmitter()
.off(APJS.EventType.Touch, this.touchCallback, this);
}
}
private advance(): void {
this.cycleIndex = (this.cycleIndex + 1) % FilterStack.STATES.length;
const s = FilterStack.STATES[this.cycleIndex];
// Drive the bloom directly. State 0 ("Off") sets enabled=false; every
// other state turns it on with a different intensity + threshold.
if (s.intensity > 0) {
this.bloom.enabled = true;
this.bloom.intensity = s.intensity;
this.bloom.threshold = s.threshold;
} else {
this.bloom.enabled = false;
}
this.statusComp.text = (this.cycleIndex + 1) + " of " + FilterStack.STATES.length + "\n" + s.name;
console.log("[FilterStack] state " + s.name + " — intensity=" + s.intensity + " threshold=" + s.threshold);
}
}
State table
| Index | Name | bloom.intensity | bloom.threshold | Look |
|---|---|---|---|---|
| 0 | Off | — (enabled = false) | — | Clean baseline. |
| 1 | Subtle | 1.5 | 0.8 | Faint glow on the brightest highlights only. |
| 2 | Medium | 5 | 0.4 | Soft halo on the sphere; mid-tones lift. |
| 3 | Strong | 10 | 0.2 | Aggressive glow; background washes warm. |
| 4 | Cinematic | 18 | 0.05 | Near-whiteout; everything above 5% brightness blooms. |
Each row reads the same physical scene — only bloom.intensity and
bloom.threshold differ, plus the .enabled toggle for state 0.
The Post-Process API surface
PostProcess is a component that holds an ordered chain of PostEffect
sub-objects. At runtime each instantiated effect is exposed via a typed
accessor on the component — pp.bloom, pp.vignette,
pp.chromaticAberration, pp.distort, pp.bokehBlur, pp.grain,
pp.lensFlare, pp.motionBlur, plus pp.fxaa and pp.custom. Each
accessor is null until you author the SceneObject via the matching
builtin (add_builtin_object("Vignette"), etc.).
Per-effect runtime properties:
| Effect | add_builtin_object name | Properties |
|---|---|---|
Bloom (this demo) | "Bloom" | intensity (1–25 range typical), threshold (0–1, pixels brighter glow), softKnee (smoothness around threshold), diffuse (blur radius of the glow), clamp (max bloom brightness), color (tint), anamorphicRatio (horiz/vert stretch), fastMode |
Vignette | "Vignette" | power (radial darkness; ~1.2–2.0 reads), contrast (sharpness of falloff between center and edge) |
ChromaticAberration | "Chromatic Aberration" | intensity (RGB-channel shift; ~1–3 visible). fastMode and spectralLUT are deprecated |
Distort | "Distort" | barrelPower (negative = fisheye, positive = pincushion; ±0.2–0.5), rotation (radians), zoom, amplitude (Vector2f wave amplitude), frequency (Vector2f wave frequency), speed (Vector2f animation), offset (Vector2f phase shift) |
BokehBlur | "Bokeh Blur" | size (kernel radius), iterations (more = smoother, more expensive), shape (BokehBlurShapeType.Circle / Hexagon), downsample (perf trade), fastCircle (cheap circle approximation) |
Grain | "Grain" | strength (overall noise amount), color (0 = monochrome, 1 = colored grain), speed (animation rate) |
LensFlare | "Lens Flare" | intensity, position (Vector2f in normalized 0–1 screen coordinates; (0.7, 0.3) puts it upper-right) |
MotionBlur | "Motion Blur" | intensity (frame-accumulation strength; ~0.5–0.9) |
PostEffect.enabled is the universal on/off — properties stay set when
you flip it off and back on, so you only need to write tuning code once
during init.
Sample tunings (from the upstream API examples)
Drop-in starting values for each effect, lifted from the source examples:
// Bloom (this demo)
bloom.threshold = 0.2;
bloom.intensity = 1.5;
bloom.diffuse = 4.5;
bloom.softKnee = 0.5;
bloom.color = new APJS.Color(1.0, 0.8, 0.6, 1.0);
bloom.fastMode = true;
// Vignette
vignette.power = 1.2;
vignette.contrast = 1.5;
// ChromaticAberration
chromaticAberration.intensity = 3;
chromaticAberration.fastMode = false;
// Distort
distort.barrelPower = -0.2;
distort.zoom = 0.2;
distort.amplitude = new APJS.Vector2f(0.1, 0.05);
distort.frequency = new APJS.Vector2f(8.0, 4.0);
distort.speed = new APJS.Vector2f(0.5, 0.0);
// BokehBlur
bokehBlur.size = 5.0;
bokehBlur.iterations = 3;
bokehBlur.shape = APJS.BokehBlurShapeType.Hexagon;
bokehBlur.downsample = 2;
// Grain
grain.strength = 0.5;
grain.color = 1.0;
grain.speed = 3.0;
// LensFlare
lensFlare.intensity = 0.8;
lensFlare.position = new APJS.Vector2f(0.7, 0.3);
// MotionBlur
motionBlur.intensity = 0.9;
Customize
On the PostProcessCamera SceneObject's FilterStack component:
statusText— any 2D Text SceneObject; the script looks up itsTextcomponent at runtime.pulseHz— set to0to freeze the active intensity at its base (good for static comparison screenshots), or push to2+for a rapid strobe.STATES— astatic readonlyarray at the top of the script. Add or remove rows; modulo-wrap kicks in automatically.- The constants — re-tune per scene. The defaults are shaped for a bright orange emissive subject; for a darker scene drop the threshold values toward 0.
Swapping in a different effect
To convert this demo into a Vignette / Distort / Grain / Lens Flare / etc. demo:
Rebuild the PostProcessCamera with the matching builtin:
add_builtin_object(object_type="Vignette") // or "Distort", "Grain", ...This replaces the Bloom-spawned camera with a Vignette-spawned one.
pp.vignettewill be alive;pp.bloomwill be null.Update the script to read the new sub-effect:
private vignette!: APJS.Vignette;
// ...
this.vignette = this.pp.vignette as APJS.Vignette;
if (!this.vignette) { /* warn */ return; }Tune the new effect's properties — see the sample tunings above for starting values.
Suggestions for further play
- Replace the sin-wave pulse with a
Tweenanimation onbloom.intensity— see the TweenAnimation reference and TweenEasingType reference. ABounceease into peak intensity makes the glow snap on punchily; anEaseInOutSinereads softer. - Bind cycle-up and cycle-down to two screen halves: tap top half to
intensify, tap bottom half to relax. See the
Events & Input tutorial for hit-test
patterns via
TouchUtils.isScreenPointOnImage. - Use the
Custompost-effect (add_builtin_object("Custom")) to plug in your own shader Material — combine it with aRender Textureto feed a custom pass that draws over only part of the screen.
What you learned
This tutorial used:
- The General + Post Effect render-group split — the canonical EH scene structure for any post-process work. The Post Effect Camera is a compositor, not a scene Camera.
add_builtin_object("<EffectName>")— the only construction path that produces a livepp.<effectName>accessor. MutatingpostProcessListafter the fact does not retro-instantiate sub-effects.Bloom— the eight tunable properties (intensity,threshold,softKnee,diffuse,color,clamp,fastMode,anamorphicRatio).PostEffect.enabled— the universal runtime on/off toggle, no chain rebuild required.- Per-frame property mutation — sin-wave pulse on
bloom.intensity, demonstrating the runtime mutation pattern that generalizes to anyPostEffectproperty. @serializeProperty SceneObjectfor the status text — wired in the inspector, looked up to a runtimeTextreference in lazy-init.- Script colocated with
PostProcesson the same SceneObject — a singlegetSceneObject().getComponent("PostProcess")resolves the chain. No cross-object reference field needed.
Read the full PostProcess reference, the Bloom reference, the Vignette reference, the ChromaticAberration reference, the Distort reference, the BokehBlur reference, the Grain reference, the LensFlare reference, the MotionBlur reference, the PostEffect base reference, and the Post-Process namespace overview.
For the touch-event subscription pattern, see the
Events & Input tutorial. For
swapping the sin-wave pulse with a proper Tween animation, see
the TweenAnimation reference.