Skip to main content

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.

Post-Process Filter Stack demo running in Effect House preview

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 groupCameraJob
GeneralDefault scene CameraRenders the 3D scene + the live camera feed onto the Final Render Output texture.
Post EffectBloom-spawned CameraReads 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 + FilterStack script. Created via add_builtin_object("Bloom"), which guarantees pp.bloom is alive.
  • A TitleText and StatusText 2D HUD that mirrors the active state (e.g., 3 of 5 / Medium).
  • The FilterStack script attached directly to the PostProcessCamera SceneObject — same SceneObject as the PostProcess component, so a single getSceneObject().getComponent("PostProcess") resolves the chain.

Open the demo

↓ filter-stack.zip

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 FilterStack script.

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

IndexNamebloom.intensitybloom.thresholdLook
0Off— (enabled = false)Clean baseline.
1Subtle1.50.8Faint glow on the brightest highlights only.
2Medium50.4Soft halo on the sphere; mid-tones lift.
3Strong100.2Aggressive glow; background washes warm.
4Cinematic180.05Near-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:

Effectadd_builtin_object nameProperties
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 its Text component at runtime.
  • pulseHz — set to 0 to freeze the active intensity at its base (good for static comparison screenshots), or push to 2+ for a rapid strobe.
  • STATES — a static readonly array 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:

  1. 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.vignette will be alive; pp.bloom will be null.

  2. 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; }
  3. 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 Tween animation on bloom.intensity — see the TweenAnimation reference and TweenEasingType reference. A Bounce ease into peak intensity makes the glow snap on punchily; an EaseInOutSine reads 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 Custom post-effect (add_builtin_object("Custom")) to plug in your own shader Material — combine it with a Render Texture to 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 live pp.<effectName> accessor. Mutating postProcessList after 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 any PostEffect property.
  • @serializeProperty SceneObject for the status text — wired in the inspector, looked up to a runtime Text reference in lazy-init.
  • Script colocated with PostProcess on the same SceneObject — a single getSceneObject().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.

Copyright © 2026 TikTok. All rights reserved.
About TikTokHelp CenterCareersContactLegalTerms of ServicePrivacy PolicyCookies