Volumetric Fog

acron.fog fills the air near the camera with a physically based fog medium and lights it: the sun’s shafts through the trees on a misty morning, a car’s headlights and a row of streetlamps glowing in fog at night, a bank of ground fog lying in a valley. It replaces the analytic height fog of acron.sky near the camera with a volume, so fog is shadowed by the sun’s cascades and the clouds, lit by the game’s own lamps, and broken up by wind-driven noise – and every shader that already calls apply_atmosphere gets it without a change.

The technique is the froxel volume of Sébastien Hillaire (“Physically Based and Unified Volumetric Rendering in Frostbite”, SIGGRAPH 2015) and Bart Wronski (“Volumetric Fog”, SIGGRAPH 2014): the camera’s frustum is cut into cells – frustum voxels – that are thin near the eye and deeper further away; each frame every cell gets its medium and the light it scatters, and the cells are then integrated front to back so that any shader can look up how much fog lies between the eye and a point. It uses no numpy. Platform requirements are the sky’s (Sky, Weather and Forests): every pass is a fragment pass, so it runs on the OpenGL 4.5 targets and on macOS’s 4.1 for development.

A road at night in fog, with streetlamps glowing and a car's headlight beams reaching through the fog.

fog_demo.py --scene night --view side: two Spotlight headlights and nine PointLight streetlamps scattering in the fog.

Shafts of sunlight through the trees of a misty wood.

fog_demo.py --scene dawn: the sun’s cascades carve shafts into the fog of a wood in the early morning.

Getting fog

Make the fog after the environment (and after the clouds, if the game has them) and before compiling lit shaders, like the clouds: it adds FROXEL_FOG and LOCAL_LIGHTS to Environment.defines(). The fog’s density, height and colour still come from the environment’s Look:

from acron.core import Spotlight
from acron.sky import Environment, Look, SunShadows
from acron.fog import VolumetricFog, FogVolume

env = Environment(base, look=Look(fog_density=0.02, fog_falloff=0.05))
shadows = SunShadows(env)
fog = VolumetricFog(env, quality="medium", range_m=160.0)
scene.set_shader(env.make_shader("lit_basic.vert", "lit_basic.frag"))

# a headlight: an ordinary Acron spotlight
lamp = Spotlight("headlight")
lamp.set_color((3000, 2900, 2700, 1))
lamp.set_attenuation((0, 0, 1))          # inverse square
lamp.get_lens().set_fov(46)
headlight = car.attach_new_node(lamp)
fog.add_light(headlight)

# a bank of ground fog
fog.add_volume(FogVolume("ellipsoid", (40, 18, 5), density=0.06,
                         pos=(0, 150, 0)))

A shader compiled before the fog existed keeps the analytic height fog; it still works, it just does not see the volume. fog.set_enabled(False) switches the volume off without recompiling anything: every shader falls back to the analytic fog. fog.destroy() removes it.

The sample python/samples/fog_demo.py shows a road at night (headlights, streetlamps, a fog bank; Space drives the car) and a wood in the morning sun (--scene dawn). F toggles the volume, Q cycles the quality presets, + and - change the density. --screenshot out.png renders offscreen and quits; --bench prints frame times per preset at 1920 x 1080.

How it works

The grid. The froxels follow the main camera: so many across and up the screen – the preset’s count is for 1920 x 1080, and a window of another size gets as many froxels a pixel (FogQuality.for_screen, like Unreal’s grid pixel size: the high preset’s 256 x 128 is 43 x 21 at 320 x 180) – and so many slices in view depth out to range_m (128 m by default). The slices are exponential with a linear start (curve_m, range_m / 24 by default), so a 128 m range of 64 slices starts with a slice about 0.3 m deep and ends with one about 6 m deep. The grid is stored as a 2D atlas of its slices, eight to a row.

Pass 1: medium and light. Every froxel takes one sample of

  • the medium: the look’s height fog (fog_density per metre at fog_height, thinning by fog_falloff per metre above it), broken up by value noise that drifts with Look.wind, plus every local FogVolume it lies in. Extinction and scattering are kept apart: albedo is the share of the extinction that scatters;

  • the light it scatters towards the eye: the key light (sun or moon) after the atmosphere, through the sun’s shadow cascades and the clouds’ shadow map, the sky’s light from above, and every local light, each through a Henyey-Greenstein phase function (phase="cornette-shanks" for Cornette and Shanks’s).

With the medium and high presets the sample moves inside its froxel every frame – spatiotemporal blue noise when acron.pipeline runs, interleaved gradient noise otherwise – and is blended with the last frame’s result, reprojected through the last frame’s view. Shadow edges, thin beams and the noise then resolve over a few frames instead of stepping from froxel to froxel. The history is dropped when the camera jumps or the preset changes (fog.reset_history() after a cut).

Pass 2: integration. Each column of froxels is integrated front to back with Hillaire’s energy-conserving step – a slice of extinction \(\sigma_t\) and in-scatter \(S\) over a depth \(d\) adds \(S\,(1 - e^{-\sigma_t d}) / \sigma_t\), dimmed by everything in front of it – into the integrated volume: for every froxel, the light scattered towards the eye and the transmittance from the eye to its far side. (A small pass first integrates each run of eight slices, so a froxel adds up runs instead of every slice in front of it.)

Applying it. apply_atmosphere(color, world_pos) in acron.shaderlib finds the point’s froxel from its screen position and view depth and reads the integrated volume there (two filtered reads), continuing with the analytic height fog beyond the volume’s range. The sky dome reads the volume’s far end, so the sky and the ground behind the fog agree. Transparent surfaces call the same function.

Who covers what

The environment’s media are kept apart, so no light is counted twice:

  • Aerial perspective (acron.sky) is the air itself – Rayleigh and Mie scattering and ozone, on a scale of kilometres – at every distance. The volume does not hold the air.

  • The froxel volume is the fog medium from the eye to range_m of view depth: the look’s height fog, local volumes, noise, all the lights, with shadows.

  • The analytic tail is the height fog beyond that range, lit by the sun (as much of it as the clouds let through at the camera) and the sky, without shadows, local volumes or local lights. Choose range_m so the fog that matters to the scene lies inside it.

  • The clouds’ light shafts (acron.clouds, apply_light_shafts) take shadowed light out of the air’s in-scatter over kilometres, through gaps in a cloud deck. The volume shadows the fog’s in-scatter near the camera. They model different media, so both stay on: with fog, the shafts in the fog dominate near the camera, and the clouds’ shafts carry on in the distance and in the sky.

  • Cloud fog (apply_clouds) is the cloud layer’s medium and is applied after apply_atmosphere. A camera inside both a cloud and ground fog gets the cloud in front of the ground fog, which is rare and slight.

Weather

The global density is the look’s fog_density plus humidity_density times the look’s humidity plus rain_density times the clouds’ precipitation at the camera (when acron.clouds runs), times density_scale. The weather presets of acron.clouds carry fog and humidity in their looks, so changing the weather cross-fades the fog with it. The fog writes this density back into the FOG frame value, so the analytic tail, the sky and shaders that do not read the volume all agree.

Under clouds the fog is lit as the clouds let the sun through: the volume reads their shadow map per froxel, the tail takes their sun column at the camera.

Local fog volumes

A FogVolume is a box, sphere or ellipsoid of extra fog:

steam = fog.add_volume(FogVolume(
    "sphere", 3.0,             # radius; a box or ellipsoid takes 3 sizes
    density=0.4,               # extinction per metre inside
    albedo=(0.95, 0.95, 1.0),  # the share that scatters, per channel
    edge=0.5,                  # how much of the size fades out softly
    noise=0.8,                 # how much the wind's noise breaks it up
    node=vent_np))             # rides on a node (or pos=, hpr=)
fog.remove_volume(steam)

A volume on a node moves, turns and scales with it every frame. The presets read 8, 16 and 32 volumes at most (volume_capacity holds up to 32).

Lights

The fog lights itself with Acron’s own PointLight and Spotlight nodes: fog.add_light(node_path, gain=1.0) registers one, and every frame its position, direction, colour, attenuation, max_distance, cone (half the lens’s wider field of view, with a soft rim) and exponent are read again, so a light that moves, turns or changes colour needs nothing more. A hidden light is left out. The presets read 16, 32 and 64 lights at most.

The same lights light surfaces: with LOCAL_LIGHTS (on by default; VolumetricFog(light_surfaces=False) leaves it out) the reference material lit_basic adds their light, and a game’s shader calls local_light_irradiance(p, n) from local_lights.glsl. LocalLights also works on its own, without the fog.

Units. A light’s colour is display-referred, as a material lit by engine lights only sees the same light: the fog and local_light_irradiance divide it by the frame’s exposure, so the beam looks the same by day and by night and matches what the light does to surfaces. Acron’s default attenuation (1, 0, 0) does not fall off; give real lamps (0, 0, 1) (inverse square, world units) and a bright colour – the sample’s headlights are 3000, its streetlamps 420. A light reaches as far as its max_distance, or until it has fallen to 1/256 of its brightest channel, at most 200 world units.

Seen from behind a headlight, light reaches the eye by scattering back, which one forward lobe hardly does, while dense fog sends much of it back by scattering many times. light_anisotropy (0.2 by default) is the local lights’ own phase g, standing in for that; anisotropy (0.6) is the sun’s, moon’s and sky’s.

Local lights cast no shadows in the fog: a headlight’s beam goes through the car in front. Shadows for spotlights would need a shadow map per light and are not implemented; keep beams out of geometry by placing the light just in front of its housing, and use the light’s max_distance to stop it at walls it should not pass.

Quality presets

quality= picks a preset; set_quality() switches at run time without recompiling lit shaders. A FogQuality of the game’s own takes any grid whose depth is a multiple of 8 (and of 64 past it); its width and height, like the presets’, are for 1920 x 1080.

Preset

Grid

Temporal

Noise

Lights

Volumes

GPU (ms)

low

64 x 32 x 32

no

1 oct.

16

8

0.06

medium

128 x 64 x 64

yes

2 oct.

32

16

0.35

high

256 x 128 x 64

yes

3 oct.

64

32

1.1

The GPU times are the scatter and integration passes at 1920 x 1080 in the sample’s night scene (eleven lights, one fog volume), measured on macOS only (OpenGL 4.1 on an Apple M4 Pro) by drawing each pass many times a frame. It grows with the number of froxels (so with the window’s size) and of lights in reach. The look-up in lit shaders and the sky adds well under 0.1 ms. The Windows and Linux targets have not been measured yet. The CPU side is a few Python calls a frame plus about 2 µs per light and per volume packed.

Compared with the analytic fog

With the default anisotropy (0.6), white albedo and no noise, an open scene looks the same with and without the volume: the analytic height fog uses the same optical depth, the same phase function and the same sky light. The volume adds what the analytic fog cannot: shadows (shafts, and dark fog in the shade of a wood or a cliff), local lights, local volumes and noise, and it darkens fog under clouds. In the engine’s tests the mean colour of sky, middle and ground of an open scene differ by under 1 %.

The in-scatter is single scattering: very dense fog around a bright lamp is not as milky as the real thing. light_anisotropy and the light’s colour are the knobs for that.

In a game’s shaders

Nothing changes for a shader that already calls apply_atmosphere(color, world_pos) – that one call reads the volume when it runs. A shader made with Environment.make_shader() gets the defines and the textures it needs. Two includes are new:

Include

Functions

fog.glsl

(included by atmosphere.glsl) height_fog(p), fog_segment(a, b, albedo, g, key), apply_fog_volume(color, p), fog_volume_at(uv, depth_m), fog_slice_w / fog_slice_depth

local_lights.glsl

local_light_irradiance(p, n), local_light_count(), local_light(i), local_light_falloff(light, p, to_light)

apply_atmosphere_sky(radiance, dir) (in atmosphere.glsl) is the sky shader’s half. A shader that draws a view other than the main camera’s – a reflection, a probe – gets the analytic fog for points off the main camera’s screen; the environment probe’s sky asks for it with SKY_PROBE.

Known limitations

  • No shadows from local lights (see Lights).

  • One view: the volume follows the main camera (base.cam). Split screen or a second camera reads the analytic fog.

  • Fragment passes only. A compute path for the OpenGL 4.5 targets (a 3D texture written with image stores) would save the atlas’s bookkeeping but is not written; the fragment passes run everywhere the environment runs.

  • Single scattering (see above); no light shafts from local lights through gaps in geometry, for the same reason as the shadows.

  • The noise is procedural value noise, not a baked 3D texture; its time wraps every hour (a jump in the drift once an hour).

  • Look.fog_max caps the analytic tail only.

  • Local volumes and local lights beyond range_m are not seen.

References

  • Sébastien Hillaire, “Physically Based and Unified Volumetric Rendering in Frostbite”, SIGGRAPH 2015 Advances in Real-Time Rendering.

  • Bart Wronski, “Volumetric Fog: Unified Compute Shader-Based Solution to Atmospheric Scattering”, SIGGRAPH 2014 Advances in Real-Time Rendering.

  • Charles M. Cornette and Jack G. Shanks, “Physically Reasonable Analytic Expression for the Single-Scattering Phase Function”, Applied Optics 1992.

  • L. G. Henyey and J. L. Greenstein, “Diffuse Radiation in the Galaxy”, Astrophysical Journal 1941.

  • Alan Wolfe et al., “Spatiotemporal Blue Noise Masks”, EGSR 2022 (the jitter, with acron.pipeline).

  • Jorge Jimenez, “Next Generation Post Processing in Call of Duty: Advanced Warfare”, SIGGRAPH 2014 (interleaved gradient noise).