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.
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.
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 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.