Skip to content

Many Planets, One Chunk Pool: Planetary LOD in UE5

26 July 2026 · Devlog

One streaming procedural planet is fine. Put four in the same level and you pay for all four, including the three you aren’t looking at. Here’s how PlanetGen V1.6 handles that with one chunk pool, a central arbiter, and a LOD transition with no dither, no alpha and no pop, plus why you should measure your terrain instead of trusting Max Height, and the hysteresis and deferred teardown that keep it stable.

Why a second streaming planet doubles everything

A streaming planet isn’t a mesh, it’s a population. One PlanetGen planet at a 50 km radius is a pool of 6,144 chunk actors (6 faces × 32²), each with procedural mesh, collision, foliage and grass components. For the world you’re standing on, that’s fine: it’s what streaming buys you.

Place four planets and you get four pools, roughly 24,000 actors, four sets of async build tasks and four collision cooks. Memory and tick cost scale linearly with planets you’re not even looking at.

Three obvious fixes, three dead ends

  • Stream fewer chunks per planet. You’d cut view distance on the planet you’re actually standing on. Wrong trade.
  • Level streaming or sublevels. The boundary is a sphere you can approach from any angle at any altitude, not a box you drive through.
  • Make the far planets static meshes. Now they don’t match the terrain you land on, and the swap is a hard pop.

The real problem isn’t rendering distant planets. It’s that the expensive thing, the pool, has no reason to exist for a planet you’re 900 km away from. Every good LOD system starts by identifying what it refuses to pay for.

One arbiter, one live pool

Planets are separated by enormous distances; that’s what makes them planets. So at any instant exactly one of them deserves full streamed terrain, and every other one is scenery. Rather than letting each planet decide for itself, one arbiter decides which planet is real: the nearest runs LOD0 (streaming terrain), the rest run LOD1 (a far sphere). At most one chunk pool is alive, ever, however many planets you place.

The UPlanetGenLODSubsystem arbiter

It’s a world subsystem with one job:

  • Planets self-register on BeginPlay and unregister on EndPlay. There’s no manager to place and no list to maintain.
  • Planets are held as weak pointers and pruned on tick, so a destroyed or streamed-out planet can’t linger in the arbiter.
  • Each tick it finds the nearest planet, applies LOD0 to exactly that one and LOD1 to everyone else.
for (const TWeakObjectPtr<ACLMPlanet>& WP : Planets)
if (ACLMPlanet* Pl = WP.Get())
Pl->SetPlanetLODActive(Pl == Active); // no-op unless the state actually flips

Rings never register. They stream at all distances and opt out via SupportsPlanetLOD().

One authority, N passive participants. The planets don’t know about each other and never will.

Nearest by surface, not by centre

Planets are different sizes, so ranking them by centre distance gets it wrong: a gas giant’s centre can be far away while its ground is right under you. The arbiter ranks by surface distance, dist(player, centre) − radius, which goes negative once you’re below the mean radius:

return FVector::Dist(PlayerPos, GetPlanetCenter()) - PlanetRadiusCm();

Activation range is also expressed in surface terms, defaulting to 5 × planet radius, so a bigger world claims you earlier, at proportionally the same moment. This is deliberately decoupled from the gravity radius: LOD needs lead time to stream, gravity doesn’t.

Two kinds of hysteresis

  • Leaving. The active planet is only released past LOD0Distance × LODHysteresis (default 1.3), so hovering exactly on the line can’t spawn and destroy 6,000 actors twice a second.
  • Switching. A different planet must be closer by a margin, 25% of the larger radius, before it can steal LOD0, so drifting along the midpoint between two worlds doesn’t flip-flop.

Both are cheap scalar comparisons that prevent a class of bug that only shows up in playtesting. Any threshold that triggers expensive work needs two thresholds.

What LOD1 actually is

LOD1 is the same planet, sampled coarsely. It’s one cube-sphere mesh at LODResolution 32 per face, a globe of about 6k vertices, built from the same noise, the same biomes and the same vertex colours as the streamed chunks. It isn’t a stand-in asset; it’s a stand-in sample rate, so coastlines, mountains and ice caps land in the same places.

By default it reuses the terrain material through its own dynamic instance, so the colour bands match exactly across the handoff. No pool, no collision, no foliage: one draw call’s worth of planet.

Hiding the transition with the terrain

Why every standard LOD swap looks bad here

  • Hard swap: a whole planet’s silhouette changes in one frame. Unmissable.
  • Dither or fade: you’d see stars through the planet mid-blend, and it costs a translucent pass on a full-screen object.
  • Distance-based mesh LOD: the near version isn’t a mesh, it’s thousands of actors that arrive over several seconds.

The far sphere and the streamed terrain also coexist for a while, so you can’t cut between them. But the overlap isn’t a problem to eliminate. It’s the tool.

Don’t fade the sphere, bury it

Both representations are concentric spheres about the same centre, so one can simply sit inside the other.

  • Far state: scale the LOD1 sphere slightly above the terrain peaks. It becomes a snug shell that covers everything, and chunk detail can’t poke through.
  • Near state: scale it below the lowest terrain. The streamed chunks now occlude it completely, and the depth buffer hides it for free.

Then animate the scale between the two. No dither, no translucency, no material work, no popping silhouette. That’s the whole trick; the rest is making it robust.

Sizing the envelope

Measure, don’t trust Max Height

Min/Max Height are outer bounds, and layered noise times a continental mask almost never reaches them. Size the envelope from those numbers and the shell floats hundreds of metres above the real peaks, leaving a visible gap when it retracts. So while it builds, the far mesh reports what the noise actually produced: measured min, measured max, and the miss.

LODMeasuredMinHeightCm / LODMeasuredMaxHeightCm / LODMeasuredMissCm

Everything downstream sizes off the measurement, not the authoring.

The clearance term that actually matters

The far mesh under-samples. Between two of its vertices the real terrain does whatever it likes, and that’s exactly what pokes through. That miss scales with planet size: on a 50 km world each LOD1 cell is kilometres wide, so the miss approaches the full relief, while on a small planet it’s near zero.

A fixed percentage-of-relief clearance can’t express that. So we measure the miss and use it, multiplied by 1.5 because the midpoint sample isn’t necessarily the worst point in the cell:

AutoClear = LODMeasuredMissCm * 1.5f; // + optional authored LODEnvelopeClearance

The right constant is the one you measured. The one you guessed is a bug with a delay.

The expand formula, and the one that failed

My first attempt scaled the shell so LOD1’s ocean floor cleared the terrain peaks: (RTerrMax + C) / RLODmin. But the scale is uniform, so that factor dragged LOD1’s own peaks up by the same ratio, inflating the sphere by the entire relief and opening a huge gap.

The fix relies on relief being tiny next to the radius: scaling so that the peak rises by Clearance lifts the whole shell by approximately Clearance.

Expand = 1 + Clearance / RTerrMax; // snug cover, a few metres proud
Submerge = (RTerrMin - Clearance) / RLODmax; // highest LOD1 vert below lowest terrain

Submerge genuinely needs the floor-vs-peak span. It’s a different question, so it gets a different formula.

The water sphere rides along

The ocean sphere can’t submerge; it has to end up at exactly sea level, which is scale 1.0. So it lerps Expand → 1.0 on a compressed progress, reaching accurate size at the moment the land shell passes through 1.0, then holding.

A single LODProgress value (0 = far, 1 = close) drives the land, water and atmosphere shells, so the whole system agrees on one number.

Readiness: don’t submerge into a hole

If the shell retracts on a timer, it can shrink away before the chunks exist, and you’re looking through the planet. So the retract is gated on real progress: count built active chunks against the in-range set. Chunks load nearest-first, so around 90% built means the ground you can see is covered and the stragglers are over the horizon. It takes 90% to start submerging and 70% to stay submerged. That’s a third hysteresis gate, so a few churning leading-edge chunks as you walk don’t pop the envelope back out.

The hold state

While terrain streams, the shell sits neither at the far envelope (a visible dome over your head) nor exactly at the surface (z-fighting on flat ground). It holds a few metres under the terrain: built chunks occlude it cleanly, and un-built regions still show the low-poly surface. No holes, no shimmer.

On the first update it snaps straight to the correct state, so spawning on a planet never shows a dome that has to visibly retract. “Nearly correct” positions z-fight; always commit to one side of the surface.

Streaming the pool in and out

Deferred teardown

When you leave a planet, the arbiter releases it immediately, but the pool isn’t freed yet. The shell expands back out to the envelope first, and only at full expansion is the pool torn down. Otherwise the chunks vanish while the shell is still shrunk, leaving a one-frame hole through the planet. Symmetrically, the pool is requested early, on approach, so streaming has lead time.

if (!bWantLOD0 && bLOD0Active && LODProgress <= 0.001f) { TeardownChunkPool(); }

Time-slicing both directions

Spawning thousands of actors costs hundreds of milliseconds. So does destroying them: 6,000 actors in one frame is a hitch either way.

  • Both directions are budgeted per tick: ChunkPoolSpawnPerTick (32) and ChunkBuildsPerTick (24).
  • Terrain streams as the pool grows; it doesn’t wait for the full pool to exist.
  • In-flight async build and grass tasks hold weak pointers and refcounted snapshots, so draining the pool underneath them is safe.
  • Editor preview has no tick, so those paths fill immediately. Same code, different budget.

Every allocation you time-slice, remember to time-slice the free.

Debug controls

Four buttons made this debuggable:

  • Force LOD0: stream terrain here regardless of position.
  • Force LOD1: go back to the far sphere.
  • Force Both: stream the terrain and pin the shell at the full envelope. This is the QA tool: if anything pokes through the shell, your clearance is wrong and you can see it.
  • Release LOD Override: hand control back to the arbiter.

All four are CallInEditor and BlueprintCallable, so they work in the editor and in PIE.

The result: one pool, as many planets as you like

The video’s final shot is continuous: launch from one planet, watch the terrain underneath seal into a smooth globe, cross to a second planet whose shell opens into streamed terrain on the descent, then land and walk. No loading screen, and the actor count stays flat throughout. The best compliment this system can get is that nobody noticed it working.

Honest tradeoffs

  • One planet is real at a time. There’s no collision, no foliage and no landing on an LOD1 world. Two players on two different planets need a different strategy.
  • The transition takes seconds (10 by default). Travel fast enough and you can outrun it; it then holds below the surface until terrain is ready rather than showing a hole.
  • Extreme-relief planets may want extra LOD Envelope Clearance. The auto term is measured, not omniscient.
  • LOD1 coastlines are only as crisp as LOD Resolution (32 per face). Raise it for hero planets; it’s cheap.
  • Uniform scale assumes concentric spheres. This approach doesn’t transfer to non-spherical worlds, which is exactly why rings opt out.

The transferable pattern

Three ideas generalise beyond planets:

  1. Arbitrate the scarce thing. When a resource is globally scarce (a chunk pool, a Sky Atmosphere, a render target), don’t let N objects each try to own it. Put one arbiter in front and give everyone else a cheap stand-in.
  2. Hide geometry with geometry. Occlusion is free and artifact-free; dithering and alpha are neither.
  3. Measure the content instead of trusting the authored bounds. The constant you need is in your data.

The system ships in PlanetGen V1.6 on Fab for UE 5.4–5.8, with full C++ source: PlanetGenLODSubsystem.cpp plus the LOD block in CLMPlanet.cpp. The next video covers the atmosphere half of this system: how one Sky Atmosphere serves a whole solar system.