Taos Engine ▦ Taos: API Documentation

Taos Engine — Quick-Start Guide

A practical guide to driving the Taos engine from your own code. The companion book covers theory; this page covers the calls you make.

If you want to understand why the renderer is shaped the way it is, read Chapter 3 — Rendering Architecture and Chapter 12 — Game Engine Design. This guide assumes you just want to wire something up and run it.

For the engine's specialized subsystems — audio, signed distance fields, heightmap terrain, geospatial globe streaming, and rigid-body physics — see the module guides indexed in this folder.

The three API layers#

Taos exposes three layers stacked on top of each other. You can drop down to a lower layer whenever the one above gets in the way.

Layer Entry point Use when
Engine + preset Engine.create({ preset }) You want a working renderer in ~20 lines.
Engine + features engine.addFeature(...) You want to pick the render passes yourself, but keep scene/camera/draw bucketing automatic.
Manual render graph new RenderGraph(ctx, cache) You're prototyping a new pass, or the engine's frame loop doesn't fit.

This guide walks the top layer in depth, then sketches the layers below.


Hello, Cube#

A complete, runnable, external Vite + TypeScript project that consumes Taos as a GitHub dependency and renders a single rotating cube under the deferred preset. Six files. The version below was scaffolded end-to-end and verified against a running browser (zero validation errors, cube visible on screen).

Runnable in-repo copy. A build-verified version of this project lives at standalone/hello_cube/ — it consumes the engine from local source (../../src) instead of GitHub, so npm install && npm run dev works straight from a clone. Use it as a scaffold to copy. You can also ▶ run the built demo right here.

Project layout#

hello-cube/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── index.html
└── src/
    └── main.ts

package.json#

{
  "name": "hello-cube",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "taos": "git+https://github.com/brendan-duncan/TaosEngine.git"
  },
  "devDependencies": {
    "@webgpu/types": "^0.1.69",
    "typescript": "^5.4.5",
    "vite": "^8.0.14"
  }
}

Taos isn't currently published to npm; you install it directly from GitHub. Append #main, a branch name, or a commit SHA to pin (...TaosEngine.git#4d63da7).

tsconfig.json#

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["@webgpu/types"],
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src/**/*"]
}

moduleResolution: "bundler" is required because Taos's source uses .js extensions when importing .ts files (import { Foo } from './foo.js' resolves ./foo.ts). This is the standard convention for ESM projects: native ES modules require an explicit file extension in import specifiers, and since the TypeScript compiler (and bundlers like Vite) emit .js files, the import must name the .js output rather than the .ts source. Always include the .js extension when importing — omitting it will break resolution.

vite.config.ts#

import { defineConfig } from 'vite';

export default defineConfig({});

That's it for configuring Vite.

index.html#

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Taos — Hello Cube</title>
  <style>
    html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #000; }
    canvas { display: block; width: 100vw; height: 100vh; }
  </style>
</head>
<body>
  <canvas id="canvas"></canvas>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

src/main.ts#

import {
  Vec3,
  Mesh,
  PbrMaterial,
  Engine,
  GameObject,
  Camera,
  MeshRenderer,
  DirectionalLight,
  deferredPreset,
} from 'taos/index.js';

async function main(): Promise<void> {
  const canvas = document.getElementById('canvas') as HTMLCanvasElement;

  const engine = await Engine.create({
    canvas,
    contextOptions: { enableErrorHandling: true },
    renderPreset: deferredPreset({ transparent: false, taa: false, aces: false }),
  });
  const device = engine.ctx.device;

  const cubeMesh = Mesh.createCube(device);
  const cubeMat = new PbrMaterial({ albedo: [0.8, 0.3, 0.2, 1], roughness: 0.4, metallic: 0 });

  const cube = engine.scene.add(
    new GameObject({ name: 'Cube', components: new MeshRenderer(cubeMesh, cubeMat) }));

  engine.scene.add(new GameObject({ name: 'Sun', components: new DirectionalLight(
    new Vec3(0.3, -1, 0.4).normalize(), new Vec3(1, 0.95, 0.9), 3.0, 3) }));

  const cam = engine.scene.add(
    new GameObject({ name: 'Camera', components: Camera.createPerspective() }));
  cam.setPosition(0, 1, 4);

  let angle = 0;
  engine.beforeFrame((f) => {
    angle += f.dt * 0.6;
    cube.setAxisAngle(Vec3.UP, angle);
  });

  engine.run();
}

main().catch((e: unknown) => {
  const msg = e instanceof Error ? e.message : String(e);
  document.body.innerHTML = `<pre style="color:#f88;padding:1rem;font-family:ui-monospace,monospace">${msg}</pre>`;
  console.error(e);
});

A few things worth calling out:

  • Imports use taos/<subdir>/index.js, not taos/src/.... Taos's "exports" map in package.json makes the src/ prefix unnecessary.
  • Camera placement matters. Taos's default forward is -Z (see camera_controller.tshorizontal forward = (-sinY, 0, -cosY)). With the camera at (0, 1, 4) and identity rotation, it looks down -Z toward the cube at the origin. Putting the camera on the -Z side without a corresponding yaw = Math.PI will leave the scene off-screen.
  • new GameObject({ name, components }) attaches component(s) at construction. components accepts a single Component or an array.
  • engine.scene.add(go) returns the GameObject, so you can construct, add, and keep a reference in one expression. The transform setters (setPosition/setScale/setRotation/setEulerRotation/setAxisAngle) also return the GameObject for chaining.
  • In-place rotation. cube.rotation is a Quaternion; the GameObject's setAxisAngle shorthand rewrites the quaternion's components in place so we don't allocate per frame.

Run it#

npm install
npm run dev

Vite serves on http://localhost:5173/. You should see a slowly rotating red cube on a black background. engine.run() starts a requestAnimationFrame tick that walks the scene, buckets draws, builds the render graph from the preset's features, compiles it, and submits the command buffer each frame — the beforeFrame callback is your hook into that loop.


Core concepts#

GameObject and Component#

A GameObject is a transform with a parent, children, and components. A Component is a behavior attached to a GameObject.

const go = new GameObject({ name: 'Player' });
go.setPosition(0, 1, 0)
  .setEulerRotation(0, Math.PI / 4, 0)  // see src/math/quaternion.ts
  .setScale(1, 1, 1);

go.addComponent(new MeshRenderer(mesh, material));
const cam = go.getComponent(Camera);  // typed lookup

The transform setters (setPosition, setScale, setRotation, setEulerRotation, setAxisAngle) write the existing position/rotation/ scale instances in place and return the GameObject, so they chain and avoid per-call allocation. Quaternion is no longer needed just to set a rotation inline.

Under the hood each GameObject owns a Transform (reachable as go.transform); position / rotation / scale are forwarding accessors onto it, so go.position.y += dt mutates the transform directly. The world-space values — recovered from the parent chain — come off the same object:

const wp = go.worldPosition;            // transform-owned Vec3 — copy before holding it
go.getWorldPosition(scratch);           // allocation-free: writes into `scratch`
const m = go.localToWorld();            // fresh Mat4 clone of the cached world matrix
go.localToWorldInto(out);               // allocation-free variant for per-frame loops

The transform caches its world matrix and invalidates by compare-on-read, not dirty flags: it compares the current TRS scalars against the last-baked values on read and only re-multiplies the levels of the parent chain that actually moved, so go.position.y += … needs no notification call. The world getters reuse transform-owned storage; use the getWorld*(out) / localToWorldInto(out) forms when you need to keep a value across frames. See Chapter 12 §12.2.1 for the full mechanism.

Most GameObjects only ever get one component. The constructor's components option attaches it inline (it accepts a single Component or an array):

const sun = new GameObject({
  name: 'Sun',
  components: new DirectionalLight(
    new Vec3(1, -0.8, 0).normalize(),
    new Vec3(1.0, 0.95, 0.9),
    3.0, 3,
  ),
});
engine.scene.add(sun);

The component is attached (its onAttach runs) before the constructor returns, and you get the GameObject back so you can chain engine.scene.add(...) or grab a reference. Pass an array to attach several at once. For position/rotation set the matching options alongside components.

Parent/child:

const wheel = new GameObject({ name: 'Wheel' });
const car = new GameObject({ name: 'Car' });
car.addChild(wheel);  // wheel.position is now in car-local space

Custom components subclass Component:

const AXIS_Y = new Vec3(0, 1, 0);

class Spinner extends Component {
  speed = 1.0;
  private _angle = 0;
  update(dt: number): void {
    this._angle += this.speed * dt;
    this.gameObject.rotation.setAxisAngle(AXIS_Y, this._angle);
  }
}
go.addComponent(new Spinner());

The engine calls update(dt) on every component each frame, and updateRender(ctx) after that for components that need post-update GPU work. Override onAttach() / onDetach() for setup and teardown.

Scene#

Scene is a flat list of root GameObjects.

engine.scene.add(go);
engine.scene.remove(go);

const allLights = engine.scene.getComponents(DirectionalLight);
const cam = engine.scene.findCamera();          // first Camera in the tree
const sun = engine.scene.findSunLight(); // first sun in the tree

engine.scene.findCamera() is what populates engine.camera on the first frame if you haven't set it explicitly.

Camera#

Camera is a component. Use the static factories:

Camera.createPerspective(fovDeg, near, far, aspect);
Camera.createOrthographic(orthoSize, near, far, aspect);

The aspect ratio is recomputed on canvas resize as long as you call engine.run()RenderContext.update() (invoked at the top of every frame) detects the resize and the engine invalidates downstream state.

Pinning the camera. On the first frame, the engine resolves engine.camera by calling scene.findCamera() — which returns the first Camera component on a root GameObject. For the common case of one camera attached to a root, you don't need to assign engine.camera at all:

const cameraGO = new GameObject({ name: 'Camera' });
cameraGO.addComponent(Camera.createPerspective(60, 0.1, 100, aspect));
engine.scene.add(cameraGO);
// engine.camera = camera   // ← not needed; engine.findCamera() picks it up

Set engine.camera = otherCamera explicitly when:

  • the scene has multiple cameras and you need to choose one (findCamera returns the first match — registration order),
  • the camera GameObject is parented under another GameObject rather than being a scene root (findCamera only walks roots),
  • you want to switch between cameras at runtime (split-screen toggle, picture-in-picture, etc.).

Lights#

DirectionalLight, PointLight, SpotLight are all components — add them to a GameObject and put it in the scene.

sunGO.addComponent(new DirectionalLight(
  new Vec3(1, -0.8, 0).normalize(),  // direction (toward the ground)
  new Vec3(1.0, 0.95, 0.9),           // color
  3.0,                                 // intensity
  3,                                   // cascades for shadow mapping
));

Sun direction follows the light-travel convention (the vector points the way the photons go). Note: the atmosphere pass internally uses the opposite (toward-sun) convention — the deferred preset wires the conversion for you.

For point and spot lights the deferred preset needs pointSpotLights: true (it's on by default). The forward preset always supports them.

Physical units. intensity is already a physical quantity (the BRDF is radiometrically consistent): directional intensity is illuminance in lux, point/spot intensity is luminous intensity in candela (illuminance at 1 m). Author real-world values via the convenience accessors — lux on directional, lumens on point/spot (bulbs are rated in lumens; the setter converts with cd = lm/4π, or the cone solid angle for spots):

sun.lux = 100000;                     // direct midday sun
bulb.lumens = 800;                    // a 60W-equivalent bulb → ~64 cd
spot.outerAngle = 25; spot.lumens = 1200;

Real values only look right with a physical exposure — pair them with the physical-camera exposure below (camera on the preset / TonemapFeature).

Mesh and Material#

Meshes are loaded or generated from src/assets/mesh.ts:

Mesh.createCube(device, size?);
Mesh.createSphere(device, radius, latSegments, lonSegments);
Mesh.createPlane(device, width, depth, segX?, segZ?);
Mesh.createBox(device, w, h, d);
Mesh.createCone(device, radius, height, segments);
Mesh.createTorus(device, ...);
Mesh.fromData(device, vertices, indices);

For glTF, use GltfLoader.

A MeshRenderer ties a mesh to a material:

const mr = new MeshRenderer(mesh, material);
mr.castShadow = true;  // default; set false to skip shadow passes
go.addComponent(mr);

The built-in material is PbrMaterial (albedo, roughness, metallic, optional textures, optional transparency). After mutating its parameters you must re-call material.update(device.queue) to push the new uniforms — the engine does not do this for you.

const mat = new PbrMaterial({
  albedo: [0.9, 0.2, 0.1, 1],
  roughness: 0.3,
  metallic: 0.0,
  transparent: false,
});
mat.update(device.queue);   // upload uniforms

Setting transparent: true routes the renderer to the forward path for that draw (even under the deferred preset).


Render presets#

A preset is a function that registers a coordinated bundle of RenderFeature instances on an engine. Taos ships three:

All three presets accept the same shared options where they make sense — sky, shadow, IBL, TAA, DoF, bloom, exposure/aces/hdrCanvas — and only diverge for pipeline-specific knobs. Source lives at src/renderer/presets/.

Sky options (shared)#

Each preset takes a sky?: SkyOption tagged union picking exactly one of:

type SkyOption =
  | { kind: 'none' }                                                          // default
  | { kind: 'color'; color: [r, g, b, a] }                                    // flat clear
  | { kind: 'texture'; texture: Texture; exposure?: number }                  // equirectangular HDR
  | { kind: 'atmosphere'; planet?, horizonless?, useMultiscatterLut? };       // procedural scattering

'none' lets the first lit pass clear HDR itself. 'color' registers {@link ConstantColorSkyFeature} — a clear-only pass. 'texture' registers {@link SkyTextureFeature}; 'atmosphere' registers {@link AtmosphereFeature} (plus {@link AtmosphereLutsFeature} automatically when useMultiscatterLut is set).

forwardPreset(opts?)#

Minimal forward-lit PBR pipeline (optional sky → optional shadow → forward-lit → optional TAA / DoF / bloom → tonemap). Best for simple material-showcase scenes. See forward_preset.ts for the option list. Highlights:

forwardPreset({
  sky: { kind: 'texture', texture: hdrCubemap, exposure: 1.0 },
  ibl: iblTextures,         // pre-computed diffuse+specular IBL
  directional: () => sun,   // optional; otherwise scene's first DirectionalLight
  shadow: false,            // off by default; pass `true` or options to enable
  taa: false,
  dof: false,
  bloom: false,
  exposure: 1.0,
  aces: true,
  hdrCanvas: false,         // request a high-bit-depth swapchain
});

forwardPlusPreset(opts)#

Tiled forward+ pipeline (optional sky → optional shadow → forward+ → optional TAA / DoF / bloom → tonemap). The forward+ feature runs a compute pre-pass that bins point lights into 16×16 screen tiles, so each fragment shades only the lights its tile actually received — pick this when you have many small point lights, or need transparency/MSAA in the main pass. See forward_plus_preset.ts:

forwardPlusPreset({
  pointLights: () => livePointLights,  // required; called once per frame
  sky: { kind: 'texture', texture: hdrCubemap, exposure: 1.0 },
  ibl: iblTextures,
  directional: () => sun,
  shadow: { shadowFar: 140 },          // or `false` to disable
  taa: false, dof: false, bloom: false,
  exposure: 1.0, aces: true,
});

pointLights is a callback so the preset can read the live list each frame (mutating positions/colors on the returned objects is fine — the GPU buffer is re-uploaded every frame).

deferredPreset(opts?)#

The standard deferred pipeline: shadow → geometry → AO → sky → deferred lighting → optional point/spot → optional transparent overlay → optional TAA, DoF, bloom → tonemap. See deferred_preset.ts. Highlights:

deferredPreset({
  sky: { kind: 'atmosphere' },  // or { kind: 'texture', texture, exposure }, etc.
  ibl: iblTextures,
  ao: 'gtao',                   // 'gtao' | 'hbao+' | 'ssao' | false
  transparent: true,            // add a forward overlay for transparent meshes
  overlayLighting: 'forward+',  // use ForwardPlusPass for the overlay shading
                                //   (default 'forward'; ignored when transparent: false)
  pointSpotLights: true,        // include the point/spot deferred light pass
  taa: true,                    // temporal AA
  dof: false,                   // depth of field — true or an options object
  bloom: true,                  // true or BloomFeatureOptions
  shadow: { /* ShadowFeatureOptions */ },
  lighting: { /* DeferredLightingFeatureOptions */ },
  exposure: 1.0,
  aces: true,
});

overlayLighting is the deferred-only knob: it picks which forward pass shades the transparency overlay. 'forward' (the default) uses {@link ForwardPass} — cheap when transparents are few. 'forward+' swaps in {@link ForwardPlusPass}, sharing the gbuffer depth as the cull pre-pass's input so the per-tile light list stays tight even when transparents need to see hundreds of point lights.

You can also stack a preset with extra features:

await Engine.create({
  canvas,
  renderPreset: deferredPreset({ taa: true }),
  features: [new MyCustomFeature()],
});

Per-frame hooks#

The engine exposes three places to run your own code each frame.

engine.beforeFrame((frame) => {
  // Top of the frame, before scene.update / camera matrix caching.
  // Right place to mutate GameObject transforms (input handling, controllers).
  cameraController.update(cameraGO, frame.dt);
});

engine.beforeRender((frame) => {
  // While a fresh render graph is being built, after every feature has added
  // its passes. Escape hatch for one-off inline passes — declare a pass via
  // frame.graph.addPass(...) and (if it produces a new HDR) reassign
  // frame.hdr so downstream features pick it up.
  //
  // Runs every frame because the graph is rebuilt every frame.
});

engine.afterFrame((frame) => {
  // After the command buffer has been submitted.
  // Use for UI updates, FPS readout, the render-graph viz overlay, etc.
  statsEl.textContent = `FPS: ${engine.ctx.fps}`;
});

The Frame object passed to each hook holds the per-frame state: dt, time, frameIndex, the bucketed draw lists (opaque, transparent, shadowCasters), the live graph, and the current resource handles (hdr, gbuffer, shadowMap, ao, depth, exposureBuffer, plus a Map of feature-specific extras).

Why three hooks? beforeFrame runs before the scene is updated — that's where input belongs. beforeRender runs during graph build — that's where custom passes belong. afterFrame runs after submission — that's where UI belongs.


Driving features directly#

Presets are just convenience constructors. You can opt out and register features one by one:

import {
  Engine,
  GeometryFeature,
  ShadowFeature,
  DeferredLightingFeature,
  TonemapFeature,
} from 'taos/src/engine/index.js';

const engine = await Engine.create({ canvas });
engine.addFeature(new GeometryFeature());
engine.addFeature(new ShadowFeature());
engine.addFeature(new DeferredLightingFeature());
engine.addFeature(new TonemapFeature({ exposure: 1.0, aces: true }));

TonemapFeature selects the curve with tonemapper ('none' / 'aces' / 'aces-fitted'; the legacy aces boolean still maps to Narkowicz). For physically-based exposure, pass a camera instead of a raw exposure — it derives the exposure from aperture/shutter/ISO via EV100 each frame, so camera sliders are live (the presets accept the same camera option):

new TonemapFeature({ tonemapper: 'aces-fitted', camera: { aperture: 16, shutterTime: 1/125, iso: 100 } });

Auto-exposure (AutoExposureFeature, histogram metering) and the physical camera are alternative exposure modes — meter the scene, or set the camera — not both at once; they share the exposureCompensation (EV-stops) bias.

Other knobs on the engine. Feature lookup keys are the class names (ShadowFeature.name === 'ShadowFeature'); pass lookup keys are the pass's name property ('GeometryPass', 'TonemapPass', etc.):

engine.setFeatureEnabled('TAAFeature', false);        // toggle without removing
engine.removeFeature('BloomFeature');                 // remove + dispose
const taa = engine.getFeature('TAAFeature');          // typed lookup, null-safe
const shadow = engine.feature('ShadowFeature');       // throws if missing

const geometryPass = engine.getPass('GeometryPass');  // by Pass.name

To plug in your own feature, implement RenderFeature:

class FogOverlayFeature implements RenderFeature {
  readonly name = 'FogOverlay';
  enabled = true;
  private pass!: MyFogPass;

  setup(engine: Engine): void {
    this.pass = MyFogPass.create(engine.ctx);
  }

  update(frame: Frame): void {
    this.pass.updateParams(frame.ctx, /* ... */);
  }

  addPasses(frame: Frame): void {
    if (!frame.hdr) return;
    const out = this.pass.addToGraph(frame.graph, { hdr: frame.hdr, depth: frame.depth });
    frame.hdr = out.output;  // chain into the HDR target
  }

  destroy(): void { this.pass.destroy(); }
}

The addPasses contract: read the current frame.hdr (and any other handle you need), declare your pass on frame.graph, write the resulting handle back so downstream features see your output. Order matches feature registration order.


Dropping below the engine: the manual render graph#

If you want to drive the graph yourself — no Engine, no RenderFeature, no preset — go straight to the render-graph API. The canonical small reference is samples/rg_forward_simple.ts. The shape:

const ctx = await RenderContext.create(canvas, { enableErrorHandling: true });
const cache = new PhysicalResourceCache(ctx.device);

const forwardPass = ForwardPass.create(ctx);
const tonemapPass = TonemapPass.create(ctx);

function frame(): void {
  ctx.update();

  forwardPass.setDrawItems(drawItems);
  forwardPass.updateCamera(ctx);
  forwardPass.updateLights(ctx, directional, points, spots);

  const graph = new RenderGraph(ctx, cache);
  const bb = graph.setBackbuffer('canvas');
  const lit = forwardPass.addToGraph(graph);
  tonemapPass.addToGraph(graph, { hdr: lit.output, backbuffer: bb });

  void graph.execute(graph.compile());
  requestAnimationFrame(frame);
}

You lose: the scene walk, draw bucketing, camera matrix caching, the ResizeObserver, the feature lifecycle. You gain: full control over which passes exist and how they're wired.

The rg_*_full.ts samples are the cleaner references for the full deferred and forward+ wiring (clouds, atmosphere, TAA, bloom, etc.).


Manual frame stepping#

If you have your own outer loop (XR, server-driven sim, screenshot automation), don't call engine.run(). Call engine.frame() instead:

async function customLoop() {
  while (running) {
    doMyOtherWork();
    await engine.frame();
  }
}

engine.frame() resolves once the command buffer has been submitted.


Cleanup#

engine.stop();      // pause the RAF loop, can be resumed with engine.run()
engine.destroy();   // tear down features, release the physical resource cache

destroy() does not release the GPUDevice itself — destroy that yourself if you're done with WebGPU entirely.


Module guides#

The subsystems below sit on top of the engine described here. Each guide is self-contained and assumes you've read this page first.

  • Core — the full taos barrel: math, assets, controllers, animation, the feature catalog, RenderContext
  • Audio — buses, spatial SFX, music, effects, mic, analysis
  • SDF — baking signed distance fields from meshes, particle collision/attraction
  • Terrain — CDLOD heightmap terrain with virtual-texture streaming
  • Geo — floating-origin geospatial globe, 3D Tiles + quantized-mesh streaming
  • Physics — Jolt rigid-body simulation and scene coupling
  • Packs — bundle assets into .pack archives: async load, dependencies, platform variants, HTTP Range streaming

See also#