Skip to main content

Math: Orbiting Asteroids

A mini playground that orbits asteroids around a planet, all driven by the Math classes — Vector3f, Quaternionf, and Vector3f.lerp. Three asteroids start in pre-configured orbits with distinct radius, speed, and tilt; tapping anywhere spawns a new asteroid that scales in with Vector3f.lerp and joins the cluster on a randomized orbit.

Orbiting Asteroids demo running in Effect House preview

What you'll build

  • A 3D scene with one Planet at the origin and three Asteroids on individually tilted circular orbits.
  • An OrbitController script driving each asteroid's per-frame position with Vector3f arithmetic and a Quaternionf tilt rotation.
  • A SpawnOnTap script that listens for global touches and instantiates a new asteroid prefab on each tap, using Vector3f.lerp to tween its scale from zero to full.

Every tunable parameter — radius, angular speed, tilt axis, tilt angle, phase offset, fade duration, max-active count — is an @serializeProperty field visible in the editor inspector.

Open the demo

↓ orbiting-asteroids.zip

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

  • Camera — repositioned to (0, 2, 14) with an 8° downward pitch so the orbit plane reads as orbital, not as a flat horizontal sweep.
  • Directional Light / Environment Light — defaults, untouched.
  • Planet — a Sphere at the origin, scale 0.3 (world radius 1.5), blue Standard PBR material.
  • AsteroidSource (in the Resources panel as a Prefab) — the grey reference Sphere both the pre-built and tap-spawned asteroids instantiate from. The source SceneObject was consumed when the prefab was created, so it does not appear in the live scene tree.
  • Asteroid_0, Asteroid_1, Asteroid_2 — three prefab instances, each carrying an OrbitController component with distinct radius / speed / tiltAxis / tiltAngle / phase values.
  • GameController — empty SceneObject hosting the SpawnOnTap script. Its asteroidPrefab field is wired to the AsteroidSource Prefab so taps can instantiate new asteroids at runtime.

Read the scripts

OrbitController.ts

Each asteroid carries one of these. The orbit math is the spotlight: a flat 2D circle in the XZ plane, tilted into 3D by a quaternion built with Quaternionf.makeFromAngleAxis, then offset from a world origin point.

@component()
export class OrbitController extends APJS.BasicScriptComponent {
// The orbit's circular radius around the world origin (world units).
@serializeProperty radius: number = 3;

// Angular speed in radians/sec.
@serializeProperty speed: number = 1;

// Axis used to tilt the orbit plane. (0,1,0) = horizontal orbit.
@serializeProperty tiltAxis!: APJS.Vector3f;

// Angle (radians) the orbit plane is tilted by around tiltAxis.
@serializeProperty tiltAngle: number = 0.5;

// Phase offset so the three pre-built asteroids start at different angles.
@serializeProperty phase: number = 0;

// World point the asteroid orbits around. We orbit the origin by default.
private origin: APJS.Vector3f = new APJS.Vector3f(0, 0, 0);

// Running angle (radians).
private angle: number = 0;

onUpdate(dt: number): void {
// @serializeProperty fields are not yet populated during onStart;
// only safe to read on the first onUpdate.
if (!this.tiltAxis) return;

if (this.angle === 0) {
this.angle = this.phase;
}
this.angle += this.speed * dt;

// Build the unrotated orbit point on the XZ plane: (cos*r, 0, sin*r).
const flat = new APJS.Vector3f(
Math.cos(this.angle) * this.radius,
0,
Math.sin(this.angle) * this.radius,
);

// Rotate the orbit plane around the chosen axis by tiltAngle.
// Quaternionf.makeFromAngleAxis builds a unit quaternion that
// represents the rotation.
const axis = this.tiltAxis.clone().normalize();
const tilt = APJS.Quaternionf.makeFromAngleAxis(this.tiltAngle, axis);
const rotated = tilt.multiplyVector(flat);

// Final position = origin + rotated offset. We use Vector3f.add
// (which mutates), so clone the origin first.
this.getSceneObject().getTransform().localPosition =
this.origin.clone().add(rotated);
}
}

A few things to notice:

  • Lazy init guards @serializeProperty fields. tiltAxis is a reference type — it is null during onStart and is only populated by the engine before the first onUpdate tick. The if (!this.tiltAxis) return; line is the canonical way to wait for that wiring.
  • Vector3f instance methods mutate. vec.add(other), vec.normalize(), and vec.subtract(other) all return this and modify the receiver. Call .clone() first if you want a fresh vector — that is exactly what this.origin.clone().add(rotated) does.
  • Building the orbit in two steps keeps the math readable. First a flat circle on the XZ plane (flat), then a rotation that lifts that plane by tiltAngle around tiltAxis. Since each asteroid carries its own tiltAxis, the three pre-built orbits fan out in different directions around the planet.
  • Quaternionf.makeFromAngleAxis(angle, axis) is the static constructor for an angle-axis rotation. The axis is expected to be unit-length, so axis.clone().normalize() is defensive — the inspector might receive any non-zero vector.
  • quat.multiplyVector(v) returns a new Vector3f that is v rotated by quat. This is the hot path of the function.

SpawnOnTap.ts

Lives on the GameController. Listens for global touch events through APJS.EventManager.getGlobalEmitter(), instantiates a new asteroid from the wired prefab on each Began tap, attaches an OrbitController at runtime with randomized parameters, and tweens the new asteroid's scale from zero to its final size with Vector3f.lerp.

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

@component()
export class SpawnOnTap extends APJS.BasicScriptComponent {
// The prefab spawned on each tap. Wire to AsteroidSource in the inspector.
@serializeProperty asteroidPrefab!: APJS.Prefab;

// Hard cap on simultaneously-live spawned asteroids.
@serializeProperty maxAsteroids: number = 12;

// Final scale matching the pre-built asteroids' inspector scale.
@serializeProperty finalScale: number = 0.06;

// Duration (seconds) of the spawn-in scale tween.
@serializeProperty fadeSeconds: number = 0.4;

private spawned: APJS.SceneObject[] = [];
private fading: FadeEntry[] = [];
private touchCallback!: (event: APJS.IEvent) => void;

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

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

onUpdate(dt: number): void {
// Animate any in-flight scale tweens with Vector3f.lerp.
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 s = APJS.Vector3f.lerp(f.from, f.to, k);
f.obj.getTransform().localScale = s;
if (k >= 1) this.fading.splice(i, 1);
}
}

private spawnOne(): void {
if (!this.asteroidPrefab) return;
if (this.spawned.length >= this.maxAsteroids) return;

// Instantiate a new asteroid, parented to the GameController this
// script lives on. OrbitController computes a world-relative position
// so the parent only matters for hierarchy.
const inst = this.asteroidPrefab.instantiate(this.getSceneObject());
if (!inst) return;

// Attach an OrbitController at runtime and randomize its parameters.
// User scripts can't import each other in APJS, so we cast the
// returned component to `any` and assign serialized fields directly.
const orbit = inst.addComponent("OrbitController") as any;
if (orbit) {
orbit.radius = 2 + Math.random() * 3;
orbit.speed = 0.4 + Math.random() * 1.4;
orbit.tiltAxis = new APJS.Vector3f(
Math.random() - 0.5,
Math.random() - 0.5,
Math.random() - 0.5,
);
orbit.tiltAngle = Math.random() * Math.PI * 0.5;
orbit.phase = Math.random() * Math.PI * 2;
}

// Start the new asteroid invisible (scale 0) and tween up to finalScale.
inst.getTransform().localScale = new APJS.Vector3f(0, 0, 0);
const targetScale = new APJS.Vector3f(
this.finalScale, this.finalScale, this.finalScale,
);
this.fading.push({
obj: inst,
t: 0,
from: new APJS.Vector3f(0, 0, 0),
to: targetScale,
});
this.spawned.push(inst);
}
}

The interesting calls:

  • APJS.EventManager.getGlobalEmitter() is the project-wide event bus. Subscribing to APJS.EventType.Touch here means every screen tap triggers this callback — no need for per-object hit testing.
  • APJS.Vector3f.lerp(a, b, t) is a static method. It returns a new Vector3f that is the linear interpolation between a and b at fraction t. Compare with the instance methods (add, subtract, multiplyScalar) which mutate the receiver. The fade loop never mutates from or to; each tick gets a fresh interpolated vector, which is exactly what lerp's static signature delivers.
  • Prefab.instantiate(parent) requires a non-null parent SceneObject. Passing this.getSceneObject() parents new asteroids under the GameController so they are easy to inspect or wipe out as a group.
  • SceneObject.addComponent("OrbitController") dynamically attaches the script class registered under that name. Because user scripts can't cross-import in APJS, the returned Component is cast to any so the serialized fields can be written without the TypeScript compiler complaining about the missing class type.

Customize

Open Asteroid_0, Asteroid_1, or Asteroid_2 in the editor — every @serializeProperty field on OrbitController shows up in the inspector:

  • radius (default 3) — distance from the world origin.
  • speed (default 1) — angular speed in radians/sec.
  • tiltAxis (default (0,1,0)) — unit axis the orbit plane is tilted around. Try (1, 0, 0) for a vertical orbit, or (0.7, 1, 0.3) for an oblique sweep.
  • tiltAngle (default 0.5) — radians of tilt around tiltAxis. 0 is a flat XZ orbit; Math.PI / 2 is a perpendicular orbit.
  • phase (default 0) — starting angle, useful for spreading the three asteroids around the orbit instead of bunching them together.

On GameControllerSpawnOnTap:

  • maxAsteroids (default 12) — hard cap on simultaneously-live spawned asteroids. Increase to stress-test, decrease for a calmer scene.
  • finalScale (default 0.06) — the target scale every spawned asteroid lerps to. The pre-built asteroids use the same value, so the cluster stays visually consistent.
  • fadeSeconds (default 0.4) — duration of the Vector3f.lerp scale tween. 0 removes the tween entirely; larger values produce a slower, more deliberate spawn.

Suggestions for further play:

  • Replace the orbit math with Quaternionf.slerp between two rotations to get an asteroid that swings between configurations.
  • Have SpawnOnTap also pick a random Color and apply it to the spawned asteroid's MeshRenderer material so each new asteroid is a different hue.
  • Use Vector3f.distance(other) in OrbitController to fade the asteroid's scale down when it gets close to the camera.

What you learned

This tutorial used:

  • Vector3f — the constructor, clone, normalize, and add instance methods (note: instance methods mutate the receiver), plus the static Vector3f.lerp for tween animation.
  • QuaternionfQuaternionf.makeFromAngleAxis(angle, axis) to produce an angle-axis rotation, and quat.multiplyVector(v) to apply it to a Vector3f.
  • The @serializeProperty decorator — exposing primitives, vectors, and prefab references to the editor inspector. Reference-type fields are null during onStart and only safe to read in the first onUpdate tick.

Read the full Vector3f reference, the Quaternionf reference, and the Math namespace overview.

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