Volumetric lighting in WebGPU

A while ago I played around with atmospheric scattering in my webgpu renderer.

The results were generally positive, but I didn’t like the traditional approach of marching rays through the atmosphere for every pixel. It works, but it’s a lot of calculations.

At the time, I read a paper by Sebastian Hillaire of Epic Games, called "A Scalable and Production Ready Sky and Atmosphere Rendering Technique”.

There was a small burb in there about creating a LUT for volumetrics. In the grand scheme of things, for the sky - it’s not the most important part.

A few weeks ago, I came back to it, and my goal was to add both the atmosphere, as well as volumetric lighting to the engine.

What I mean by that are things like light shafts

Volumetric fog

And local light scattering events


Somewhat unsurprisingly, this was achieved years ago, the first reference I could find dates back to 2014, SIGGRAPH paper titled “Volumetric Fog: Unified compute shader based solution to atmospheric scattering” by Bartlomiej Wronski, Ubisoft.

The paper/presentation had basically the same goals

The funny thing to me, is that I found this paper in a reverse order. I read everything from 2020s, then papers from 2017-19, then 2016 and only later did I get to this one.

I highly recommend the paper, it’s quite easy to read and has pretty much everything you need, the tech has changed surprisingly little since then.

The first thing I was going for were light shafts (aka “God Rays”), and with a bit of effort, here’s what I got:




The basic idea is quite simple, you pre-integrate scattering and transmission to a froxel texture (3d texture aligned on NDC view frustum, FRustum VOXEL)

The tricky part for me was the physics, but after a bit of reading it turns out to be way simpler than it seems at a first glance, it’s all about scattering and transmission

For now I put a pause on it, as I hit a point where denoising is necessary and other unpleasant parts of turning theory into production-ready technique. But I’m super excited about it in general. The whole thing takes sub-1ms time to compute even on 10 year old hardware, and during shading it’s basically free (1 texture lookup).

Other interesting paper I would recommend is:

  • “Physically Based and Unified Volumetric Rendering” by Sebastien Hillaire, from SIGGRAPH 2015, back when he was at EA

This is a recurring theme for me, both in graphics research and other technical areas:

Something doesn’t work, I don’t understand why, so I read a dozen papers and write a few prototypes and suddenly it all makes sense.

16 Likes

Thanks for sharing!

1 Like

Made a Demo

Screenshot 2025-11-09 154956
image

It’s quite dark, because atmosphere density is cranked way up to exaggerate scattering effects. It’s still physically based, just a different type of atmosphere, think something like Venus, or Jupiter perhaps. So transmittance is quite low, similar to something like a very foggy day, a snow storm or a dust storm here on earth.

I integrated better noise into the sampling part, so there are fewer aliasing artifacts. The integration part is still a bit noisy though, haven’t touched that.

What we have here:

  • directional light integration
  • Mie scattering + absorption
  • Rayleigh scattering ( pretty much no absorption according to physical model )
  • Ozone absorption ( no scattering )
  • Multiple scattering integration for the sun (see Hillaire 2020)
  • Visibility taken into account (shadowmaps)
3 Likes

As a separate point. I notices that three.js recently merged volumetric lighting with an example

The effect is done using post-process as far as I can understand, you can see that by aliasing of the effect along the edges


The problem with this approach is 3 fold:

  1. Raymarching is expensive, and you’re doing it per pixel of your post-process pass. In this case it’s 1/4 of the original resolution, so for 1024x1024 resolution your pass resolution would be 256x256 and you have to march every one of those for a total of 12 steps each

    That’s 786,432 evaluations in total. The fact that you are taking 12 steps also means that your Z resolution is going to be incredibly low.
  2. Aliasing. Resolution mismatch means you have to upscale the result somehow. The most basic ray would be to just stretch the image, basically what happens here. You end up with some jaggies and there’s noise in the image. So let’s slap some blur on it

    Looks pretty, even if it destroys the edges.
  3. Bandwidth. You have to do a separate compositing pass, this is a standard cost of doing post-processing passes though and resolution is relatively low, but the cost is still there.

The reason AAA industry doesn’t use this approach is pretty much those first two points. The modern approach is to build a 3d lookup table separately, and then just sample it during draw.

The demo I posted used 128x128*64 resolution, which amounts to slightly more samples 1,048,576, about 30% more to be exact. But we’re getting 530% more resolution in Z axis.

Using a 3d texture you don’t have to worry about edges either, here’s an example:




By contrast, even if you crank up resolution of post process to 1/2 of the original (which is already 4x the pixels), and push the blur radius to the max on the slider, you still have aliasing


When I say “aliasing”, I mean that the edges of the “fog” do not align with the edges of the geometry, you can see that the silhouette has been completely destroyed.

It might sound harsh, and it is, but I’m still blown away by the work that @sunag and @Mugen87 did here. Very impressive, even despite the limitations. I especially like the density injection part via a TSL function, very elegant.

I’m guessing that the volumetrics here are a bit of stretch, the system was designed for post-processing volumes, like blurring parts of an image, or applying toon shaders etc, so the architecture was not designed specifically for volumetric lighting.

3 Likes

Added support for local lights.

Also played around with different phase functions for Mie scattering.

If anyone is interested, there’s a paper by NVIDIA from 2023:

It offers a very different parametrization, instead of anisotropy parameter G it offers a physical parameter of particle size, which, I thought, was quite neat. It also appears to have a much better fit to the ground truth Mie function shape. The authors boast 95% fit.

HG seem to the be standard (Henyey and Greenstein from 1941), as it’s relatively simple and it’s all over the existing code bases.

I discovered Cornette-Shanks approximation (CS) a while ago, which offers a better fit than HG in a way, as it provides a stronger back-scattering component.

Here’s a plot from the NVIDIA paper to show what I mean

The HG+D is the function from that paper. The “Mie” is a plot from the simulator (ground truth).

I found that if your media is largely homogenous and there is little density variation, it’s hard to tell much of a difference between these functions. But still - maybe someone will find this useful.

Here is what I got with local lights (all lights supported that is)


and without the volumetrics





One more thing, on the post-processing approach versus 3d texture (froxels).

The post-processign approach doesn’t support transparencies. Here’s a shot with a glowing crystal

There is a large spherical light source in the crystal

And the crystal itself is barely transparent, you can see through it a little.

Here it is close up to prove

You can see a bit of the background through it

Why is this important? Post processing does not support this, you’re running a post-process on top of everything, so transparencies can’t be used, they don’t write depth so there is no ray length to integrate to.

You could make it work, by running a separate post process for every triangle, but that’s not a realistic option.

1 Like

demo seems to be broken

Probably takes a while to load. Sponza scene is about 80 Mb, the server I host it on is a bit slow as well :sweat_smile:

Anything in the console by any chance?

1 Like

Warning (once):
ID3D12Device::GetDeviceRemovedReason failed with DXGI_ERROR_DEVICE_HUNG (0x887A0006)

  • While handling unexpected error type Internal when allowed errors are (Validation|DeviceLost).
    at CheckHRESULTImpl (…\third_party\dawn\src\dawn\native\d3d\D3DError.cpp:121)

Backend messages:

  • Device removed reason: DXGI_ERROR_DEVICE_HUNG (0x887A0006)

sounds like a device specific issue? :stuck_out_tongue:

Error:
installHook.js:1 AbortError: Failed to execute ‘mapAsync’ on ‘GPUBuffer’: [Device] is lost.

overrideMethod @ installHook.js:1
Promise.then
release @ index-BUHqecGP.js:10366
#a @ index-BUHqecGP.js:445
finish @ index-BUHqecGP.js:445
render @ index-BUHqecGP.js:13808
(anonymous) @ index-BUHqecGP.js:16934
a @ index-BUHqecGP.js:432
requestAnimationFrame
a @ index-BUHqecGP.js:432

repeated a lot of times

trying to reload/restart chrome, sometimes it works…

I am using Intel Iris Xe Graphics

It does, thanks a lot for that, will be looking into it


Spent more time on the problem, learned a lot more about optics and participating media.

The most interesting thing is - I split the code into 3 distinct passes:

  1. Participating Media integration
  2. Light integration
  3. Final gather

It’s the same as what guys from Frostbite proposed

In the first pass we take user-defined volumes and resample them into a froxel grid. This allows us to defined 100s or 1000s of distinct particle volumes on the scene without a significant performance overhead.

The second pass calculates in-scattering of all lights in the scene. I’ve added a multiscattering approximation using Sony’s Magnus Wrenninge approximation (see “Oz: The Great and Volumetric”). Another important piece here is - I integrate optical depth for each light, meaning that lights correctly dim with distance. You can see it on the screenshot where the torch on the left of the screen gets sharply attenuated down the further the light has to travel through the volume

The final gather is fairly simple, we just march through the volume back to front and accumulate visible light and extinction. The only complicated thing I do here is using polynomial curve approximation for integration, instead of doing the standard Riemann sum. This is something Frostbite presentation also highlights:

You can see here multiple volumes with increasing scattering properties. It is easy to understand that integrating scattering and then transmittance is not energy conservative.
We could reverse the order of operations. You can see that we get somewhat get back the correct albedo one would expect but it is overall too dark and temporally integrating that is definitely not helping here.

So how to improve this? We know we have one light and one extinction sample.

We can keep the light sample: it is expensive to evaluate and good enough to assume it constant on along the view ray inside each depth slice.

But the single transmittance is completely wrong. The transmittance should in fact be 0 at the near interface of the depth layer and exp(-mu_t d) at the far interface of the depth slice of width d.

What we do to solve this is integrate scattered light analytically according to the transmittance in each point on the view ray range within the slice. One can easily find that the analytical integration of constant scattered light over a definite range according to one extinction sample can be reduced this equation.
Using this, we finally get consistent lighting result for scattering and this with respect to our single extinction sample (as you can see on the bottom picture).


While I was trying to wrap my head around the physics part of this, I ended up writing a Mie simulator in JS based on SIGGRAPH paper from 2007 “Computing the Scattering Properties of Participating Media Using Lorenz-Mie Theory”. I wanted a system that doesn’t just work for smoke, or just for fog, or just for clouds, but for all types of participating media. Incidentally, here’s a table I generated, feel free to use it, I would appreciate attribution:

/**
 * 📚 Precomputed standard atmospheric particle library for rendering.
 *
 * This file is GENERATED. Do not edit by hand.
 * Generation settings: 380–780 nm, step_size=1nm, xyz CMFs, medium=air. Integrated to sRGB for D65 illuminant
 *
 * Each entry contains:
 * - radius: Particle radius in meters.
 * - cross_section_scattering: [R, G, B] scattering coefficients (m²).
 * - cross_section_extinction: [R, G, B] extinction coefficients (m²).
 * - g: Asymmetry parameter (average cosine of the scattering angle).
 */
export const MIE_PARTICLES_STANDARD_PRECOMPUTED = {

  // --- 💧 Water-Based (Haze, Fog, Clouds) ---

  /**
   * Continental haze (ammonium sulfate surrogate)
   * SMALL — faint land/city haze: distant skyline slightly washed out on a sunny day.
   * Diameter: 100 nm
   */
  CONTINENTAL_HAZE_SMALL: {
    radius: 5.0e-8,
    cross_section_scattering: [1.0372407599145680e-16, 2.1697944076390811e-16, 4.9834805745060257e-16],
    cross_section_extinction: [1.0390516205855430e-16, 2.1717567420831002e-16, 4.9879159253139388e-16],
    g: 0.06687111747972882,
  },
  /**
   * MEDIUM — typical daytime urban/valley haze with gentle desaturation.
   * Diameter: 500 nm
   */
  CONTINENTAL_HAZE_MEDIUM: {
    radius: 2.5e-7,
    cross_section_scattering: [5.4565579877961842e-13, 6.8621977746322113e-13, 8.2869951608010304e-13],
    cross_section_extinction: [5.4570905150853122e-13, 6.8627271753511908e-13, 8.2881953884176050e-13],
    g: 0.72195208606467531,
  },
  /**
   * LARGE — thicker land haze; think post‑inversion murk softening hills.
   * Diameter: 1.0 µm
   */
  CONTINENTAL_HAZE_LARGE: {
    radius: 5.0e-7,
    cross_section_scattering: [3.0366185612989802e-12, 2.1644690526988712e-12, 1.3935109366522711e-12],
    cross_section_extinction: [3.0370652035911430e-12, 2.1649491115406566e-12, 1.3945337813123169e-12],
    g: 0.6137288243405209,
  },

  /**
   * Maritime haze (sea‑salt/brine)
   * SMALL — light coastal humidity haze over the ocean.
   * Diameter: 100 nm
   */
  MARITIME_HAZE_SMALL: {
    radius: 5.0e-8,
    cross_section_scattering: [5.5914464124496594e-17, 1.1568131266404890e-16, 2.6209632958954776e-16],
    cross_section_extinction: [5.5915758040155253e-17, 1.1568241782228503e-16, 2.6209808791233174e-16],
    g: 0.062667411576877274,
  },
  /**
   * MEDIUM — bright, milky air on a breezy beach or around harbors.
   * Diameter: 500 nm
   */
  MARITIME_HAZE_MEDIUM: {
    radius: 2.5e-7,
    cross_section_scattering: [2.7132966283888992e-13, 4.0042270998895428e-13, 5.5420120025016184e-13],
    cross_section_extinction: [2.7132992896636519e-13, 4.0042294960829909e-13, 5.5420156922320274e-13],
    g: 0.75572608442123446,
  },
  /**
   * LARGE — thick marine layer look before it becomes fog.
   * Diameter: 1.0 µm
   */
  MARITIME_HAZE_LARGE: {
    radius: 5.0e-7,
    cross_section_scattering: [3.0886970942611371e-12, 3.1506283070098210e-12, 2.6753836337037947e-12],
    cross_section_extinction: [3.0886995489983882e-12, 3.1506303784552175e-12, 2.6753868296970645e-12],
    g: 0.82009537287886958,
  },

  /**
   * Fog & Cloud Droplets
   * SMALL — light mist: dawn over a lake, waterfall spray, breath fog.
   * Diameter: 2.0 µm
   */
  FOG_DROPLET_SMALL: {
    radius: 1.0e-6,
    cross_section_scattering: [6.6872146821004324e-12, 5.4309369486615811e-12, 7.5571588161013023e-12],
    cross_section_extinction: [6.6872171928267207e-12, 5.4309373592604994e-12, 7.5571591319672130e-12],
    g: 0.66286908969703728,
  },
  /**
   * MEDIUM — typical road fog reducing visibility to a few hundred meters.
   * Diameter: 10.0 µm
   */
  FOG_DROPLET_MEDIUM: {
    radius: 5.0e-6,
    cross_section_scattering: [1.7036472789376784e-10, 1.6665060947239930e-10, 1.6466175909414485e-10],
    cross_section_extinction: [1.7036520771027743e-10, 1.6665065143578210e-10, 1.6466177643977473e-10],
    g: 0.85263072331984047,
  },
  /**
   * LARGE — bright, thick cloud core or dense sea fog.
   * Diameter: 20.0 µm
   */
  CLOUD_DROPLET_LARGE: {
    radius: 1.0e-5,
    cross_section_scattering: [6.5315742448115536e-10, 6.5618165104565060e-10, 6.5250478893917499e-10],
    cross_section_extinction: [6.5315858886811270e-10, 6.5618181350359152e-10, 6.5250489005400894e-10],
    g: 0.86080713114012297,
  },


  // --- 🔥 Combustion (Smoke & Soot) ---

  /**
   * Biomass Smoke (e.g., Wood, Wildfire) — "Brown Carbon"
   * SMALL — fresh wood‑smoke right above the flames.
   * Diameter: 400 nm
   */
  SMOKE_PARTICLE_SMALL: {
    radius: 2.0e-7,
    cross_section_scattering: [2.5095238381331490e-13, 3.2817527241713552e-13, 4.7184181688431561e-13],
    cross_section_extinction: [2.5244957121744367e-13, 3.3073489397071378e-13, 4.7766204956433065e-13],
    g: 0.64153537730937171,
  },
  /**
   * MEDIUM — typical wildfire/chimney smoke drifting across a valley.
   * Diameter: 800 nm
   */
  SMOKE_PARTICLE_MEDIUM: {
    radius: 4.0e-7,
    cross_section_scattering: [2.2186086454447846e-12, 1.8611290477348588e-12, 1.2017961650839806e-12],
    cross_section_extinction: [2.2347400788139265e-12, 1.8849701372888018e-12, 1.2529531542589939e-12],
    g: 0.68088789083915369,
  },
  /**
   * LARGE — aged regional smoke layers that turn the sun orange.
   * Diameter: 2.0 µm
   */
  SMOKE_PARTICLE_LARGE: {
    radius: 1.0e-6,
    cross_section_scattering: [9.7154096631596466e-12, 6.5695017319655712e-12, 6.5950874641241054e-12],
    cross_section_extinction: [9.9744536671262377e-12, 6.9383549994562171e-12, 7.2416530016257137e-12],
    g: 0.73891619526845487,
  },

  /**
   * Soot (e.g., Diesel Exhaust) — "Black Carbon"
   * SMALL — very dark sooty exhaust: candle wick zone or fresh tailpipe soot.
   * Diameter: 50 nm
   */
  SOOT_PARTICLE_SMALL: {
    radius: 2.5e-8,
    cross_section_scattering: [4.9471331742851396e-18, 1.1425655543629920e-17, 3.3950349242847612e-17],
    cross_section_extinction: [4.9469989827650098e-16, 6.4148352307268963e-16, 9.4380272697116261e-16],
    g: 0.018039326863221513,
  },
  /**
   * MEDIUM — traffic/industrial pollution haze mixing into city air.
   * Diameter: 200 nm
   */
  SOOT_AGGREGATE_MEDIUM: {
    radius: 1.0e-7,
    cross_section_scattering: [1.6352855504532771e-14, 2.6526196915106281e-14, 3.7046081158626368e-14],
    cross_section_extinction: [5.6514531230912217e-14, 7.3826992547192368e-14, 8.8338524712659901e-14],
    g: 0.31674236124633798,
  },
  /**
   * LARGE — heavy dirty smoke near source: burning oil/tires.
   * Diameter: 400 nm
   */
  SOOT_AGGREGATE_LARGE: {
    radius: 2.0e-7,
    cross_section_scattering: [1.6063607357301153e-13, 1.6542541029497725e-13, 1.6709682614452953e-13],
    cross_section_extinction: [3.6165255982805808e-13, 3.6212311121035904e-13, 3.5517926834511851e-13],
    g: 0.69133198032115784,
  },


  // --- 💨 Solid Particulates (Dust & Pollen) ---

  /**
   * Mineral Dust (e.g., Desert, Sand)
   * SMALL — far‑range dusty air softening distant mountains.
   * Diameter: 1.5 µm
   */
  FINE_DUST_SMALL: {
    radius: 7.5e-7,
    cross_section_scattering: [2.6570379687422880e-12, 3.6996919661206611e-12, 4.5471939176491137e-12],
    cross_section_extinction: [2.8819492220384537e-12, 4.1008076519912007e-12, 5.1659038204372376e-12],
    g: 0.63886827482281139,
  },
  /**
   * MEDIUM — moving dust clouds from vehicles or field winds.
   * Diameter: 5.0 µm
   */
  COARSE_DUST_MEDIUM: {
    radius: 2.5e-6,
    cross_section_scattering: [3.5472196264524937e-11, 3.3430210987054714e-11, 3.2075451113920786e-11],
    cross_section_extinction: [4.2441528450403185e-11, 4.3597399152097500e-11, 4.3350184971955535e-11],
    g: 0.84075100028702532,
  },
  /**
   * LARGE — sandstorm wall: near‑camera blowing sand/tan curtains.
   * Diameter: 15.0 µm
   */
  COARSE_DUST_LARGE: {
    radius: 7.5e-6,
    cross_section_scattering: [3.1816278385853973e-10, 2.9406557017587955e-10, 2.6643041098315311e-10],
    cross_section_extinction: [3.7355130308305938e-10, 3.7144494812949936e-10, 3.6814067045857220e-10],
    g: 0.85247658559411998,
  },

  /**
   * Pollen (Organic)
   * MEDIUM — seasonal pollen haze; yellow‑green tint in spring air.
   * Diameter: 20.0 µm
   */
  POLLEN_PARTICLE_MEDIUM: {
    radius: 1.0e-5,
    cross_section_scattering: [6.3652345959538284e-10, 6.2647624730458445e-10, 5.8718713214140584e-10],
    cross_section_extinction: [6.5918545142090177e-10, 6.5700023143709686e-10, 6.5127465699910976e-10],
    g: 0.81082270359712194,
  },
  /**
   * LARGE — visible puffs from trees (e.g., pine) or catkins in forests.
   * Diameter: 30.0 µm
   */
  POLLEN_PARTICLE_LARGE: {
    radius: 1.5e-5,
    cross_section_scattering: [1.3961132818036018e-9, 1.3739693858138355e-9, 1.2750015299393954e-9],
    cross_section_extinction: [1.4562652589727905e-9, 1.4596101004954230e-9, 1.4523332353409636e-9],
    g: 0.81884345812519288,
  },
};

The table is generated for D65 luminant, in linear sRGB using 1nm spectral sweep from 380nm to 780nm, so it’s radiometrically 100% accurate. The refraction index for each type of media was pulled from published tables from Applied Optics mostly.

What this means in practice, is we can create volumes like so:

const fog = new ParticipatingMediaVolume();

fog.transform.position.set(-55, 4, -5.116);
fog.transform.scale.set(10, 3, 20); // 10m by 3m by 20m
fog.transform.rotation.fromAxisAngle(Vector3.up, 83 * (Math.PI / 180));
fog.fade_distance = 0.1; // fade density of the volume to 0 over the distance of 10cm at the edge of the volume

fog.particle_spec = VolumetricsParticleSpec.fromMeep(MIE_PARTICLES_STANDARD_PRECOMPUTED.CLOUD_DROPLET_LARGE);

fog.density = 1527046979; // N, number of particles per cubic meter. Yes there are a LOT of water droplets in dense fog :D

scene.volumetrics.add(fog);

And you get a result like this:

If we set the particle specification to something else, visual appearance changes quite obviously:
COARSE_DUST_MEDIUM

MARITIME_HAZE_MEDIUM

SMOKE_PARTICLE_LARGE

SOOT_AGGREGATE_MEDIUM

You’ll notice difference absorption/scattering behavior. Some types of media scatter more light, some absorb more, some scatter more light forward, some scatter light uniformly. These behaviors are also spectrum-dependent, meaning light changes color. If you look over the screenshots above, you’ll notice that the same yellow torch shifts color quite drastically in different types of volume, and at different depth.

And if we get up close, we can see that the light shafts are still there

And you can mix volumes quite naturally as well

We have a volume of dense smoke cross over a light volume of fog

here’s what that looks like inside


4 Likes

Working on TAA for the volumetrics, still got a few snags to sort out. In the screenshot above we have the entire scene submerged in a giant volume of fog, note that the fog is NOT global, it’s still local, we just choose to give it huge size.

You can still see another smaller volume of smoke in the background, but still inside the larger volume, showing off the seamless “fog in the fog” support

Here is another shot from a different part of the scene

3 Likes

Finished TAA for the volumetrics, it’s stable under motion and reduces the noise significantly. Now, I added a large number of volumetrics to a test scene for tuning

The whole scene is submerged in a large thin volume with a bit of haze, so the distant objects grow a bit dim, and there’s an extra glow around light sources.

There’s a dense cloud underneath the scene


And to test for scalability, I added a small cloud of soot to each torch

I’m pretty happy with the results, but there are still a few things I want to reduce noise in the distance

A few more shots from the same scene


1 Like