atmosphere.package
- HeightFog.component
- PhysicalAtmosphere.component
- aerialPerspective.renderFeature
- atmo_common.shaderModule
- atmo_composite.computeShader
- atmo_integrator.shaderModule
- atmo_multiscatter.computeShader
- atmo_multiscatter_lookup.shaderModule
- atmo_skyview.computeShader
- atmo_transmittance.computeShader
- atmo_transmittance_lookup.shaderModule
- atmosphere.module
- fog.module
- heightFog.renderFeature
- height_fog.computeShader
- package.yaml17 B
- readme.md11.7 KB
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.
| field | effect |
|---|---|
aerial | How much of the atmospheric result reaches the image, in [0, 1]. 0 leaves geometry untouched. |
sky | Replace the engine's sky with the scattering result, so sky and geometry agree at the horizon. |
worldUnitsPerKm | How many world units make a kilometre. At 1000 a world unit is a metre. |
groundLevelY | The world-space Y that sits at sea level. |
exposure | Radiance to display range. Raise for a brighter sky; aerial perspective brightens with it. |
skyIntensity | Scales the sky's radiance without touching the aerial perspective. |
rayleigh / mie / ozone | Multipliers on the three species. Rayleigh carries the blue, Mie the haze around the sun, ozone the blue that survives at twilight. |
mieAnisotropy | Forward-scattering bias of the Mie lobe, in (-0.95, 0.95). Higher tightens the glow around the sun. |
aerialSteps | Steps marched between the camera and a surface. |
aerialMaxKm | Ceiling on how much air a surface is seen through. 0 means no ceiling. |
groundAlbedo | Reflectance 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 / sunAngularRadius | The solar disc and its size. The real sun is 0.00465 radians, which is under two pixels at a typical field of view. |
sunColor | Tint 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
| field | effect |
|---|---|
color | The 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. |
density | Extinction per world unit at heightRef. 0 turns fog off and releases the pass. |
heightFalloff | Exponential thinning with altitude, per world unit. 0 gives uniform density — a distance-only fog. |
heightRef | The world-space Y that density describes. Put it at the valley floor. |
start | Distance before fog starts accumulating, so near geometry stays readable. |
inscatter | How far the medium goes toward sunColor at the centre of the sun, in [0, 1]. 0 makes fog the same colour in every direction. |
sunColor | The colour fog takes on when looking into the sun. |
inscatterAnisotropy | Forward-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:
| field | effect |
|---|---|
sunDirection | The direction the sun's light travels. The lobe points against it. A call that leaves it out takes the scene's own sun direction. |
sunIntensity | The 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