vidar.games

Volumetric fog

2026-11-09 ยท Discuss on X

Same ruined hall, same sun, same frame. Left: the usual height fog, one grey layer. Right: the fog is stored in 1.8 million cells, each lit and shadowed on its own, so light shafts appear.

The problem

Most game fog is a formula on distance and height. It knows nothing about light, so fog behind a pillar is as bright as fog in the sun, there are no shafts through windows, and a lamp doesn't glow in the haze around it.

How it works

The source (Vapor, a Unity project) fills a 3D grid shaped like the camera frustum: 160 x 88 cells across the screen, 128 slices deep, packed close to the camera (slice depth goes with the 4th power).

Each frame:

  1. Density: every cell gets a fog amount from a height gradient times 3 octaves of moving noise.
  2. Light: every cell checks the sun's shadow map and applies a phase function, so fog looking toward the sun glows more. Point lights add a falloff term without shadows.
  3. Scatter: light times density. The result is 20% this frame, 80% last frame's value at the same world point, and each cell samples at a small random depth offset every frame.
  4. Integrate: walk the slices front to back, adding scattered light and multiplying transmittance.
  5. Apply: each pixel reads the cell at its depth: color x transmittance + scattered light.

What I hit

The source writes the 3D grid from compute shaders, and the integrate step is one thread walking 128 slices in a loop. WebGL2 has neither. I packed the grid into one 2560 x 704 texture (16 x 8 tiles) and replaced the loop with a parallel prefix scan: combining two runs of fog is just add-then-multiply, so 7 passes of doubling strides give every slice its running total.

The temporal blend needs history. My video is rendered in parallel chunks, so each chunk replays 24 frames first or it starts noisy.

My build

three.js 0.170, WebGL2. 1,802,240 cells. Per frame: a 1024 px depth map from the sun, one scatter pass, 8 integration passes, one composite. Grid depth runs from 0.3 m to 42.3 m; the last slice is 1.3 m thick, the inset slice sits 8.7 m out. Extinction 0.055 per m, my value.

Trade-offs

When to use it

Interiors with windows, forests, dusty or smoky scenes where light direction matters. For open terrain with distant haze, height fog is enough.

New breakdowns go out on X first: @vidar_0208. If this one helped, you can buy me a coffee.

Source and credits

Technique source: Vapor by Arthur Brussee, https://github.com/ArthurBrussee/Vapor, MIT license, commit 9d584d35c68336774983b797307ef4ecf1c358be. Ported to three.js and GLSL from its VaporSim compute shader, VaporFramework include, VaporPost shader and the Vapor and VaporLight components: the frustum-aligned 160 x 88 x 128 grid with 4th-power slice spacing, fog density from a height gradient and fractal noise, the shadowed directional light with Rayleigh and Mie phase, the unshadowed point light falloff, the per-cell depth jitter with temporal reprojection (averaging speed 0.2), front-to-back integration and the final color x transmittance + in-scatter apply. The grid size, noise weights and frequencies, phase and atmosphere constants and averaging speed come from the source. The integration loop was rewritten as a parallel prefix scan because WebGL2 has no compute shaders. No files were copied unchanged.

Render framework: three.js by mrdoob and contributors, https://github.com/mrdoob/three.js, MIT license, npm package version 0.170.0. Used for rendering, shadow maps and render targets. It is not the source of the fog technique.

My own code: the ruined hall scene and its procedural stone textures, the height fog used for the left side of the comparison, the fog density, extinction and range values for this scene, value noise in place of the source's noise texture, a 4-tap smoothing of the fog lookup, the sun sweep, the lantern, the slice inset, labels and video. No external models or assets. No IA model.