Skip to content

Environment Map Filtering GPU pipeline #19076

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

mate-h
Copy link
Contributor

@mate-h mate-h commented May 5, 2025

Objective

This PR implements a robust GPU-based pipeline for dynamically generating environment maps in Bevy. It builds upon PR #19037, allowing these changes to be evaluated independently from the atmosphere implementation.

While existing offline tools can process environment maps, generate mip levels, and calculate specular lighting with importance sampling, they're limited to static file-based workflows. This PR introduces a real-time GPU pipeline that dynamically generates complete environment maps from a single cubemap texture on each frame.

Closes #9380

Solution

Implemented a Single Pass Downsampling (SPD) pipeline that processes textures without pre-existing mip levels or pre-filtered lighting data.

Single Pass downsampling (SPD) pipeline:

  • takes the 512x512 Cubemap as an input, and creates 9 MIP levels.
  • each level is progressively down-sampled.
  • It is actually broken into two passes due to architectural limitations.
  • largely based on Jasmine's code from github

Pre-filtering pipeline: composed of multiple Radiance Map (specular mips) generation passes, followed by the irradiance map pass (diffuse).
The pre-filtering pipeline is largely based on these articles:

Interesting note: the fireflies are almost completely gone by using the forward tonemap and reverse tonemap trick, while setting the whitepoint to 1.0. If the white point is set to higher, the fireflies come back. This is only apparent for envrironment maps with "hot" spots (i.e. values close to the maximum).

Previous work: #9414

Testing

The reflection_probes.rs example has been enhanced with:

  • A fourth display option (toggled via spacebar)
  • Adjustable roughness for the center sphere (using Up/Down keys)

First test case and use-case, we load a KTX texture with the RGBA 16-bit floating point format. I obtained this texture from polyhaven.com and used the ./convert.sh goegap_road_2k.exr command to convert it to a cubemap.

I had to install 4 different command line tools to get this to work (not ideal): OpenEXR CLI, ImageMagick, OpenImageIO, and finally the KTX command line tool. And I lose half the precision in this process going from 32-bit input to 16-bit output image.

convert.sh

#!/bin/bash
# create cubemap from equirectangular
file_name=$1
# remove the extension
file_name=$(basename "$file_name" .exr)
exrenvmap -c -li -w 512 -m -z none "$file_name.exr" "cubemap_%.exr"
echo "Created cubemap from equirectangular"

# fix the exr files with imagemagick
files=cubemap_*.exr
for file in $files; do
    magick "$file" "$file"
    oiiotool "$file" --fixnan box3 -o "$file"
    echo "Processed $file"
done

rm "$file_name.ktx2" > /dev/null 2>&1

ktx create --format R16G16B16A16_SFLOAT \
    --assign-tf linear \
    --cubemap \
    --zstd 3 \
    "cubemap_+X.exr" "cubemap_-X.exr" "cubemap_+Y.exr" "cubemap_-Y.exr" "cubemap_-Z.exr" "cubemap_+Z.exr" \
    "$file_name.ktx2"
echo "Created $file_name.ktx2"
ktx info "$file_name.ktx2" | grep "vkFormat"

Showcase

Screenshot 2025-05-05 at 12 01 42 AM
Screenshot 2025-05-05 at 12 01 36 AM

User facing API:

commands.spawn((
    LightProbe,
    FilteredEnvironmentMapLight {
        environment_map: world.load_asset("environment_maps/goegap_road_2k.ktx2"),
        ..default()
    },
    Transform::from_scale(Vec3::splat(2.0)),
));

Computed Environment Maps

To use fully dynamic environment maps, create a new placeholder image handle with Image::new_fill, extract it to the render world. Then dispatch a compute shader, bind the image as a 2d array storage texture. Anything can be rendered to the custom dynamic environment map.
This is already demonstrated in PR #19037 with the atmosphere.rs example.

We can extend this idea further and run the entire PBR pipeline from the perspective of the light probe, and it is possible to have some form of global illumination or baked lighting information this way, especially if we make use of irradiance volumes for the realtime aspect. This method could very well be extended to bake indirect lighting in the scene.
#13840 should make this possible!

Notes for reviewers

This PR includes a 7.2 MB KTX file for testing, this can of course be removed if it adds too much weight in the repo. We could include this "magic script" I came up with somewhere in the codebase as well. Robswain@ was suggesting that we implement the cubemap generation in a compute pipeline as well, so we could use equirectangular env maps directly instead (future work).

@alice-i-cecile alice-i-cecile added C-Feature A new feature, making something new possible A-Rendering Drawing game state to the screen M-Needs-Release-Note Work that should be called out in the blog due to impact labels May 5, 2025
@alice-i-cecile alice-i-cecile added this to the 0.17 milestone May 5, 2025
@alice-i-cecile alice-i-cecile added S-Needs-Review Needs reviewer attention (from anyone!) to move forward D-Complex Quite challenging from either a design or technical perspective. Ask for help! labels May 5, 2025
@JMS55 JMS55 self-requested a review May 6, 2025 00:14
@@ -197,6 +197,22 @@ impl Image {
})
.map(DynamicImage::ImageRgba8)
}
TextureFormat::Rgba16Float => {
Copy link
Contributor

Choose a reason for hiding this comment

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

What is this needed for?

Copy link
Contributor

Choose a reason for hiding this comment

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

Might be a little big, maybe shrink the resolution a bit?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd like for bevy to include some spatiotemporal blue noise from https://github.com/electronicarts/fastnoise.

We shouldn't use the temporal aspect for this feature, but I'd like to use a STBN texture now so we don't have multiple copies of blue noise.

@@ -37,6 +44,8 @@ enum ReflectionMode {
// Both a world environment map and a reflection probe are present. The
// reflection probe is shown in the sphere.
ReflectionProbe = 2,
// A prefiltered environment map is shown.
PrefilteredEnvironmentMap = 3,
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like calling this a prefilter envmap. The "EnvironmentMap" option is also prefiltered, just offline :P

TextureDescriptor {
label: Some("prefilter_environment_map"),
size: Extent3d {
width: 512,
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to hardcode to 512x512? 256x256 might be cheaper and not worse quality? Or maybe we should let the user configure.

};

for (entity, bind_groups, env_map_light) in self.query.iter_manual(world) {
// Copy original environment map to mip 0 of the intermediate environment map
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens if the original cubemap is not 512x512?


// Tonemapping functions to reduce fireflies

const white_point: f32 = 1.0;
Copy link
Contributor

@JMS55 JMS55 May 6, 2025

Choose a reason for hiding this comment

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

What's the white point calculation here? It's different from what I used in TAA, where was the math derived from? And is it meant to be user-configurable?

fn get_uniform_direction(index: u32) -> vec3f {
var dir = vec3f(0.0);

switch(index % 64u) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Array indexing or even indexing into a buffer might make more sense here? Idk.

}

// Calculate tangent space for the given normal
fn calculate_tangent_frame(normal: vec3f) -> mat3x3f {
Copy link
Contributor

Choose a reason for hiding this comment

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

Assuming this is what I think it is, I suggest using https://github.com/JMS55/bevy/blob/solari6/crates/bevy_solari/src/scene/sampling.wgsl#L89-L97.

If you want to be cool you can move it into some shared file I can later use in bevy_solari :)

irradiance = irradiance / total_weight * PI;
}

// Add some low-frequency ambient term to avoid completely dark areas
Copy link
Contributor

Choose a reason for hiding this comment

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

Why does this matter?


@compute
@workgroup_size(8, 8, 1)
fn generate_irradiance_map(@builtin(global_invocation_id) global_id: vec3u) {
Copy link
Contributor

Choose a reason for hiding this comment

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

FYI this uses the lambert BRDF, which is not what Bevy uses for StandardMaterial.

Generally I don't think it matters, we probably already assume lambert elsewhere, but you could technically importance sample the other BRDF if you did some more math.

@JMS55
Copy link
Contributor

JMS55 commented May 6, 2025

Some thoughts on the user API:

  • I think I'd like to stick to the old GenerateEnvironmentMapLight name rather than FilteredEnvironmentMapLight.
  • Similarly, we should give users control over when to re-generate the envmaplight. Maybe a boolean that auto-resets on extract like we did for the TAA reset.
  • Does it require using LightProbe? Can you use it for a global envmaplight attached to the camera?

// Importance sample GGX normal distribution function for a given roughness
fn importance_sample_ggx(xi: vec2f, roughness: f32, normal: vec3f) -> vec3f {
// Use roughness^2 to ensure correct specular highlights
let a = roughness * roughness;
Copy link
Contributor

Choose a reason for hiding this comment

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

I forget which is which, but we need to be careful of using roughness vs perceptual_roughness in this code.

@JMS55
Copy link
Contributor

JMS55 commented May 6, 2025

image

Ran it through NSight.

  1. 0.17 * 6 = 1.02ms of vkCmdCopyBufferToImages 😬
  2. 0.05ms vkCmdCopyImage that I believe is from this PR (no labels 😢)
  3. 0.03ms for SPD in two passes
  4. 0.33ms for radiance map generation. Has good occupancy to start, but drops off towards the end. The workgroups for the smaller mips, I guess? Bottlenecked by texture reads of course.
  5. 0.14ms for irradiance map generation. Very low occupancy the whole time. Very low active threads per warp. I'm not really sure why.

Generally those buffer to image copies are way too slow, need to figure out what those are and remove them. Radiance pass looks good, and irradiance pass isn't that slow but has very suspiciously low active threads per warp.

@JMS55
Copy link
Contributor

JMS55 commented May 6, 2025

Based on the shader profiler, the reason irradiance map is performing poorly is the giant switch statement absolutely kills perf as every thread gets masked out. Should 100% index into an array for that one.

@JMS55
Copy link
Contributor

JMS55 commented May 6, 2025

Oh, I see that you're also doing a separate dispatch per mip for the radiance pass. Does each level depend on the previous?

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-Feature A new feature, making something new possible D-Complex Quite challenging from either a design or technical perspective. Ask for help! M-Needs-Release-Note Work that should be called out in the blog due to impact S-Needs-Review Needs reviewer attention (from anyone!) to move forward
Projects
Status: No status
Development

Successfully merging this pull request may close these issues.

Automatic Skybox -> EnvironmentMapLight generation
3 participants