Log inGet started
Package

atmosphere.package

Newest version

This package holds more than this page lists. See all of its files in its world.

readme.md

atmosphere system

The medium between the camera and everything it can see, in two forms:

  • PhysicalAtmosphere — Earth's scattering, computed. Distant geometry desaturates and shifts toward the sky, and the sky's colour follows from the sun's elevation.
  • HeightFog — an authored exponential fog that pools in valleys and brightens toward the sun. Cheaper, and directable in a way physics is not.

They compose: fog for the look a scene wants, atmosphere for the distance cue underneath it.

Physical atmosphere

Nothing about a wide shot says "computer-generated" more than a ridge five kilometres away rendering at the same contrast and saturation as one five metres away. Real air scatters: distance costs contrast, shifts colour toward the sky, and does it differently at dawn than at noon because the light travels through a different length of atmosphere.

That comes out of integrating Rayleigh, Mie and ozone along the view ray rather than ramping a colour by depth — which is also why the sky, computed from the same integral, agrees with the geometry in front of it at the horizon.

local id = entity.spawn("atmosphere").id
entity(id).component.add("PhysicalAtmosphere", {
    aerial = 1.0,           -- how much of the result reaches the image
    sky = true,             -- the visible sky comes from the same scattering
    worldUnitsPerKm = 1000, -- 1 world unit is a metre
})

worldUnitsPerKm is the field to get right first. Scattering is computed in kilometres against Earth's coefficients, so it is what maps the scene onto real atmospheric distances. Set it too low and a courtyard hazes like a valley.

The sun's direction and brightness are read from the scene's own directional light, so a day cycle reaches the sky with nothing on the component moving.

Two of the fields decide what hue the scattering comes out at, and they are the ones to reach for when a scene wants a sky that is not Earth's blue. groundAlbedo is the reflectance of the ground the sky is lit over: it feeds the multiple-scattering term, so a warm ground carries its colour up into the air above it. sunColor tints the light entering the atmosphere, and so tints the sky and the aerial perspective together. Both default to the physical values — a neutral grey ground and a white sun, the pair that puts the warm colour of a low sun in the transmittance through a longer air path rather than in a tint. A desert brown-out is groundAlbedo = { 0.68, 0.44, 0.23 } with sunColor = { 1.0, 0.60, 0.30 }; the other fields move the sky between blue and grey and no further.

fieldeffect
aerialHow much of the atmospheric result reaches the image, in [0, 1]. 0 leaves geometry untouched.
skyReplace the engine's sky with the scattering result, so sky and geometry agree at the horizon.
worldUnitsPerKmHow many world units make a kilometre. At 1000 a world unit is a metre.
groundLevelYThe world-space Y that sits at sea level.
exposureRadiance to display range. Raise for a brighter sky; aerial perspective brightens with it.
skyIntensityScales the sky's radiance without touching the aerial perspective.
rayleigh / mie / ozoneMultipliers on the three species. Rayleigh carries the blue, Mie the haze around the sun, ozone the blue that survives at twilight.
mieAnisotropyForward-scattering bias of the Mie lobe, in (-0.95, 0.95). Higher tightens the glow around the sun.
aerialStepsSteps marched between the camera and a surface.
aerialMaxKmCeiling on how much air a surface is seen through. 0 means no ceiling.
groundAlbedoReflectance of the ground the sky is lit over, per channel in [0, 1]. Feeds the multiple-scattering term, so it is what gives the sky a hue of its own. Defaults to (0.3, 0.3, 0.3), a neutral grey.
sunDisc / sunAngularRadiusThe solar disc and its size. The real sun is 0.00465 radians, which is under two pixels at a typical field of view.
sunColorTint on the light entering the atmosphere, colouring the sky and the aerial perspective together. Defaults to white, the physical value; reaches the integral as written, so a channel above 1 brightens the light the whole sky is built from.

Raising mie is the quickest way to see what the model is doing: at high values transmittance reaches zero inside the scene and geometry, ground and sky merge into one continuous haze with no seam anywhere.

For code that adjusts the atmosphere at runtime — weather, a cutscene, a quality slider — reach for the module directly:

local atmosphere = require("@builtin::systems.atmosphere.atmosphere")

atmosphere.set({ aerial = 0.6, mie = 3 })  -- any subset; the rest is kept
atmosphere.get()                           -- the settings in force
atmosphere.active()                        -- are the passes running
atmosphere.clear()                         -- off, settings kept

Height fog

Distance-only fog thickens with range and nothing else, which reads as a grey card sliding over the frame. Real fog is denser near the ground, so a valley pools fog while a hilltop above it stays clear, and it scatters light forward, so looking toward the sun through it is brighter than looking away. Both come from integrating density along the view ray in world space rather than blending by screen depth.

local id = entity.spawn("fog").id
entity(id).component.add("HeightFog", {
    color = { 0.62, 0.68, 0.78 },  -- what the far field comes out at
    density = 0.03,                -- extinction per world unit at heightRef
    heightFalloff = 0.12,          -- how fast density thins with altitude
    heightRef = 0,                 -- the altitude `density` describes
    inscatter = 0.85,              -- how far the medium goes toward sunColor
})

Every field is live: edit one in the inspector and the fog follows. Setting density to 0 turns fog off without losing the rest of the settings.

local fog = require("@builtin::systems.atmosphere.fog")

fog.set({ color = { 0.1, 0.1, 0.13 }, density = 0.05 })  -- any subset; the rest is kept
fog.get()                                                -- the settings in force
fog.active()                                             -- is the pass running
fog.clear()                                              -- fog off, settings kept
fieldeffect
colorThe colour of the medium itself, and so the colour a surface deep enough in it comes out at. Defaults to (0.62, 0.68, 0.78), a daylight grey.
densityExtinction per world unit at heightRef. 0 turns fog off and releases the pass.
heightFalloffExponential thinning with altitude, per world unit. 0 gives uniform density — a distance-only fog.
heightRefThe world-space Y that density describes. Put it at the valley floor.
startDistance before fog starts accumulating, so near geometry stays readable.
inscatterHow far the medium goes toward sunColor at the centre of the sun, in [0, 1]. 0 makes fog the same colour in every direction.
sunColorThe colour fog takes on when looking into the sun.
inscatterAnisotropyForward-scattering bias in [0, 0.95). Higher tightens the lobe around the sun.

color is what decides the far field of the frame. Fog mixes each surface toward it by how much of the medium the view ray crossed, so past the range where transmittance runs out every surface reads as that one colour — a station, a line of pylons and a snow plain all arrive at it together. It is the medium's colour looking away from the sun, and inscatter carries the medium from there toward sunColor as the view turns into the light. The default is a daylight grey, which is the value a night or an interior scene restates as its own key: at density = 0.005 under a black sky it puts a bright grey-blue band along the horizon.

The components are linear and mix linearly: { 0.5, 0, 0 } reads as half the red of { 1, 0, 0 } at the same density. fog.set keeps a component past 1 as written, and the medium's contribution is clipped at the frame's white, so what a value above 1 buys is the density at which the channel reaches white — at density = 0.005 over sixty units a red of 8 is already there while a red of 1 is about a third of the way, and where the medium is most of what reaches the camera the two are indistinguishable. Fog describes the medium between the camera and a surface, so the sky comes through at its own colour whatever color is.

inscatter is normalised against the lobe's own peak, so it means the same thing at any inscatterAnisotropy: 1 puts the full sunColor at the centre of the sun and falls off from there.

fog.set takes two further fields, which aim the in-scattering lobe:

fieldeffect
sunDirectionThe direction the sun's light travels. The lobe points against it. A call that leaves it out takes the scene's own sun direction.
sunIntensityThe sun's brightness feeding that lobe. A call that leaves out both this and sunDirection takes the scene's own sun intensity; a call that names a direction keeps the intensity already in force.

A surface that stands outside the medium

Fog is a property of the medium, but whether a given surface is seen through it belongs to the surface: a painted backdrop that carries its own distance, or a marker that has to stay readable, wants its own colour however deep in the fog it stands. The standard pbr shader carries that declaration as receives_fog, and both passes here honour it — an opted-out surface comes out of them at the colour it shaded to.

asset.create("material", "marker", { shader = "pbr", receives_fog = 0 })

The geometry pass writes the declaration into the per-pixel surface flags (@builtin::shaderModules.surface_flags, carried in the alpha of @scene.normal), so any screen-space pass can read it.

That field is a G-buffer channel, which sets its reach: an opaque draw on the deferred path writes it. A draw the renderer sends through the forward fragment entry writes no G-buffer — that is every draw on the forward path, and the transparent phase on either path — so its pixels take the medium whatever their material declares. On the forward path both passes here read a source that declares no flags, which says the same thing for the whole frame.

A shader that is not pbr makes the same declaration from surface() or fragment():

zero_set_fog_exempt(true);   // this pixel takes no fog

Shared behaviour

sunDirection and sunIntensity track the scene's sun in both, so a day cycle reaches them without being told. Pin them through set when a scene wants something other than its own sun driving the medium.

Both features take their camera from @frame.camera, so they are correct in offscreen captures and render-to-texture cameras, not only the live viewport.

Height fog leaves sky pixels alone — it describes the medium in front of a surface, and the sky renders its own horizon. The physical atmosphere renders the sky itself when sky is on, precisely so the two cannot disagree.

Files

atmosphere.package/
  PhysicalAtmosphere.component/    -- the scene's air, authored and persisted
  atmosphere.module/               -- atmosphere.set / .get / .clear / .active
  aerialPerspective.renderFeature/ -- the LUTs and the composite pass
  HeightFog.component/             -- the scene's fog, authored and persisted
  fog.module/                      -- fog.set / .get / .clear / .active
  heightFog.renderFeature/         -- the fog compute pass