The first planets I rendered looked a bit rough. They used just simplex noise in the pixel shader, a few octaves of fBm for colour, another layer for height. From a distance they looked alright, but were pretty rough upclose..

A noise-based lava world from close up
Close-up of a noise-based lava world. The orange-red surface is covered in a serpentine cracked pattern; there is no real terrain, just stretched procedural noise.
A noise-based Earth-like planet from medium distance
From medium distance the noise reads as surface variation: oceans, green landmasses, and clouds. Up close the procedural origin would be obvious.
A noise-based desert planet up close
A desert noise planet with a brown-orange surface, scattered craters, and a thin blue atmosphere.

These don't look great. Since every pixel is computed independently, so there is no way to author larger scale details which I could figure out a noise pattern for (Me being not particularly great a maths didn't help here). You can add more octaves, but you hit limits and the surface still does not look like it has features, it looks like it has patterns.

The second issue is while you probably could make it look good at a distance, I'm aiming for a proper space game! (Minus the landing), the planets need to look good both up close and from a distance.

#From patterns to terrain

I moved away from noise in the pixel shader and started simulating physical processes on a spherical grid instead: thermal erosion, hydraulic erosion, sediment deposition, and river network formation. The result is a cubemap of actual terrain data. Height, temperature, precipitation, snow cover, vegetation density, rock exposure, wetness, and erosion amount.

An erosion-simulated planet with cloud cover
The erosion simulation produces continents, coastlines, and biome variation. A warm glow catches the left limb; white clouds scatter across the surface.
Close-up of the erosion-simulated planet's limb
A tight view of the limb showing the cyan atmospheric glow, and irregular coastlines.
The simulated planet with thick cloud cover
Heavy clouds obscure much of the surface, but the underlying terrain data drives where land and ocean appear. A bright streak cuts across the right side of the frame.

This happens once per planet during cook time.

A river network is a global feature. Where a river ends depends on where it started, and that dependency propagates across the entire sphere. Noise has no such dependency. You can approximate river valleys with ridged multifractals, but they do not connect to anything.

The erosion outputs are baked into cubemaps. CubemapA stores elevation, temperature, precipitation, and snow cover. CubemapB stores vegetation density, rock exposure, wetness index, and erosion amount. These cubemaps are the foundation everything else builds on. They capture continental structure at a scale far above individual rocks or pebbles, but the structure is physically coherent.

#Why that still was not enough

The erosion cubemaps give decent looking terrain at low frequency but weren't great up close. The problem they do not solve is close-up detail. The cubemap resolution is fixed, and if you want to support a player flying close enough the size requirement is huge. You need a way to add detail at the scale the camera actually sees, without shipping a multi-gigabyte texture. In the above images, it was 6x8k textures for the cube map which would get horribly expensive for the number of planets I'm hoping to have in the end.

Virtual texturing I hope will solve this. Instead of one giant texture, you define a virtual texture space much larger than anything you could fit in memory. This space is subdivided into tiles, each at a specific resolution. At runtime, you only keep the tiles that are actually visible, at the resolution actually needed, in a much smaller physical texture cache. As the camera moves, new tiles are fetched and old ones evicted. The shader samples the virtual space, and a lookup table redirects the sample to the right physical tile.

It is the same concept as web map tiles or texture streaming in modern engines, just happening on the GPU with procedurally generated content rather than downloaded images. The visible surface of a planet at any moment only covers a tiny fraction of the total surface area, so you only need a tiny fraction of the total data resident.

The pre-simulated cubemaps and the runtime tile generator work together. The cubemaps provide low-frequency structure: continental shape, mountain ranges, river basins, biome boundaries. The runtime tile generator samples those cubemaps and adds high-frequency detail on top: rocks, craters, surface variation, biome-appropriate micro-detail. Each tile is generated at the resolution it needs, with the erosion data as its foundation.

Without the erosion simulation, the runtime generator would be adding detail to noise, and the result would still look like wallpaper up close. With it, the detail has at least some context: craters land on the right terrain type, rock formations follow slope and erosion patterns. The surface still looks procedural if you stare at it long enough, but it no longer looks like a textured sphere.

#Quick asside: the inverse impostor cube

There is no planet mesh. Instead, I use an inverse texture cube inspired by Ben Golus' post "Rendering a Sphere on a Quad"

The cube is rendered inside-out using front-face culling. The pixel shader runs on the back faces as seen from the camera, not the front faces. This matters mainly because the camera can then be inside the cube and the planet is still rendered. With normal front-face culling, the moment your near plane crosses an outward-facing triangle the cube would disappear. Inverting it means the cube is visible from any camera position, including inside its own bounds. For planets that is the difference between rendering correctly from low orbit and clipping out the moment you get close. For things like gas giants this lets you add further procedural detail and volumetrics when up close. This also happens to be why I kept with the cube instead of the quad, when trying the quad approach near clipping became an issue.

#The cubemap-cube-quadtree mental model

Cube mapping. Sphere coordinates are addressed via cube faces. Six faces, each a 2D parameterisation of a sixth of the sphere. Same scheme as a regular skybox cubemap.

Per-face quadtree. Each face is subdivided into a quadtree. Level 0 is one tile per face. Level N is 4^N tiles per face. Pax uses MaxLevel = 7, so the finest level is 128 by 128 tiles per face, six faces.

Tile. A tile is a rectangular region of one face at one quadtree level, identified by (face, level, x, y). The TileId struct is a record with those four bytes plus an Owner tag, because tile slots are shared across planets and each tile remembers which planet it belongs to.

public void GetChildren(Span<TileId> children)
{
    byte childLevel = (byte)(this.Level + 1);
    ushort baseX = (ushort)(this.X * 2);
    ushort baseY = (ushort)(this.Y * 2);

    children[0] = new TileId(this.Face, childLevel, baseX, baseY, this.Owner);
    children[1] = new TileId(this.Face, childLevel, (ushort)(baseX + 1), baseY, this.Owner);
    children[2] = new TileId(this.Face, childLevel, baseX, (ushort)(baseY + 1), this.Owner);
    children[3] = new TileId(this.Face, childLevel, (ushort)(baseX + 1), (ushort)(baseY + 1), this.Owner);
}
TileId.cs — the quadtree split in four lines

#CPU tile feedback

In Pax's case I didn't need to use GPU feedback for tile selection. Tile selection runs entirely on the CPU each frame. Since the planets are just simple spheres it's the easiest way to handle it.

PlanetTileFeedback.GatherRequiredTiles does a quadtree traversal over the planet's tile tree. Per tile, it computes the screen-space coverage from the current camera position, using camera FOV, screen width, and a foreshortening factor. If the tile's projected area exceeds a threshold, it descends to children. If not, it stops and emits the tile as a leaf. The output is a list of TileRequest capped at MaxLeafTiles = 2048.

That said, the CPU approach has problems. It does not account for occlusion: a tile hidden behind a moon still gets requested. The screen-space coverage heuristic is approximate at glancing angles.

#Quirks

Moving to the VT system was fairly smooth, except for a few issues.

A bug where tiles failed to load, leaving black angular cutouts
Missing tiles created blocky black silhouettes on both the large desert planet and the smaller blue world.
A checkerboard bug from miscomputed atlas UVs
The indirection table was mapping every texel to the full atlas slot instead of the correct sub-rect.

I was hoping to use a singular VT table for all planets since I wont have many at high resolution levels at once. Couldn't get the indirection table working, so for now I'm using a texture per planet which isn't ideal.

Getting the tiling system working had a few issues too, as seen from the second image.

#Results

I think it's ended up looking pretty decent!

Ring shadowed VT-tiled planet VT-tiled Gas Giant

There's more work to do, particularly around things like clouds and the volumetric effects when approaching gas giants, but that's a problem for future me.

#What I would do differently

The CPU tile feedback is fine for one or two planets in view. For dense systems with many bodies on screen, the quadtree traversal cost adds up. A coarser per-planet culling pass would help.

The 4096-slot atlas has been enough so far. With more planets in view at once, LRU thrashing might become an issue. A tiered cache, small high-frequency atlas plus large low-frequency atlas, is on the list.

No occlusion in the feedback pass. Hidden-surface tiles get requested anyway. A depth-aware traversal would be the fix here, but I haven't profiled whether it actually matters.