Skip to main content

Assets: Prefab Pool

An object-pooling pattern for prefab instances. The scene contains a Cube prefab resource and a PrefabPoolDemo component that pre-instantiates hidden copies, activates one on each tap, and recycles the oldest active cube when the pool is full.

Assets Prefab Pool demo preview

What you'll build

  • A CubePrefabSource Cube converted into a prefab resource.
  • A prewarmed pool of inactive prefab instances parented under GameController.
  • Tap-triggered showcase slots that keep active prefab instances readable in the camera preview.
  • A recycling rule that reuses the oldest active cube instead of allocating endlessly.
  • A RecordStart reset that hides every pooled instance before recording begins.

Open the demo

↓ assets-prefab-pool.zip

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

  • Camera - moved back to view the 3D cubes.
  • CubePrefabSource prefab resource - generated from a Cube and consumed from the scene.
  • TitleText, StatusText, and HintText - 2D readouts above the 3D action.
  • GameController - empty SceneObject hosting PrefabPoolDemo.

Read the script

PrefabPoolDemo.ts

@component()
export class PrefabPoolDemo extends APJS.BasicScriptComponent {
@serializeProperty()
cubePrefab!: APJS.Prefab;

@serializeProperty()
statusText!: APJS.SceneObject;

@serializeProperty()
hintText!: APJS.SceneObject;

@serializeProperty()
@spinBox(2, 16, 1)
poolSize: number = 8;

@serializeProperty()
@slider(1, 5, 0.1)
spawnRadius: number = 3.5;

@serializeProperty()
@slider(0, 240, 5)
spinSpeed: number = 90;

private static readonly SLOTS: number[][] = [
[-1.85, 1.05, -0.25, 0.12, -18, 35, 0],
[-0.65, 1.18, 0.15, 0.12, -14, -28, 0],
[0.65, 1.05, -0.15, 0.12, -20, 32, 0],
[1.85, 1.18, 0.2, 0.12, -16, -36, 0],
[-1.55, -0.55, 0.2, 0.13, -10, -32, 0],
[-0.45, -0.72, -0.1, 0.13, -16, 38, 0],
[0.75, -0.55, 0.25, 0.13, -12, -40, 0],
[1.85, -0.72, -0.2, 0.13, -18, 30, 0],
];

private static readonly PALETTE: number[][] = [
[0.10, 0.70, 0.95],
[0.28, 0.83, 0.50],
[1.00, 0.78, 0.25],
[0.95, 0.33, 0.26],
[0.56, 0.48, 0.95],
[0.95, 0.47, 0.82],
[0.18, 0.86, 0.80],
[1.00, 0.58, 0.22],
];

private status!: APJS.Text;
private hint!: APJS.Text;
private pool: APJS.SceneObject[] = [];
private active: APJS.SceneObject[] = [];
private touchCallback!: (event: APJS.IEvent) => void;
private recordStartCallback!: (event: APJS.IEvent) => void;
private inited: boolean = false;
private spawnCount: number = 0;

onUpdate(deltaTime: number): void {
if (!this.inited) {
if (!this.cubePrefab || !this.statusText || !this.hintText) return;
this.status = this.statusText.getComponent("Text") as APJS.Text;
this.hint = this.hintText.getComponent("Text") as APJS.Text;
if (!this.status || !this.hint) return;

const host = this.getSceneObject();
for (let i = 0; i < this.poolSize; i++) {
const obj = this.cubePrefab.instantiate(host);
if (!obj) continue;
this.prepareInstance(obj, i);
obj.setEnabledInHierarchy(false);
this.pool.push(obj);
}

this.touchCallback = (event: APJS.IEvent) => {
const touch = event.args[0] as APJS.TouchData;
if (touch.phase !== APJS.TouchPhase.Began) return;
this.spawnNext();
};
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.Touch, this.touchCallback, this);

this.recordStartCallback = (_event: APJS.IEvent) => {
this.resetPool();
};
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.RecordStart, this.recordStartCallback, this);

this.inited = true;
this.refreshText();
console.log("[PrefabPoolDemo] pool ready " + this.pool.length + "/" + this.poolSize);
}

for (const obj of this.active) {
const transform = obj.getTransform();
const euler = transform.localEulerAngles;
transform.localEulerAngles = new APJS.Vector3f(
euler.x + this.spinSpeed * deltaTime * 0.5,
euler.y + this.spinSpeed * deltaTime,
euler.z,
);
}
}

onDestroy(): void {
const emitter = APJS.EventManager.getGlobalEmitter();
if (this.touchCallback) emitter.off(APJS.EventType.Touch, this.touchCallback, this);
if (this.recordStartCallback) emitter.off(APJS.EventType.RecordStart, this.recordStartCallback, this);
}

private spawnNext(): void {
if (this.pool.length === 0) return;
let obj: APJS.SceneObject | undefined;
for (const candidate of this.pool) {
if (!candidate.enabled) {
obj = candidate;
break;
}
}
if (!obj) {
obj = this.active.shift();
}
if (!obj) return;

const slotIndex = this.spawnCount % PrefabPoolDemo.SLOTS.length;
this.placeInSlot(obj, slotIndex);
obj.setEnabledInHierarchy(true);

const existing = this.active.indexOf(obj);
if (existing >= 0) this.active.splice(existing, 1);
this.active.push(obj);
this.spawnCount += 1;
this.refreshText();
console.log("[PrefabPoolDemo] spawned #" + this.spawnCount + " in showcase slot " + (slotIndex + 1));
}

private resetPool(): void {
for (const obj of this.pool) {
obj.setEnabledInHierarchy(false);
}
this.active = [];
this.spawnCount = 0;
this.refreshText();
console.log("[PrefabPoolDemo] record reset");
}

private refreshText(): void {
const hidden = this.pool.length - this.active.length;
this.status.text =
"Pool size: " + this.pool.length +
" | Active: " + this.active.length +
" | Hidden: " + hidden +
"\nTaps spawned: " + this.spawnCount;
this.hint.text =
"Tap to acquire the next hidden Prefab instance.\nCubes use showcase slots so each pooled copy stays visible.\nWhen the pool is full, the oldest active cube is recycled.";
}

private prepareInstance(obj: APJS.SceneObject, index: number): void {
const renderer = obj.getComponent("MeshRenderer") as APJS.MeshRenderer;
if (!renderer || !renderer.mainMaterial) return;

const material = renderer.mainMaterial.clone();
const c = PrefabPoolDemo.PALETTE[index % PrefabPoolDemo.PALETTE.length];
this.setMaterialColor(material, new APJS.Color(c[0], c[1], c[2], 1));
this.setFloatIfPresent(material, "_MRAOMetallic", 0.0);
this.setFloatIfPresent(material, "_MRAORoughness", 0.42);
renderer.mainMaterial = material;
}

private placeInSlot(obj: APJS.SceneObject, slotIndex: number): void {
const slot = PrefabPoolDemo.SLOTS[slotIndex % PrefabPoolDemo.SLOTS.length];
const spread = this.clamp(this.spawnRadius / 3.5, 0.75, 1.25);
const transform = obj.getTransform();
transform.localPosition = new APJS.Vector3f(slot[0] * spread, slot[1] * spread, slot[2]);
transform.localScale = new APJS.Vector3f(slot[3], slot[3], slot[3]);
transform.localEulerAngles = new APJS.Vector3f(slot[4], slot[5], slot[6]);
}

private setMaterialColor(material: APJS.Material, color: APJS.Color): void {
const keys = ["_AlbedoColor", "_BaseColor", "u_Color"];
for (const key of keys) {
if (material.getColor(key) !== undefined) {
material.setColor(key, color);
return;
}
}
material.setColor("_AlbedoColor", color);
}

private setFloatIfPresent(material: APJS.Material, key: string, value: number): void {
if (material.getFloat(key) !== undefined) material.setFloat(key, value);
}

private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
}

Key API ideas

  • APJS.Prefab.instantiate(parent) creates a new SceneObject instance from a prefab resource.
  • Prewarm instances during initialization, hide them with setEnabledInHierarchy(false), and reuse them later.
  • this.getSceneObject() is a convenient parent for instances managed by the same component.
  • Use EventManager.getGlobalEmitter().on(APJS.EventType.Touch, ...) for tap-driven spawning.
  • Unregister global callbacks in onDestroy() to avoid duplicate listeners after reloads.
  • Reset transient runtime state on RecordStart when the recording should begin from a clean scene.
Copyright © 2026 TikTok. All rights reserved.
About TikTokHelp CenterCareersContactLegalTerms of ServicePrivacy PolicyCookies