Skip to content

Fixed cluster sampling issues for volumetric point and spot lights#23387

Open
JeroenHoogers wants to merge 1 commit intobevyengine:mainfrom
JeroenHoogers:fix-clustered-volumetric-light-issues
Open

Fixed cluster sampling issues for volumetric point and spot lights#23387
JeroenHoogers wants to merge 1 commit intobevyengine:mainfrom
JeroenHoogers:fix-clustered-volumetric-light-issues

Conversation

@JeroenHoogers
Copy link
Copy Markdown
Contributor

@JeroenHoogers JeroenHoogers commented Mar 16, 2026

Objective

Solution

  • The problem was that only the point / spot lights that overlap with the first z-slice of the cluster grid were considered in the ray marching algo. In the updated code, I moved the ray marching to the outer loop. Then for each sample, I get the cluster index from the sample's view position, with the index I find all lights contributing to this cluster and add up their light contributions.
  • Another issue I found while looking into this issue is that the ray origin for the volumetric tracing was the camera origin, even when the camera was outside the FogVolume. This is now fixed in both directional light and the point / spotlight implementations.

Testing

I tested using the code given in the bug report and adapted it to the latest version of bevy:

use bevy::{
    color::palettes::css::RED,
    core_pipeline::{tonemapping::Tonemapping},
    light::{
        FogVolume, VolumetricFog, VolumetricLight,
    },
    post_process::bloom::Bloom,
    prelude::*,
};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .insert_resource(GlobalAmbientLight::NONE)
        .add_systems(Startup, setup)
        .add_systems(Update, move_camera)
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn((
        Transform::from_xyz(-2., 0., 0.),
        SpotLight {
            intensity: 500_000.0, // lumens
            color: RED.into(),
            shadow_maps_enabled: true,
            ..default()
        },
        VolumetricLight,
    ));

    commands.spawn((
        Transform::from_xyz(2., 0., 0.),
        SpotLight {
            intensity: 500_000.0, // lumens
            color: RED.into(),
            shadow_maps_enabled: true,
            ..default()
        },
        VolumetricLight,
    ));

    commands.spawn((
        FogVolume::default(),
        Transform::from_scale(Vec3::splat(35.0)),
    ));

    commands.spawn((
        Camera3d::default(),
        Camera { ..default() },
        Transform::from_xyz(0., 0., 5.),
        Tonemapping::TonyMcMapface,
        Bloom::default(),
        VolumetricFog {
            ..default()
        },
    ));
}

fn move_camera(
    mut query: Query<&mut Transform, With<Camera3d>>,
    input: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
) {
    for mut transform in query.iter_mut() {
        if input.pressed(KeyCode::ArrowUp) {
            transform.translation.z -= 10. * time.delta_secs();
        }

        if input.pressed(KeyCode::ArrowDown) {
            transform.translation.z += 10. * time.delta_secs();
        }

        if input.pressed(KeyCode::KeyZ) {
            transform.translation.z = 5.;
        }

        if input.pressed(KeyCode::KeyX) {
            transform.translation.z = 130.;
        }
    }
}

Showcase

After changes (using code from: #18371):

volumetric_spotlights.mp4

Note: The flickering of the light is due to the regular sampling interval, adding more samples or jittering the ray origin minimizes this problem.

Ray marching when camera is outside of FogVolume (Before):
camera_outside_before

After:
camera_outside_after

…ixed raymarching sampling issue when camera is outside the fogvolume
@alice-i-cecile alice-i-cecile added this to the 0.19 milestone Mar 16, 2026
@alice-i-cecile alice-i-cecile added C-Bug An unexpected or incorrect behavior A-Rendering Drawing game state to the screen D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Mar 16, 2026
@github-project-automation github-project-automation bot moved this to Needs SME Triage in Rendering Mar 16, 2026
@alice-i-cecile alice-i-cecile added the X-Uncontroversial This work is generally agreed upon label Mar 16, 2026
// Calculate where we are in the ray.
let P_world = Ro_world + Rd_world * f32(step) * step_size_world;
let P_view = Rd_view * f32(step) * step_size_world;
let P_view = view_start_pos + Rd_view * f32(step) * step_size_world;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix has a similar theme to this PR #23406. Upon cross checking they are separate fixes, both needed. the other one is for fog AABB traversal / far-plane intersection.
this one fixes per-sample view position which affects both clustering and the shadow cascades sampled. so worth testing the shadows as well to see.


let cluster_index = clustering::view_fragment_cluster_index(frag_coord.xy, P_view.z, is_orthographic);
var clusterable_object_index_ranges = clustering::unpack_clusterable_object_index_ranges(cluster_index);
for (var i: u32 = clusterable_object_index_ranges.first_point_light_index_offset;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if flipping these loops around between for each cluster and for each step introduces any performance regression. while it's definitely the right approach, this requires recomputing the cluster each step meaning more work per fragment. I wonder how/if we can save this. Optimization could be left for later anyway in a later PR.


// Calculate absorption (amount of light absorbed by the fog) and
// out-scattering (amount of light the fog scattered away).
let sample_attenuation = exp(-step_size_world * density * (absorption + scattering));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like the correct fix compared to the directional path above. Same discrete Beer–Lambert extinction per step as before, the atmosphere-side is more analytic along the actual path, which could be a separate improvement.

@@ -338,33 +338,35 @@ fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
// Point lights and Spot lights
let view_z = view_start_pos.z;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks unused, can remove this

@mate-h
Copy link
Copy Markdown
Contributor

mate-h commented Mar 29, 2026

Oh and also thanks for posting this PR, must have been a tough one to track down!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Rendering Drawing game state to the screen C-Bug An unexpected or incorrect behavior D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Needs-Review Needs reviewer attention (from anyone!) to move forward X-Uncontroversial This work is generally agreed upon

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

SpotLight / PointLight artifacts in FogVolume

3 participants