Engineering Deep Dive · Three.js r169 · WebGL 2

How We Built a AAA Browser FPS in 4MB

A from-scratch look at the Three.js architecture, WebGL rendering pipeline, hitscan ballistics, and performance budget behind Forkspawn — a browser FPS that loads with zero download and runs at 60fps.

Written for developers. No marketing fluff. Read the source, then play the demo.

~4MB
Total Payload
60fps
Target Framerate
0
Texture Downloads
1
CDN Dependency

The Architecture at a Glance

Forkspawn is a single-page Three.js application. There's no game engine download, no Unity WebGL megabuild, no asset pipeline. The entire game — renderer setup, scene graph, input handling, AI, combat — lives in one React component that dynamically imports Three.js from a CDN at runtime and constructs the world from procedural geometry and canvas-generated textures. Here's the stack:

┌─────────────────────────────────────────────────────┐ │ React Component (Game.tsx) │ │ ├─ Dynamic import() → Three.js r169 (CDN, cached) │ │ ├─ Scene graph: sky, arena, enemies, viewmodel │ │ ├─ Input: Pointer Lock API + touch virtual stick │ │ ├─ Combat: THREE.Raycaster hitscan + tracers │ │ ├─ AI: wave spawner + chase/attack state machine │ │ └─ Game loop: requestAnimationFrame @ 60fps │ ├─────────────────────────────────────────────────────┤ │ WebGLRenderer │ │ ├─ MeshStandardMaterial (PBR: roughness/metalness) │ │ ├─ PCFSoftShadowMap (2048×2048 directional) │ │ ├─ ACESFilmicToneMapping + exposure 1.1 │ │ ├─ FogExp2 (atmospheric depth) │ │ └─ devicePixelRatio capped at 2 │ ├─────────────────────────────────────────────────────┤ │ Assets │ │ ├─ 0 external textures (all procedural <canvas>) │ │ ├─ 0 external models (all primitive geometry) │ │ └─ 0 audio files (Web Audio API synth) │ └─────────────────────────────────────────────────────┘

The result: the game JS ships at ~4MB (gzips under 1MB), Three.js loads from jsDelivr's CDN and is cached by the browser across visits, and there are zero texture or model downloads. First paint to playable is under 2 seconds on a warm cache.

Loading Three.js Without Bundling It

The first decision: don't bundle Three.js. The full library is ~600KB minified; bundling it into the page chunk bloats the initial download and couples the game to the build. Instead we load it as an ES module via native dynamic import() at runtime, then cache the module on window so a second mount (demo → full version) reuses it without a second network request.

// Three.js r169 module build from jsDelivr — CORS-enabled, cached
// across visits. The UMD build (window.THREE) was removed after r160.
const THREE_CDN =
  "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js";

function loadThree(): Promise<AnyTHREE> {
  if (window.THREE) return Promise.resolve(window.THREE);

  // Use Function() to hide import() from webpack/turbopack static
  // analysis — ensures it stays a native browser dynamic import
  // at runtime, not a build-time code-split chunk.
  const dynamicImport = new Function("url", "return import(url)");
  return dynamicImport(THREE_CDN).then(mod => {
    window.THREE = mod;  // cache for next mount
    return mod;
  });
}

The new Function("url", "return import(url)") trick is the key: bundlers statically analyze import() calls and try to resolve them at build time. Wrapping it in a runtime-constructed function hides it from the analyzer, so the browser performs a genuine native dynamic import at runtime — pulling Three.js from the CDN, not from a local chunk.

The WebGL Rendering Pipeline

Forkspawn's visual quality comes not from expensive assets but from a correctly-configured rendering pipeline. Every setting below is a deliberate trade-off between fidelity and the 60fps budget on a mid-range laptop GPU.

PBR Materials with MeshStandardMaterial

Every surface uses MeshStandardMaterial — Three.js's physically-based shading model. The floor is rough (0.9) and barely metallic (0.1) to read as worn concrete; walls are semi-metallic (0.3) with moderate roughness (0.8) to catch the directional light as specular streaks. Enemy armor is low-metalness (0.2) with camo-textured albedo. The metalness/roughness workflow is what gives the scene its "real materials" feel without any normal maps or baked AO.

const floorMat = new THREE.MeshStandardMaterial({
  map: floorTex,        // procedural canvas texture
  roughness: 0.9,
  metalness: 0.1,
});

const renderer = new THREE.WebGLRenderer({
  antialias: true,
  powerPreference: "high-performance",
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

Dynamic Lighting & Soft Shadows

A single DirectionalLight simulates moonlight — the primary light source, casting 2048×2048 PCF soft shadows across the entire arena. A warm PointLight(the brand orange, 0xff6b1a) adds a fill glow from the far end of the arena, giving the scene color contrast between cool moonlight and warm ambient. An AmbientLight lifts the shadowed surfaces so they don't crush to pure black.

const moonLight = new THREE.DirectionalLight(0x8090c0, 1.2);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(2048, 2048);
moonLight.shadow.camera.set(-50, 50, 50, -50);
moonLight.shadow.bias = -0.0005;  // kill shadow acne

const warmLight = new THREE.PointLight(0xff6b1a, 0.8, 80);

Tone Mapping & Atmospheric Fog

ACES Filmic tone mapping (exposure 1.1) compresses the high-dynamic-range lighting into a display-ready range with filmic rolloff in the highlights — this is the single setting that makes a WebGL scene stop looking "web-game" and start looking "cinematic." FogExp2 (density 0.012) adds exponential atmospheric depth, so distant enemies fade into the haze and the arena feels larger than its 120×120 unit footprint.

renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

scene.fog = new THREE.FogExp2(0x0a0a12, 0.012);

Zero-Asset Texturing: Procedural Canvas Atlases

The single biggest contributor to the 4MB budget: not a single texture file is downloaded. Every surface texture — concrete floor, metal panel walls, enemy camo fatigues, the night sky — is drawn programmatically onto a <canvas> 2D context at load time, then uploaded to the GPU as a THREE.CanvasTexture. This replaces a traditional texture atlas (a packed PNG sheet that must be downloaded, decoded, and UV-mapped) with a few hundred lines of Canvas 2D draw calls that execute in milliseconds and cost zero network bytes.

The floor texture is a 512×512 canvas: a dark base fill, 8,000 random noise pixels for grain, a faint 8×8 grid for tile seams, then wrapped and repeated 16× across the arena plane. The wall texture draws an 8×8 metal-panel grid with rivets and grime. The enemy texture layers a vertical gradient, 40 camo blotches, and a red visor strip. Each is a self-contained function:

function makeFloorTexture(THREE): AnyObj {
  const c = document.createElement("canvas");
  c.width = c.height = 512;
  const ctx = c.getContext("2d")!;
  ctx.fillStyle = "#1a1a1e"; ctx.fillRect(0, 0, 512, 512);
  // 8000 noise pixels for concrete grain
  for (let i = 0; i < 8000; i++) { /* random dark/light specks */ }
  // faint grid for tile seams
  for (let i = 0; i <= 8; i++) { /* stroke grid lines */ }
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(16, 16);  // tile across the floor
  return tex;
}

Why this matters for a browser FPS: texture downloads are the dominant payload in any web game. A single 4K PBR texture set (albedo + normal + roughness) is 15–25MB. Forkspawn's entire visual asset budget iszero bytes — the "textures" are code that runs once at startup. The trade-off is less micro-detail than a hand-authored 4K texture, but with tone mapping and fog the result reads as convincingly atmospheric at a fraction of the cost.

Draw Calls, Shared Materials & Frustum Culling

Draw calls are the dominant GPU overhead in a browser game — each is a CPU→GPU dispatch with fixed cost. Forkspawn keeps them low through three strategies:

// One wall material, reused across all 4 walls — one texture, one bind
const wallMat = new THREE.MeshStandardMaterial({
  map: wallTex, roughness: 0.8, metalness: 0.3,
});
for (const def of wallDefs) {
  const wall = new THREE.Mesh(new THREE.BoxGeometry(def.w, 12, def.d), wallMat);
  wall.castShadow = wall.receiveShadow = true;
  scene.add(wall);
}

The enemy model is a Group of 5 primitive meshes (capsule body, sphere head, two shoulder pads) sharing two materials. A wave of 10 enemies is 50 meshes — well within budget, and frustum culling drops any enemy behind the player instantly.

Hitscan Ballistics: Raycasting Instead of Projectiles

The combat system is where browser FPS performance is won or lost. The naive approach — spawn a bullet mesh per shot, simulate its velocity each frame, test collision against every enemy — creates two problems: a growing array of per-frame physics objects (at 600 RPM that's 10 live bullets to track), and an O(bullets × enemies) collision loop every frame. Forkspawn eliminates both with instant hitscan raycasting.

On every shot, a THREE.Raycaster is cast from the camera position along the view direction (with per-weapon spread applied as random jitter to the direction vector). The ray is intersected against all colliders and enemy hitboxes in a single pass — walls, crates, enemy bodies, and enemy heads. The nearest intersect wins. If it's an enemy mesh, damage is applied (headshots = 2×); if it's a wall, a spark spawns at the impact point. There are zero persistent bullet objects and zero per-frame collision loops. The cost is O(visible enemies + colliders) per shot, not per frame.

const raycaster = new THREE.Raycaster();
raycaster.far = 200;  // arena diagonal ~170; skip far misses

function shoot() {
  // ... fire-rate / ammo checks ...
  const origin = camera.position.clone();
  const baseDir = new THREE.Vector3();
  camera.getWorldDirection(baseDir);

  // Build target list: living enemy bodies + heads (+ static colliders)
  const targets = [...colliderMeshes];
  for (const enemy of enemies)
    if (enemy.alive) targets.push(enemy.mesh.children[0], enemy.mesh.children[1]);

  const pelletCount = w.name.includes("Breacher") ? 8 : 1;  // shotgun
  for (let p = 0; p < pelletCount; p++) {
    const dir = baseDir.clone();
    dir.x += (Math.random() - 0.5) * w.spread;  // hip-fire cone
    dir.y += (Math.random() - 0.5) * w.spread;
    dir.normalize();
    raycaster.set(origin, dir);

    const hits = raycaster.intersectObjects(targets, false);
    if (hits.length > 0) {
      const hit = hits[0];       // nearest intersect wins
      const ud = hit.object.userData;
      if (ud.enemyIdx !== undefined) {
        const isHead = ud.isHead;
        enemy.hp -= isHead ? w.damage * 2 : w.damage;  // headshot 2×
        spawnTracer(origin, hit.point);
      } else {
        spawnParticles(hit.point, 0x886633, 4);  // wall spark
      }
    }
  }
}

Headshot Hitboxes via userData Tagging

Each enemy is a Group: children[0] is the capsule body, children[1] is the sphere head. At spawn time both are tagged with userData.enemyIdx (their index in the enemies array) and userData.isHead (boolean). When the raycaster returns an intersect, we read hit.object.userData to resolve which enemy was hit and whether it was a headshot — no separate bounding-volume pass, no manual ray-sphere math. The raycaster's built-in sphere/AABB intersection does the geometry; userData does the bookkeeping.

Tracers: Making Hitscan Feel Like a Bullet

Pure hitscan has a UX problem: the damage tick is instant and invisible, so the shot doesn't "feel" like a gun. Forkspawn spawns a tracer line — a THREE.Line from the muzzle to the impact point — that fades out over 60 ms. This is purely cosmetic; the damage is already applied. The tracers are recycled (geometry disposed on expiry, not GC'd), so even the shotgun's 8 pellets per shot produce no sustained allocation pressure.

The Game Loop: requestAnimationFrame at 60fps

The loop is a single requestAnimationFrame callback that runs update → render each frame. Movement is delta-time normalized (multiply velocity by dt in seconds), so the game behaves identically at 60fps, 120fps, or during a frame hiccup — no frame-rate-dependent speed bugs. Player collision is resolved against a precomputed array of THREE.Box3 AABBs (walls + crates), tested per-axis so the player slides along walls instead of sticking.

Input splits cleanly by platform: desktop uses the Pointer Lock API for mouse-look (the cursor disappears and mouse deltas drive yaw/pitch), with WASD + click-to-fire. Mobile gets a virtual joystick (left thumb moves, right thumb drags to look) and on-screen fire/reload/jump/sprint buttons. The touch input state is a plain object the loop reads each frame — the DOM UI and the Three.js engine are fully decoupled.

How It All Fits in 4MB — And Loads With No Download

The "no download" promise is literal: there is no installer, no launcher, no .exe, no app store. The game is a web page. When you click Play, here's what crosses the network:

WHAT DOWNLOADS SIZE (approx) ───────────────────────────────────────────── Game JS chunk (React + game logic) ~4MB (gzip ~900KB) Three.js r169 (CDN, cached) ~600KB (gzip ~150KB) HTML / CSS / fonts ~50KB ───────────────────────────────────────────── Textures 0 bytes ← procedural Models / GLTF 0 bytes ← primitives Audio 0 bytes ← Web Audio synth ───────────────────────────────────────────── TOTAL (first load, cold cache) ~4.6MB TOTAL (return visit, warm cache) ~4MB (Three.js cached)

Three.js is served from jsDelivr's CDN with proper Cache-Control headers, so after the first visit it's in the browser's HTTP cache and costs zero bytes on every return. The game JS chunk is the only first-party payload, and it gzips to under 1MB. On a typical broadband connection that's a sub-second download; on a warm cache it's instant.

Compare that to a Unity WebGL export: the engine runtime alone is 8–15MB, and a minimal scene with textures pushes 30–80MB before the first frame. Forkspawn's entire payload is smaller than a single Unity loading screen.

Optimization Techniques — Summary

TechniqueWhat It SavesImpact
Procedural canvas texturesNetwork: ~20MB of PNGsZero texture downloads
Hitscan raycastingCPU: per-frame projectile loopO(n) per shot, 0 per frame
Shared materialsGPU: material state changes16 meshes → ~2 binds
Frustum culling (built-in)GPU: off-screen draws~50% of scene skipped
Capped pixel ratio (2×)GPU: fragment fill rateNo 4K overdraw on Retina
CDN-loaded Three.jsNetwork: bundle size600KB cached across visits
Delta-time movementCPU: frame-rate bugsIdentical at 60/120fps
Tracer recyclingCPU/GC: per-shot allocGeometry disposed, not GC'd

FAQ: Building a Three.js FPS

Can Three.js really achieve AAA visuals in a browser?

Yes — if you configure the pipeline correctly. The settings that matter: MeshStandardMaterialfor PBR shading, PCFSoftShadowMap for soft shadows, ACESFilmicToneMappingfor filmic color, and FogExp2 for atmospheric depth. The visual gap between a default-configured Three.js scene and a "AAA" one is almost entirely in these four settings, not in asset quality. See it in action →

Why hitscan instead of projectile physics?

For a fast-firing FPS (carbine at 600 RPM), simulating 10+ live projectile meshes per frame — each with velocity integration and collision tests against every enemy — is the single biggest CPU sink. Hitscan collapses that to one raycast per shot with no persistent objects. The trade-off is no bullet travel time, but at carbine engagement distances (<50m) that's physically negligible and what every AAA shooter (CoD, CS, Valorant) does for hitscan weapons. Tracers provide the visual feedback.

How do you handle LOD (level of detail)?

Forkspawn's arena is small enough (120×120 units) and the enemy count low enough (≤15 simultaneous) that explicit geometric LOD — swapping high/low-poly meshes by distance — isn't needed; every enemy is already only 5 low-poly primitives. The "LOD" that matters here is frustum culling(skip off-screen meshes entirely) and shadow LOD (the 2048² shadow map covers the whole arena in one pass rather than per-light cascades). For a larger open-world browser game, Three.js's built-in THREE.LOD object would swap mesh detail by distance — the same technique, just automated.

Does this work on mobile?

Yes. The game detects touch devices and swaps Pointer Lock for a virtual joystick + drag-look + on-screen buttons. The pixel ratio cap (2×) prevents Retina/4K mobile screens from crushing the fill rate. WebGL 2 is supported on all modern mobile browsers (Safari 15+, Chrome on Android). Performance is gated by the GPU, not the techniques — the same optimizations that hold 60fps on a laptop keep it smooth on a flagship phone.

Can I see the source?

The game engine lives in a single Game.tsx component (~1,700 lines) — renderer setup, procedural textures, hitscan combat, AI, input, the full loop. The best way to understand it is to play the demo and inspect the canvas in your browser's devtools. Try the demo →

Enough Reading. Try the Demo.

The architecture is one thing. Feeling the recoil, the headshots, and the 60fps is another. Play the Forkspawn demo free in your browser — no sign-up, no download, ~4MB.

No sign-up · No download · Plays in your browser

Built with Three.js · WebGL 2 · No game engine · No downloads