Skip to main content

bevy_pbr/light_probe/
generate.rs

1//! Like [`EnvironmentMapLight`], but filtered in realtime from a cubemap.
2//!
3//! An environment map needs to be processed to be able to support uses beyond a simple skybox,
4//! such as reflections, and ambient light contribution.
5//! This process is called filtering, and can either be done ahead of time (prefiltering), or
6//! in realtime, although at a reduced quality. Prefiltering is preferred, but not always possible:
7//! sometimes you only gain access to an environment map at runtime, for whatever reason.
8//! Typically this is from realtime reflection probes, but can also be from other sources.
9//!
10//! In any case, Bevy supports both modes of filtering.
11//! This module provides realtime filtering via [`bevy_light::GeneratedEnvironmentMapLight`].
12//! For prefiltered environment maps, see [`bevy_light::EnvironmentMapLight`].
13//! These components are intended to be added to a camera.
14use bevy_app::{App, Plugin, Update};
15use bevy_asset::{embedded_asset, load_embedded_asset, AssetServer, Assets, RenderAssetUsages};
16use bevy_core_pipeline::mip_generation::{
17    self, DownsamplePass, DownsamplePipeline, DownsamplePipelineKey, DownsamplingConstants,
18};
19use bevy_ecs::{
20    component::Component,
21    entity::Entity,
22    query::Without,
23    resource::Resource,
24    schedule::IntoScheduleConfigs,
25    system::{Commands, Query, Res, ResMut},
26};
27use bevy_image::Image;
28use bevy_math::{Quat, UVec2, Vec2};
29use bevy_render::{
30    diagnostic::RecordDiagnostics,
31    init_gpu_resource,
32    render_asset::RenderAssets,
33    render_resource::{
34        binding_types::*, AddressMode, BindGroup, BindGroupEntries, BindGroupLayoutDescriptor,
35        BindGroupLayoutEntries, CachedComputePipelineId, ComputePassDescriptor,
36        ComputePipelineDescriptor, DownlevelFlags, Extent3d, FilterMode, MipmapFilterMode,
37        PipelineCache, Sampler, SamplerBindingType, SamplerDescriptor, ShaderStages, ShaderType,
38        SpecializedComputePipelines, StorageTextureAccess, Texture, TextureAspect,
39        TextureDescriptor, TextureDimension, TextureFormat, TextureSampleType, TextureUsages,
40        TextureView, TextureViewDescriptor, TextureViewDimension, UniformBuffer,
41    },
42    renderer::{RenderAdapter, RenderContext, RenderDevice, RenderQueue},
43    sync_component::{SyncComponent, SyncComponentPlugin},
44    sync_world::RenderEntity,
45    texture::{CachedTexture, GpuImage, TextureCache},
46    Extract, ExtractSchedule, Render, RenderApp, RenderStartup, RenderSystems,
47};
48
49// Implementation: generate diffuse and specular cubemaps required by PBR
50// from a given high-res cubemap by
51//
52// 1. Copying the base mip (level 0) of the source cubemap into an intermediate
53//    storage texture.
54// 2. Generating mipmaps using [single-pass down-sampling] (SPD).
55// 3. Convolving the mip chain twice:
56//    * a [Lambertian convolution] for the 32 × 32 diffuse cubemap
57//    * a [GGX convolution], once per mip level, for the specular cubemap.
58//
59// [single-pass down-sampling]: https://gpuopen.com/fidelityfx-spd/
60// [Lambertian convolution]: https://bruop.github.io/ibl/#:~:text=Lambertian%20Diffuse%20Component
61// [GGX convolution]: https://gpuopen.com/download/Bounded_VNDF_Sampling_for_Smith-GGX_Reflections.pdf
62
63use bevy_light::{EnvironmentMapLight, GeneratedEnvironmentMapLight};
64use bevy_shader::ShaderDefVal;
65use core::cmp::min;
66use tracing::info;
67
68use crate::Bluenoise;
69
70/// Stores the bind group layouts for the environment map generation pipelines
71#[derive(Resource)]
72pub struct GeneratorBindGroupLayouts {
73    pub downsampling_first: BindGroupLayoutDescriptor,
74    pub downsampling_second: BindGroupLayoutDescriptor,
75    pub radiance: BindGroupLayoutDescriptor,
76    pub irradiance: BindGroupLayoutDescriptor,
77    pub copy: BindGroupLayoutDescriptor,
78}
79
80/// Samplers for the environment map generation pipelines
81#[derive(Resource)]
82pub struct GeneratorSamplers {
83    pub linear: Sampler,
84}
85
86/// Pipelines for the environment map generation pipelines
87#[derive(Resource)]
88pub struct GeneratorPipelines {
89    pub downsample_first: CachedComputePipelineId,
90    pub downsample_second: CachedComputePipelineId,
91    pub copy: CachedComputePipelineId,
92    pub radiance: CachedComputePipelineId,
93    pub irradiance: CachedComputePipelineId,
94}
95
96/// Configuration for downsampling strategy based on device limits
97#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]
98pub struct DownsamplingConfig {
99    // can bind ≥12 storage textures and use read-write storage textures
100    pub combine_bind_group: bool,
101}
102
103pub struct EnvironmentMapGenerationPlugin;
104
105impl Plugin for EnvironmentMapGenerationPlugin {
106    fn build(&self, _: &mut App) {}
107    fn finish(&self, app: &mut App) {
108        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
109            let adapter = render_app.world().resource::<RenderAdapter>();
110            let device = render_app.world().resource::<RenderDevice>();
111
112            // Cubemap SPD requires at least 6 storage textures
113            let limit_support = device.limits().max_storage_textures_per_shader_stage >= 6
114                && device.limits().max_compute_workgroup_storage_size != 0
115                && device.limits().max_compute_workgroup_size_x != 0;
116
117            let downlevel_support = adapter
118                .get_downlevel_capabilities()
119                .flags
120                .contains(DownlevelFlags::COMPUTE_SHADERS);
121
122            if !limit_support || !downlevel_support {
123                info!("Disabling EnvironmentMapGenerationPlugin because compute is not supported on this platform. This is safe to ignore if you are not using EnvironmentMapGenerationPlugin.");
124                return;
125            }
126        } else {
127            return;
128        }
129
130        embedded_asset!(app, "environment_filter.wesl");
131        embedded_asset!(app, "copy.wesl");
132
133        app.add_plugins(SyncComponentPlugin::<GeneratedEnvironmentMapLight, Self>::default())
134            .add_systems(Update, generate_environment_map_light);
135
136        let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
137            return;
138        };
139
140        render_app
141            .add_systems(
142                ExtractSchedule,
143                extract_generated_environment_map_entities.after(generate_environment_map_light),
144            )
145            .add_systems(
146                Render,
147                (
148                    prepare_generated_environment_map_bind_groups
149                        .in_set(RenderSystems::PrepareBindGroups),
150                    prepare_generated_environment_map_intermediate_textures
151                        .in_set(RenderSystems::PrepareResources),
152                    (downsampling_system, filtering_system)
153                        .chain()
154                        .after(RenderSystems::PrepareBindGroups)
155                        .before(RenderSystems::Render),
156                ),
157            )
158            .add_systems(
159                RenderStartup,
160                initialize_generated_environment_map_resources
161                    .after(init_gpu_resource::<DownsamplePipeline>)
162                    .after(init_gpu_resource::<SpecializedComputePipelines<DownsamplePipeline>>),
163            );
164    }
165}
166
167/// Initializes all render-world resources used by the environment-map generator once on
168/// [`bevy_render::RenderStartup`].
169pub fn initialize_generated_environment_map_resources(
170    mut commands: Commands,
171    render_device: Res<RenderDevice>,
172    render_adapter: Res<RenderAdapter>,
173    pipeline_cache: Res<PipelineCache>,
174    asset_server: Res<AssetServer>,
175    downsample_pipeline: Res<DownsamplePipeline>,
176    mut specialized_downsample_pipelines: ResMut<SpecializedComputePipelines<DownsamplePipeline>>,
177) {
178    // Combine the bind group and use read-write storage if it is supported
179    let combine_bind_group =
180        mip_generation::can_combine_downsampling_bind_groups(&render_adapter, &render_device);
181
182    let downsample_key_first = DownsamplePipelineKey {
183        texture_format: TextureFormat::Rgba16Float,
184        array_texture: true, // cubemap
185        combine_bind_groups: combine_bind_group,
186        pass: DownsamplePass::First,
187    };
188    let downsample_key_second = DownsamplePipelineKey {
189        pass: DownsamplePass::Second,
190        ..downsample_key_first
191    };
192
193    // Bind group layouts
194    let radiance = BindGroupLayoutDescriptor::new(
195        "radiance_bind_group_layout",
196        &BindGroupLayoutEntries::sequential(
197            ShaderStages::COMPUTE,
198            (
199                // Source environment cubemap
200                texture_2d_array(TextureSampleType::Float { filterable: true }),
201                sampler(SamplerBindingType::Filtering), // Source sampler
202                // Output specular map
203                texture_storage_2d_array(
204                    TextureFormat::Rgba16Float,
205                    StorageTextureAccess::WriteOnly,
206                ),
207                uniform_buffer::<FilteringConstants>(false), // Uniforms
208                texture_2d_array(TextureSampleType::Float { filterable: true }), // Blue noise texture
209            ),
210        ),
211    );
212
213    let irradiance = BindGroupLayoutDescriptor::new(
214        "irradiance_bind_group_layout",
215        &BindGroupLayoutEntries::sequential(
216            ShaderStages::COMPUTE,
217            (
218                // Source environment cubemap
219                texture_2d_array(TextureSampleType::Float { filterable: true }),
220                sampler(SamplerBindingType::Filtering), // Source sampler
221                // Output irradiance map
222                texture_storage_2d_array(
223                    TextureFormat::Rgba16Float,
224                    StorageTextureAccess::WriteOnly,
225                ),
226                uniform_buffer::<FilteringConstants>(false), // Uniforms
227                texture_2d_array(TextureSampleType::Float { filterable: true }), // Blue noise texture
228            ),
229        ),
230    );
231
232    let copy = BindGroupLayoutDescriptor::new(
233        "copy_bind_group_layout",
234        &BindGroupLayoutEntries::sequential(
235            ShaderStages::COMPUTE,
236            (
237                // Source cubemap
238                texture_2d_array(TextureSampleType::Float { filterable: true }),
239                // Destination mip0
240                texture_storage_2d_array(
241                    TextureFormat::Rgba16Float,
242                    StorageTextureAccess::WriteOnly,
243                ),
244            ),
245        ),
246    );
247
248    let layouts = GeneratorBindGroupLayouts {
249        downsampling_first: downsample_key_first.bind_group_layout(),
250        downsampling_second: downsample_key_second.bind_group_layout(),
251        radiance,
252        irradiance,
253        copy,
254    };
255
256    // Samplers
257    let linear = render_device.create_sampler(&SamplerDescriptor {
258        label: Some("generator_linear_sampler"),
259        address_mode_u: AddressMode::ClampToEdge,
260        address_mode_v: AddressMode::ClampToEdge,
261        address_mode_w: AddressMode::ClampToEdge,
262        mag_filter: FilterMode::Linear,
263        min_filter: FilterMode::Linear,
264        mipmap_filter: MipmapFilterMode::Linear,
265        ..Default::default()
266    });
267
268    let samplers = GeneratorSamplers { linear };
269
270    // Pipelines
271    let shader_defs: Vec<ShaderDefVal> = if cfg!(feature = "bluenoise_texture") {
272        vec![ShaderDefVal::Int("HAS_BLUE_NOISE".into(), 1)]
273    } else {
274        vec![]
275    };
276
277    let env_filter_shader = load_embedded_asset!(asset_server.as_ref(), "environment_filter.wesl");
278    let copy_shader = load_embedded_asset!(asset_server.as_ref(), "copy.wesl");
279
280    // Radiance map for specular environment maps
281    let radiance = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {
282        label: Some("radiance_pipeline".into()),
283        layout: vec![layouts.radiance.clone()],
284        immediate_size: 0,
285        shader: env_filter_shader.clone(),
286        shader_defs: shader_defs.clone(),
287        entry_point: Some("generate_radiance_map".into()),
288        zero_initialize_workgroup_memory: false,
289        constants: vec![],
290    });
291
292    // Irradiance map for diffuse environment maps
293    let irradiance = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {
294        label: Some("irradiance_pipeline".into()),
295        layout: vec![layouts.irradiance.clone()],
296        immediate_size: 0,
297        shader: env_filter_shader,
298        shader_defs: shader_defs.clone(),
299        entry_point: Some("generate_irradiance_map".into()),
300        zero_initialize_workgroup_memory: false,
301        constants: vec![],
302    });
303
304    // Copy pipeline handles format conversion and populates mip0 when formats differ
305    let copy_pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {
306        label: Some("copy_pipeline".into()),
307        layout: vec![layouts.copy.clone()],
308        immediate_size: 0,
309        shader: copy_shader,
310        shader_defs: vec![],
311        entry_point: Some("copy".into()),
312        zero_initialize_workgroup_memory: false,
313        constants: vec![],
314    });
315
316    let pipelines = GeneratorPipelines {
317        downsample_first: specialized_downsample_pipelines.specialize(
318            &pipeline_cache,
319            &downsample_pipeline,
320            downsample_key_first,
321        ),
322        downsample_second: specialized_downsample_pipelines.specialize(
323            &pipeline_cache,
324            &downsample_pipeline,
325            downsample_key_second,
326        ),
327        radiance,
328        irradiance,
329        copy: copy_pipeline,
330    };
331
332    // Insert all resources into the render world
333    commands.insert_resource(layouts);
334    commands.insert_resource(samplers);
335    commands.insert_resource(pipelines);
336    commands.insert_resource(DownsamplingConfig { combine_bind_group });
337}
338
339pub fn extract_generated_environment_map_entities(
340    query: Extract<
341        Query<(
342            RenderEntity,
343            &GeneratedEnvironmentMapLight,
344            &EnvironmentMapLight,
345        )>,
346    >,
347    mut commands: Commands,
348    render_images: Res<RenderAssets<GpuImage>>,
349) {
350    for (entity, filtered_env_map, env_map_light) in query.iter() {
351        let Some(env_map) = render_images.get(&filtered_env_map.environment_map) else {
352            continue;
353        };
354
355        let diffuse_map = render_images.get(&env_map_light.diffuse_map);
356        let specular_map = render_images.get(&env_map_light.specular_map);
357
358        // continue if the diffuse map is not found
359        if diffuse_map.is_none() || specular_map.is_none() {
360            continue;
361        }
362
363        let diffuse_map = diffuse_map.unwrap();
364        let specular_map = specular_map.unwrap();
365
366        let render_filtered_env_map = RenderEnvironmentMap {
367            environment_map: env_map.clone(),
368            diffuse_map: diffuse_map.clone(),
369            specular_map: specular_map.clone(),
370            intensity: filtered_env_map.intensity,
371            rotation: filtered_env_map.rotation,
372            affects_lightmapped_mesh_diffuse: filtered_env_map.affects_lightmapped_mesh_diffuse,
373        };
374        commands
375            .get_entity(entity)
376            .expect("Entity not synced to render world")
377            .insert(render_filtered_env_map);
378    }
379}
380
381// A render-world specific version of FilteredEnvironmentMapLight that uses CachedTexture
382#[derive(Component, Clone)]
383pub struct RenderEnvironmentMap {
384    pub environment_map: GpuImage,
385    pub diffuse_map: GpuImage,
386    pub specular_map: GpuImage,
387    pub intensity: f32,
388    pub rotation: Quat,
389    pub affects_lightmapped_mesh_diffuse: bool,
390}
391
392#[derive(Component)]
393pub struct IntermediateTextures {
394    pub environment_map: CachedTexture,
395}
396
397/// Returns the total number of mip levels for the provided square texture size.
398/// `size` must be a power of two greater than zero. For example, `size = 512` → `9`.
399#[inline]
400fn compute_mip_count(size: u32) -> u32 {
401    debug_assert!(size.is_power_of_two());
402    32 - size.leading_zeros()
403}
404
405/// Prepares textures needed for single pass downsampling
406pub fn prepare_generated_environment_map_intermediate_textures(
407    light_probes: Query<(Entity, &RenderEnvironmentMap)>,
408    render_device: Res<RenderDevice>,
409    mut texture_cache: ResMut<TextureCache>,
410    mut commands: Commands,
411) {
412    for (entity, env_map_light) in &light_probes {
413        let base_size = env_map_light.environment_map.texture_descriptor.size.width;
414        let mip_level_count = compute_mip_count(base_size);
415
416        let environment_map = texture_cache.get(
417            &render_device,
418            TextureDescriptor {
419                label: Some("intermediate_environment_map"),
420                size: Extent3d {
421                    width: base_size,
422                    height: base_size,
423                    depth_or_array_layers: 6, // Cubemap faces
424                },
425                mip_level_count,
426                sample_count: 1,
427                dimension: TextureDimension::D2,
428                format: TextureFormat::Rgba16Float,
429                usage: TextureUsages::TEXTURE_BINDING
430                    | TextureUsages::STORAGE_BINDING
431                    | TextureUsages::COPY_DST,
432                view_formats: &[],
433            },
434        );
435
436        commands
437            .entity(entity)
438            .insert(IntermediateTextures { environment_map });
439    }
440}
441
442/// Constants for filtering
443#[derive(Clone, Copy, ShaderType)]
444#[repr(C)]
445pub struct FilteringConstants {
446    mip_level: f32,
447    sample_count: u32,
448    roughness: f32,
449    noise_size_bits: UVec2,
450}
451
452/// Stores bind groups for the environment map generation pipelines
453#[derive(Component)]
454pub struct GeneratorBindGroups {
455    pub downsampling_first: BindGroup,
456    pub downsampling_second: BindGroup,
457    pub radiance: Vec<BindGroup>, // One per mip level
458    pub irradiance: BindGroup,
459    pub copy: BindGroup,
460}
461
462/// Prepares bind groups for environment map generation pipelines
463pub fn prepare_generated_environment_map_bind_groups(
464    light_probes: Query<(Entity, &IntermediateTextures, &RenderEnvironmentMap)>,
465    render_device: Res<RenderDevice>,
466    pipeline_cache: Res<PipelineCache>,
467    queue: Res<RenderQueue>,
468    layouts: Res<GeneratorBindGroupLayouts>,
469    samplers: Res<GeneratorSamplers>,
470    render_images: Res<RenderAssets<GpuImage>>,
471    bluenoise: Res<Bluenoise>,
472    config: Res<DownsamplingConfig>,
473    mut commands: Commands,
474) {
475    // Skip until the blue-noise texture is available to avoid panicking.
476    // The system will retry next frame once the asset has loaded.
477    let Some(stbn_texture) = render_images.get(&bluenoise.texture) else {
478        return;
479    };
480
481    assert!(stbn_texture.texture_descriptor.size.width.is_power_of_two());
482    assert!(stbn_texture
483        .texture_descriptor
484        .size
485        .height
486        .is_power_of_two());
487    let noise_size_bits = UVec2::new(
488        stbn_texture.texture_descriptor.size.width.trailing_zeros(),
489        stbn_texture.texture_descriptor.size.height.trailing_zeros(),
490    );
491
492    for (entity, textures, env_map_light) in &light_probes {
493        // Determine mip chain based on input size
494        let base_size = env_map_light.environment_map.texture_descriptor.size.width;
495        let mip_count = compute_mip_count(base_size);
496        let last_mip = mip_count - 1;
497        let env_map_texture = env_map_light.environment_map.texture.clone();
498
499        // Create downsampling constants
500        let downsampling_constants = DownsamplingConstants {
501            mips: mip_count - 1, // Number of mips we are generating (excluding mip 0)
502            inverse_input_size: Vec2::new(1.0 / base_size as f32, 1.0 / base_size as f32),
503            _padding: 0,
504        };
505
506        let mut downsampling_constants_buffer = UniformBuffer::from(downsampling_constants);
507        downsampling_constants_buffer.write_buffer(&render_device, &queue);
508
509        let input_env_map_first = env_map_texture.clone().create_view(&TextureViewDescriptor {
510            dimension: Some(TextureViewDimension::D2Array),
511            ..Default::default()
512        });
513
514        // Utility closure to get a unique storage view for a given mip level.
515        let mip_storage = |level: u32| {
516            if level <= last_mip {
517                create_storage_view(&textures.environment_map.texture, level, &render_device)
518            } else {
519                // Return a fresh 1×1 placeholder view so each binding has its own sub-resource and cannot alias.
520                create_placeholder_storage_view(&render_device)
521            }
522        };
523
524        // Depending on device limits, build either a combined or split bind group layout
525        let (downsampling_first_bind_group, downsampling_second_bind_group) =
526            if config.combine_bind_group {
527                // Combined layout expects destinations 1–12 in both bind groups
528                let bind_group = render_device.create_bind_group(
529                    "downsampling_bind_group_combined_first",
530                    &pipeline_cache.get_bind_group_layout(&layouts.downsampling_first),
531                    &BindGroupEntries::sequential((
532                        &samplers.linear,
533                        &downsampling_constants_buffer,
534                        &input_env_map_first,
535                        &mip_storage(1),
536                        &mip_storage(2),
537                        &mip_storage(3),
538                        &mip_storage(4),
539                        &mip_storage(5),
540                        &mip_storage(6),
541                        &mip_storage(7),
542                        &mip_storage(8),
543                        &mip_storage(9),
544                        &mip_storage(10),
545                        &mip_storage(11),
546                        &mip_storage(12),
547                    )),
548                );
549
550                (bind_group.clone(), bind_group)
551            } else {
552                // Split path requires a separate view for mip6 input
553                let input_env_map_second =
554                    textures
555                        .environment_map
556                        .texture
557                        .create_view(&TextureViewDescriptor {
558                            dimension: Some(TextureViewDimension::D2Array),
559                            base_mip_level: min(6, last_mip),
560                            mip_level_count: Some(1),
561                            ..Default::default()
562                        });
563
564                // Split layout (current behavior)
565                let first = render_device.create_bind_group(
566                    "downsampling_first_bind_group",
567                    &pipeline_cache.get_bind_group_layout(&layouts.downsampling_first),
568                    &BindGroupEntries::sequential((
569                        &samplers.linear,
570                        &downsampling_constants_buffer,
571                        &input_env_map_first,
572                        &mip_storage(1),
573                        &mip_storage(2),
574                        &mip_storage(3),
575                        &mip_storage(4),
576                        &mip_storage(5),
577                        &mip_storage(6),
578                    )),
579                );
580
581                let second = render_device.create_bind_group(
582                    "downsampling_second_bind_group",
583                    &pipeline_cache.get_bind_group_layout(&layouts.downsampling_second),
584                    &BindGroupEntries::sequential((
585                        &samplers.linear,
586                        &downsampling_constants_buffer,
587                        &input_env_map_second,
588                        &mip_storage(7),
589                        &mip_storage(8),
590                        &mip_storage(9),
591                        &mip_storage(10),
592                        &mip_storage(11),
593                        &mip_storage(12),
594                    )),
595                );
596
597                (first, second)
598            };
599
600        // create a 2d array view of the bluenoise texture
601        let stbn_texture_view = stbn_texture
602            .texture
603            .clone()
604            .create_view(&TextureViewDescriptor {
605                dimension: Some(TextureViewDimension::D2Array),
606                ..Default::default()
607            });
608
609        // Create radiance map bind groups for each mip level
610        let num_mips = mip_count as usize;
611        let mut radiance_bind_groups = Vec::with_capacity(num_mips);
612
613        for mip in 0..num_mips {
614            // Calculate roughness from 0.0 (mip 0) to 0.889 (mip 8)
615            // We don't need roughness=1.0 as a mip level because it's handled by the separate diffuse irradiance map
616            let roughness = mip as f32 / (num_mips - 1) as f32;
617            let sample_count = 32u32 * 2u32.pow((roughness * 4.0) as u32);
618
619            let radiance_constants = FilteringConstants {
620                mip_level: mip as f32,
621                sample_count,
622                roughness,
623                noise_size_bits,
624            };
625
626            let mut radiance_constants_buffer = UniformBuffer::from(radiance_constants);
627            radiance_constants_buffer.write_buffer(&render_device, &queue);
628
629            let mip_storage_view = create_storage_view(
630                &env_map_light.specular_map.texture,
631                mip as u32,
632                &render_device,
633            );
634            let bind_group = render_device.create_bind_group(
635                Some(format!("radiance_bind_group_mip_{mip}").as_str()),
636                &pipeline_cache.get_bind_group_layout(&layouts.radiance),
637                &BindGroupEntries::sequential((
638                    &textures.environment_map.default_view,
639                    &samplers.linear,
640                    &mip_storage_view,
641                    &radiance_constants_buffer,
642                    &stbn_texture_view,
643                )),
644            );
645
646            radiance_bind_groups.push(bind_group);
647        }
648
649        // Create irradiance bind group
650        let irradiance_constants = FilteringConstants {
651            mip_level: 0.0,
652            // 32 phi, 32 theta = 1024 samples total
653            sample_count: 1024,
654            roughness: 1.0,
655            noise_size_bits,
656        };
657
658        let mut irradiance_constants_buffer = UniformBuffer::from(irradiance_constants);
659        irradiance_constants_buffer.write_buffer(&render_device, &queue);
660
661        // create a 2d array view
662        let irradiance_map =
663            env_map_light
664                .diffuse_map
665                .texture
666                .create_view(&TextureViewDescriptor {
667                    dimension: Some(TextureViewDimension::D2Array),
668                    ..Default::default()
669                });
670
671        let irradiance_bind_group = render_device.create_bind_group(
672            "irradiance_bind_group",
673            &pipeline_cache.get_bind_group_layout(&layouts.irradiance),
674            &BindGroupEntries::sequential((
675                &textures.environment_map.default_view,
676                &samplers.linear,
677                &irradiance_map,
678                &irradiance_constants_buffer,
679                &stbn_texture_view,
680            )),
681        );
682
683        // Create copy bind group (source env map → destination mip0)
684        let src_view = env_map_light
685            .environment_map
686            .texture
687            .create_view(&TextureViewDescriptor {
688                dimension: Some(TextureViewDimension::D2Array),
689                ..Default::default()
690            });
691
692        let dst_view = create_storage_view(&textures.environment_map.texture, 0, &render_device);
693
694        let copy_bind_group = render_device.create_bind_group(
695            "copy_bind_group",
696            &pipeline_cache.get_bind_group_layout(&layouts.copy),
697            &BindGroupEntries::with_indices(((0, &src_view), (1, &dst_view))),
698        );
699
700        commands.entity(entity).insert(GeneratorBindGroups {
701            downsampling_first: downsampling_first_bind_group,
702            downsampling_second: downsampling_second_bind_group,
703            radiance: radiance_bind_groups,
704            irradiance: irradiance_bind_group,
705            copy: copy_bind_group,
706        });
707    }
708}
709
710/// Helper function to create a storage texture view for a specific mip level
711fn create_storage_view(texture: &Texture, mip: u32, _render_device: &RenderDevice) -> TextureView {
712    texture.create_view(&TextureViewDescriptor {
713        label: Some(format!("storage_view_mip_{mip}").as_str()),
714        format: Some(texture.format()),
715        dimension: Some(TextureViewDimension::D2Array),
716        aspect: TextureAspect::All,
717        base_mip_level: mip,
718        mip_level_count: Some(1),
719        base_array_layer: 0,
720        array_layer_count: Some(texture.depth_or_array_layers()),
721        usage: Some(TextureUsages::STORAGE_BINDING),
722    })
723}
724
725/// To ensure compatibility in web browsers, each call returns a unique resource so that multiple missing mip
726/// bindings in the same bind-group never alias.
727fn create_placeholder_storage_view(render_device: &RenderDevice) -> TextureView {
728    let tex = render_device.create_texture(&TextureDescriptor {
729        label: Some("lightprobe_placeholder"),
730        size: Extent3d {
731            width: 1,
732            height: 1,
733            depth_or_array_layers: 6,
734        },
735        mip_level_count: 1,
736        sample_count: 1,
737        dimension: TextureDimension::D2,
738        format: TextureFormat::Rgba16Float,
739        usage: TextureUsages::STORAGE_BINDING | TextureUsages::TEXTURE_BINDING,
740        view_formats: &[],
741    });
742
743    tex.create_view(&TextureViewDescriptor::default())
744}
745
746pub fn downsampling_system(
747    query: Query<(&GeneratorBindGroups, &RenderEnvironmentMap)>,
748    pipeline_cache: Res<PipelineCache>,
749    pipelines: Option<Res<GeneratorPipelines>>,
750    mut ctx: RenderContext,
751) {
752    let Some(pipelines) = pipelines else {
753        return;
754    };
755
756    let Some(downsample_first_pipeline) =
757        pipeline_cache.get_compute_pipeline(pipelines.downsample_first)
758    else {
759        return;
760    };
761
762    let Some(downsample_second_pipeline) =
763        pipeline_cache.get_compute_pipeline(pipelines.downsample_second)
764    else {
765        return;
766    };
767
768    let Some(copy_pipeline) = pipeline_cache.get_compute_pipeline(pipelines.copy) else {
769        return;
770    };
771
772    let diagnostics = ctx.diagnostic_recorder();
773    let diagnostics = diagnostics.as_deref();
774
775    for (bind_groups, env_map_light) in &query {
776        // Copy base mip using compute shader with pre-built bind group
777        {
778            let mut compute_pass =
779                ctx.command_encoder()
780                    .begin_compute_pass(&ComputePassDescriptor {
781                        label: Some("lightprobe_copy"),
782                        timestamp_writes: None,
783                    });
784
785            let pass_span = diagnostics.pass_span(&mut compute_pass, "lightprobe_copy");
786
787            compute_pass.set_pipeline(copy_pipeline);
788            compute_pass.set_bind_group(0, &bind_groups.copy, &[]);
789
790            let tex_size = env_map_light.environment_map.texture_descriptor.size;
791            let wg_x = tex_size.width.div_ceil(8);
792            let wg_y = tex_size.height.div_ceil(8);
793            compute_pass.dispatch_workgroups(wg_x, wg_y, 6);
794
795            pass_span.end(&mut compute_pass);
796        }
797
798        // First pass - process mips 0-5
799        {
800            let mut compute_pass =
801                ctx.command_encoder()
802                    .begin_compute_pass(&ComputePassDescriptor {
803                        label: Some("lightprobe_downsampling_first_pass"),
804                        timestamp_writes: None,
805                    });
806
807            let pass_span =
808                diagnostics.pass_span(&mut compute_pass, "lightprobe_downsampling_first_pass");
809
810            compute_pass.set_pipeline(downsample_first_pipeline);
811            compute_pass.set_bind_group(0, &bind_groups.downsampling_first, &[]);
812
813            let tex_size = env_map_light.environment_map.texture_descriptor.size;
814            let wg_x = tex_size.width.div_ceil(64);
815            let wg_y = tex_size.height.div_ceil(64);
816            compute_pass.dispatch_workgroups(wg_x, wg_y, 6); // 6 faces
817
818            pass_span.end(&mut compute_pass);
819        }
820
821        // Second pass - process mips 6-12
822        {
823            let mut compute_pass =
824                ctx.command_encoder()
825                    .begin_compute_pass(&ComputePassDescriptor {
826                        label: Some("lightprobe_downsampling_second_pass"),
827                        timestamp_writes: None,
828                    });
829
830            let pass_span =
831                diagnostics.pass_span(&mut compute_pass, "lightprobe_downsampling_second_pass");
832
833            compute_pass.set_pipeline(downsample_second_pipeline);
834            compute_pass.set_bind_group(0, &bind_groups.downsampling_second, &[]);
835
836            let tex_size = env_map_light.environment_map.texture_descriptor.size;
837            let wg_x = tex_size.width.div_ceil(256);
838            let wg_y = tex_size.height.div_ceil(256);
839            compute_pass.dispatch_workgroups(wg_x, wg_y, 6);
840
841            pass_span.end(&mut compute_pass);
842        }
843    }
844}
845
846pub fn filtering_system(
847    query: Query<(&GeneratorBindGroups, &RenderEnvironmentMap)>,
848    pipeline_cache: Res<PipelineCache>,
849    pipelines: Option<Res<GeneratorPipelines>>,
850    mut ctx: RenderContext,
851) {
852    let Some(pipelines) = pipelines else {
853        return;
854    };
855
856    let Some(radiance_pipeline) = pipeline_cache.get_compute_pipeline(pipelines.radiance) else {
857        return;
858    };
859    let Some(irradiance_pipeline) = pipeline_cache.get_compute_pipeline(pipelines.irradiance)
860    else {
861        return;
862    };
863
864    let diagnostics = ctx.diagnostic_recorder();
865    let diagnostics = diagnostics.as_deref();
866
867    for (bind_groups, env_map_light) in &query {
868        // Radiance convolution pass
869        {
870            let mut compute_pass =
871                ctx.command_encoder()
872                    .begin_compute_pass(&ComputePassDescriptor {
873                        label: Some("lightprobe_radiance_map"),
874                        timestamp_writes: None,
875                    });
876
877            let pass_span = diagnostics.pass_span(&mut compute_pass, "lightprobe_radiance_map");
878
879            compute_pass.set_pipeline(radiance_pipeline);
880
881            let base_size = env_map_light.specular_map.texture_descriptor.size.width;
882
883            // Process each mip at different roughness levels
884            for (mip, bind_group) in bind_groups.radiance.iter().enumerate() {
885                compute_pass.set_bind_group(0, bind_group, &[]);
886
887                // Calculate dispatch size based on mip level
888                let mip_size = base_size >> mip;
889                let workgroup_count = mip_size.div_ceil(8);
890
891                // Dispatch for all 6 faces
892                compute_pass.dispatch_workgroups(workgroup_count, workgroup_count, 6);
893            }
894
895            pass_span.end(&mut compute_pass);
896        }
897
898        // Irradiance convolution pass
899        // Generate the diffuse environment map
900        {
901            let mut compute_pass =
902                ctx.command_encoder()
903                    .begin_compute_pass(&ComputePassDescriptor {
904                        label: Some("lightprobe_irradiance_map"),
905                        timestamp_writes: None,
906                    });
907
908            let irr_span = diagnostics.pass_span(&mut compute_pass, "lightprobe_irradiance_map");
909
910            compute_pass.set_pipeline(irradiance_pipeline);
911            compute_pass.set_bind_group(0, &bind_groups.irradiance, &[]);
912
913            // 32×32 texture processed with 8×8 workgroups for all 6 faces
914            compute_pass.dispatch_workgroups(4, 4, 6);
915
916            irr_span.end(&mut compute_pass);
917        }
918    }
919}
920
921/// System that generates an `EnvironmentMapLight` component based on the `GeneratedEnvironmentMapLight` component
922pub fn generate_environment_map_light(
923    mut commands: Commands,
924    mut images: ResMut<Assets<Image>>,
925    query: Query<(Entity, &GeneratedEnvironmentMapLight), Without<EnvironmentMapLight>>,
926) {
927    for (entity, filtered_env_map) in &query {
928        // Validate and fetch the source cubemap so we can size our targets correctly
929        let Some(src_image) = images.get(&filtered_env_map.environment_map) else {
930            // Texture not ready yet – try again next frame
931            continue;
932        };
933
934        let base_size = src_image.texture_descriptor.size.width;
935
936        // Sanity checks – square, power-of-two, ≤ 8192
937        if src_image.texture_descriptor.size.height != base_size
938            || !base_size.is_power_of_two()
939            || base_size > 8192
940        {
941            panic!(
942                "GeneratedEnvironmentMapLight source cubemap must be square power-of-two ≤ 8192, got {}×{}",
943                base_size, src_image.texture_descriptor.size.height
944            );
945        }
946
947        let mip_count = compute_mip_count(base_size);
948
949        // Create a placeholder for the irradiance map
950        let mut diffuse = Image::new_fill(
951            Extent3d {
952                width: 32,
953                height: 32,
954                depth_or_array_layers: 6,
955            },
956            TextureDimension::D2,
957            &[0; 8],
958            TextureFormat::Rgba16Float,
959            RenderAssetUsages::all(),
960        );
961
962        diffuse.texture_descriptor.usage =
963            TextureUsages::TEXTURE_BINDING | TextureUsages::STORAGE_BINDING;
964
965        diffuse.texture_view_descriptor = Some(TextureViewDescriptor {
966            dimension: Some(TextureViewDimension::Cube),
967            ..Default::default()
968        });
969
970        let diffuse_handle = images.add(diffuse);
971
972        // Create a placeholder for the specular map. It matches the input cubemap resolution.
973        let mut specular = Image::new_fill(
974            Extent3d {
975                width: base_size,
976                height: base_size,
977                depth_or_array_layers: 6,
978            },
979            TextureDimension::D2,
980            &[0; 8],
981            TextureFormat::Rgba16Float,
982            RenderAssetUsages::all(),
983        );
984
985        // Set up for mipmaps
986        specular.texture_descriptor.usage =
987            TextureUsages::TEXTURE_BINDING | TextureUsages::STORAGE_BINDING;
988        specular.texture_descriptor.mip_level_count = mip_count;
989
990        // When setting mip_level_count, we need to allocate appropriate data size
991        // For GPU-generated mipmaps, we can set data to None since the GPU will generate the data
992        specular.data = None;
993
994        specular.texture_view_descriptor = Some(TextureViewDescriptor {
995            dimension: Some(TextureViewDimension::Cube),
996            mip_level_count: Some(mip_count),
997            ..Default::default()
998        });
999
1000        let specular_handle = images.add(specular);
1001
1002        // Add the EnvironmentMapLight component with the placeholder handles
1003        commands.entity(entity).insert(EnvironmentMapLight {
1004            diffuse_map: diffuse_handle,
1005            specular_map: specular_handle,
1006            intensity: filtered_env_map.intensity,
1007            rotation: filtered_env_map.rotation,
1008            affects_lightmapped_mesh_diffuse: filtered_env_map.affects_lightmapped_mesh_diffuse,
1009        });
1010    }
1011}
1012
1013impl SyncComponent<RenderApp, EnvironmentMapGenerationPlugin> for GeneratedEnvironmentMapLight {
1014    type Target = RenderEnvironmentMap;
1015}