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]:
opennessgrows at1/0.55per second while the player is withinopenRadius(5 m).opennessshrinks back at the same rate once the player is pastcloseRadius(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:
- Distance check: if player > 30 m, go idle.
- 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. - Kite: if LOS clear and dist > preferred + 1.5, walk toward player. If dist < preferred − 1.5, back away. Otherwise stand still.
- 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 = 6only if the perturbed shot would land within ~0.55 m of the player and the wall hit isn't in the way. - 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 hitrayCapsule(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: 22so 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)
PointLightparented 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— wrapsSSGIPass, reads the persistent TAA history texture asprevRadiance, writesframe.ssgi.DeferredLightingFeature.addPassesforwardsframe.ssgito the underlying pass as the indirect-diffuse term.deferredPreset({ ssgi: true | SsgiFeatureOptions, ... })— insertsSsgiFeaturebetween 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 = truefor sharp shadow cones. - A handful of cool-blue fill point lights, no shadow.
- A
FlickerLightcomponent on a subset of torches drives stochastic intensity modulation (stable dwell + flicker burst with per-frame randomdipFloor..1modulation) for the dying-bulb feel. - 12 door spotlights — two per door, ceiling-mounted, aimed at the door
center via a from-to quaternion.
castShadow = trueso the cone edges are sharp;range = 6 mkeeps 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_lifetimewarm orange → magenta.size_over_lifetime0.09 → 0.012 m — sparks taper to points.
- Renderer: velocity-aligned
sparksprites, 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.inputStrafein[-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'sautoFire = true); touchup callsonFireUp. Each frame, whileautoFire, the main loop callsweapon.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. - JUMP →
controller.inputJumpRequested = true(one-shot, consumed insidecontroller.update). - RELOAD →
onReloadcallback →weapon.reload(). - CROUCH → toggles
controller.inputCrouchingand 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.emissiveFactordoes 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.6withWALL_HEIGHT = 4.5, leaving a 1.9 m open header above the door when closed. DeferredLightingFeaturehard-requires a shadow map even when there's no directional light. TheShadowFeaturehandles "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 + 5lands 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 isfloorY + 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 removesBloomFeature+TonemapFeatureafterEngine.create, adds the particle feature, then re-adds bloom and tonemap. The recreated bloom/tonemap pay one extrasetup()call; acceptable for once at startup.