Skip to content

How I Fake Atmospheres on Distant Planets in UE5

29 July 2026 · Devlog

Unreal renders one Sky Atmosphere for the whole view, and I have four planets. In PlanetGen V1.6 every distant planet still has a thin rim of air and a cloud deck, and flying toward one takes you down through real volumetric cloud into a scattering sky with no visible cut. This post covers the stand-in shells, the scattering cross-fade, the per-pixel cloud fade, and the altitude clamp you need when planets with different mountains share one atmosphere asset.

Sky Atmosphere is a global effect, not a prop

UE renders one Sky Atmosphere and one Volumetric Cloud for the whole view. They aren’t per-actor components you can sprinkle around: place a second Sky Atmosphere and you get a conflict, not a second planet’s air.

Yet in a multi-planet level every planet needs to look like it has an atmosphere from any distance, and the planet you’re at needs the real thing: scattering, aerial perspective, and volumetric cloud you can fly through.

The nearest planet owns the sky

The LOD subsystem already decides which planet is nearest for terrain, and I reuse that decision verbatim. The single Sky Atmosphere is moved onto that planet’s centre and configured from its data asset:

Atmo->SetWorldLocation(Planet->GetActorLocation());
Atmo->TransformMode = ESkyAtmosphereTransformMode::PlanetCenterAtComponentTransform;
Atmo->SetBottomRadius(RadiusKm);
Atmo->SetAtmosphereHeight(A->ResolveAtmosphereHeightKm(RadiusKm));

Only the component moves; the sun and sky light on the same actor stay put. It’s reconfigured on handoff only, never per frame, because every scattering change rebuilds the transmittance and multi-scatter LUTs. One scarce global resource, one arbiter: the same pattern as the chunk pool, by design.

Per-planet atmosphere assets

Each planet’s settings live in a UPlanetGenAtmosphereAsset, one data asset for both LODs that you can share or specialise: Rayleigh and Mie scattering, ground albedo, multi-scattering, aerial perspective, cloud altitude and sun shadow distance. A naming convention saved a lot of confusion:

  • LOD0… affects only the real Sky Atmosphere / Volumetric Cloud (the near planet).
  • LOD1… affects only the distant stand-in shells.
  • Unprefixed is shared geometry both derive from, so near and far move together.

An atmosphere height of 0 means auto (1.5% of the planet radius), so it scales with world size.

Stand-in shells for every other planet

Once the atmosphere belongs to the nearest planet, the other three are bare airless spheres, and a planet without a limb glow reads as an asteroid. Full scattering per planet is off the table, so what’s the cheapest thing that reads as “this world has air”? Two shells: a fresnel halo at the top of the atmosphere, and a translucent cloud layer.

The fresnel halo

The halo is a single smooth ~48-segment sphere at the top of the atmosphere: additive, unlit, driven by a limb-weighted fresnel, with no shadows and no scattering integral. Effectively free. The asset pushes HaloColor, HaloIntensity, HaloFalloff and HaloEdgeSoftness, and the colour usually matches the LOD0 Rayleigh colour so the two agree across the handoff.

The catch: fresnel is brightest at grazing angles, which is exactly the shell’s silhouette. The halo hits full brightness at the precise pixel where it stops existing, giving a hard bright line against space.

The fix is to fade the outermost slice of the rim back to zero so it dissolves instead of ending. That also pushes the bright band inward, closer to how real atmosphere glows just above the limb rather than at the very top of the air. LOD1 Far Halo Edge Softness controls it: 0.01 is a hard cut; higher is softer, wider and slightly dimmer.

Any effect that peaks at a silhouette needs an explicit edge treatment. The maths doesn’t know your mesh ends.

The cloud deck

Volumetric clouds only raymarch a short distance, so from orbit they aren’t there at all. The shell is what sells clouds at planetary scale. It’s deliberately its own mesh, not painted into the LOD1 surface material: the far sphere reuses the terrain material, so clouds there would end up on the streamed LOD0 terrain too.

The noise is procedural macro plus micro, authored in the asset as feature sizes in km, because the material wants a world-position multiplier like 0.00002, which tells you nothing:

CloudMacroScale = 1 / (MacroSizeKm * KmToCm); // km in, multiplier out

Micro detail is subtracted from the macro shape, which makes the edges ragged instead of dappled.

Cloud altitude when planets share an asset

One asset, different mountains

The atmosphere asset is usually shared, but peak heights are per planet. A cloud layer authored at 1 km is fine on a planet with 600 m peaks and buried inside the mountains on the next one.

The rule: the authored value wins whenever it clears. Otherwise, lift the stand-in shell only and log exactly what happened. The real Sky Atmosphere and Volumetric Cloud keep the authored value, so the LOD0 look never changes.

[PlanetGen] 'Planet_B': LOD1 cloud shell lifted from 1.00 km to 1.26 km to clear
this planet's terrain peak (1.20 km). The real Volumetric Cloud layer still uses
the authored bottom/height untouched.

When you silently correct someone’s data, tell them. A log line here saves a support thread later.

The shell sits at the layer top

The volumetric layer has a bottom altitude and a thickness, but one shell has to stand in for all of it. I put it at the layer top (bottom + height), the surface you actually see looking down from outside. At the bottom it would sink toward the ground and intersect terrain far more readily. The halo then has to clear the cloud shell as well as the peaks, so the ordering is terrain < clouds < halo.

Shells ride the LOD envelope

This was a subtle bug: the terrain grew, the clouds didn’t. The terrain LOD system deliberately inflates the far sphere above the peaks so chunk detail can’t poke through. Unscaled shells get swallowed by that inflated terrain, and the clouds end up under the ground. So the shells take the same scale as the LOD sphere, clamped to at least 1:

const float ShellScale = FMath::Max(LandScale, 1.f);

The clamp exists because at LOD0 the far sphere shrinks below the real terrain, and the shells must not follow it underground. Up close they belong at their true authored altitude.

Cross-fading the handoff

The pop

Approach a planet and at some frame it becomes LOD0: the Sky Atmosphere is configured and a full scattering sim appears instantly, at full strength. The halo is still up, so you go from no real atmosphere to two atmospheres in one frame.

The fix is to cross-fade them. The halo fades out on 1 − LODProgress, the real atmosphere fades in on LODProgress, and they roughly sum to a constant through the handoff. The same progress value drives the terrain envelope: one number, one truth.

Fade the scattering, not the radius

There are three ways to fade an atmosphere, and only one works:

  • Grow the radius from zero. The sky’s whole character morphs mid-transition. A tiny atmosphere isn’t a dim atmosphere; it’s a different-looking one.
  • Start BottomRadius under the surface. This doesn’t remove the pop, it just moves it to the moment the atmosphere engulfs you.
  • Scale the scattering. At zero scattering it’s genuinely invisible, and the shape stays correct the entire way. This is the one.
Atmo->SetRayleighScatteringScale(A->RayleighScatteringScale * Blend);
Atmo->SetMieScatteringScale (A->MieScatteringScale * Blend);

“Fade it out” is never one decision. Pick the parameter whose zero is genuinely nothing.

Throttling the LUT rebuild

Pushing a scattering value every frame rebuilds the transmittance and multi-scatter LUTs every frame, for a fade nobody can resolve that finely. Instead I push on a 2% step: ~50 updates across the whole transition, far more than the eye needs. Explicit endpoint checks make sure it lands exactly on fully-out and fully-in rather than stopping 1.5% short:

const bool bEndpoint = (Blend >= 1 && Last < 1) || (Blend <= 0 && Last > 0);
if (!bEndpoint && FMath::Abs(Blend - Last) < 0.02f) return;

Quantise the middle, never the endpoints. A fade that stops at 0.98 is a bug you’ll chase for an hour.

Fading the cloud shell per pixel

Clouds belong to both LODs

The halo is replaced by the real atmosphere, so fading it on LOD is correct. The cloud shell isn’t replaced: volumetric clouds only trace nearby, so even standing on the surface, the clouds toward the horizon are still the shell. Fade it on LOD and the horizon loses its cloud deck the moment you arrive. So the cloud shell stays fully on at both LODs and does its fading somewhere else entirely.

Distance to that point on the shell

A whole-object fade asks “how far is the shell?”, which is meaningless for a sphere wrapping the entire world. Instead, the material fades per pixel on camera-to-pixel distance: invisible nearer than Fade Start, fully visible past Fade End.

The clouds right above you dissolve and hand over to the volumetric layer, while the ones toward the horizon stay, which is exactly the split you want. Start and end are authored in km on the asset and pushed live, so you can tune them while flying.

The edge-on problem

A thin sphere seen sideways is a flat sheet. Wherever your sight line runs along the shell (the limb from orbit, or straight out at cloud altitude), it stops reading as a layer and shows up as a sheet slicing the view.

The fix is a fade on |dot(surface normal, view)|, where 1 is looking straight through the layer and 0 is exactly edge-on. The abs() matters: it behaves identically from underneath (on the ground looking up) and from above. A small gap between start and end gives a crisp cloud limb; a wider gap, a softer one.

Matching near and far clouds

The volumetric clouds and the shell clouds are different renderers with different noise. They’ll never match exactly, but they must read as the same weather.

  • The volumetric material exposes Layout_CloudGlobalScale. The asset drives it from LOD0 Cloud Global Scale through a dynamic instance created once; a MID per handoff would leak instances.
  • Gotcha: it moves the opposite way to the LOD1 macro size. Lower global scale means bigger clouds, so tune the two against each other.
  • Keep LOD1 micro size at roughly 1/10th to 1/15th of macro, the same octave ratio the volumetric material uses.

Lighting the stand-ins

Every shell shades around its own planet

The day/night terminator needs a planet centre, and the shared Material Parameter Collection holds only the nearest planet’s centre, which is wrong for every distant one. A shell is one sphere whose pivot is the planet centre, so it gets its centre per planet for free.

The far terrain sphere has the same trap. A distant planet never ran its terrain init, so its material instance can be null. Bind the raw material and it shades around the material default origin. Always create the instance, and always push the real centre. SunDirection is fine to read from the collection, since one sun lights everybody.

“Which planet am I?” is a per-object question. Never answer it from a global.

The preview button runs the shipping code

ApplyPlanetAtmosphere and FindSkyComponentsIn are statics, called by both the runtime handoff and the editor’s Toggle LOD0 Atmosphere button, so the in-editor preview can’t drift from what the game produces. Visibility-only toggles for LOD1 Clouds and LOD1 Atmosphere let you isolate one shell at a time without touching LOD or sky state. The editor toggle also re-pushes planet centre and sun direction, or a preview would shade against a stale sun.

Tradeoffs and cost

  • Only the nearest planet has real scattering and aerial perspective. The shells are a look, not a simulation.
  • The shell cloud deck is static geometry. Animate it in the material if you want weather to drift.
  • Near and far cloud shapes are tuned to agree, not derived from the same field. A shared noise texture is the durable fix.
  • Procedural shell noise can shimmer at grazing angles at extreme distance. Texture-based noise removes it.

The cost is two unlit/translucent spheres per distant planet (~48 segments), a handful of MID scalar pushes, one throttled scattering write during transitions, and a sky-light recapture only after you’ve moved 3% of the planet radius. No extra render passes.

Stand-in, cross-fade, per-pixel

  • Arbitrate the global and give everyone else a stand-in: the same shape as the chunk-pool LOD, applied to a rendering feature.
  • Cross-fade on the parameter whose zero is truly nothing, and drive both halves from one shared progress value.
  • When something is shared between LODs, fade it per pixel by distance instead of per object. The object is the wrong granularity.

This ships in PlanetGen V1.6 on Fab for UE 5.4 – 5.8, with halo and cloud shell starter materials included. The full C++ is in PlanetGenLODSubsystem.cpp and CLMPlanet.cpp. The terrain half of this system, one chunk pool across a whole solar system, has its own video.