Skip to content

Real Climate Zones on a Procedural Planet

26 June 2026 · Devlog

Most procedural terrain picks its surface from altitude, and on a sphere that makes the poles look exactly like the equator. For PlanetGen V1.3 I built the planet’s climate out of two noise fields, temperature and humidity, to get Earth-like biomes on a procedural sphere: cold poles, hot deserts and ice caps. In this article I walk through the temperature × humidity model, the lessons that cost me hours (3D noise, auto-scaling, sharpening, latitude vs. climate, seamless normals), the polar-cap trick, and the honest tradeoffs.

Height is not a biome

Most procedural terrain decides the surface by altitude: low ground is grass, higher ground is rock, peaks are snow. On a sphere that has an awkward consequence. A coastline near the equator and a coastline near the pole look identical, and snow only ever shows up on mountains.

That means no deserts, no tundra, and no sense of where you are on the planet. If you can fly around a planet, the pole should feel different from the equator.

Organised by climate, not elevation

Real worlds are organised by climate. Biomes are a function of temperature and moisture, which is the idea behind the Whittaker diagram. Two independent inputs give you a 2×2 matrix of climate “corners”.

Altitude still matters, and snow still caps mountains, but it rides on top of climate instead of replacing it. So the plan is to drive the surface from two low-frequency fields and blend between them.

Building the climate model

The biome matrix

The four corners are Tundra, Boreal, Desert and Tropical:

DRY ◀── Humidity ──▶ WET
COLD ┌──────────────┬──────────────┐
▲ │ TUNDRA │ BOREAL │
│ │ cold/dry │ cold/wet │
Temperature├──────────────┼──────────────┤
│ │ DESERT │ TROPICAL │
▼ │ hot/dry │ hot/wet │
HOT └──────────────┴──────────────┘

Each corner owns four numbers: SnowStart, RockStart, GrassMult and SandMult. Everything else is interpolation between them.

Temperature: latitude plus noise

Base temperature comes from latitude, measured off the planet’s pole axis. Pure latitude gives perfect bands, which is boring. Pure noise gives no poles, which is wrong. So I blend the two, and LatitudeWeight sets the mix: 1 gives clean climate bands, 0 is fully noise-driven. As you lower it, the bands dissolve into blobs.

dir = normalize(vertexPos - PlanetCenter);
lat01 = 1 - abs(dot(dir, PoleAxis)); // 0 at pole → 1 at equator
T = saturate( lat01*LatitudeWeight + tNoise*(1-LatitudeWeight) );

Humidity: the second axis

Humidity is its own 3D noise field with its own seed offset, uncorrelated with temperature. That’s what lets a desert and a boreal forest sit at the same latitude. Two seed offsets, TempSeedOffset and HumidSeedOffset, keep height, temperature and moisture fully independent of each other.

Lesson: sample in 3D, not 2D

The planet is a cube-sphere: six faces, each with its own UV space. Sample climate in 2D UV and you get visible discontinuities along all 12 cube edges.

Sampling the noise at the 3D world position instead is continuous everywhere, and it’s identical across chunk borders because it’s deterministic, with no caching involved. It’s the same reason the terrain height is sampled in 3D; the climate had to follow. Position-based sampling is both seam-free and recomputable: no stitching and no neighbour lookups.

Lesson: auto-scale to planet radius

A 6 km planet and a 60 km planet should look the same. With a fixed noise frequency, a big planet gets tiny confetti biomes and a small planet gets one giant biome. Scaling the frequency to the radius means you always get roughly the same number of bands:

TempFrequency = (2.5 / PlanetRadius) * ClimateBandMultiplier;
HumidFrequency = (2.0 / PlanetRadius) * ClimateBandMultiplier;

ClimateBandMultiplier is the one knob: higher values give more, smaller biomes.

Blending the four corners

Temperature T and moisture M, both in the 0–1 range, pick a point inside the 2×2 matrix. Every threshold is then a bilinear interpolation of the four corner values:

// (T, M) in [0,1] pick a point inside the 2×2 matrix
SnowStart = bilerp(Corner[].SnowStart, T, M); // also Rock / Grass / Sand

A vertex isn’t “in Tundra”. It might be 70% Tundra, 20% Boreal and 10% Desert, blended. One formula drives the snow line, rock line, grass density and sand amount per vertex.

Lesson: a raw blend is mush, so sharpen it

A straight bilerp smears every biome into every other one, so nowhere is fully anything. What I wanted was “once you’re in the desert, you’re in the desert.” The fix is to smoothstep temperature and humidity toward their extremes before blending:

T = smoothstep(0.5 - BiomeBlend, 0.5 + BiomeBlend, T);
M = smoothstep(0.5 - BiomeBlend, 0.5 + BiomeBlend, M);

BiomeBlend is the transition half-width. Low values give flat, 100% interiors that only blend at the seams; high values give soft gradients everywhere. On a desert patch, 0.5 washes the sand out everywhere, while 0.12 gives crisp sand with a thin fade.

Lesson: measure height from the sea, not the noise

This one showed up as deserts going black. The biome thresholds (DesertSandTopNorm and the snow lines) are authored relative to sea level. But the raw noise height is normalised over the whole range, deep ocean floor included, so sea level sits around 0.44 rather than 0. Comparing a sea-relative threshold against a full-range height made everything read as underwater, and the sand vanished.

The fix is to renormalise to height above sea before any biome test:

NormForBiome = saturate( (Height - SeaZ) / (MaxHeight - SeaZ) ); // 0 at shore → 1 at peak

If a biome layer is all or nothing, check what zero means in your height metric first.

The controls

Everything lives in one struct, BiomeParams, with sensible defaults:

  • bEnabled: when off, you get the classic height-only behaviour, with zero change to existing worlds.
  • LatitudeWeight, BiomeBlend and ClimateBandMultiplier: the three knobs that set the feel.
  • Four climate corners (Tundra, Boreal, Desert, Tropical), each with Snow, Rock, Grass and Sand values.

Cold corners use a negative SnowStart, so snow reaches down to sea level and below it.

Material changes

Desert sand gets its own channel

Beaches and deserts are different sand. The shoreline beach already lived on UV1.x, as a band just above the waterline. Arid desert sand needs a different look, so it gets UV1.y, driven by the biome’s SandMult. Both fade out under snow, so a frozen coast never reads as sandy.

The result is one material with two independent sand layers, authored with different textures. The blend order is Grass → Rock → Snow → Beach (UV1.x) → Desert (UV1.y) → Underwater.

The planet slope mask

On a sphere there’s no global up. “Up” is radial, pointing away from the planet centre, so it’s a different direction at every point:

radial = normalize(AbsoluteWorldPosition - PlanetCenter); // PlanetCenter = vector param
slope = dot(VertexNormalWS, radial); // 1 = flat ground, → 0 = cliff

The lesson here: don’t use ObjectPosition. That’s the chunk origin, so the mask seams at every chunk border. Feed the real planet centre in as a parameter, set per planet through a dynamic material instance. That also makes it work for planets placed anywhere in the level, not just at the world origin.

Lesson: seamless normals

The slope mask exposed a hidden seam. As soon as I had it, chunk borders lit up, because vertex normals didn’t match across tiles. Each chunk computed its normals from a grid clamped at its own edge, so edge vertices had no real neighbours.

The fix was to sample an extended one-vertex border ring, so edge normals use the actual neighbouring terrain. A mask makes a good seam detector: if your normals are wrong, a slope mask will show it.

Polar ice caps

My ice caps had beaches

At the poles I wanted snow running straight into the water. Instead I got a beach ring at icy coastlines: snow stopped above the waterline, and sand filled the gap down to the sea. In a debug view with snow (VC.A) in red and beach (UV1.x) in cyan, the red caps had cyan rings around them. Tweaking the climate noise didn’t fix it, because some “polar” coasts simply read as warm.

Latitude is not climate

There are two different definitions of “polar” here: polar by latitude (geometry) and cold by climate noise (the field). They only line up when LatitudeWeight = 1. Below that, the noise pushes some high-latitude coasts warm, the snow line lifts, and a beach appears.

You can’t noise your way out of a geometric requirement. So instead of trying to fix the soft system, I added a hard one next to it.

The hard polar cap

The cap is a latitude override that ignores the noise:

latDeg = degrees( asin( abs(dot(dir, PoleAxis)) ) ); // 90 at pole → 0 at equator
polar = saturate( (latDeg - (PolarLatitude - PolarBlend)) / PolarBlend );
SnowMask = max(SnowMask, polar); // force full snow to the shoreline
// beach *= (1 - SnowMask) → sand simply vanishes inside the cap

PolarLatitude and PolarBlend define the cap. Poleward of that line, snow is forced to the shore (and a little below sea level) and beach and sand are removed, regardless of climate. The result is clean white caps every time.

The lesson: decouple a hard requirement from a soft, noisy system instead of fighting the noise.

Climate-filtered foliage

A cactus should only grow in the desert. Every foliage entry and grass asset carries a multi-select ClimateBiomes mask (Tundra, Boreal, Desert, Tropical). Combined with the surface biome (grass, rock, snow or beach), that picks a cell of the full matrix:

  • Cacti: desert only.
  • Pines: boreal only.
  • Palms: tropical.

It’s the same scatter system, just climate-gated. The filter only applies when biomes are enabled, so existing assets keep working untouched.

The result

Put together, that’s one planet with real geography: two noise fields, four corners, and seam-free output across every chunk and every cube face. In the video’s result shot you can follow it from a white polar cap with a hard snow-to-sea line, through the boreal belt and a dry desert basin with cacti, down to a green tropical coast with beaches.

Tradeoffs

This is where the model stops:

  • Four corners only. It’s a 2×2 climate model, not a full ecology. There’s no savanna-vs-steppe nuance beyond what the blend gives you.
  • Per-vertex climate. There’s no biome-specific erosion or geology. Biomes recolour and re-scatter; they don’t reshape terrain.
  • The polar cap is a clean latitude band. It’s deliberately not a noisy coastline. If you want ragged ice, that’s a different tool.
  • Biome borders are blend bands, not hard lines. That’s by design, but it means no crisp political-map edges.

Performance

  • Climate adds two extra low-frequency noise samples per vertex: temperature and humidity.
  • All of it runs on the chunk worker thread, in the same async build that already makes the terrain. The game thread never sees it.
  • Because it’s position-based and deterministic, there’s no seam fixup, no neighbour passes and no caching to maintain.
  • The net runtime cost after a chunk is built is zero. It’s baked into vertex colours and two UV channels.

The point is that this is a build-time cost, not a frame-time cost.

A pattern that isn’t just for planets

Temperature × humidity with bilinear corners is the canonical biome model, and it works on flat worlds too. The more useful lesson is structural: when a soft system like noise can’t guarantee a hard rule like “poles are frozen”, layer a hard override on top instead of bending the noise. And auto-scale your noise to your world size, so the content is resolution-independent.

Two fields, four corners, one override. The rest is interpolation.

Getting PlanetGen V1.3

Spatial biomes and polar caps ship in PlanetGen V1.3, available on Fab for UE 5.4 – 5.8. There’s a playable packaged demo on Patreon if you want to fly the planet yourself. The full C++ source is included in the plugin, and CLMPlanetChunk.cpp has the production climate and polar code. The next video covers wiring the climate into your own terrain material, channel by channel.