Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

FPS Shooter — Technical Deep Dive

A first-person shooter tech demo built on the Taos WebGPU engine. Four hand-authored rooms connected by hallways and sliding doors; ranged AI that takes cover and shoots back; emissive laser tracers that cast real-time light; an indoor procedural-texture environment with flickering torches, shadowed point + spot lights, GTAO, SSGI, IBL, TAA, and bloom; a swirling particle orb on a pedestal in the starting room.

Everything — collision, hitscan, pathfinding, AI, doors, HUD, particles — is sample-local code on top of the engine's deferred render preset plus a few in-house helpers. No game-logic dependencies outside Taos itself.

Overview#

What you see and do:

  • Spawn in Room A (west) facing east toward a sliding door at x = -6.
  • Walk through the door into a short Hallway AB, then another door into the Room B central hub.
  • Two more hallways branch off the hub — north to Room C, south to Room D. Each has its own sliding doors at both ends. Six doors total.
  • Six enemies are distributed round-robin across rooms B, C, and D — no enemies in your starting room, so the first encounter is a deliberate push.
  • Enemies are slow ranged turrets. They hold a ~11 m preferred range and fire red hitscan tracers; if you break line-of-sight behind a column or closed door they fall back to A* and try to reposition.
  • You shoot yellow tracers; each hit drops ~25 hp off an enemy with 50 hp.
  • On death, a 5 s respawn timer puts you back at the start.
  • On victory (all enemies dead), a 10 s timer respawns a fresh wave.

Controls:

Key Action
W A S D / arrows Walk
Mouse (pointer lock) Aim
Shift Crouch (lowers camera + capsule; lets you duck under low cover)
Ctrl / Alt Sprint
Space Jump
Left click Fire
R Reload
G Toggle render-graph viz overlay

On touch devices an on-screen overlay self-installs the first time the device dispatches a touchstart (so a desktop session pays nothing):

Region / button Action
Virtual joystick (bottom-left) Walk (analog forward/strafe)
Drag anywhere else Aim (yaw + pitch)
FIRE (large, bottom-right) Hold to autofire — weapon's fire cooldown caps the rate
JUMP Tap to jump
RELOAD Tap to reload
CROUCH Toggle crouch

Pipeline at a glance:

ShadowFeature → GeometryFeature → AOFeature(gtao) → SsgiFeature
  → ConstantColorSky → DeferredLightingFeature (consumes SSGI + IBL + GTAO)
  → PointSpotLightFeature (shadowed point/spot lights)
  → ForwardOverlayFeature (transparent laser sprites)
  → TAAFeature → ParticleFeature (orb sparks) → BloomFeature → TonemapFeature

The orb particle feature is inserted between TAA and bloom so the sparks bloom but don't smear through the temporal resolve. This requires removing the preset's bloom + tonemap after Engine.create and re-adding them after ParticleFeature — see fps_shooter.ts.

File layout#

samples/fps_shooter.html              harness (canvas, HUD root, chrome scripts)
samples/fps_shooter.ts                main() — engine boot, level, player, enemies, loop
samples/fps_shooter/
  collision.ts                        Triangle, rayTriangle, capsuleVsTriangles
  level_builder.ts                    7 rooms/hallways, walls, floors, lights, doors
  door.ts                             sliding door FSM + proximity hysteresis
  fps_controller.ts                   pointer-lock + WASD + capsule physics + crouch
  pathfinding.ts                      nav grid + A* + line-of-sight smoothing
  weapon.ts                           primitive gun + hitscan + tracer
  enemy.ts                            enemy + Health + flash + topple-on-death
  enemy_ai.ts                         slow-ranged turret AI (idle/reposition/shoot)
  tracers.ts                          transient emissive sprites + point lights
  flicker_light.ts                    stochastic intensity modulator for torches
  procedural_textures.ts              CPU-generated albedo + normal maps
  orb_particles.ts                    ParticleGraphConfig for the pedestal orb
  hud.ts                              DOM overlay (crosshair / health / ammo / overlays)
  fps_touch_controls.ts               touch overlay (joystick + aim drag + buttons)

Collision — collision.ts#

Generic mesh-collision primitives kept sample-local. A flat array of world-space Triangles drives everything: player movement, enemy movement, hitscan, line-of-sight checks, even nav-grid construction. Performance is fine because the arena has a few hundred triangles and the linear scan is well under a millisecond per call.

interface Triangle { a, b, c, normal: Vec3 }
interface RayHit  { t, point, normal: Vec3; triIndex: number }

function rayTriangle(orig, dir, tri, tMax): number          // Möller–Trumbore; -1 on miss
function raycastTriangles(orig, dir, tris, tMax): RayHit | null
function closestPointOnTriangle(p, tri): Vec3                // Ericson §5.1.5
function capsuleVsTriangles(base, top, r, tris, iter = 4): Vec3
function rayCapsule(orig, dir, base, top, r): number         // sample-marched

capsuleVsTriangles iterates up to 4 times: for each triangle whose closest-point-on-segment is within the radius, it pushes the capsule along the contact normal by (radius − distance + ε). Bails the moment no triangle penetrates. Fine for the sample's geometry; a BVH could swap in at the same callsite if the level grew significantly.


Level — level_builder.ts#

Hand-authored from 7 axis-aligned region rectangles (Rooms A/B/C/D + 3 hallways). Every wall segment is one Mesh.createBox placed by the addAxisWall(x1, z1, x2, z2) helper, which decides axis from endpoint deltas and fattens the box perpendicular by WALL_THICKNESS. Floors and ceilings are per-region slabs.

Materials#

Each surface type gets a PbrMaterial with a procedural albedo + normal map. Both maps come from the same heightfield (so the bumps match the visible features — brick mortar dents, slab seams, basalt cracks):

// procedural_textures.ts
makeFloorTexture(device)       → albedo: grimy concrete with darker seams
makeFloorNormalTexture(device) → normal: same seams, fine grain bumps
makeWallTexture / makeWallNormalTexture        → red brick + raised brick faces
makePillarTexture / makePillarNormalTexture    → cracked basalt with crack grooves
makeCeilingTexture / makeCeilingNormalTexture  → riveted panels with raised rivets
makeDoorAlbedoTexture                           → dark metal panels
makeDoorEmissiveTexture                         → orange power conduits + cyan scan-bar + pilot lights

Normal maps are central-difference gradients on the heightfield, packed to rgba8unorm (linear, NOT sRGB — sRGB would corrupt the encoded vector).

Dynamic triangle list#

BuiltLevel.triangles is a single array that the rest of the code holds a reference to. The static walls/floors/ceilings/pillars are baked into it once; door triangles are appended every frame by BuiltLevel.updateDoors(dt, [playerPos]) after each door has updated its openness. The reference stays stable so FpsController, EnemyAi, and Weapon (which captured it at startup) automatically see the live state.

The nav grid is built against staticTriangles only — doors are dynamic and shouldn't appear as obstacles, so A* always finds a path even when the door is closed.


Doors — door.ts#

Each door is a vertical box with a proximity trigger and an opening/closing animation. State machine collapses to a single openness in [0, 1]:

  • openness grows at 1/0.55 per second while the player is within openRadius (5 m).
  • openness shrinks back at the same rate once the player is past closeRadius (7 m).
  • The 2 m hysteresis prevents the door from chattering at the threshold.

Each frame the door rebuilds its world-space triangles (12 per door — cheap) from the current openness, so capsule collision and AI LOS see the door in its actual position.

The door box is shorter than the wall (DOOR_HEIGHT = 2.6) so it has room to slide up without z-fighting the ceiling slab; the maxOpenTopY cap puts the top edge 0.1 m below the ceiling when fully open. Player can walk under the open door (door bottom = 1.8 m > capsule height ~1.75 m).

Doors only trigger on the player, not enemies. Enemies trapped on the far side of a closed door stay trapped until the player opens the door — the player decides when to engage each room.


FPS controller — fps_controller.ts#

First-person controller built on GameObject. Pointer-lock yaw/pitch with ±89° pitch clamp; WASD horizontal motion sub-stepped (3 chunks per axis) so sprinting into thin walls doesn't tunnel; gravity + coyote-time jump; capsule-vs-triangle collision against level.triangles.

The crouch system smoothly lerps _eyeH and _capH from standing (EYE_HEIGHT = 1.65, HEIGHT = 1.75) to crouching (CROUCH_EYE_HEIGHT = 0.95, CROUCH_HEIGHT = 1.05) at 14/s while ShiftLeft is held. Speed drops to CROUCH_SPEED = 2.0 (vs WALK_SPEED = 5.0); sprint (Ctrl/Alt) is disabled while crouching.

Ground is detected with a short downward raycast from the capsule's base hemisphere center; the player snaps to the surface and clears vertical velocity on contact.

The controller also exposes public analog input fields that the touch overlay drives:

controller.inputForward       // analog -1..1 (touch joystick Y)
controller.inputStrafe        // analog -1..1 (touch joystick X)
controller.inputJumpRequested // one-shot, consumed each frame
controller.inputCrouching     // sustained toggle
controller.inputSprinting     // sustained toggle
controller.usePointerLock     // touch sets false to disable click-to-lock
controller.applyLookDelta(dx, dy)  // touch drag → yaw/pitch

These OR with the keyboard each frame, so a mouse+keyboard player and a touch player can use the controller without either path knowing about the other.


Enemy AI — enemy_ai.ts#

Slow ranged turret behavior. Three states (idle / reposition / shoot) and a single tunable preferred range (~11 m).

Per-frame logic:

  1. Distance check: if player > 30 m, go idle.
  2. Multi-sample LOS (_sampleLos): cast three rays from the enemy eye to the player's head, chest, and waist. All three must be unobstructed to count as "in sight" — otherwise a column blocking the torso reads as no-LOS even if the head pokes out.
  3. Kite: if LOS clear and dist > preferred + 1.5, walk toward player. If dist < preferred − 1.5, back away. Otherwise stand still.
  4. Fire: with a 1.4 s cooldown, build a hitscan ray from the enemy eye to the player eye with a small inaccuracy cone (±0.06 rad). Spawn a red tracer to either the player or the first wall behind them; deal SHOT_DAMAGE = 6 only if the perturbed shot would land within ~0.55 m of the player and the wall hit isn't in the way.
  5. Pathfind: if LOS blocked, A* on the nav grid with findPath + smoothPath (line-of-sight skipping). Re-paths on a 0.4 s cooldown or when the player drifts > 2.5 m from the previous goal.

Enemy-vs-level collision uses the same capsuleVsTriangles; enemy-vs- enemy is a simple XZ circle push-apart against getEnemies().


Pathfinding — pathfinding.ts#

A grid-based navigation system built once at level construction.

Build: voxelize the floor into a 2D grid of square cells. For each cell, raycast straight down from floorY + agentHeight + 0.5; the cell is walkable iff the ray hits a roughly-horizontal surface, clearance above is at least agentHeight, and four short horizontal probes don't hit any wall within agentRadius.

Query: A* with 8-connected neighbors, octile heuristic, and a binary min-heap for the open set. Diagonals are blocked if either adjacent orthogonal cell is occupied (no corner-cutting through walls).

Smooth: greedy string-pulling — for each pair of cells (i, j), if a capsule-radius-inflated straight raycast between cell centers is clear, drop the cells between them.


Weapon + tracers — weapon.ts, tracers.ts#

The weapon is a primitive gun mesh (cylinder barrel + box receiver) parented to the camera with a recoil offset that decays exponentially.

Hitscan picks the closer of:

  • raycastTriangles(origin, dir, level.triangles, RANGE) — level hit
  • rayCapsule(origin, dir, enemy.base, enemy.top, Enemy.RADIUS) for each enemy — enemy hit

On enemy hit: enemy.flash() (red tint for 0.12 s) + Health.damage(DAMAGE = 25).

Visual feedback runs through a shared TracerManager so the player and enemies share emissive materials:

  • Tracer: transparent emissive box stretched along the beam (0.018 m thick, emit: 22 so bloom catches it).
  • Muzzle flash: small emissive cube at the muzzle, emit: 30+.
  • Point light per tracer / flash: a warm yellow (player) or red (enemy) PointLight parented to the transient GameObject. The light is reaped automatically when the tracer's TTL expires, so each shot briefly washes the surrounding walls in shot color.

Materials carry an emissive map. The trick: PbrMaterial's default emissive map is a 1×1 black texel, so emissiveFactor * 0 = 0 — until you provide a white emissive map. tracers.ts builds one with Texture.createSolid(device, 255, 255, 255, 255) and binds it on every tracer material, turning emissiveFactor into a direct HDR multiplier.


SSGI integration#

The engine ships an SSGIPass; this sample exposes it as a feature. Added to the codebase:

  • Frame.ssgi: ResourceHandle | null — new slot, cleared each frame.
  • SsgiFeature — wraps SSGIPass, reads the persistent TAA history texture as prevRadiance, writes frame.ssgi.
  • DeferredLightingFeature.addPasses forwards frame.ssgi to the underlying pass as the indirect-diffuse term.
  • deferredPreset({ ssgi: true | SsgiFeatureOptions, ... }) — inserts SsgiFeature between AO and deferred lighting.

The sample passes ssgi: { strength: 1.4, numRays: 4, numSteps: 16, radius: 3.0, thickness: 0.5 }. SSGI bounces last frame's resolved HDR, so the GI lags one frame and re-converges over a few frames after a sharp camera move.


Lighting#

Indoor scene with no rendered sky — the deferred preset clears to a near-black color. Illumination comes from a constellation of PointLights and door SpotLights:

  • 5 warm orange "torches" (one per main room + one in Hallway AB) with castShadow = true for sharp shadow cones.
  • A handful of cool-blue fill point lights, no shadow.
  • A FlickerLight component on a subset of torches drives stochastic intensity modulation (stable dwell + flicker burst with per-frame random dipFloor..1 modulation) for the dying-bulb feel.
  • 12 door spotlights — two per door, ceiling-mounted, aimed at the door center via a from-to quaternion. castShadow = true so the cone edges are sharp; range = 6 m keeps them tight.
  • One pulsing orb light on the pedestal in Room A — intensity = 55 × (0.85 + 0.25·sin(t·3.4) + 0.10·sin(t·11.1)), range 8 m, no shadow.

A DynamicSky baked once at startup provides IBL (the rendered sky stays the constant black). engine.feature(DeferredLightingFeature).setIbl(...) plugs the IBL textures into the deferred lighting pass for an ambient floor on otherwise-shadowed walls.


Orb particles — orb_particles.ts#

A swirling sphere of glowing sparks on the Room A pedestal. The ParticleGraphConfig is:

  • Emitter: sphere shell of radius 0.18 m, 240 particles/s, lifetime 0.7–1.4 s, max 700 alive, initial speed 0.8–1.6 m/s.
  • Modifiers:
    • vortex (strength 10) — the headline swirling motion.
    • point_attractor (strength 7, radius 1.4) — keeps particles orbiting the orb center instead of flying off.
    • curl_noise (scale 3, strength 1.6) — adds a little life.
    • drag (0.9) + speed_limit (4 m/s) — caps runaway energy.
    • color_over_lifetime warm orange → magenta.
    • size_over_lifetime 0.09 → 0.012 m — sparks taper to points.
  • Renderer: velocity-aligned spark sprites, additive blend, emit: 22 (well above the bloom threshold).

Inserted between TAA and bloom so the sparks bloom but don't smear.


Touch controls — fps_touch_controls.ts#

Mobile / touch input layer. Self-installs an on-screen DOM overlay the first time a real touchstart reaches the page (via a one-shot window.addEventListener('touchstart', ..., { once: true, capture: true })), so a desktop session never builds the overlay and pays nothing.

Layout (iPhone-portrait tuned, scales fine on tablets / landscape):

+----------------------------------------+
|                                        |
|                                        |
|         (aim drag — anywhere)          |
|                                        |
|                                        |
|                                        |
|                            [RELOAD][CROUCH]
| (JOY)                              [JUMP][FIRE]
+----------------------------------------+

Wiring:

  • Joystick → mutates controller.inputForward / controller.inputStrafe in [-1, 1]. A 16 % dead-zone snaps near-center back to 0.
  • Aim drag — any touch on the canvas that's not on a button is tracked; per-frame deltas feed controller.applyLookDelta(dx * LOOK_SENS, dy * LOOK_SENS). Multi-touch works: each finger drags independently.
  • FIRE button — held-fire pattern. Touchdown calls onFireDown (sets the main loop's autoFire = true); touchup calls onFireUp. Each frame, while autoFire, the main loop calls weapon.tryFire(...); the weapon's internal fire cooldown (0.10 s) caps the actual rate, so this pattern produces sustained automatic fire from a single hold.
  • JUMPcontroller.inputJumpRequested = true (one-shot, consumed inside controller.update).
  • RELOADonReload callback → weapon.reload().
  • CROUCH → toggles controller.inputCrouching and tints the button to reflect on/off state.

Initialization also sets controller.usePointerLock = false so the canvas click that mobile browsers synthesize from a tap doesn't try to acquire pointer lock (which would either fail silently or hijack the overlay).

A small global stylesheet hides the desktop chrome buttons (source viewer, fullscreen, render-graph viz, deep-dive) once the touch overlay is live — they sit bottom-right and would stack directly under the FIRE column.

setupFpsTouchControls(canvas, opts) returns { isActive, cancel } so the host can interrogate whether touch is in use and pull the pending init listener if the sample tears down before any touch arrives.


HUD — hud.ts#

Inline-styled DOM overlay. Health bar bottom-left, ammo bottom-CENTER (under the crosshair — the source/fullscreen chrome buttons live bottom-right and would otherwise obscure it), kill chip top-right, crosshair + hit-marker pips center, full-screen overlays for click-to-play, YOU DIED — respawning in N…, and VICTORY — next wave in N….


Render-graph viz#

Press G (or the floating button bottom-right after Esc-ing to release pointer lock) to open the engine's built-in graph visualizer. The sample wires it with:

const graphViz = createRenderGraphViz(null).attach();
engine.afterFrame(() => {
  if (engine.currentGraph && engine.currentCompiled) {
    graphViz.setGraph(engine.currentGraph, engine.currentCompiled);
  }
});

You can see every pass (shadow → gbuffer → GTAO → SSGI → atmosphere LUTs → constant-color sky → deferred lighting → point/spot lighting → forward overlay → TAA → particles → bloom → tonemap), every resource read/write, and where each pass writes the HDR target.


Implementation notes / surprises#

  • PbrMaterial.emissiveFactor does nothing without a non-black emissive map. The default 1×1 emissive is black, so the multiply zeros it out. A 1×1 white texture turns the factor into a real multiplier. Took an hour to notice — the lasers were transparent but completely unlit until the white map was bound.
  • Door must be shorter than the wall. A door tall enough to fill the wall can never fully retract above the ceiling — it'll always z-fight the ceiling slab in the overlap. The sample uses DOOR_HEIGHT = 2.6 with WALL_HEIGHT = 4.5, leaving a 1.9 m open header above the door when closed.
  • DeferredLightingFeature hard-requires a shadow map even when there's no directional light. The ShadowFeature handles "no directional light" gracefully — it produces an empty cascades list and a small dummy shadow map. Keep it enabled; the cost is one tiny pass.
  • The nav-grid scan-from-Y must stay below the indoor ceiling. The default of floorY + 5 lands inside a 4.5 m ceiling slab — every cell's downward raycast hits the ceiling underside (normal.y = −1) and gets rejected. The current default is floorY + agentHeight + 0.5, safe for any normal indoor ceiling.
  • Multi-sample LOS matters. Cast 1 ray to the player's eye and the enemy will happily fire through any hip-high column with the player's head sticking out. Cast 3 rays (head + chest + waist), all must clear, and cover behaves like cover.
  • Reorder features by remove + re-add — the engine has no "insert at index". To insert the particle feature between TAA and bloom, the sample removes BloomFeature + TonemapFeature after Engine.create, adds the particle feature, then re-adds bloom and tonemap. The recreated bloom/tonemap pay one extra setup() call; acceptable for once at startup.