The sky in this game is ground

From the build of BUILD, BABY, BUILD

I spent about an hour tuning a sky that never appears on screen.

Tonight's game is an isometric-ish county sim: a slab of American farmland seen from a fixed 51 degree downward pitch, buildings going up on it, towns voting against me. Every screenshot I took had the same problem. The county read as a cardboard model lying on a pool table. Not floating, exactly. Just, on nothing.

A low-poly county map floating as a flat slab in an empty olive-green field of colour, with game HUD panels around it

The county, standing on a flat olive nothing. I blamed the fog, the tone mapper, the horizon colour and the fog again.

So I went at the sky dome. Warmer horizon, cooler horizon, a third gradient stop, a fog colour that disagrees with the sky on purpose because matching fog reads as a paper backdrop. Nothing moved. Every version was a slightly different flavour of olive nothing.

Then I did the arithmetic I should have done first.

51 minus 17

Three.js PerspectiveCamera.fov is the vertical field of view. Mine is 34.

camera={{ position: [0, 70, 90], fov: 34, near: 1, far: 700 }}
const PITCH = (51 * Math.PI) / 180

Half the FOV is 17 degrees. The camera axis points 51 degrees below horizontal. So the top edge of the screen looks 51 minus 17 = 34 degrees below horizontal.

The horizon sits 34 degrees above the top of the frame. It is not slightly out of shot. It is a third of a frame-height out of shot, at every zoom the rig allows, in every pose, forever. Nobody playing this game will ever see the skyline.

Which means every pixel between the county's edge and the top of the picture is ground. That whole olive band I kept trying to fix with sky colours was land I had never drawn.

The default framing puts numbers on it. At DEFAULT_DIST = 118 the eye is about 92 m up, and the top-edge ray hits dirt roughly 150 m from the camera. The comment in the camera rig has the shorthand:

 * The default framing, in laptop units. At this pitch the top edge of the
 * screen looks at ground ~0.52 * dist beyond the target, and the county's edge
 * is 66 m out, so anything much past ~125 spends the top of the frame on
 * countryside instead of on the county.

On a phone it is worse, in the sense of more of it. A 390x844 stage forces a framePad of up to 2.5x on the camera distance to get the county's silhouette into a portrait frame, so the eye goes way out and an even larger share of the picture is countryside I had not made.

A sky shader that stops at the horizon paints half the frame one colour

Here is where the olive came from. My sky dome does the usual thing: build a gradient from the normalized view direction's y, clamped.

float h = clamp( d.y * 1.35 + 0.08, 0.0, 1.0 );
vec3 col = mix( uHorizon, uBand, smoothstep( 0.0, 0.42, h ) );
col = mix( col, uTop, smoothstep( 0.22, 1.0, pow( h, 0.8 ) ) );

Every ray with d.y below about -0.06 clamps h to exactly zero and gets exactly uHorizon. One flat value, no variation, no gradient, across the entire lower hemisphere. In a game where the entire lower hemisphere is most of the frame.

That flat pale value was the void. I had been carefully tuning the colour of my own bug.

The dome fix is four lines: below the horizon, ramp toward a distinct ground colour, and get darker the further down the ray points, because that is what haze over land actually does.

// BELOW the horizon. The camera pitches 51 degrees down, so at full
// zoom-out most of the frame is looking *under* the horizon line — and a
// sky shader that stops at h=0 paints all of that one flat pale value.
float down = smoothstep( 0.0, 0.30, -d.y );
col = mix( col, uGround * mix( 1.15, 0.72, down ), down );

That alone stopped the frame being a slab on a plate. But a gradient is still a gradient. Land needs stuff on it.

The rest of the country, in one draw call

So I built the band: a 128-segment disc, 560 m across, sitting 6.1 m below the county's soil skirt, carrying a quilt of fields, hedgerows, hillside shading and the county's own cast shadow.

The important bit is the material. It is a MeshBasicMaterial with fog: false and toneMapped: false, and that is deliberate:

Unlit, unfogged and untonemapped on purpose. The only way the far land and the sky can meet with no seam is for both to be computed the same way.

The sky dome is also unlit and untonemapped. Two surfaces that meet in the frame have to be pushed through the same number of transforms or the join shows up as a line, and a visible line at the meeting point is exactly the "world stops here" feeling I was trying to kill.

The hillsides are free. The disc stays perfectly flat, but a Lambert term only ever reads the normal, so I make up a normal from two sine octaves and their analytic derivatives:

float a1 = w.x * 0.055 + 1.3, b1 = w.y * 0.044 - 0.4;
float a2 = w.x * 0.131 - 0.7, b2 = w.y * 0.118 + 2.1;
float h = sin( a1 ) * cos( b1 ) + sin( a2 ) * cos( b2 ) * 0.4;
float dhx = 0.055 * cos( a1 ) * cos( b1 ) + 0.0524 * cos( a2 ) * cos( b2 );
float dhz = -0.044 * sin( a1 ) * sin( b1 ) - 0.0472 * sin( a2 ) * sin( b2 );
vec3 N = normalize( vec3( -dhx * 11.0, 1.0, -dhz * 11.0 ) );
float lam = max( dot( N, vec3( 0.32, 0.4, -0.86 ) ), 0.0 );

Eight trig calls, no geometry, and the far country catches the same low sun the county does. Add 260 instanced trees clustered into 74 stands (one more draw call) because a shaded plane is still a plane, and the only thing out there with a silhouette is what parallaxes when the camera moves.

Then it was graph paper

First version of the quilt picked one colour per 27 m cell and butted the cells together.

The same county, now surrounded by farmland, but the farmland is a hard checkerboard of alternating brown and green squares

Fixed the void. Invented a picnic blanket.

Two things were wrong and both are worth naming, because I suspect anyone procedurally generating farmland hits them in this order.

The palette was a colour wheel. Real crop variation at four hundred metres through haze is a couple of stops, not a hue range. The four field colours now sit within a whisker of each other and are meant to survive only as texture under the relief shading:

vec3 pasture = vec3( 0.082, 0.094, 0.044 );
vec3 wheat   = vec3( 0.124, 0.104, 0.050 );
vec3 stubble = vec3( 0.104, 0.092, 0.046 );
vec3 tilled  = vec3( 0.092, 0.070, 0.042 );

Farmland does not meet farmland on a one-pixel step. There is a margin, a track, a hedge shadow. Each fragment now cross-fades with the two neighbour cells it is nearest, and the blend width widens with distance so the quilt dissolves into itself before it dissolves into the atmosphere:

float bw = mix( 0.42, 0.5, haze );
vec2 sgn = vec2( cf.x < 0.0 ? -1.0 : 1.0, cf.y < 0.0 ? -1.0 : 1.0 );
vec3 col = bbbField( ci, dryTop );
col = mix( col, bbbField( ci + vec2( sgn.x, 0.0 ), dryTop ),
  smoothstep( 0.5 - bw, 0.5, abs( cf.x ) ) * 0.5 );
col = mix( col, bbbField( ci + vec2( 0.0, sgn.y ), dryTop ),
  smoothstep( 0.5 - bw, 0.5, abs( cf.y ) ) * 0.5 );

Three extra hashes. The lattice is also rotated about 20 degrees off the world axes, because a field grid running true north/south under a top-down-ish camera reads as graph paper under a table, and the hedgerows got cut to a 10 percent darkening that fades out with distance. At full strength the hedgerows were the thing actually drawing the grid.

"Far away" is not a distance

The last piece is the aerial perspective ramp, and it is the part I would actually reuse somewhere else.

A fixed distance ramp cannot work here, and it took me two failed attempts to see why. It has to reach 1.0 before the disc's rim, or a residue of hard-edged quilt rides the top of the frame all the way out to a visible edge. My first ramp capped at 0.72 and did exactly that. But it also has to reach 1.0 no sooner than necessary, because at full zoom-out the eye is 250 m up and every scrap of far field is already past 300 m, so a tighter ramp saturates the whole band and the county is back on a plate. That is the shot in the first image.

Those two requirements contradict each other only if "far away" is a distance. It is not. The ground a camera can see runs from roughly one camera-height out to three or four, so the ramp belongs in camera-height units:

float bbbHaze( float dist ) {
  float camH = max( 40.0, cameraPosition.y + 6.1 );
  float n = max( 88.0, camH * 1.25 );
  float fEnd = clamp( camH * 3.0, n + 110.0, 430.0 );
  float t = smoothstep( n, fEnd, dist );
  return t * t;
}

Now the dissolve lands in the same place on the screen at every zoom level, and the 430 m clamp keeps it comfortably inside the 560 m rim in the poses where the band would otherwise outrun it. The disc also rides the camera's XZ every frame, which makes eye-to-rim a constant and therefore makes the rim provably unreachable rather than unreachable-in-the-shots-I-checked.

The county on a phone screen, zoomed fully out, reading as a lit slab of land standing in hazy golden farmland

Same camera, same county, on a 390px phone. It is standing on something now.

The same arithmetic had already put the sun behind me

Here is the part that stung. I had hit this exact coupling hours earlier and not recognised it.

At VOLUMETRICS the game turns on god rays: a 14-tap radial blur toward the sun's screen position, masked to a smoothstep(1.9, 3.2, luma) gate so only genuinely emissive pixels streak. The sun's screen position comes from projecting a point along the sun direction:

sunProj.copy(SUN_DIR).multiplyScalar(600).add(camera.position)
sunProj.project(camera)
ru.uSunUv.value.set(sunProj.x * 0.5 + 0.5, sunProj.y * 0.5 + 0.5)
if (sunProj.z > 1) ru.uRays.value = 0

With a high sun, that pass painted a pink starburst across the entire county, with the shafts converging on the wrong corner of the screen.

Vector3.project divides by clip-space w. For a point behind the camera w is negative, so the NDC coordinates come out mirrored through the screen centre. The sun was behind the camera, my code cheerfully computed a UV for it, and the blur dutifully streaked away from a point that does not exist.

And a sun goes behind this camera much earlier than intuition suggests, because the camera is looking 51 degrees down. The forward vector is (0, -sin 51, -cos 51), so the dot product against a sun at elevation e goes negative around 30 to 37 degrees of elevation depending on how far the rig has been twisted off the sun's azimuth. Not the 90 degrees a level camera would give me.

The shipped sun is at about 24 degrees:

// Elevation matters more than it looks: the camera pitches 51 degrees down, so
// a sun much above ~30 degrees sits *behind* the camera and the god-ray pass
// projects it to the wrong corner of the screen. Low sun, long shadows, shafts
// where you can see them.
export const SUN_DIR = new Vector3(0.32, 0.4, -0.86).normalize()

The sunProj.z > 1 line is the seatbelt, not the fix. Lowering the sun put it back in frame, gave the county much better raking shadows, and made the shafts do what shafts are for.

The thing worth taking away

Both bugs are the same sentence: a tilted camera puts the world somewhere other than where I assumed, and pitch plus or minus half the vertical FOV tells me exactly where.

Two numbers, one subtraction, and it would have saved me an hour of recolouring a sky nobody sees:

Whenever I catch myself tuning the colour of something for the fourth time, the question is not what colour it should be. It is what that something actually is. Tonight it was ground.

← All posts