vibespire.ai / Contour /

how it's made

open the valley β†—

Vibespire presents

How the Contour Valleys Are Made

A heightmap is cut into nine flat shelves, and the boundary between two shelves gets found twice. Once in the geometry, where sixteen table entries turn it into a chamfered cliff. Once again in the finished frame, where a shader that has never heard of the terrain re-derives the same line out of depth and normals and draws it. Everything else here β€” the palette, the sprites with no colours in them, the camera that refuses to move less than a whole pixel β€” exists to keep those two lines on the same grid.

A HEIGHT NINE SHELVES ONE LINE, TWICE

The field

Nine shelves and a rule about huts

The landscape starts as one number per lattice corner. A hundred and twelve metres of world, eighty cells across, so a cell is a metre and forty; eighty-one by eighty-one corners, six and a half thousand heights. Then all of that precision is thrown away. Each corner is floored into one of nine levels, a metre seventy apart, and the integer is the only thing that survives into the picture. terraces.ts never reads the continuous height at all.

The height itself is domain-warped value noise β€” two extra noise lookups dragging the sample point around before the fractal sum, which is the cheapest way to buy drainage-shaped features instead of fractal blobs. Value noise rather than gradient noise on purpose: value noise has visible plateaus where Perlin has none, and a field about to be sliced into flat steps wants plateaus. They give the terraces something to agree with. Over that, a river carved against distance to a wandering polyline β€” an exponential for the channel and a wider, shallower one for the trough it sits in. Carving against a path rather than another octave of noise is what makes a river look like it went somewhere; noise gives you puddles in a row.

Quantising wants a little noise added before the floor, so the contour lines do not all snap to a smooth curve at once. The first version sampled that noise at close to one sample per cell, which is white noise, and white noise added to a height does not roughen a contour. It lifts isolated single corners a whole level above their neighbours β€” and a single cell lifted one level is a metre-seventy box with a flat green lid. The valley grew huts. Hundreds of them, scattered along every cliff, looking uncannily like a settlement.

Two fixes, both in heightfield.ts. The jitter now runs at roughly a fifth of a sample per cell, so it is correlated over four or five cells and makes the contour wander rather than making the ground speckle. And the level grid gets one pass of a 3Γ—3 median β€” the right tool rather than a blur precisely because a median does not move an edge. Nine samples with six of one level and three of another still return the majority, so a terrace boundary survives intact while a single stray corner is outvoted and folded back into the shelf it was interrupting.

A blur would have removed the huts and the cliffs together.

The mesher

Marching squares, not boxes

The obvious way to draw a quantised heightmap is a box per cell, and it looks like Minecraft: every edge is a right angle on the lattice, and the lattice is the only thing you see. Marching squares instead treats each level as a filled region over the corner grid, and cuts the cells the region's boundary passes through diagonally. Right angles become 45Β° chamfers and the grid stops announcing itself.

A cell has four corners, each either at or above the level or below it, so there are sixteen cases. Two tables in terraces.ts cover all of them: FILL, the filled polygon as a list of corner and edge-midpoint indices, and EDGE, the boundary as segments between those midpoints. The two diagonal cases β€” corner 0 and corner 2 in, the other two out β€” are the ambiguous ones, and this mesher resolves them by keeping the corners apart: two separate triangles and two separate boundary segments, not a saddle.

Each level then contributes exactly two things: a flat top at its own height, and a skirt one step deep hung on its boundary. Stack nine of those and the landform is complete, and no level ever has to know how far down the ground is β€” the level below it has already covered that. Getting each skirt facing outward is the one piece of real geometry in the file: every boundary segment carries the index of a corner known to be inside, and outward is whichever perpendicular leads away from it. That rule holds for all sixteen cases including the ambiguous pair, where sampling the field near the cell centre β€” the first thing I tried β€” cannot.

Stacking fills means most of them are buried: level 4's fill sits directly under level 5's wherever both are full, and the camera is never below the ground. So a cell that is full at this level and full at the next one is skipped entirely. That single condition is the difference between emitting the volume of the landform and emitting only its surface. On a typical seed it drops between eighteen and thirty-three thousand cells, and the mesh comes out at about twenty thousand triangles.

The real FILL and EDGE tables from src/terraces.ts, run over a real Heightfield. Solid dots are corners that reached the level; hollow ones did not. The pink line is the contour the skirt gets hung on. Switch to boxes to see the same level meshed a cell at a time.

Colour

Ramps are built, not picked

Nothing in this project has an eyedropped colour. Every material gets a four- or five-step ramp generated from one base hue, and the ramp obeys the rule pixel artists apply by hand: as a colour lightens it drifts toward yellow and loses saturation; as it darkens it drifts toward violet and gains it.

buildRamp in palette.ts takes a hue, a saturation and a lightness range. For each step it computes how far that step is from the middle of the ramp, and rotates the hue that fraction of 26Β° toward 48Β° at the light end or 254Β° at the dark one. Saturation runs the other way, scaling from 1.3Γ— the base at the bottom of the ramp to 0.68Γ— at the top. The mid-tone keeps the hue it was given; only the ends travel.

This is not a stylistic flourish, it is the difference between a render and a drawing. A ramp with a fixed hue reads as painted plastic no matter how carefully the values are spaced, because nothing in the world is lit by one colour. Five themes each supply base hues for ground, rock, foliage, water and sky; the seed picks one and nudges every hue by up to Β±9Β°, which is enough that two temperate valleys are not the same green and not enough to turn one into a different season.

buildRamp from src/palette.ts, the same function the terrain and every sprite are coloured through. The lower strip is the identical ramp with the bend set to zero. The numbers under each are the hue each swatch actually landed on.

The union of every ramp, plus a shadowed and a sunward variant of each β€” those being the values stepped lighting actually produces β€” is the palette the finished frame is quantised to. It comes out at 58 or 59 colours on almost every seed. The post pass matches each pixel to the nearest entry after adding a 4Γ—4 ordered dither, and the distance metric matters more than it looks: straight RGB distance mottled every lake. The palette holds greens, browns and blues at overlapping brightnesses, so nudging a blue by a fortieth for the dither was enough to make a khaki the nearest colour, and the water came out as tweed. Splitting the error into brightness and hue and charging 4.4 times as much for the hue means the dither can only ever choose between two colours of the same family β€” which is what an ordered dither is supposed to do.

The sprites

Twenty-three drawings with no colours in them

The grass, the pines, the boulders, the heron and the deer were generated as ordinary pixel art by gpt-image-2, one prompt each, on a flat magenta field. Then every one of them had its colour thrown away.

What is committed in public/art/sprites.png is an index. The red channel holds which of four tones a pixel is. The green channel holds which of the instance's two ramps that tone should be taken from. Blue is unused, alpha is a hard 0 or 255. At draw time the shader reads a tone and a ramp selector and looks up an actual colour in a sixteen-entry uniform β€” four families of four.

That is why there is one set of twenty-three sprites and five seasons. A pine generated once is dark green in a temperate valley, rust in an autumn one and near-black at dusk, because it never had a colour of its own to argue with. It also means a generated asset cannot be the one thing in the frame that is off-palette, which is the usual fate of generated art dropped into a hand-tuned scene.

matte

Distance from pure magenta, ramped rather than thresholded, so the drawn anti-aliased rim survives the downsample as partial coverage and gets cut hard later at a threshold we choose.

despill

Magenta is the one hue where red and blue both run ahead of green. That condition finds backdrop contamination without touching a brown, a grey or a green.

downsample

Repeated halving in a canvas, which is premultiplied and therefore alpha-correct. A single draw from 1024 to 26 pixels samples too sparsely and loses whole blades of grass.

index

Tones cut at the 15th, 45th and 78th percentile of the sprite's own luminance β€” by rank, not by range.

That last one is worth the sentence. Cutting the luminance range into four even bands was the first version, and it failed on exactly the sprites you would expect. A boulder lit from one side has a bright cap and a large dark body, so its histogram is two humps, and slicing the range put nine tenths of the rock in the darkest tone. Boulders rendered as featureless black blobs. Slicing by rank instead guarantees every sprite uses all four of its tones. The cuts are not at 25, 50 and 75 because a pixel-art sprite wants a thin dark outline and a fat middle, not four equal bands.

The green channel is derived, not authored. A tree is split on hue β€” anything between 62Β° and 178Β° with some saturation in it is foliage and takes the first ramp, everything else is trunk and takes the second. A deer and a heron are split on rank, at the 45th percentile, so the pale parts come off the pale ramp. A grass tuft is not split at all.

The atlas is 512Γ—256 and weighs seventeen kilobytes.

The buffer

Two attachments and a depth texture

The scene is drawn once, into a buffer a couple of hundred texels tall, and it carries out three things rather than one. Colour goes to attachment zero. The view-space normal goes to attachment one, with a number in its alpha saying what kind of surface the pixel is. Depth comes off the target's own 24-bit depth texture.

Every material in the project is a raw GLSL3 shader written by hand β€” no MeshStandardMaterial, no engine shadow plumbing, no built-in fog. That costs a lot, and it buys two things the engine will not give up. A material has to write a normal beside its colour or the edge pass in the next section has nothing to work with. And colour management is switched off entirely, because the whole picture is quantised onto a palette authored by eye in sRGB, and banded lighting in linear space puts its steps somewhere other than where the ramps were spaced. Do that and the shading stops landing on the palette, and the quantiser spends its time choosing between two wrong answers.

The kind flag in alpha exists because a billboard's normal is a lie. Every sprite faces the camera by construction, so the crease detector run over a field of grass would find a fold at the edge of every single blade. Surfaces are 0, billboards are 1, water is 2, and the post pass gates on it.

The edge pass

Finding the same line twice

The outlines are not geometry. There is no second copy of any mesh, no inverted hull, no line list. A fragment shader reads four neighbouring texels β€” up, down, left, right, and nothing else β€” and works out from depth and normals where the edges must have been.

The dark line comes from depth. For each of the four neighbours the shader adds up how much farther away it is than the centre, and runs the total through a smoothstep between 0.16 and 0.34. A pixel whose neighbours are behind it is on the near side of a silhouette, and gets the line drawn just inside its own outline. A four-tap cross rather than a 3Γ—3 kernel, because anything wider produces a line two texels thick somewhere, and at this resolution a two-pixel outline is a border.

The bright line comes from normals, and is the more delicate of the two. A crease is a place where the normal changes; the trick is telling a convex fold from a concave one, because the highlight belongs only on the convex ones. The test is the sign of the depth difference: on the inside of a fold the neighbouring surface is nearer, so gating the crease on the neighbour being farther keeps the highlight on the outside of the fold. Then the normal difference is not measured as a magnitude but projected onto a fixed direction. Take the magnitude and every terrace edge lights up on both of its sides, which reads as a wireframe. Project it and only one side lights β€” which reads as light.

The ground is built by finding contours and the picture is drawn by finding edges, and neither half knows the other exists.

The camera

Snap it, then give the movement back

Render a 3D scene into a 320-pixel buffer and pan the camera across it, and the image does not slide the way a 2D pixel-art scene slides. Every surface drifts across the texel lattice at its own sub-texel rate, so edges bubble, single pixels blink on and off along every contour, and the whole picture crawls. It is the loudest tell that a pixel-art game is 3D, and it is fixable in two halves.

One. Snap the camera's position to the texel grid, measured along its own right and up vectors. Only those two components matter: sliding an orthographic camera along its view direction does not move its image at all. Now every world point lands on the same texel frame after frame and the crawling stops dead β€” but the camera can only move in whole-texel jumps, so panning judders.

Two. Give the discarded fraction back at the other end. The snap threw away part of a texel; shift the finished image by exactly that fraction when it is blown up to the display. At a 4Γ— upscale a third of a texel is between one and two real pixels, so the picture slides smoothly while its own pixels stay square, aligned and exactly where the geometry put them. The buffer is rendered one texel wider on every side so the shift never samples off the end of it.

WANTED SNAPPED β€” the picture holds still, the motion judders SHIFTED β€” the same picture, moved 1 display pixel +2β…“ texels +2 texels Β· β…“ left over +2 texels of geometry, +β…“ of screen

That claim is measurable, so test/snap.mjs measures it. Freeze the wind, the water, the clouds and the birds; screenshot; pan by a known number of display pixels; screenshot again; then compare the second frame against the first shifted by every offset in range and keep the best. A pure translation scores near zero. Anything that re-rasterised does not.

upscale: 4Γ— β€” one texel is 4 display pixels

pan  4 px (one whole texel)
   snap on   best shift  -4 px β†’ 0.05% of pixels differ
   snap off  best shift  -4 px β†’ 25.79%
pan  1 px (one display pixel)
   snap on   best shift  -1 px β†’ 0.00% of pixels differ
   snap off  best shift   0 px β†’ 14.47%

The second pair is the one to read twice. Panned by a single display pixel with snapping on, the picture moved one pixel and not one pixel in it changed. With snapping off, the best matching offset was zero β€” the picture did not move at all. It just reshuffled fourteen per cent of itself where it stood. That is the crawl, with a number on it.

Getting there took one more fix that the technique's usual description does not mention. The first working version still shimmered, and the geometry was not the problem: the dither was. A 4Γ—4 ordered pattern indexed by screen position is fixed to the display, so with the terrain finally holding still, the pattern laid over it slid, and every dithered slope crawled exactly as before. The fix is to index the dither by the screen texel plus the camera's own integer grid position, which is constant for a given patch of ground however far the view pans. That is the difference between 26% and 0.05% in the first pair above. The sky, being at infinity, keeps the display-anchored index β€” it should not move when the camera does.

One more consequence, and it is a constraint rather than a trick: the upscale factor is forced to a whole number and the buffer size follows from it, not the other way round. A fractional upscale means some texels land on three display pixels and some on four, the picture acquires a moirΓ© of fat and thin rows, and a sub-texel shift can no longer be expressed as a whole number of display pixels at all. And rotation is outside what any of this can fix β€” turning the camera changes the grid every surface is being measured against, so yaw moves in 15Β° steps and the pitch is art direction that does not move much.

Light

Three bands, a shadow map of its own, and rays through it

The diffuse term is clamped, scaled by 1.35, offset by 0.34 and floored into three bands. The offset is the interesting knob: on a landscape made entirely of flat tops and vertical walls, moving the terminator off where the geometry says it should be is the difference between two tones and four. Without it every terrace top gets one value and every wall gets one other, and the render reads as a technical drawing.

The shadow map is rendered by hand β€” a 2048Β² depth texture from an orthographic camera at the sun β€” rather than using the engine's. That decision paid twice. The second time was the god rays, which are not geometry either: a post-processing pass marches fourteen steps through the air between the camera and whatever it hit, asking the same shadow lookup the surfaces use whether each point along the way is lit, with the density falling off as exp(βˆ’y/9) so the sky does not fog over. That question β€” is this point in the air lit β€” is one an engine's shadow system will not answer, because its map is not addressable from a post pass.

The first time it paid was the grass. Every ground-cover sprite is lit exactly once, per instance, in the vertex shader, from the position of its own base, with an upward normal β€” the same value the terrace under it received. Shading a billboard by its own geometry gives it a normal that faces the camera and therefore a brightness that changes as you pan. Shading it by its footing makes a tuft of grass exactly as bright as the ground it grew out of, and that is the whole reason a field of quads sits in a meadow instead of hovering over it.

Ground cover casts no shadow at all, on purpose. Between two and five thousand tufts casting into that map produce grey mush at this scale, and since each tuft already takes its light from the shadow at its own base, a grass shadow would only ever be grass shadowing itself. Cloud shadows are a tiling noise texture read in world XZ and panned by the wind, projected straight down rather than along the sun β€” which is wrong, and looks better, because a correctly projected cloud shadow slides at a different rate from the cloud casting it and at this resolution the mismatch reads as a bug.

The bill

What it costs

The style is not free and several of the prices are structural. Here is the list.

The camera cannot turn freely. Snapping fixes translation and nothing else. Rotation changes the grid every surface is measured against, so yaw is stepped and free tumbling would bring the crawl straight back.
Cell size is a tuning parameter, not a quality dial. Marching squares zigzags at cell resolution and every zigzag is a corner the edge pass then finds. Eighty cells across a hundred and twelve metres works; a hundred and sixteen across sixty-eight turned every cliff into a picket fence of highlights.
No screen-space refraction, no reflections. The water knows its own depth by re-reading the same quantised heightmap the mesh was built from, which is exact and free β€” but it is also the reason there is nothing in the water except the water.
Every material is hand-written. Nothing off the shelf drops into this scene. A new kind of object means a new GLSL3 shader that writes both attachments and a matching depth variant for the shadow pass.
Sprites are lit flat. A tree's form comes entirely from the four tones painted into it; the world only says how much light reaches it. Nothing casts a shaped shadow across a canopy.
Water coverage is aimed at, not guaranteed. The level is chosen per seed to hit six to seventeen per cent, but a seed whose lowest shelf alone holds a third of the map cannot be given less than a third of a map of water.
WebGL 2 only. Two colour attachments in one pass is the whole design, and there is no fallback that keeps the outlines.

The debts. The look is t3ssel8r's, and the terraced terrain in particular; the walkthrough that sent me down this road is Dylearn's recreation of it in Godot, which is where the stepped lighting and the depth-and- normal edge pass come from. The sub-texel camera β€” the half of this that the walkthrough leaves out and that everything else turned out to depend on β€” is described in David Holland's writeup of his own Godot version, along with the trick of lighting a grass quad from its base. The outline heuristics are reimplemented here from those descriptions rather than ported.

What I would tell anyone starting this: build the snap first and measure it, before any of the art. Every hour spent on palettes and sprites before the picture holds still is an hour spent judging colours through a shimmer.

Open Contour β†—
Marcin Balinski

Marcin Balinski

I build things quickly, mostly by hand and increasingly with a model sitting next to me. Most of what I make starts as a question about how something works and turns into a small machine for finding out.

vibespire is where those experiments live. Poke at them, break them, read how they're made β€” and if something sparks an idea for you too, say hello.