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, sonpm install && npm run devworks 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, nottaos/src/.... Taos's"exports"map in package.json makes thesrc/prefix unnecessary. - Camera placement matters. Taos's default forward is
-Z(see camera_controller.ts —horizontal forward = (-sinY, 0, -cosY)). With the camera at(0, 1, 4)and identity rotation, it looks down-Ztoward the cube at the origin. Putting the camera on the-Zside without a correspondingyaw = Math.PIwill leave the scene off-screen. new GameObject({ name, components })attaches component(s) at construction.componentsaccepts a singleComponentor 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.rotationis aQuaternion; the GameObject'ssetAxisAngleshorthand 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 (
findCamerareturns the first match — registration order), - the camera GameObject is parented under another GameObject rather than
being a scene root (
findCameraonly 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
taosbarrel: 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
.packarchives: async load, dependencies, platform variants, HTTP Range streaming
See also#
- samples/rg_deferred_simple.ts — the engine + preset minimal example
- samples/rg_forward_simple.ts — the manual-graph minimal example
- samples/rg_deferred_full.ts — the deferred preset with every option turned on
- samples/rg_forward_full.ts — same for forward+
- crafty/main.ts and crafty/renderer_setup.ts — the showcase voxel game
- Chapter 12 — Game Engine Design — the theory behind GameObject/Component/Scene
- Chapter 3 — Rendering Architecture — the theory behind the render graph