← all posts

Low-poly Three.js that looks expensive

2 min read

threejswebglgraphics

Sample post - replace with your own writing.

The home page of this site is one continuous camera flight from the seabed to orbit — an oil rig, a Highland glen, a cloud deck, a jet, the works. It runs at 60 fps on a mid-range phone, ships zero image textures, and the whole 3D payload is smaller than one hero JPEG on an average marketing page. This post is the toolbox.

Commit to the facet

Low-poly done half-heartedly looks unfinished. Low-poly done with conviction looks designed. The difference is mostly one flag:

const mat = new THREE.MeshStandardMaterial({
  flatShading: true,   // the whole aesthetic, one boolean
  roughness: 0.9,
  metalness: 0.0,
  vertexColors: true
});

Flat shading gives every triangle a single normal, so light breaks across the surface in clean planes instead of smooth gradients. Suddenly your 300-triangle boulder reads as a deliberate sculpture rather than a failed sphere. Lean into it: exaggerate silhouettes, avoid near-coplanar triangles (they shade almost identically and read as noise), and let big facets catch big light.

Vertex colours replace textures

Every surface in the scene gets its colour from vertex data, not texture lookups. That means no texture memory, no mipmap shimmer, no loading waterfall — and gradients that are impossible to get from tiling images:

const colours = geometry.attributes.position.array.map(/* … */);
const col = new THREE.BufferAttribute(new Float32Array(count * 3), 3);
for (let i = 0; i < count; i++) {
  const h = heightAt(i);                    // sample your own data
  const c = ramp(h, slopeAt(i));            // sand → grass → crag → scree
  col.setXYZ(i, c.r, c.g, c.b);
}
geometry.setAttribute('color', col);

Two tricks make this sing:

  • Bake your ambient occlusion by hand. Darken vertices in crevices, under overhangs, between hull plates. It costs nothing at runtime and adds most of the perceived depth. Real shadow maps stay off.
  • Never use the palette colour raw across a whole mesh. Jitter each vertex a few percent in lightness. Uniform colour screams "programmer art"; 4% noise reads as material.

Instancing is the whole ballgame

The terrain wants ~400 pine trees. Four hundred meshes is four hundred draw calls and a dead phone. One InstancedMesh is a single draw call:

const pines = new THREE.InstancedMesh(coneGeo, pineMat, 400);
const m = new THREE.Matrix4();
for (let i = 0; i < 400; i++) {
  m.compose(positions[i], quat, scales[i]);
  pines.setMatrixAt(i, m);
  pines.setColorAt(i, colourJitter(baseGreen, rng));
}

Kelp stalks, cloud puffs, boulders, barnacles on the rig legs — anything that repeats gets instanced. The scene's budget is draw calls, not triangles: modern phones will happily push 300k triangles, but 300 draw calls will stutter. I keep the whole world under 80 calls.

Determinism or madness

One rule saved me days: no Math.random() in model factories. Every generator takes a seeded PRNG:

export function mulberry32(seed) {
  return () => {
    seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

Seeded generation means a screenshot from Tuesday is comparable with one from Thursday, art-direction feedback like "that boulder cluster is too dense" refers to a boulder cluster that still exists, and a visual regression is a diffable event rather than a vibe.

The performance checklist

The boring list that actually holds 60 fps:

  1. Clamp device pixel ratio — renderer.setPixelRatio(Math.min(devicePixelRatio, 2)). DPR 3 is a battery heater, not a quality upgrade.
  2. No per-frame allocation. Every update() mutates pre-built vectors and matrices; the GC never gets invited.
  3. One material per palette role, shared across meshes — fewer shader switches.
  4. Merge static geometry; instance repeated geometry; and delete the helper that told you to do so before shipping.
  5. No post-processing passes. Fog plus vertex AO buys 90% of the atmosphere for 0% of the frame budget.

None of these techniques are new — this is 2010-era demo-scene thrift applied to 2026 hardware. That's exactly why it feels fast: you're spending a phone-class GPU on a workload sized for a netbook, and the headroom becomes smoothness. Constraint is the aesthetic and the performance strategy, and it photographs beautifully.