Terraforma ← Back to the Atelier
Field Guide · Technical Survey

How to sculpt a world from arithmetic.

Terraforma is a procedural terrain atelier: a seeded landscape generator you steer with six instruments — relief, scale, detail, warp, ridges, sea level — across five biome palettes, then print as a signed survey poster. Everything on the page is computed live in the browser. No textures, no models, no assets. This guide documents the full machinery.

01

Concept — an atelier, not a demo

The framing is a fictional cartographic survey studio. The UI borrows from scientific instruments: an "Instruments" panel with hairline sliders, a monospaced meta strip with a world number and invented coordinates, and a serif wordmark set in wide tracking. The terrain itself is the only saturated element on screen — the chrome stays ink-dark and quiet so the biome palettes carry the color story.

Two decisions define the feel: the terrain is an island diorama (a radial falloff sinks every landmass into an endless fogged sea, so every seed composes like an object on a plinth), and every parameter change morphs — the mesh never snaps, it flows to its new shape. That is what makes the controls feel god-like rather than form-like.

SeedMulberry32 PRNG shuffles a permutation table
FieldWarped fBm + ridged noise + island falloff
PaintHeight & slope sampled through a biome ramp
MorphOld heights ease to new over ~1s
PrintOffscreen render composited on a paper canvas
ShareWhole world state encoded in the URL hash
02

Stack

03

Seeded simplex, warped and ridged

Determinism matters: World Nº 48213 must always be the same world. So the 2D simplex noise runs on a permutation table shuffled by a seeded Mulberry32 PRNG — reseeding is just re-shuffling 256 bytes:

// tiny seeded PRNG → shuffled permutation table → deterministic noise
function mulberry32(a){ return function(){
  a|=0; a=a+0x6D2B79F5|0;
  let t=Math.imul(a^a>>>15, 1|a);
  t=t+Math.imul(t^t>>>7, 61|t)^t;
  return ((t^t>>>14)>>>0)/4294967296; } }

function seedNoise(seed){
  const rnd=mulberry32(seed), p=new Uint8Array(256);
  for(let i=0;i<256;i++) p[i]=i;
  for(let i=255;i>0;i--){ const j=Math.floor(rnd()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
  for(let i=0;i<512;i++) perm[i]=p[i&255];
}

The permutation shuffle — the whole "seed" is these 256 swaps

On top of raw simplex sit two classic fractal constructions. fBm stacks octaves with halving amplitude for rolling hills; ridged noise folds the signal with 1−|n|, turning zero-crossings into sharp crests — that is where mountain ranges come from:

function fbm(x,y,oct){
  let a=.5, f=1, s=0, n=0;
  for(let i=0;i<oct;i++){ s+=a*snoise(x*f,y*f); n+=a; a*=.5; f*=2.02; }
  return s/n;
}
function ridged(x,y,oct){
  let a=.5, f=1, s=0, n=0;
  for(let i=0;i<oct;i++){ s+=a*(1-Math.abs(snoise(x*f,y*f))); n+=a; a*=.5; f*=2.1; }
  return (s/n)*2-1;  // re-center to [-1,1]
}

Lacunarity is deliberately ≠ 2.0 (2.02 / 2.1) to break octave alignment artifacts

04

The terrain field

Every vertex height is a small pipeline. First the sample position is domain-warped — offset by two low-octave fBm reads — which bends straight noise contours into deltas, fjords and swirls (the Warp slider scales this). Then fBm and ridged noise blend under the Ridges slider, and a radial island falloff pushes the rim below sea level:

// per vertex (x,z) — SEG×SEG grid, recomputed on parameter change
let wx=x*f, wz=z*f;
if(warp>0){  // domain warp: LOW-frequency noise displacing noise,
             // offset kept well under one base feature size
  const qx=fbm(wx*.4+13.7, wz*.4+7.3, 3), qz=fbm(wx*.4-4.2, wz*.4+9.1, 3);
  wx+=qx*warp*.7; wz+=qz*warp*.7;
}
const base =fbm(wx,wz,oct);       // hills
const ridge=ridged(wx*.9+31.4, wz*.9-17.2, rOct);
let   h=base*(1-ridgeAmt)+ridge*ridgeAmt;

let e=(h+1)*.5;                   // power curve: coastal plains,
e=Math.pow(e,1.7); h=e*2-.52;     // steep peaks

const r=Math.sqrt(x*x+z*z)/(SIZE*.52);  // island falloff → diorama
const fall=1-smoothstep(.6, 1.02, r);
h=h*fall-(1-fall)*.55;            // rim sinks below the sea
y=h*13*relief;

The height function — six instruments feed five constants

Two hard-won lessons live in this snippet. First, warp amplitude: an early version offset the sample position by several base-feature sizes — the terrain collapsed into white-noise spikes, because a warp that jumps farther than a feature width makes adjacent vertices sample unrelated noise. Keep the offset below ~1 feature size and sample the warp fields at ~0.4× the base frequency. Second, aliasing: octave count is clamped so the highest octave's wavelength never drops below two grid cells (allowed = log₂(0.8/cellSize)/log₂(2.02)+1) — beyond Nyquist, "detail" is just per-vertex static.

The site's Pipeline section makes this function inspectable: six canvas plates re-run the same seed with successive terms enabled — raw fBm, then warp, then ridge blend, then falloff, then the biome tint, then hillshade — each ~22,500 samples, rendered progressively when the section scrolls into view. A "re-survey" button re-charts whatever world is currently on stage, so the plates always explain your terrain, not a canned example.

The offsets (13.7, 7.3, −4.2…) are arbitrary decorrelation shifts so the warp fields and the ridge field don't echo the base field. The water is a separate 900×900 plane with a procedurally generated normal map (a 256px canvas of fBm converted to a tangent-space normal encoding) whose UV offset drifts each frame — cheap, believable shimmer with zero vertex work.

05

Biome ramps — color as data

Each biome is a plain object: a 6-stop elevation ramp, plus sky pair, fog, sun, water, rock and mist colors. Painting is pure lookup — normalize height above sea level to t∈[0,1], sample the ramp, then push toward the rock color where the local slope is steep (cliffs stay bare while plateaus keep vegetation):

const t = (y - seaY) / (maxH - seaY) + jitter;   // jitter = low-amp noise, breaks banding
const slope = (|hL-hR| + |hU-hD|) / (cell*9);    // central differences
const rockAmt = smoothstep(.52,.98,slope) * .72
              * smoothstep(.03,.2,t);           // beaches stay sandy
color = mix(sampleRamp(stops,t), rockColor, rockAmt);

// cheap ambient occlusion: a vertex below its 4 neighbours is a hollow
const rel = (hL+hR+hU+hD)/4 - y;
if(rel>0) color *= 1 - Math.min(1, rel/2.4)*.32;

Elevation ramp × slope mask × hollow-darkening — the entire "material system"

The five palettes

Verdant — temperate island, teal sea, warm sky
Dune — ochre desert under an amber dusk
Glacier — ice shelf blues into blown-out white peaks
Basalt — volcanic char under an ember sky, obsidian sea
Orchid — an alien survey in violet and rose

Switching biomes cross-fades everything at once: vertex colors are repainted each frame sampling both ramps and mixing by an eased blend factor, while fog, sun, hemisphere, water and mote colors lerp in the render loop and the CSS sky gradient transitions in parallel. One click, five coordinated fades, no snap.

06

Morph & motion design

The mesh keeps two height buffers — heightsA (from) and heightsB (to). Any change computes a new target and rewinds a clock; the loop eases between them with easeOutExpo. Because a change mid-morph snapshots the current interpolated heights as the new "from", dragging a slider quickly never stutters — the terrain chases you fluidly:

function rebuild(dur){
  const e=easeOutExpo(morphT);
  for(let i=0;i<VERTS;i++)         // freeze current pose as the new origin
    heightsA[i]=heightsA[i]+(heightsB[i]-heightsA[i])*e;
  heightField(heightsB);           // compute the new destination
  morphT=0; morphDur=dur;
}

Interruptible morphing — the core of the "god-mode" feel

07

Poster export — the take-home

"Export Poster" turns the current view into a 1500×2100 signed print. The trick is reusing the live renderer: resize it offscreen to ~1.35× the poster's image window, render one frame, grab toDataURL() synchronously (no preserveDrawingBuffer needed if you read immediately after rendering), then restore the viewport. A 2D canvas composites paper, sky gradient, the render, a hairline frame, letterspaced Fraunces title, survey metadata and the biome's swatch strip:

renderer.setPixelRatio(1);
renderer.setSize(imgW*1.35, imgH*1.35, false);
camera.aspect=imgW/imgH; camera.updateProjectionMatrix();
renderer.render(scene,camera);
const shot=renderer.domElement.toDataURL('image/png');  // read NOW, same task
// …restore size, then composite on a 2D canvas:
ctx.fillStyle='#EFE8D8'; ctx.fillRect(0,0,W,H);          // paper
ctx.fillStyle=skyGradient; ctx.fillRect(M,M,imgW,imgH);   // sky behind alpha render
ctx.drawImage(shot, M,M,imgW,imgH);
// letterspaced title: draw glyph by glyph, advancing by measureText + tracking
for(const ch of 'TERRAFORMA'){ ctx.fillText(ch,tx,ty); tx+=ctx.measureText(ch).width+26; }

Await document.fonts.load() first, or the canvas falls back to system serif

Because the renderer clears to transparent, the poster compositor can paint the same sky gradient underneath the render — the printed sky always matches the biome exactly.

08

Shareable state — the world in a URL

Because every world is a pure function of (seed, six params, biome), the entire state fits in a hash fragment. Every interaction debounce-writes it with history.replaceState (no scroll jumps, no history spam), and boot parses + clamps it before the first frame — so a shared link rises out of the sea already shaped like the sender's world:

// #w=48291&r=1.15&s=2.10&d=6&wp=0.80&rg=0.30&sl=0.160&b=0
const h=new URLSearchParams(location.hash.slice(1));
params.relief=clamp(parseFloat(h.get('r')), .2, 2.2);  // clamp EVERYTHING —
// a hash is user input; out-of-range values must not break the mesh

// on change (180ms debounce):
history.replaceState(null,'','#'+hashString());

"Copy survey link" writes the hash synchronously, then hits the async Clipboard API with a textarea fallback

A small toast confirms the copy. The same determinism powers the poster's settings line — link and print are two encodings of the same recipe.

09

Replicate it