Operation Blackout — golden hour in the ruined plaza

Operation Blackout

WebGL2 · Three.js · zero asset files

A first-person shooter that runs in a browser tab and is trying very hard not to look like it. There are no asset files. Every texture, mesh, sound and skybox in these screenshots is generated by code at load time — nothing is downloaded, nothing is authored in a DCC tool.

Assets shipped
0
all procedural
Draw calls
1,191
budget 1,800
Triangles
3.3M
budget 3.5M
Bundle
4 MB
whole game

What it looks like

Real captures from the running game on an Apple M4, taken by the project's own screenshot harness — not mock-ups.

Looking down a lane toward the contested centre of the map
Concrete with aggregate and air pockets, brick courses, window reveals — all procedurally generated PBR.
Mid-firefight with smoke and a loaded HUD
The HUD under load: compass, minimap contacts, objective markers with live ranges, killfeed, low-ammo state.
Interior with bounce lighting and contact shadows
Interior bounce and contact shadows. Nothing reads as unlit, which is hard without baked lightmaps.
Close range on ground and wall materials
Texel density and roughness breakup — the difference between "a wall" and "a grey box with a photo on it".

A critic that can't be talked round

The hard problem here isn't writing an ambient-occlusion pass. It's that you stop being able to see your own work — you look at a frame for the thirtieth time and it looks fine, because you've been staring at it while it got worse.

So the quality bar is enforced by tooling instead of opinion. A capture harness drives real Chrome on the GPU, steps the simulation by hand so temporal effects converge identically every run, and refuses to produce a misleading frame: it reads Vite's error-overlay shadow DOM so a broken build can never be graded as a bad-looking one, and it raycasts around the camera and fails rather than shooting from inside a wall.

An analyser then scores twelve measurable ways a hobby frame differs from a shipped one — flat untextured area, Laplacian detail density, shadow detail retention, highlight clipping, gradient banding, edge aliasing, hue entropy. Every threshold is calibrated against frames judged by eye, in both directions: it has to pass frames that look good and still fail one that looks bad, or it isn't measuring anything.

It caught its own author being wrong. When five scenarios failed on saturation at once, the metric turned out to be the problem, not the renderer — plain HSV saturation is unstable in shadow, so frames with deep shade scored as garish while looking muted. It's luminance-weighted now.

Rendering

A hand-written hybrid-forward HDR frame graph. No post-processing library.

StageTechnique
G-bufferMRT prepass: view normals + roughness, screen velocity, linear depth
GTAOHorizon-based, cosine-weighted, spatially denoised and temporally accumulated
SSRHi-Z min-depth pyramid march, roughness-aware cone tracing, IBL fallback on miss
TAAHalton(2,3) jitter, YCoCg variance clipping, closest-depth velocity dilation
Motion blur / DOFTile-max dilation; physical circle-of-confusion, separate near and far fields
BloomSix-mip Karis-average dual filter, thresholded scene-referred
CompositeAgX (ACES and Khronos-neutral selectable), ASC-CDL, split tone, chroma rolloff
FinalEdge-only chromatic aberration, CAS sharpen, luminance-dependent grain, dither

Ambient occlusion and reflections are not pasted over the finished image. A material injector patches every lit material at Three's shader-chunk include markers, so GTAO multiplies indirect diffuse and specular occlusion only, and SSR replaces the image-based radiance term by confidence rather than adding on top of it.

Atmosphere

Physically-based scattering to a PMREM cube, raymarched clouds at half resolution with mip-aware sampling and temporal accumulation, froxel volumetrics with blue-noise jitter, four cascaded shadow maps with texel-snapped stable projections.

Materials

Baked on the GPU at boot: domain-warped FBM, Worley aggregate, cavity-driven grime, edge wear, and stochastic sampling to kill visible tiling. Full PBR set per material, normals from a real height field.

Physics

Rapier at a fixed 120 Hz with interpolated visuals. Articulated ragdolls with realistic joint limits, Voronoi glass shatter, blast-impulse prop displacement. Ground is a heightfield, not a 180k-triangle trimesh.

Ballistics

Real projectiles, not hitscan — muzzle velocity, drop, drag and travel time, material penetration with energy loss, ricochet at shallow angles. Deterministic per-shot recoil patterns, so they're learnable.

Audio

Every sound synthesised in the WebAudio graph. No files. Layered gunshot transients, procedurally generated convolution impulse responses per environment, distance-modelled air absorption.

Feel

Acceleration/friction movement with slide, mantle and vault. Gait-cycle weapon bob with footplants synced to audio, trauma-squared camera shake driven by noise, critically damped springs throughout.

Controls

Move · sprintWASD · Shift
Crouch · prone · jumpCtrl/C · Z · Space
Fire · aimMouse 1 · Mouse 2
Reload · swap · weaponsR · Q · 1 2
Grenade · melee · useG · V · F
Scoreboard · pauseTab · Esc

Any setting works as a URL parameter: ?preset=ultra, ?renderScale=0.75, ?hud=0. A buffer inspector is built in for when something looks wrong and you need to know why: ?rpdebug=ao|normals|velocity|ssr|bloom.

Honest status

Built by parallel agents against a frozen interface contract. The three rendering-foundation subsystems completed their self-critique loops; the eight gameplay subsystems were cut short partway through, so their code is first-draft and has never been through visual iteration. It renders correctly and you can walk around it — weapons, AI and audio are largely unverified in actual play.

Where it stands against the real thing: materials, tonal response and atmosphere are genuinely close. The weapon viewmodel still reads closer to plastic than machined aluminium, ground cover is sparse, and there's no character work in frame. Nobody would mistake a still from this for Modern Warfare — but it's no longer obvious in 200 milliseconds that it's a browser tab.

Wide establishing shot across the map
Establishing shot. Perimeter blocks carry parapets and rooftop masses so the skyline doesn't collapse into one flat grey mass.