Skip to main content

Scene: Spawn and Fade Cubes

A staggered reveal that fans six pre-built cubes out into two cluster parents — LeftCluster and RightCluster — one cube every quarter-second. Each reveal flips setEnabledInHierarchy(true), reassigns SceneObject.parent to the chosen cluster, randomizes the cube's Transform.localPosition and localEulerAngles, and tweens the local scale up from zero. Tap anywhere to reset and replay with a fresh random configuration.

Spawn and Fade Cubes demo running in Effect House preview

What you'll build

  • A scene with a hidden CubeContainer parent holding six colored cubes, plus two empty cluster parents LeftCluster and RightCluster at (±1.5, 0, 0).
  • A CubeStaggeredSpawner script that runs on CubeContainer, holds the six cubes as a SceneObject[] @serializeProperty array, and reveals them on a stagger timer.
  • Each reveal exercises three core Scene APIs: SceneObject.parent (runtime reparenting), SceneObject.setEnabledInHierarchy (visibility), and SceneObject.getTransform() (component lookup wrapper) for localPosition and localEulerAngles mutation.
  • A global APJS.EventType.Touch listener resets every cube to its home parent and re-runs the stagger.

Open the demo

↓ spawn-fade-cubes.zip

Unzip and open in Effect House (5.9.0+). The opening scene contains:

  • Camera — at (0, 1, 8) with an 8° downward pitch.
  • Directional Light / Environment Light — defaults.
  • CubeContainer — empty SceneObject at the origin, parent of all six cubes at scene start. Hosts the CubeStaggeredSpawner script.
  • Cube_Red, Cube_Orange, Cube_Yellow, Cube_Green, Cube_Blue, Cube_Purple — six 0.8-unit cubes (scale 0.1 on a base-8 Cube primitive), each with its own Standard PBR material. All six start hidden so the script's setEnabledInHierarchy(true) reveal reads as a real animation.
  • LeftCluster — empty SceneObject at (-1.5, 0, 0). Receives some of the cubes via runtime reparenting.
  • RightCluster — empty SceneObject at (+1.5, 0, 0). Receives the rest.

Read the scripts

CubeStaggeredSpawner.ts

Lives on CubeContainer. Six wired @serializeProperty references (cubes, leftCluster, rightCluster, homeContainer) plus three tunables. The onUpdate body advances the stagger timer, ticks any in-flight scale fades, and (on first tick) installs the touch reset handler.

interface FadeEntry {
obj: APJS.SceneObject;
t: number;
finalScale: APJS.Vector3f;
}

@component()
export class CubeStaggeredSpawner extends APJS.BasicScriptComponent {
// The 6 cubes that will fan out, randomly assigned to a cluster on each spawn.
@serializeProperty cubes: APJS.SceneObject[] = [];

// Two alternative parents the cubes get reparented to at spawn time.
@serializeProperty leftCluster!: APJS.SceneObject;
@serializeProperty rightCluster!: APJS.SceneObject;

// Where cubes return to on reset (typically the GameObject this script lives on).
@serializeProperty homeContainer!: APJS.SceneObject;

// Seconds between successive cube reveals.
@serializeProperty staggerSeconds: number = 0.25;

// Seconds the scale fade-in takes per cube.
@serializeProperty fadeSeconds: number = 0.4;

// Local-space spread within a cluster, in world units.
@serializeProperty spread: number = 0.6;

// Internal state.
private nextSpawnIndex: number = 0;
private nextSpawnAt: number = 0;
private elapsed: number = 0;
private fading: FadeEntry[] = [];
private touchCallback!: (event: APJS.IEvent) => void;
private inited: boolean = false;

onUpdate(dt: number): void {
// Lazy init — @serializeProperty references are null in onStart.
if (!this.inited) {
if (!this.cubes || this.cubes.length === 0) return;
if (!this.leftCluster || !this.rightCluster || !this.homeContainer) return;
this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase === APJS.TouchPhase.Began) this.resetAll();
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.Touch, this.touchCallback, this);
// Hide every cube on entry so the stagger reveal reads as a real animation.
for (let i = 0; i < this.cubes.length; i++) {
const c = this.cubes[i];
if (c) c.setEnabledInHierarchy(false);
}
this.inited = true;
}

this.elapsed += dt;

// Reveal one cube at each stagger tick until they're all out.
while (
this.nextSpawnIndex < this.cubes.length &&
this.elapsed >= this.nextSpawnAt
) {
this.spawnOne(this.nextSpawnIndex);
this.nextSpawnIndex++;
this.nextSpawnAt += this.staggerSeconds;
}

// Tick scale-tweens.
for (let i = this.fading.length - 1; i >= 0; i--) {
const f = this.fading[i];
f.t += dt / this.fadeSeconds;
const k = Math.min(f.t, 1);
const tr = f.obj.getTransform();
tr.localScale = new APJS.Vector3f(
f.finalScale.x * k,
f.finalScale.y * k,
f.finalScale.z * k,
);
if (k >= 1) this.fading.splice(i, 1);
}
}

onDestroy(): void {
if (this.touchCallback) {
APJS.EventManager.getGlobalEmitter()
.off(APJS.EventType.Touch, this.touchCallback, this);
}
}

private spawnOne(index: number): void {
const cube = this.cubes[index];
if (!cube) return;

// Pick a cluster — alternate left/right with a random nudge so the
// result reads as choreographed but not rigid.
const goLeft =
(index % 2 === 0) ? Math.random() < 0.7 : Math.random() < 0.3;
const cluster = goLeft ? this.leftCluster : this.rightCluster;

// Runtime reparenting: SceneObject.parent setter reassigns the
// transform under a new parent. Both clusters live at the scene
// root with their own world position, so we get instant relocation.
cube.parent = cluster;

// Position relative to the new parent (random spot inside ±spread).
const tr = cube.getTransform();
const half = this.spread * 0.5;
tr.localPosition = new APJS.Vector3f(
(Math.random() - 0.5) * this.spread,
(Math.random() - 0.5) * this.spread,
(Math.random() - 0.5) * half,
);

// Random Euler tilt — Transform.localEulerAngles reads/writes degrees,
// not radians.
tr.localEulerAngles = new APJS.Vector3f(
Math.random() * 360,
Math.random() * 360,
Math.random() * 360,
);

// Reveal + scale-tween from 0 to the final scale.
cube.setEnabledInHierarchy(true);
const final = new APJS.Vector3f(0.1, 0.1, 0.1);
tr.localScale = new APJS.Vector3f(0, 0, 0);
this.fading.push({ obj: cube, t: 0, finalScale: final });
}

private resetAll(): void {
// Hide every cube, return to the home container, clear the queue.
for (let i = 0; i < this.cubes.length; i++) {
const c = this.cubes[i];
if (!c) continue;
c.setEnabledInHierarchy(false);
c.parent = this.homeContainer;
}
this.fading.length = 0;
this.nextSpawnIndex = 0;
this.nextSpawnAt = this.staggerSeconds;
this.elapsed = 0;
}
}

The Scene-namespace calls of interest:

  • SceneObject.parent is a getter/setter pair. Assigning a new parent reassigns the object's transform to the new parent — local position and rotation are interpreted relative to the new parent's transform after the reassignment. Setting parent = null would detach the object back to the scene root.
  • SceneObject.setEnabledInHierarchy(boolean) flips the effective visibility — the object and every descendant rendered or hidden in one call. Compare against SceneObject.enabled (just this object) and SceneObject.visible (read-only effective state, accounting for ancestors).
  • SceneObject.getTransform() is a typed shortcut for getComponent("Transform") as APJS.Transform. The longer form works too; the helper just saves a cast and a runtime null guard for the common case.
  • Transform.localPosition is a Vector3f setter. Assigning a fresh Vector3f(x, y, z) is the canonical way to teleport an object; mutating components of the existing vector does not re-trigger the underlying transform update.
  • Transform.localEulerAngles is a Vector3f of degrees (not radians). The script feeds Math.random() * 360 directly so the tutorial sees the raw degree-space.
  • @serializeProperty cubes: APJS.SceneObject[] exposes a typed array reference field to the inspector. Wiring uses [{guid, type: "SceneObject"}, ...] — bare GUID strings are rejected.

Customize

On CubeContainerCubeStaggeredSpawner:

  • staggerSeconds (default 0.25) — gap between successive reveals. Larger values turn the demo into a slower drumroll.
  • fadeSeconds (default 0.4) — duration of the scale tween per cube. 0 removes the tween (snap visible).
  • spread (default 0.6) — bounding-box edge length each cube can randomly land within, relative to its assigned cluster.

Tunables on the cubes themselves (set via set_component on each Transform):

  • localScale — change the per-cube target size. The script reads the inspector value as the spawn-tween destination.
  • localPosition / localEulerAngles — adjust the initial layout the cubes return to on reset (when they're parented back to CubeContainer).

Suggestions for further play:

  • Replace the alternating-cluster heuristic with a third centerCluster parent, and have spawnOne pick uniformly across all three.
  • Drive the scale tween with Vector3f.lerp(zeroVec, finalVec, k) (the Math tutorial demonstrates this) instead of the explicit component multiplication used here.
  • Add a per-cube OrbitController (see the Math tutorial) that runs while the cube is enabled, so each revealed cube starts orbiting its cluster.

What you learned

This tutorial used:

  • SceneObject.parent — runtime reparenting; assignment moves the transform under the new parent.
  • SceneObject.setEnabledInHierarchy(boolean) — toggling effective visibility for an object and its descendants in one call.
  • SceneObject.getTransform() — the typed shortcut for getComponent("Transform").
  • Transform.localPosition and Transform.localEulerAngles — Vector3f-valued setters on a Transform; localEulerAngles is in degrees.
  • @serializeProperty for arrays (SceneObject[]) and single references (SceneObject) — wiring uses {guid, type: "SceneObject"}.

Read the full SceneObject reference, the Transform reference, the Component reference, and the Scene namespace overview.

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