Saltline is a first-person shooter that runs on Three.js right in the browser. The whole game fits in a single 1.39 MB JavaScript file plus 30 KB of CSS. It ships no GLB, FBX, PNG or MP3 files: the containers, the rifle, the robots, the wet asphalt, the sky and even the gunshots are all generated in code when the game boots.
This teardown walks through those procedural techniques: how the game builds geometry, bakes PBR textures on the GPU, renders rain and puddles, packs combat effects into a handful of draw calls and synthesizes audio with DSP. You can play Saltline at meowsolo.com/gameaaa before reading.

What Saltline is and what this analysis is based on
Saltline drops you into a container port on a rainy evening. The mission is to cut three relays of a security network that has gone rogue, then reach the boat. There are two enemy types: hounds are four-legged robots, gulls are quadrotor drones.
The original game was shared publicly as a Claude Artifact. Meow Solo recovered the bundle, kept game.js byte-for-byte identical and hosts it standalone at meowsolo.com/gameaaa. The game uses Three.js r180, WebGL2 and plain JavaScript. The analysis below is a static read of the reformatted bundle; function names are minified, so systems are described by what they do.
Measured at runtime on the high preset at 1600×900, the title screen costs about 307 draw calls, 1.87 million triangles, 137 textures and 107 shader programs. The level holds 298 props, 47 of them containers, and 96 labels packed into a single atlas.

Three asset mechanisms repeated in every system
Everything in Saltline goes through one of three mechanisms:
- Geometry: assembled from hand-written primitives, with transforms and vertex colors baked in, then merged per material.
- Materials: textures baked once on the GPU in GLSL, plugged into a
MeshStandardMaterialwith injected shader code. - Effects: ambient effects (rain, birds, blinking lights) live entirely in the vertex shader; gameplay effects (particles, shell casings, decals) run on CPU pools with fixed budgets.
Geometry: assembled from hand-written primitives
The part-list pattern and merging per material
Every builder shares the same "part list" pattern: create a primitive, bake its transform into the geometry, write vertex colors, push it into a bucket keyed by material, and finally merge each bucket into one mesh. The game has two builder classes for props, a command set for building the level, a batcher for the backdrop and a rig builder; all of them follow the same idea.
The static level is then merged once more by material.uuid, cast/receive shadow flags and layer. As a result, the draw call count tracks the number of materials rather than the number of objects. Meshes under a node flagged noMerge (fans, relays, crane spreaders) stay separate so they can still move. Props do not use InstancedMesh.
Hand-written primitives
| Technique | How it works |
|---|---|
| UV in meters | Box projection along the dominant normal axis; every primitive outputs UVs in meters, so texel density stays uniform after merging |
| Chamfer box | 24 vertices, 44 triangles; the bevel strip shares vertices with both adjacent faces, so normals interpolate on their own and edges catch highlights without extra vertices |
| Rounded box | BoxGeometry with 3×3×3 segments; edge and corner vertices are projected onto a rounding radius around an inner core box |
| Loft | A 2D profile swept along a list of stations through a mapping function, with automatic winding fixes; used for container walls and barriers |
| Container corrugation | A trapezoid profile that emits vertices only at the 4 break points of each period |
| Dents | A seeded list of dents whose depths accumulate with a (1−d²)² falloff |
| Lathe | Analytic normals from the profile tangent; a point is repeated to create a hard edge |
| Extrude | Smooth normals when the angle between two edges is below a threshold, hard otherwise; real holes via Shape.holes, no CSG |
| Pipes and cables | TubeGeometry on a CatmullRomCurve3, segment count based on length |
| Normal welding | Face normals summed by position quantized to 0.5 mm, smoothing across UV seams |
The container wall below combines the trapezoid corrugation profile, seeded dents, vertex colors that darken the grooves and a logo decal drawn with Canvas2D.

Soft shapes: tarps, sandbags, crushed cartons
Soft shapes are all subdivided rounded boxes run through a deformation function, then normal-welded.
- Cargo tarp: a heightfield that takes the maximum of "tents" raised around each crate, plus noise and sine folds. The lashing straps are sampled from the tarp surface itself, so they always sit flush.
- Sandbags: ends pinched by a power curve, the top puffed up, sagging per layer, plus noise.
- Crushed carton: the top collapses with the square of height; the packing tape goes through the same deformation, so it follows the dented surface.

Vertex colors as a second texture
- AO in the corrugation grooves: 17–18% darker based on groove depth.
- Dirt and AO when a prop is finalized: two octaves of value noise, hemispherical AO from
normal.y, and a 20 cm band near the ground that is darker and browner.
_ = (0.82 + 0.18*m) * (0.92 + 0.08*A) * (0.76 + 0.24*clamp(ny*0.6 + 0.62, 0, 1));
M = clamp(1 - y/0.2, 0, 1); // band near the ground
_ *= 1 - 0.42*M*M*(0.55 + 0.45*A);- Fake holes: a dark plane or disc offset by 1–2 mm instead of a boolean operation.
- Values above 1: produce bright steel inside a dark material, folding several "materials" into one draw call; edge wear only applies to vertices on bevel faces.
- Aerial perspective: distance haze is baked into the skyline's vertex colors.
Seeds, caching and colliders
- Seeds: each builder has its own mulberry32 RNG; labels, colors, dents and open drawers all come from the seed.
- Container geometry cache: the key combines name, quantized seed, door angle and wear. The container body uses a placeholder material that is remapped to
paint_<color>when an instance is created, so all 8 paint colors share one geometry. - Colliders: hand-written AABB lists ship with each prop. Props placed off-axis are sliced into at most 10 pieces along their long axis. The same AABBs feed raycasts and the nav grid.
Level and backdrop
- The level is procedural code with hard-coded coordinates and no data file. Container stacks get per-box position jitter, yaw and seed.
- LOD by re-running the builder: distant cranes come from the same builder as the near crane, forced onto a single material and dropping every piece smaller than 2.6 m.
- Ship hull: a parametric function of station, height and side; normals from central differences.
- Distant lights: every light is an 8-triangle octahedron in one batch; a
blinkattribute (period, phase, duty) is evaluated in the vertex shader. Lit ship windows are thin boxes picked by hash, with no texture. - Birds: 12 birds in one mesh, with flight paths and wingbeats computed in the vertex shader.
Materials: PBR textures baked once on the GPU
29 texture families written in GLSL
Each of the 29 texture families is a GLSL surf(uv) function built on a shared library. The library includes a periodic PCG hash (so textures tile seamlessly), gradient noise, fbm, Voronoi F1/F2, scratches and a paint-peeling model with flaking patches, oxide halos and rust streaks running downward.
The shader runs once into a render target with 3 attachments:
o0 = vec4(sqrt(clamp(c, 0.0, 1.0)), sat(a)); // albedo + tint mask
o1 = vec4(sat(ao), clamp(r, 0.03, 1.0), sat(m), 1.0); // AO, roughness, metalness
float hh = sat(h) * 255.0; float fl = floor(hh);
o2 = vec4(fl / 255.0, hh - fl, 0.0, 1.0); // 16-bit height across 2 channels- A second pass turns height into a normal map using 8-neighbor differences; bump strength is computed from the real tile size.
- The result is copied into a texture with mipmaps and anisotropic filtering. Size depends on quality, from 256 to 2048. Intermediate targets are destroyed after 4 seconds without use.
- Textures are generated on demand: a family that no material has requested is never baked.
The three tiles below are the rusted-steel texture the game bakes, read straight back from the GPU at runtime: albedo, the normal map derived from height, and ORM with AO in red, roughness in green and metalness in blue.

Eight more families were extracted the same way: rusted steel, concrete, pallet wood, cardboard, diamond plate, chain-link, hazard stripes and galvanized steel.

Albedo alpha is a tint mask
The paint shader outputs the untinted layer (rust, primer, grime) separately from how much paint remains. At render time, the paint color is added according to alpha:
diffuseColor.rgb = sampledDiffuseColor.rgb + diffuse * sampledDiffuseColor.a;One set of paint textures yields 8 colors, while rust and grime keep their own colors. The first tile is the color channel of the container paint texture, almost black because it only holds the untinted parts. The second tile is the alpha channel, the paint that remains. The last four are composited with the formula above using four of the game's paint colors: red, blue, green and orange.

Patching MeshStandardMaterial with onBeforeCompile
All 48 material presets are MeshStandardMaterial. Shader code is injected through onBeforeCompile, so the game keeps Three.js shadows, IBL and BRDF intact. Each preset defines a texture family, a tint mode, rough/metal factors, a wetness parameter set and a macro-variation parameter set. The injected code adds:
- A single ORM fetch: AO, roughness and metalness come from one texture.
- Macro variation: 3D world-space value noise shifts albedo and roughness to break up tiling.
- Rain wetness: strongest on upward-facing surfaces, streaky on vertical ones, a damp band near the ground and beads of water up close. Albedo gets darker and more saturated, roughness drops and normals flatten.
- Triplanar: a variant for skinned and instanced meshes.
- Distant chain-link: the alpha threshold drops with the mip level so wires stay about 1 pixel wide, then switches to dithering once the mesh cells are smaller than a pixel.
- Anti-sparkle: roughness increases with how short the filtered normal-map vector is.
The rifle has its own detail shader: object-space noise for anisotropic scratches, machining rings, polymer stippling and raindrops.
Decals and labels drawn with Canvas2D
- Decals are drawn with Canvas2D using an RNG seeded from a hash of the name.
- Stencil lettering: draw the text, then cut a vertical gap with
destination-out. - Wear: polygons, scratches and specks are cut out with
destination-out. - The background is pre-filled with a very low-alpha color so mipmaps don't produce dark fringes.
- Labels are redrawn once the webfont finishes loading.
- Decal material:
transparent,depthWrite: false,polygonOffset −2.
Every decal in the port, extracted from canvas at runtime: warning signs, shipping line logos, a CSC plate, arrows, stencil numbers, rust runs, oil stains and scorch marks. The vertical gaps in the stencil letters and the chipped patches are the destination-out cuts; the faint frame around each decal is the low-alpha background that prevents dark fringes.

Container numbers, weights, lot codes and relay names are packed into a 2048 px wide atlas with shelf packing: labels are laid out in rows by height, and a new row starts when one fills up. Each label is drawn with its edges stretched into a padding gutter so mipmaps don't bleed into the neighboring label. The level's 96 labels fill only about the top half of the atlas.

Lighting, shadows and post-processing
One sky shader that doubles as the environment map
A single fragment shader on a box draws the whole sky: a 2D cloud layer from domain-warped fbm, a break in the clouds toward the sun, cloud self-shadowing from density differences along the sun direction, and lightning inside the clouds. The same shader is captured through PMREM as the environment map, with the sun disc removed and brightness clamped to avoid fireflies.

PCSS sun shadows on a single shadow map
- One
DirectionalLightwith one shadow map fitted to the level's AABB, no cascades. Size 1024–4096 depending on quality. - The
getShadowfunction in the Three.js shader chunks is replaced with PCSS: a Vogel disk rotated by interleaved gradient noise, 8 blocker-search taps, 10–16 filter taps, penumbra width based on blocker distance, plus receiver-plane depth bias. - Shadows update every other frame, except on ultra.
- 6 shared
PointLights handle muzzle flashes, explosions and lights that follow objects.
A rifle that never clips through walls
A second camera with a 54° FOV (the world camera uses 74°) sees only the rifle layer. The rifle is drawn into the same HDR target with gl.depthRange(0, 0.05), so it always sits in front of everything while still getting the same post-processing as the scene.
A 9-pass post-processing chain
The scene renders into a HalfFloat target with 0/2/4× MSAA and a depth texture. Pass order:
- Linear depth at half resolution.
- 12-tap AO with normals reconstructed from depth and bilateral filtering.
- Volumetric raymarch with 10–40 steps that reads the sun shadow map directly; two-lobe Henyey-Greenstein phase; denser air inside the warehouse box; far range solved analytically.
- Bilateral upsample, then AO and fog composited into the scene.
- Camera motion blur via depth-based reprojection.
- 6-level bloom with a Karis average on the first level, plus an anamorphic streak.
- Auto exposure, with a sun-occlusion check that drives the lens flare.
- Color pass: chromatic aberration, lens dirt, flare, ACES tonemapping pulled toward AgX, split-toning, vignette and damage effects.
- FXAA, sharpening, film grain and dithering.
Because the volumetric pass reads the sun shadow map, light shafts through the warehouse door are clipped exactly by the door frame, and the air inside is thicker than outdoors.

Quality presets and dynamic resolution
| Preset | Scale | MSAA | Shadow | Texture | Volumetric steps | Planar | Motion blur | Rain |
|---|---|---|---|---|---|---|---|---|
| low | 0.7 | 0 | 2048 | 512 | 12 | off | off | 0.5 |
| medium | 0.85 | 2 | 2048 | 1024 | 16 | 0.5 | off | 0.8 |
| high | 1 | 4 | 4096 | 1024 | 24 | 0.5 | on | 1 |
| ultra | 1 | 4 | 4096 | 2048 | 40 | 1 | on | 1 |
The low preset also disables AO. The game picks a preset automatically: mobile devices get low; software GPUs, Mali, Adreno, PowerVR and non-Arc Intel get medium; everything else gets high. At runtime, the resolution scale floats between 0.6 and 1.0 based on frame time, and the game drops a preset if it hits the floor twice.

Wet ground, sea and rain
Ground data generated on the CPU
- 80×80 m mask: R is base height with 26 hand-placed puddle dips and tire ruts, G is wetness, B is oil, A is wear along the traffic lanes.
- Tileable detail: grain, mottling and ridged-fbm cracks, plus two slope maps in one texture.
- Lane markings: drawn with Canvas2D in meter units.
- The same
Uint8Arraybehind the mask answersisPuddleandwetnessAtfor gameplay, so what looks wet is exactly what gameplay treats as wet.
A wet ground shader driven by a single depth value
The ground shader is also injected into MeshStandardMaterial through onBeforeCompile. Every wetness state is derived from a single depth value:
float depth = 0.5 - mk.r + (sn - 0.5) * 0.11 + ... + groove * 0.04 + crack * 0.02 - paint * 0.01;
float puddle = smoothstep(0.0, 0.035, depth) * uWetG; // mirror surface
float soak = smoothstep(-bandW, 0.0, depth) * uWetG; // 10–30 cm soak band
float fringe = smoothstep(-0.65, -0.02, depth); // ~1 m damp halo- Albedo darkens with wetness, soak and puddle depth.
- Roughness goes from 0.74 (dry) through 0.42 (damp) and 0.2 (soaked) down to 0.055 (standing water).
- Normals flatten inside puddles.
- Rain ripples: each grid cell spawns one drop per cycle, across three layers of different cell sizes; beyond 10–34 m they are replaced by added roughness.
- Expansion joints are drawn as SDFs and antialiased with
fwidth. - Oil stains only darken and add gloss; there is no iridescence.

Planar reflections
- A mirror camera across the y = 0 plane with an oblique near plane.
- A mipmapped HalfFloat target sized by the quality preset's
planarfactor times the resolution. - Blur comes from the mip chain based on roughness; UVs are distorted by the normal.
- The result overrides the IBL
radiancevariable, so everything else stays the standard BRDF. - Sea, rain, splashes and the rifle are hidden in the reflection render.
A Gerstner-wave sea
The sea uses a custom mesh, dense near the camera and sparser with distance. The vertex shader sums 4 Gerstner waves; the fragment shader uses the analytic slopes of those 4 waves plus 7 finer waves. Foam depends on the distance to the quay wall and the ship hull's SDF. The wave formula has a CPU version so boats bob in sync with the water surface.

9,000 raindrops with no CPU state
About 9,000 instanced quads, scaled by the preset's rain factor, with no state kept on the CPU. Positions are computed entirely in the vertex shader and wrapped around the camera:
p = aSeed.xyz * uBox + vel * uTime;
p = mod(p - cameraPosition + 0.5 * uBox, uBox) - 0.5 * uBox + cameraPosition;- Drop width has a minimum in pixels, compensated with alpha.
- Drops are culled inside interior AABBs, except for the exact roof-hole cells the level exports.
- Splashes read the puddle mask in the vertex shader to cluster where water collects.
Combat VFX in 5 draw calls
All combat effects fit in 5 draw calls: decals, debris, shell casings, lit particles and additive particles.
- Procedurally generated atlases: the smoke atlas stores normals in RG, thickness in B and density in A; the additive atlas holds glow, streaks, muzzle stars, rings, fire and tracer droplets; bullet-hole decals use three canvases for color, ORM and height converted to normals.
- Particle pools: two struct-of-arrays pools, 1,400 sorted lit particles and 3,040 unsorted additive particles. The CPU simulation covers drag, gravity, turbulence and floor collisions.
- Quads built in the vertex shader in 3 modes: billboard, velocity-stretched and surface-aligned.
- Smoke: soft particles read depth; lighting uses a fake sphere normal plus the atlas normal, wrap diffuse and forward scattering; sun visibility comes from one raycast per effect.
- Tracers: 48 slots, each tracer made of 3 additive quads with a minimum pixel width.
- Debris: an
InstancedMeshof 256 distorted boxes. - Shell casings: an
InstancedMeshof 60 casings fromLatheGeometry, with quaternion integration, up to 4 bounces and an audio event per bounce. - Bullet holes: 160 recycled quads aligned to the surface normal, unlit.
- Budget: fixed and not scaled by quality; the only LOD kicks in at 55 m.

Rifle, hands and enemies
A procedural rifle and hands
- Rifle: a few hundred primitives grouped into 7 buckets: 4 static ones plus the bolt, magazine and round. The receiver uses an extruded profile with holes; the barrel and suppressor use lathes; the Picatinny rail is a loop of 49 teeth.
- Hands: lofted through rings of vertices, no skinning. Finger joints use slerped rings cut along the bisecting plane so segments never intersect. The trigger finger interpolates between two baked poses.
- Sight: the reticle is computed from the angle off the bore axis instead of UVs, so there is no parallax.
- Muzzle flash: 8 quads with star petals generated procedurally in the fragment shader and a new seed every shot.

Rifle motion from 9 springs
Sway, recoil and tilt come from 9 damped springs kicked by velocity impulses. The integrator substeps at 8 ms when a frame runs long, so the springs never blow up:
function ma(s, target, dt) {
let n = dt > 0.012 ? Math.ceil(dt / 0.008) : 1, h = dt / n;
for (let o = 0; o < n; o++) { s.v += ((target - s.x) * s.k - s.v * s.c) * h; s.x += s.v * h; }
return s.x;
}Reloads are hand-written keyframes for the left hand with smootherstep interpolation. Magazine-out, magazine-in and chambering events fire at time thresholds.
Enemy rigs and animation in code
- Code-built rigs: bones are declared by name and parent; each geometry piece is rigidly bound to one bone with weight 1, plus AO from
normal.y. - Instances: geometry is built once; each enemy gets its own
SkinnedMeshandSkeletonsharing that geometry. - No AnimationMixer, no clips: controllers set bone rotation, position and scale directly every frame. Details are toggled by scaling bones down to nearly zero.
- Hound: analytic 3-DOF leg IK; diagonal leg pairs step when the error between a planted foot and its predicted position exceeds a threshold; the body tilts with the height of all four feet through a spring.
- Gull: flies with PD control and banks with the commanded acceleration; the rotor blur disc is just a static ring.
- Analytic hit tests: spheres, capsules and ellipsoids; no raycasts against meshes.


Audio synthesized with DSP
- DSP library: about 20 primitives on
Float32Array: white noise, RBJ biquads, time-varying biquads, exponential envelopes, modal synthesis, chirps, grains and tanh. - Recipes: every sound is a sum of layers. A gunshot combines crack, thump, body and mechanical noise; outdoor and indoor reverb tails are pre-baked into two stereo buffers.
- Fixed seeds: a hash of the sound name plus a variant index, so buffers are identical every session. Playback adds round-robin and random pitch offsets.
- Seamless loops: crossfade the tail into the head, round frequencies to the loop length and run one warm-up pass to initialize filter state.
- Reverb: impulse responses are synthesized in code too, feeding two
ConvolverNodes that crossfade by indoor zone. - Music: 5 stems play in sync; only their gains change with combat intensity.
- Generation schedule: 19 buffers at boot, the rest in 6 ms slices driven by a generator, without a Worker.
- Master: a compressor followed by tanh soft-clipping; a 45-voice pool with priorities and voice stealing.
Lessons for your own procedural Three.js game
You don't need to copy all of Saltline to benefit from how it is organized. These eight principles apply to most small web games:
- Merge by material, not by object. Bake transforms into geometry, merge each bucket into one mesh and keep only moving parts separate. Draw calls then track the number of materials.
- Bake textures once instead of every frame. Write
surf(uv)in GLSL, render it into a multi-attachment render target, copy it into a mipmapped texture, and only bake the families in use. - Use alpha as a tint mask. One set of paint textures serves many colors, with no per-color texture copies.
- Patch
MeshStandardMaterialinstead of writing shaders from scratch.onBeforeCompilelets you add wetness, macro variation and triplanar mapping while keeping Three.js shadows, IBL and BRDF. - Push ambient effects into the vertex shader. Rain, birds and blinking lights need no CPU state; only effects that touch gameplay run on CPU pools.
- Cap your VFX budget. Particle pools, tracer slots and bullet holes all have limits and recycle when full, so frame time doesn't balloon with the size of a firefight.
- Seed everything. Seeded RNGs for props, decals and audio make every run identical, which makes debugging and caching easier.
- Measure frame time and adapt. Pick a preset from the GPU at boot, then scale resolution with frame time and drop a preset when needed.
Scope and limitations of this analysis
- This is a static read of the reformatted bundle; the game itself was not modified.
- Materials, decals, sky, shadows, post-processing and the renderer were read directly. Props, level, ground, FX, rifle, enemies and audio were read in parallel by several AI agents, with spot checks against the code in each part rather than line by line.
- Triangle counts per asset were not measured; the totals at the top of this post were measured at runtime.
- That puddle and sea Fresnel goes through the standard Three.js BRDF is inferred from the shader structure and has not been checked against the r180 shader chunks.
- Screenshots come from the build at meowsolo.com/gameaaa on the high preset, with the camera positioned in code to frame each shot. Textures, decals and the label atlas were extracted directly from the GPU and canvases at runtime; the four tinted paint tiles were composited with the tint formula.
FAQ
Does Saltline use any model or texture files?
No. The bundle ships no GLB, FBX, image or audio files. Models, textures, effects and audio are all generated at runtime; the only extra download is the webfont.
Which Three.js version does Saltline use?
Three.js r180 on WebGL2, written in plain JavaScript. The build at meowsolo.com/gameaaa needs no build step and no installed libraries.
Should a small Three.js game generate its assets in code?
Generating assets in code keeps the bundle small and makes variations cheap, at the cost of startup time and the effort of writing shaders. Saltline strikes the balance by baking once, caching by seed, generating only what is in use and slicing audio synthesis into 6 ms chunks so it never blocks a frame.
To check for yourself, open Saltline, switch between the four quality presets in Settings and stand next to a puddle while it rains. Meow Solo also makes two other 3D games that run in the browser: Mellow Miles, a run around Hoan Kiem Lake, and Tiệm Cà Phê Bất Ổn, a café management game.