Volumetric Fog¶
This page describes part of the Python framework that comes in the
acron wheel. It has no C++ counterpart: the fog is GLSL driven from
Python, and a C++ game that wants it renders the same passes itself.
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.
fog_demo.py --scene night --view side: two Spotlight headlights and
nine PointLight streetlamps scattering in the fog.¶
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_densityper metre atfog_height, thinning byfog_falloffper metre above it), broken up by value noise that drifts withLook.wind, plus every localFogVolumeit lies in. Extinction and scattering are kept apart:albedois 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_mof 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_mso 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 afterapply_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) |
|---|---|---|---|---|---|---|
|
64 x 32 x 32 |
no |
1 oct. |
16 |
8 |
0.06 |
|
128 x 64 x 64 |
yes |
2 oct. |
32 |
16 |
0.35 |
|
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 |
|---|---|
|
(included by |
|
|
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_maxcaps the analytic tail only.Local volumes and local lights beyond
range_mare 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).