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.

What you'll build
- A 3D scene with one Planet at the origin and three Asteroids on individually tilted circular orbits.
- An
OrbitControllerscript driving each asteroid's per-frame position withVector3farithmetic and aQuaternionftilt rotation. - A
SpawnOnTapscript that listens for global touches and instantiates a new asteroid prefab on each tap, usingVector3f.lerpto 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
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 radius1.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
OrbitControllercomponent with distinctradius/speed/tiltAxis/tiltAngle/phasevalues. - GameController — empty SceneObject hosting the
SpawnOnTapscript. ItsasteroidPrefabfield is wired to theAsteroidSourcePrefab 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
@serializePropertyfields.tiltAxisis a reference type — it isnullduringonStartand is only populated by the engine before the firstonUpdatetick. Theif (!this.tiltAxis) return;line is the canonical way to wait for that wiring. Vector3finstance methods mutate.vec.add(other),vec.normalize(), andvec.subtract(other)all returnthisand modify the receiver. Call.clone()first if you want a fresh vector — that is exactly whatthis.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 bytiltAnglearoundtiltAxis. Since each asteroid carries its owntiltAxis, 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, soaxis.clone().normalize()is defensive — the inspector might receive any non-zero vector.quat.multiplyVector(v)returns a newVector3fthat isvrotated byquat. 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 toAPJS.EventType.Touchhere 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 newVector3fthat is the linear interpolation betweenaandbat fractiont. Compare with the instance methods (add,subtract,multiplyScalar) which mutate the receiver. The fade loop never mutatesfromorto; each tick gets a fresh interpolated vector, which is exactly whatlerp's static signature delivers.Prefab.instantiate(parent)requires a non-null parent SceneObject. Passingthis.getSceneObject()parents new asteroids under theGameControllerso 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 returnedComponentis cast toanyso 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(default3) — distance from the world origin.speed(default1) — 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(default0.5) — radians of tilt aroundtiltAxis.0is a flat XZ orbit;Math.PI / 2is a perpendicular orbit.phase(default0) — starting angle, useful for spreading the three asteroids around the orbit instead of bunching them together.
On GameController → SpawnOnTap:
maxAsteroids(default12) — hard cap on simultaneously-live spawned asteroids. Increase to stress-test, decrease for a calmer scene.finalScale(default0.06) — the target scale every spawned asteroid lerps to. The pre-built asteroids use the same value, so the cluster stays visually consistent.fadeSeconds(default0.4) — duration of theVector3f.lerpscale tween.0removes the tween entirely; larger values produce a slower, more deliberate spawn.
Suggestions for further play:
- Replace the orbit math with
Quaternionf.slerpbetween two rotations to get an asteroid that swings between configurations. - Have
SpawnOnTapalso pick a randomColorand apply it to the spawned asteroid's MeshRenderer material so each new asteroid is a different hue. - Use
Vector3f.distance(other)inOrbitControllerto fade the asteroid's scale down when it gets close to the camera.
What you learned
This tutorial used:
Vector3f— the constructor,clone,normalize, andaddinstance methods (note: instance methods mutate the receiver), plus the staticVector3f.lerpfor tween animation.Quaternionf—Quaternionf.makeFromAngleAxis(angle, axis)to produce an angle-axis rotation, andquat.multiplyVector(v)to apply it to aVector3f.- The
@serializePropertydecorator — exposing primitives, vectors, and prefab references to the editor inspector. Reference-type fields arenullduringonStartand only safe to read in the firstonUpdatetick.
Read the full Vector3f reference, the Quaternionf reference, and the Math namespace overview.