# three-start

> A minimal foundation layer for Three.js — bootstrap (renderer, scene, camera, animation loop, resize) plus a unified component / lifecycle / module model. Not a replacement for Three.js; you keep using `THREE.Object3D`, `THREE.Mesh`, materials, geometries, the renderer — three-start adds the runtime around them.
>
> Package: `three-start`
> Docs: https://three-start.com
> GitHub: https://github.com/vladkrutenyuk/three-start

## Installation

```sh
npm i three-start three eventemitter3
```

`three` and `eventemitter3` are peer dependencies — they live in your project, not inside three-start.

**Requires `three >= 0.183`.** three-start uses `THREE.RenderPipeline` (introduced in r183) under the hood. Older `three` releases do not ship the symbol and will fail at runtime.

If you use TypeScript, also install `-D @types/three@^0.183.1`. three-start ships its own `.d.ts`.

## Mental model

Three concepts cover everything:

1. **`ThreeStart`** — the entry point. Owns one `ThreeContext` (renderer / scene / camera / animation loop / event bus / module registry). You configure modules before `start()`, then call `start()` to kick off the lifecycle.
2. **`Object3DBehaviour`** (a "component") — a class attached to a single `THREE.Object3D` via `addComponent(obj, ClassName)`. Has the full lifecycle (`onAwake` / `onEnable` / `onStart` / `onUpdate` / `onBeforeRender` / `onAfterRender` / `onDisable` / `onDestroy`).
3. **`ContextModule`** (a "module") — a global singleton on the context, registered before `start()`. Has the same lifecycle minus `onEnable` / `onDisable` / `onDestroy`. Use modules for input, physics, audio, asset loading, scoring — anything one-instance and shared across components.

Components reach the context via `this.ctx` and other modules via `this.modules.<key>`.

## Quickstart

```ts
import * as THREE from "three";
import { ThreeStart, Object3DBehaviour, addComponent } from "three-start";

const starter = new ThreeStart();

const { scene, camera } = starter.ctx;
camera.position.set(5, 5, 5);
camera.lookAt(0, 0, 0);

class Spin extends Object3DBehaviour {
  speed = 2;

  onUpdate() {
    this.object.rotation.y += this.speed * this.ctx.getDeltaTime();
  }
}

const cube = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial());
scene.add(cube);
addComponent(cube, Spin);

starter.mount(document.getElementById("three")!);
starter.start();
```

`mount(container)` and `start()` are independent. `mount` attaches the canvas + ResizeObserver and starts the animation loop. `start()` runs the bootstrap traversal that wakes registered modules and components on every active object.

## `ThreeStart`

```ts
new ThreeStart(options?: ThreeStartOptions): ThreeStart

starter.ctx                                // ThreeContext (always present)
starter.isStarted                          // true after start() ran

starter.addModules({ key: instance, ... }) // register modules — BEFORE start()
starter.mount(container: HTMLDivElement)   // attach canvas, fire Mount, start loop unless manageLoopManually
starter.unmount()                          // remove canvas, fire Unmount
starter.start()                            // bootstrap; idempotent, throws no errors on second call
starter.runLoop()                          // start the animation loop
starter.stopLoop()                         // pause the animation loop (resumable)
starter.dispose()                          // tear everything down
```

`ThreeStartOptions`:

```ts
interface ThreeStartOptions {
  renderer?: THREE.Renderer;        // default: new THREE.WebGPURenderer({ antialias: true })
  camera?: THREE.PerspectiveCamera; // default: new THREE.PerspectiveCamera()
  scene?: THREE.Scene;              // default: new THREE.Scene()
  manageLoopManually?: boolean;     // default false. true = skip auto-runLoop on mount()
  autoInitRenderer?: boolean;       // default true. If true, calls renderer.init() in constructor
}
```

`addModules` THROWS after `start()`. Duplicate keys log `console.warn` and skip. Modules awake in registration order.

## `ThreeContext` (`this.ctx` / `starter.ctx`)

The shared runtime. One instance per `ThreeStart`.

```ts
ctx.scene                  // THREE.Scene
ctx.camera                 // THREE.PerspectiveCamera (settable; reassigning fires CameraChanged)
ctx.renderer               // THREE.Renderer (default: WebGPURenderer)
ctx.renderPipeline         // THREE.RenderPipeline
ctx.scenePass              // THREE.PassNode (use for TSL post-processing)
ctx.modules                // registered ContextModule instances by key (read-only proxy)

ctx.getDeltaTime()         // seconds since previous frame, scaled by timescale
ctx.getTime()              // total elapsed seconds, scaled by timescale
ctx.getTimescale()         // current scale (default 1)
ctx.setTimescale(n)        // 1 = realtime, 0 = freeze, 2 = 2x

ctx.render()               // render once, fires RenderBefore / RenderAfter
ctx.requestRender()        // schedule one render on next frame; multiple calls fold into one
ctx.overrideRender(fn)     // replace the render function entirely
ctx.resetRender()          // restore the default

ctx.canvasContainer        // HTMLDivElement | null
ctx.isMounted              // boolean
ctx.isLoopRunning          // boolean

ctx.on(event, fn) / ctx.off / ctx.once
```

Events (`ThreeContextEvents`):

```ts
import { ThreeContextEvents } from "three-start";

ThreeContextEvents.Update          // each frame, before RenderBefore
ThreeContextEvents.RenderBefore    // each frame, just before draw
ThreeContextEvents.RenderAfter     // each frame, immediately after draw
ThreeContextEvents.Mount           // (container) — canvas attached
ThreeContextEvents.Unmount         // canvas detached
ThreeContextEvents.LoopRun         // animation loop started
ThreeContextEvents.LoopStop        // animation loop stopped
ThreeContextEvents.Resized         // (width, height) — first mount + every resize
ThreeContextEvents.CameraChanged   // (newCamera, prevCamera) — ctx.camera reassigned
```

`ctx.setTimescale(0)` is the cleanest "global pause" — `getDeltaTime()` returns 0 and `getTime()` stops advancing without disabling any component.

## `Object3DBehaviour` — writing a component

A class extending `Object3DBehaviour`. Override only the event methods you need — render-loop methods are subscribed only when overridden, empty components pay nothing for dispatch.

```ts
import { Object3DBehaviour } from "three-start";

class Bob extends Object3DBehaviour {
  amplitude = 0.5;
  frequency = 2;
  private baseY = 0;

  onAwake() {
    this.baseY = this.object.position.y;
  }

  onUpdate() {
    const t = this.ctx.getTime();
    this.object.position.y =
      this.baseY + Math.sin(t * this.frequency * Math.PI * 2) * this.amplitude;
  }
}
```

### Available inside event methods

`addComponent(obj, klass)` injects everything the component needs into the instance immediately after `new klass()`, before any event method runs. Inside any event method body, these accessors are guaranteed to be set:

```ts
this.object    // the THREE.Object3D this component was added to
this.ctx       // the shared ThreeContext
this.modules   // registered ContextModule instances
this.enabled   // current enabled state (default true)
```

**Do NOT** read `this.object` / `this.ctx` / `this.modules` from a field initializer or constructor — those run inside `new klass()`, before the library has had a chance to inject anything. Use field initializers for plain data only (`speed = 5`); put any logic that needs the host or the context inside `onAwake`.

### Lifecycle

```ts
onAwake()         // ONCE on first activation, before everything else
onEnable()        // EACH time the component goes from inactive → active
onStart()         // ONCE after onEnable on the first activation; siblings + modules ready
onUpdate()        // EACH frame, before onBeforeRender
onBeforeRender()  // EACH frame, just before the renderer draws
onAfterRender()   // EACH frame, immediately after the draw
onDisable()       // EACH time the component leaves the active state
onDestroy()       // ONCE on destroy(component) or destroy(host object)
```

The render-loop methods (`onUpdate` / `onBeforeRender` / `onAfterRender`) only fire on **enabled, active-in-hierarchy** components.

`onAwake` and `onStart` fire **exactly once per instance**. `onEnable` / `onDisable` can fire many times (toggling `enable()`/`disable()` or via a `setActive` cascade from an ancestor).

### Component methods

```ts
comp.enable()              // sets enabled = true; fires onEnable if currently active in hierarchy
comp.disable()             // sets enabled = false; fires onDisable if was running
comp.setEnabled(boolean)   // explicit version
comp.enabled               // getter
```

### Attaching components — always use `addComponent`

```ts
import { addComponent } from "three-start";

const ai = addComponent(enemy, EnemyAI);   // returns the typed instance
ai.target = player;                         // configure on returned instance
```

Never `new EnemyAI()` outside `addComponent` — the lib has to inject `_object`, `_ctx`, `_ext` before any lifecycle starts.

If you need initial config, prefer field initializers on the class (so the lib can call `new klass()` with zero args), then mutate the returned instance, OR pass a factory that mutates the instance after `addComponent` returns.

## `ContextModule` — writing a module

A class extending `ContextModule`. Same lifecycle as a component, minus `onEnable` / `onDisable` / `onDestroy` (modules are always on once registered).

```ts
import { ContextModule } from "three-start";

class InputModule extends ContextModule {
  private keys = new Set<string>();

  onAwake() {
    window.addEventListener("keydown", e => this.keys.add(e.code));
    window.addEventListener("keyup",   e => this.keys.delete(e.code));
  }

  isPressed(code: string) {
    return this.keys.has(code);
  }
}
```

### Lifecycle

```ts
onAwake()         // ONCE during start(), before any component lifecycle. Self-init.
onStart()         // ONCE after every module finished onAwake. Cross-module wiring.
onUpdate()        // each frame, BEFORE any component's onUpdate
onBeforeRender()  // each frame, BEFORE any component's onBeforeRender
onAfterRender()   // each frame, BEFORE any component's onAfterRender
```

The "modules first" guarantee means a component's `onUpdate` always sees state mutated in the same frame's module `onUpdate`.

### Available inside

```ts
this.ctx       // shared ThreeContext
this.modules   // sibling modules (own + every other registered module)
```

### Registering — must happen before `start()`

```ts
const starter = new ThreeStart()
  .addModules({
    input: new InputModule(),
    audio: new AudioModule(),
  });

starter.start();
```

`addModules` THROWS if called after `start()`. Modules participate in the bootstrap traversal and cannot be inserted after it has run.

You can split registration:

```ts
starter.addModules({ input: new InputModule() });
starter.addModules({ audio: new AudioModule() });
starter.start();
```

Order matters — modules awake in registration order. If module B reads from module A in `onAwake`, register A first.

## Operations (top-level functions)

```ts
import {
  addComponent,
  getComponent,
  getComponents,
  destroy,
  setActive,
  getIsActive,
  getIsActiveSelf,
} from "three-start";

addComponent(obj, Class)            // attach + return instance (typed)
getComponent(obj, Class)            // first match or null (typed)
getComponents(obj)                  // all components on obj
getComponents(obj, Class)           // filtered by class

destroy(component)                  // onDisable (if enabled) → onDestroy → removed from object
destroy(obj)                        // for every component on obj and ALL descendants:
                                    //   scene-graph traversal (parent first, then descendants)
                                    //   on each object: components in registration order
                                    // then obj.removeFromParent()

setActive(obj, true|false)          // set obj's own active flag (activeSelf); cascades through subtree
getIsActive(obj)                    // effective state: own + every ancestor active (activeInHierarchy)
getIsActiveSelf(obj)                // own flag only
```

## `setActive` — activating a subtree

Each `Object3D` has two notions of active:

| Getter | Meaning | Default |
|---|---|---|
| `getIsActiveSelf(obj)` (`activeSelf`) | The object's own flag — whatever was last passed to `setActive(obj, ...)`. | `true` |
| `getIsActive(obj)` (`activeInHierarchy`) | Effective state: own flag is `true` AND every ancestor's own flag is also `true`. | `true` |

A component runs its lifecycle only while `getIsActive(obj) === true`. The component's own `enabled` is a second gate inside that.

### Cascade rules

`setActive(obj, ...)` flips `obj`'s own flag and walks down the subtree, stopping at any descendant whose own flag is already `false` (its subtree was already inactive and stays so).

```ts
setActive(parent, false);
// → onDisable fires on every active component on `parent` and on every descendant
//   whose own activeSelf was true (i.e. whose subtree was reachable through the chain).

setActive(parent, true);
// → onEnable fires on every enabled component reachable through the now-restored chain.
//   First-time activations also run onAwake → onEnable → onStart.
```

### `setActive` does NOT touch `object.visible`

`setActive` controls the component lifecycle only. It does **not** mutate the host object's `visible` property. If you want a deactivated subtree to also stop rendering, set `obj.visible = false` yourself — Three.js cascades that flag through descendants on its own at render time.

### Default is active

`setActive` is opt-in. Any object you never pass to it has `activeSelf === true` and no extension allocated. Components on it run their lifecycle as soon as the bootstrap reaches them.

## When does a component first become active?

A component starts its lifecycle the moment **all three** are true:

1. The component was added to an `Object3D` via `addComponent(obj, klass)`.
2. The object is in the scene tree of `ThreeStart`'s context (`starter.ctx.scene`).
3. `starter.start()` was called.

Order doesn't matter. Whichever route you take, the moment the last condition is satisfied the component runs `onAwake → onEnable → onStart` and starts receiving render-loop event methods.

```ts
// Scenario A: attach before start
addComponent(obj, MyComp);
starter.ctx.scene.add(obj);
starter.start();             // ← onAwake → onEnable → onStart fire here

// Scenario B: scene first, then start, then attach
starter.ctx.scene.add(obj);
starter.start();
addComponent(obj, MyComp);   // ← here

// Scenario C: pre-stage, attach component to a detached object, parent later
starter.start();
addComponent(obj, MyComp);   // dormant — obj not in scene yet
starter.ctx.scene.add(obj);  // ← cascade fires the lifecycle here
```

## TypedEmitter — typed events on components and modules

Both `Object3DBehaviour` and `ContextModule` extend `TypedEmitter`. Pass an event map as a generic to type `emit` / `on` / `off` / `once`:

```ts
type HealthEvents = {
  damaged: [amount: number, source: THREE.Object3D];
  died: [];
};

class Health extends Object3DBehaviour<HealthEvents> {
  damage(n: number, source: THREE.Object3D) {
    this.emit("damaged", n, source);   // ✓ tuple matches
    // this.emit("dyed");               // ✗ unknown event (compile error)
  }
}

const h = getComponent(player, Health)!;
h.on("damaged", (amount, source) => { /* both typed */ });
```

`onDestroy` clears every listener on the component itself — consumers don't need to manually unsubscribe from a destroyed component.

## TypeScript: typed module registry

Without augmentation, `this.modules` is typed as `Record<string, ContextModule>` — keys are loose. Augment `ThreeStartRegister` once in your project and `this.modules.input` is fully typed everywhere:

```ts
import "three-start";
import { InputModule } from "./modules/input";
import { AudioModule } from "./modules/audio";

declare module "three-start" {
  interface ThreeStartRegister {
    modules: {
      input: InputModule;
      audio: AudioModule;
    };
  }
}
```

After this, `addModules({ ... })`, `this.modules.input`, and `ctx.modules.input` are all typed.

## Type-only re-exports

```ts
import type {
  ThreeStartOptions,
  ThreeStartModules,
  Object3DBehaviourConstructor,
  EventMap,
} from "three-start";

function spawn<T extends Object3DBehaviour>(
  obj: THREE.Object3D,
  klass: Object3DBehaviourConstructor<T>,
): T {
  return addComponent(obj, klass);
}
```

## Importing Three.js

three-start uses the WebGPU build internally (`three/webgpu`). For most user code, the standard `three` import is enough — `Object3D`, `Mesh`, materials, geometries are identical between `three` and `three/webgpu`. If you touch renderer-specific APIs (TSL nodes, `RenderPipeline`, `WebGPURenderer`-only options), import from `three/webgpu`:

```ts
import * as THREE from "three";              // most code
import { WebGPURenderer } from "three/webgpu"; // renderer-specific
```

## Common patterns

### Pre-stage an inactive subtree, switch it on later

You can stage objects, attach components, even add to the scene — all while the parent is inactive. The lifecycle is held off until the cascade reaches them.

```ts
const wave = new THREE.Group();
setActive(wave, false);          // turn off BEFORE adding components

for (let i = 0; i < 3; i++) {
  const enemy = new THREE.Mesh(geo, mat);
  addComponent(enemy, EnemyAI);  // dormant — `wave` is inactive
  wave.add(enemy);
}
starter.ctx.scene.add(wave);
starter.start();
// → none of the EnemyAI instances have run onAwake yet

setActive(wave, true);           // cascade fires onAwake → onEnable → onStart on every EnemyAI
```

### Cross-component communication

`onAwake` is only safe for self-init. Cross-references between components on the same object should happen in `onStart` — by then every sibling has finished `onAwake`:

```ts
class Health extends Object3DBehaviour { hp = 100; }

class HitFlash extends Object3DBehaviour {
  private health!: Health;

  onStart() {
    this.health = getComponent(this.object, Health)!;  // safe by onStart
  }
}
```

### Cleaning up

The library does NOT auto-dispose your resources. Anything the component allocates during its lifecycle — DOM listeners, `ctx` event subscriptions, timers, fetch controllers, RAF handles, audio nodes, geometries / materials / textures you created — must be released or unsubscribed in `onDestroy`. Pair `onAwake` with `onDestroy`, and `onEnable` with `onDisable`, for symmetric resource management.

The only thing the lib clears automatically is `TypedEmitter` listeners on the destroyed component itself (incoming subscriptions from other code).

### Cost of `onUpdate`

`onUpdate` runs every frame — 60-120+ calls per second per active component. Keep it lean:

- precompute heavy math once in `onAwake` / `onStart`
- cache references (e.g. cast `this.object as THREE.Mesh` once and store)
- allocate vectors / matrices as fields and reuse them
- never `new THREE.Vector3()` inside `onUpdate`, never load assets, never query the scene by name

The same rule applies to `onBeforeRender` / `onAfterRender`.

### Modules + components example

```ts
import * as THREE from "three";
import {
  ThreeStart, Object3DBehaviour, ContextModule,
  addComponent, getComponent, setActive, getIsActive,
} from "three-start";

class InputModule extends ContextModule {
  private keys = new Set<string>();
  onAwake() {
    window.addEventListener("keydown", e => this.keys.add(e.code));
    window.addEventListener("keyup",   e => this.keys.delete(e.code));
  }
  isPressed(code: string) { return this.keys.has(code); }
}

declare module "three-start" {
  interface ThreeStartRegister {
    modules: { input: InputModule };
  }
}

class PlayerControl extends Object3DBehaviour {
  speed = 5;
  onUpdate() {
    const dt = this.ctx.getDeltaTime();
    const k = this.modules.input;
    if (k.isPressed("KeyD")) this.object.position.x += this.speed * dt;
    if (k.isPressed("KeyA")) this.object.position.x -= this.speed * dt;
  }
}

const starter = new ThreeStart()
  .addModules({ input: new InputModule() });

const player = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial());
starter.ctx.scene.add(player);
addComponent(player, PlayerControl);

starter.mount(document.getElementById("app")!);
starter.start();
```

## Things to remember

- **Use `addComponent(obj, klass)`** — never `new MyComponent()`. The lib has to inject context state before lifecycle.
- **Register modules with `addModules({...})` BEFORE `start()`.** After `start()`, `addModules` throws.
- **`onAwake` / `onStart` fire exactly once.** Toggling `enable`/`disable` or `setActive` re-fires only `onEnable` / `onDisable`.
- **Render-loop methods only subscribe when overridden.** Empty components are free.
- **`setActive` is lifecycle-only.** It does not touch `object.visible` — set that yourself if you want.
- **`enable()` is silent under an inactive ancestor.** The flag flips, but `onEnable` doesn't fire until the cascade reaches the component.
- **Modules are always-on after registration.** No `onEnable` / `onDisable` / `onDestroy`. If you need temporary, use a component.
- **Clean up in `onDestroy`.** The lib doesn't track or auto-dispose your DOM listeners, geometries, materials, textures, timers, fetch controllers, audio nodes, etc.
- **Don't allocate inside `onUpdate` / `onBeforeRender` / `onAfterRender`.** They run every frame.
- **`this.object` / `this.ctx` / `this.modules` are NOT available in field initializers or constructors.** Use them from `onAwake` onwards.

## What this library does NOT do

- Does not import or bundle `three` — it is a peer dependency.
- Does not impose a folder structure or a build tool. Vanilla TS / Vite / Webpack / etc. all work.
- Does not replace any Three.js API. You still use `THREE.Mesh`, `THREE.Material`, `scene.add`, etc. The lib only adds component / module lifecycle on top.
- Does not provide built-in input, physics, or asset loaders. Those are user-land modules.
- Does not couple `setActive` and `visible`. Lifecycle and rendering are independent — if you want both off, set both off.
