Lighting: Three-Point Lighting
A single 3D subject lit from three directions — a DirectionalLight key,
a PointLight fill, and a SpotLight rim — plus an EnvironmentLight
for the global ambient wash. Tap anywhere to cycle through four
preset combinations: Key only, Classic 3-Point, Soft Point fill,
and Rim only. Each setup flips the per-light SceneObjects on/off via
setEnabledInHierarchy(...); the lights themselves are never deleted or
recreated.

What you'll build
- Three custom lights placed around a sphere subject:
- KeyLight —
DirectionalLightat(8, 6, 8)aimed back at the subject withlocalEulerAngles = (-25, 35, 0). The dominant front-right key. - FillLight —
PointLightat(-6, 1, 4). Soft, omnidirectional spill from the camera-left side that lifts the shadow. - BackLight —
SpotLightat(0, 4, -6)withlocalEulerAngles = (30, 180, 0). A focused rim from behind that separates the subject from the background.
- KeyLight —
- An EnvironmentLight kept on across all setups for a baseline ambient wash — without it, "Rim only" reads as nearly black.
- The auto-created default
Directional Lightis hidden (setEnabledInHierarchy(false)) so the demo's lights are the only contributors. - A
TitleTextandStatusText2D HUD that mirrors the active setup. - A
GameControllerempty SceneObject hostingLightingDirector— the script that subscribes to globalEventType.Touchand toggles the right pattern on each tap.
Open the demo
Unzip and open in Effect House (5.9.0+). The opening scene contains:
- Camera — default 3D perspective camera at
(0, 0, 14)looking at the world origin. - Subject — a
Sphereat the origin withlocalScale = (0.4, 0.4, 0.4)so it reads at a comfortable size against the default phone framing. - Directional Light (auto-created) — disabled in the inspector
(
enabled: false). We don't delete it; we just leave it dormant. - Environment Light — enabled, contributes the soft global ambient baseline.
- KeyLight / FillLight / BackLight — the three demo lights, each
with its own
Transformand matching light component. - TitleText — "Lighting: 3-Point Demo" at the top.
- StatusText —
<n> of 4plus the setup name, near the bottom. - GameController — empty SceneObject hosting
LightingDirector.
Read the script
LightingDirector.ts
@component()
export class LightingDirector extends APJS.BasicScriptComponent {
// The three custom lights placed around the subject. We drive each by
// toggling its SceneObject's enabled state — this is the simplest way to
// turn a scene light "off" at runtime.
@serializeProperty keyLight!: APJS.SceneObject; // DirectionalLight, front-right
@serializeProperty fillLight!: APJS.SceneObject; // PointLight, soft fill from left
@serializeProperty backLight!: APJS.SceneObject; // SpotLight, rim from behind
// The status label that mirrors the current setup name.
@serializeProperty statusText!: APJS.SceneObject;
// Setup index. We start at 1 = the canonical "Classic 3-Point" so the
// initial preview reads as the recognizable reference.
@serializeProperty startSetup: number = 1;
private statusComp!: APJS.Text;
private touchCallback!: (e: APJS.IEvent) => void;
private cycleIndex: number = -1;
private inited: boolean = false;
// Each setup is a 3-bit pattern over [key, fill, back]: true = on, false = off.
// 0 — Key Only: dramatic single-source, hard shadows on the unlit side.
// 1 — Classic 3-Point: industry-standard portrait setup, key + soft fill + rim.
// 2 — Soft Point: no key, no rim — gentle round-the-side wash from the fill.
// 3 — Rim Only: only the spot from behind — a silhouette / hero rim look.
private static readonly SETUPS: { name: string; key: boolean; fill: boolean; back: boolean }[] = [
{ name: "Key only", key: true, fill: false, back: false },
{ name: "Classic 3-Point", key: true, fill: true, back: true },
{ name: "Soft Point fill", key: false, fill: true, back: false },
{ name: "Rim only", key: false, fill: false, back: true },
];
onUpdate(_dt: number): void {
if (this.inited) return;
if (!this.keyLight || !this.fillLight || !this.backLight || !this.statusText) return;
this.statusComp = this.statusText.getComponent("Text") as APJS.Text;
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);
// Apply the starting setup immediately so the preview reads correctly
// before the user taps anything.
this.cycleIndex = ((this.startSetup % LightingDirector.SETUPS.length) + LightingDirector.SETUPS.length) % LightingDirector.SETUPS.length - 1;
this.advance();
this.inited = true;
console.log("[LightingDirector] ready — starting setup " + this.cycleIndex);
}
onDestroy(): void {
if (this.touchCallback) {
APJS.EventManager.getGlobalEmitter()
.off(APJS.EventType.Touch, this.touchCallback, this);
}
}
private advance(): void {
this.cycleIndex = (this.cycleIndex + 1) % LightingDirector.SETUPS.length;
const s = LightingDirector.SETUPS[this.cycleIndex];
// setEnabledInHierarchy(false) hides the SceneObject AND every component
// it carries — in this case the DirectionalLight / PointLight / SpotLight
// each stop contributing to the lighting solution. Flipping it back on
// re-engages the light without any per-component setup.
this.keyLight.setEnabledInHierarchy(s.key);
this.fillLight.setEnabledInHierarchy(s.fill);
this.backLight.setEnabledInHierarchy(s.back);
this.statusComp.text = (this.cycleIndex + 1) + " of " + LightingDirector.SETUPS.length + "\n" + s.name;
console.log("[LightingDirector] setup " + s.name);
}
}
The Lighting-namespace calls of interest:
DirectionalLight— a parallel light source used for theKeyLight. Position is irrelevant; only the SceneObject's rotation (Transform.localEulerAngles) changes the direction the light points. Cheap, suitable for an outdoor "sun"-style key.PointLight— an omnidirectional light that emits in all directions fromTransform.localPosition. Used for the softFillLightbecause its falloff naturally produces a gentle round-the-side wash — no aiming required.SpotLight— a cone-shaped light that does have a direction. Used for theBackLightbecause we want the rim to land only on the back of the subject without spilling into the scene around it. BothlocalPositionandlocalEulerAnglesmatter here.EnvironmentLight— the global ambient wash. We keep it on in every setup so that "Rim only" still has enough fill to read the silhouette; turning it off too produces a near-black frame.SceneObject.setEnabledInHierarchy(true|false)— the runtime toggle. It hides the SceneObject and its components, so the attached light component stops contributing to the lighting solution as soon as the flag flips false. Re-enabling restores the light without any component re-creation.
Cycle table
| Index | Name | Key (Directional) | Fill (Point) | Back (Spot) |
|---|---|---|---|---|
| 0 | Key only | on | off | off |
| 1 | Classic 3-Point | on | on | on |
| 2 | Soft Point fill | off | on | off |
| 3 | Rim only | off | off | on |
Each row reads the same physical scene — only setEnabledInHierarchy
differs.
Customize
On GameController → LightingDirector:
keyLight/fillLight/backLight— drop in any light-bearing SceneObjects. The script doesn't care what type each light component is; toggling the SceneObject disables whatever Light subclass lives on it.startSetup— index 0–3 (matches the table above). The script decrements then re-runsadvance(), so the first frame already shows the chosen preset. Defaults to1("Classic 3-Point").SETUPS— astatic readonlytable at the top of the script. Add or remove rows to change the cycle; modulo-wrap kicks in automatically. Bigger arrays = longer cycle.
In the editor, on each light SceneObject:
Transform.localPosition— moves the light in world space. For point/spot, position is everything. For directional, only rotation matters.Transform.localEulerAngles— aims the directional and spot lights. The demo's key sits at(-25, 35, 0)(pitched down, yawed right toward the subject); the back-spot at(30, 180, 0)(slightly down, fully reversed).- Light component intensity / color / range — tune in the
inspector to push from a soft portrait look to a high-contrast
cinema look. Halving
EnvironmentLightintensity makes "Rim only" much more dramatic.
Suggestions for further play:
- Swap the cycle for an A/B comparison: bind
GestureType.Tapto preset 1 andGestureType.LongTapto preset 0 (see the Events & Input tutorial) so the user can hold to compare hard key vs. soft fill. - Animate
Transform.localEulerAnglesof the key light over time so the "sun" sweeps across the subject — combine withTween(see the Math tutorial for rotation patterns). - Layer the Lighting setup with Portrait Segmentation so the same three-point rig lights only the user's foreground silhouette, with a different background environment. Camera-feed-first rules apply.
What you learned
This tutorial used:
DirectionalLight— infinite parallel light, aimed by Transform rotation. Used for the dominant key.PointLight— omnidirectional, positional. Used for the soft fill.SpotLight— cone-shaped, position + rotation matter. Used for the rim.EnvironmentLight— global ambient wash. Kept on across all setups so the "Rim only" preset still has enough fill to read.SceneObject.setEnabledInHierarchy(true|false)— the runtime on/off toggle for whole light SceneObjects, including their components. The cheapest way to swap lighting at runtime.@serializeProperty SceneObject× 4 for the wired light slots- status text — wired in the inspector, mapped to a runtime
Textcomponent lookup in the lazy-initonUpdate.
- status text — wired in the inspector, mapped to a runtime
Read the full DirectionalLight reference, the PointLight reference, the SpotLight reference, the EnvironmentLight reference, the Light reference, and the Lighting namespace overview.
For the touch-event subscription pattern used here, see the Events & Input tutorial. For swapping in a per-tap gesture variant (long-press, drag-to-aim), see GestureType.