Path: blob/main/crates/bevy_pbr/src/light_probe/generate.rs
6604 views
//! Like [`EnvironmentMapLight`], but filtered in realtime from a cubemap.1//!2//! An environment map needs to be processed to be able to support uses beyond a simple skybox,3//! such as reflections, and ambient light contribution.4//! This process is called filtering, and can either be done ahead of time (prefiltering), or5//! in realtime, although at a reduced quality. Prefiltering is preferred, but not always possible:6//! sometimes you only gain access to an environment map at runtime, for whatever reason.7//! Typically this is from realtime reflection probes, but can also be from other sources.8//!9//! In any case, Bevy supports both modes of filtering.10//! This module provides realtime filtering via [`bevy_light::GeneratedEnvironmentMapLight`].11//! For prefiltered environment maps, see [`bevy_light::EnvironmentMapLight`].12//! These components are intended to be added to a camera.13use bevy_app::{App, Plugin, Update};14use bevy_asset::{embedded_asset, load_embedded_asset, AssetServer, Assets, RenderAssetUsages};15use bevy_core_pipeline::core_3d::graph::{Core3d, Node3d};16use bevy_ecs::{17component::Component,18entity::Entity,19query::{QueryState, With, Without},20resource::Resource,21schedule::IntoScheduleConfigs,22system::{lifetimeless::Read, Commands, Query, Res, ResMut},23world::{FromWorld, World},24};25use bevy_image::Image;26use bevy_math::{Quat, UVec2, Vec2};27use bevy_render::{28diagnostic::RecordDiagnostics,29render_asset::RenderAssets,30render_graph::{Node, NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel},31render_resource::{32binding_types::*, AddressMode, BindGroup, BindGroupEntries, BindGroupLayout,33BindGroupLayoutEntries, CachedComputePipelineId, ComputePassDescriptor,34ComputePipelineDescriptor, DownlevelFlags, Extent3d, FilterMode, PipelineCache, Sampler,35SamplerBindingType, SamplerDescriptor, ShaderStages, ShaderType, StorageTextureAccess,36Texture, TextureAspect, TextureDescriptor, TextureDimension, TextureFormat,37TextureFormatFeatureFlags, TextureSampleType, TextureUsages, TextureView,38TextureViewDescriptor, TextureViewDimension, UniformBuffer,39},40renderer::{RenderAdapter, RenderContext, RenderDevice, RenderQueue},41settings::WgpuFeatures,42sync_component::SyncComponentPlugin,43sync_world::RenderEntity,44texture::{CachedTexture, GpuImage, TextureCache},45Extract, ExtractSchedule, Render, RenderApp, RenderStartup, RenderSystems,46};4748// Implementation: generate diffuse and specular cubemaps required by PBR49// from a given high-res cubemap by50//51// 1. Copying the base mip (level 0) of the source cubemap into an intermediate52// storage texture.53// 2. Generating mipmaps using [single-pass down-sampling] (SPD).54// 3. Convolving the mip chain twice:55// * a [Lambertian convolution] for the 32 × 32 diffuse cubemap56// * a [GGX convolution], once per mip level, for the specular cubemap.57//58// [single-pass down-sampling]: https://gpuopen.com/fidelityfx-spd/59// [Lambertian convolution]: https://bruop.github.io/ibl/#:~:text=Lambertian%20Diffuse%20Component60// [GGX convolution]: https://gpuopen.com/download/Bounded_VNDF_Sampling_for_Smith-GGX_Reflections.pdf6162use bevy_light::{EnvironmentMapLight, GeneratedEnvironmentMapLight};63use bevy_shader::ShaderDefVal;64use core::cmp::min;65use tracing::info;6667use crate::Bluenoise;6869/// Labels for the environment map generation nodes70#[derive(PartialEq, Eq, Debug, Copy, Clone, Hash, RenderLabel)]71pub enum GeneratorNode {72Downsampling,73Filtering,74}7576/// Stores the bind group layouts for the environment map generation pipelines77#[derive(Resource)]78pub struct GeneratorBindGroupLayouts {79pub downsampling_first: BindGroupLayout,80pub downsampling_second: BindGroupLayout,81pub radiance: BindGroupLayout,82pub irradiance: BindGroupLayout,83pub copy: BindGroupLayout,84}8586/// Samplers for the environment map generation pipelines87#[derive(Resource)]88pub struct GeneratorSamplers {89pub linear: Sampler,90}9192/// Pipelines for the environment map generation pipelines93#[derive(Resource)]94pub struct GeneratorPipelines {95pub downsample_first: CachedComputePipelineId,96pub downsample_second: CachedComputePipelineId,97pub copy: CachedComputePipelineId,98pub radiance: CachedComputePipelineId,99pub irradiance: CachedComputePipelineId,100}101102/// Configuration for downsampling strategy based on device limits103#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]104pub struct DownsamplingConfig {105// can bind ≥12 storage textures and use read-write storage textures106pub combine_bind_group: bool,107}108109pub struct EnvironmentMapGenerationPlugin;110111impl Plugin for EnvironmentMapGenerationPlugin {112fn build(&self, _: &mut App) {}113fn finish(&self, app: &mut App) {114if let Some(render_app) = app.get_sub_app_mut(RenderApp) {115let adapter = render_app.world().resource::<RenderAdapter>();116let device = render_app.world().resource::<RenderDevice>();117118// Cubemap SPD requires at least 6 storage textures119let limit_support = device.limits().max_storage_textures_per_shader_stage >= 6120&& device.limits().max_compute_workgroup_storage_size != 0121&& device.limits().max_compute_workgroup_size_x != 0;122123let downlevel_support = adapter124.get_downlevel_capabilities()125.flags126.contains(DownlevelFlags::COMPUTE_SHADERS);127128if !limit_support || !downlevel_support {129info!("Disabling EnvironmentMapGenerationPlugin because compute is not supported on this platform. This is safe to ignore if you are not using EnvironmentMapGenerationPlugin.");130return;131}132} else {133return;134}135136embedded_asset!(app, "environment_filter.wgsl");137embedded_asset!(app, "downsample.wgsl");138embedded_asset!(app, "copy.wgsl");139140app.add_plugins(SyncComponentPlugin::<GeneratedEnvironmentMapLight>::default())141.add_systems(Update, generate_environment_map_light);142143let Some(render_app) = app.get_sub_app_mut(RenderApp) else {144return;145};146147render_app148.add_render_graph_node::<DownsamplingNode>(Core3d, GeneratorNode::Downsampling)149.add_render_graph_node::<FilteringNode>(Core3d, GeneratorNode::Filtering)150.add_render_graph_edges(151Core3d,152(153Node3d::EndPrepasses,154GeneratorNode::Downsampling,155GeneratorNode::Filtering,156Node3d::StartMainPass,157),158)159.add_systems(160ExtractSchedule,161extract_generated_environment_map_entities.after(generate_environment_map_light),162)163.add_systems(164Render,165prepare_generated_environment_map_bind_groups166.in_set(RenderSystems::PrepareBindGroups),167)168.add_systems(169Render,170prepare_generated_environment_map_intermediate_textures171.in_set(RenderSystems::PrepareResources),172)173.add_systems(174RenderStartup,175initialize_generated_environment_map_resources,176);177}178}179180// The number of storage textures required to combine the bind group181const REQUIRED_STORAGE_TEXTURES: u32 = 12;182183/// Initializes all render-world resources used by the environment-map generator once on184/// [`bevy_render::RenderStartup`].185pub fn initialize_generated_environment_map_resources(186mut commands: Commands,187render_device: Res<RenderDevice>,188render_adapter: Res<RenderAdapter>,189pipeline_cache: Res<PipelineCache>,190asset_server: Res<AssetServer>,191) {192// Determine whether we can use a single, large bind group for all mip outputs193let storage_texture_limit = render_device.limits().max_storage_textures_per_shader_stage;194195// Determine whether we can read and write to the same rgba16f storage texture196let read_write_support = render_adapter197.get_texture_format_features(TextureFormat::Rgba16Float)198.flags199.contains(TextureFormatFeatureFlags::STORAGE_READ_WRITE);200201// Combine the bind group and use read-write storage if it is supported202let combine_bind_group =203storage_texture_limit >= REQUIRED_STORAGE_TEXTURES && read_write_support;204205// Output mips are write-only206let mips =207texture_storage_2d_array(TextureFormat::Rgba16Float, StorageTextureAccess::WriteOnly);208209// Bind group layouts210let (downsampling_first, downsampling_second) = if combine_bind_group {211// One big bind group layout containing all outputs 1–12212let downsampling = render_device.create_bind_group_layout(213"downsampling_bind_group_layout_combined",214&BindGroupLayoutEntries::sequential(215ShaderStages::COMPUTE,216(217sampler(SamplerBindingType::Filtering),218uniform_buffer::<DownsamplingConstants>(false),219texture_2d_array(TextureSampleType::Float { filterable: true }),220mips, // 1221mips, // 2222mips, // 3223mips, // 4224mips, // 5225texture_storage_2d_array(226TextureFormat::Rgba16Float,227StorageTextureAccess::ReadWrite,228), // 6229mips, // 7230mips, // 8231mips, // 9232mips, // 10233mips, // 11234mips, // 12235),236),237);238239(downsampling.clone(), downsampling)240} else {241// Split layout: first pass outputs 1–6, second pass outputs 7–12 (input mip6 read-only)242243let downsampling_first = render_device.create_bind_group_layout(244"downsampling_first_bind_group_layout",245&BindGroupLayoutEntries::sequential(246ShaderStages::COMPUTE,247(248sampler(SamplerBindingType::Filtering),249uniform_buffer::<DownsamplingConstants>(false),250// Input mip 0251texture_2d_array(TextureSampleType::Float { filterable: true }),252mips, // 1253mips, // 2254mips, // 3255mips, // 4256mips, // 5257mips, // 6258),259),260);261262let downsampling_second = render_device.create_bind_group_layout(263"downsampling_second_bind_group_layout",264&BindGroupLayoutEntries::sequential(265ShaderStages::COMPUTE,266(267sampler(SamplerBindingType::Filtering),268uniform_buffer::<DownsamplingConstants>(false),269// Input mip 6270texture_2d_array(TextureSampleType::Float { filterable: true }),271mips, // 7272mips, // 8273mips, // 9274mips, // 10275mips, // 11276mips, // 12277),278),279);280281(downsampling_first, downsampling_second)282};283let radiance = render_device.create_bind_group_layout(284"radiance_bind_group_layout",285&BindGroupLayoutEntries::sequential(286ShaderStages::COMPUTE,287(288// Source environment cubemap289texture_2d_array(TextureSampleType::Float { filterable: true }),290sampler(SamplerBindingType::Filtering), // Source sampler291// Output specular map292texture_storage_2d_array(293TextureFormat::Rgba16Float,294StorageTextureAccess::WriteOnly,295),296uniform_buffer::<FilteringConstants>(false), // Uniforms297texture_2d_array(TextureSampleType::Float { filterable: true }), // Blue noise texture298),299),300);301302let irradiance = render_device.create_bind_group_layout(303"irradiance_bind_group_layout",304&BindGroupLayoutEntries::sequential(305ShaderStages::COMPUTE,306(307// Source environment cubemap308texture_2d_array(TextureSampleType::Float { filterable: true }),309sampler(SamplerBindingType::Filtering), // Source sampler310// Output irradiance map311texture_storage_2d_array(312TextureFormat::Rgba16Float,313StorageTextureAccess::WriteOnly,314),315uniform_buffer::<FilteringConstants>(false), // Uniforms316texture_2d_array(TextureSampleType::Float { filterable: true }), // Blue noise texture317),318),319);320321let copy = render_device.create_bind_group_layout(322"copy_bind_group_layout",323&BindGroupLayoutEntries::sequential(324ShaderStages::COMPUTE,325(326// Source cubemap327texture_2d_array(TextureSampleType::Float { filterable: true }),328// Destination mip0329texture_storage_2d_array(330TextureFormat::Rgba16Float,331StorageTextureAccess::WriteOnly,332),333),334),335);336337let layouts = GeneratorBindGroupLayouts {338downsampling_first,339downsampling_second,340radiance,341irradiance,342copy,343};344345// Samplers346let linear = render_device.create_sampler(&SamplerDescriptor {347label: Some("generator_linear_sampler"),348address_mode_u: AddressMode::ClampToEdge,349address_mode_v: AddressMode::ClampToEdge,350address_mode_w: AddressMode::ClampToEdge,351mag_filter: FilterMode::Linear,352min_filter: FilterMode::Linear,353mipmap_filter: FilterMode::Linear,354..Default::default()355});356357let samplers = GeneratorSamplers { linear };358359// Pipelines360let features = render_device.features();361let mut shader_defs = vec![];362if features.contains(WgpuFeatures::SUBGROUP) {363shader_defs.push(ShaderDefVal::Int("SUBGROUP_SUPPORT".into(), 1));364}365if combine_bind_group {366shader_defs.push(ShaderDefVal::Int("COMBINE_BIND_GROUP".into(), 1));367}368#[cfg(feature = "bluenoise_texture")]369{370shader_defs.push(ShaderDefVal::Int("HAS_BLUE_NOISE".into(), 1));371}372373let downsampling_shader = load_embedded_asset!(asset_server.as_ref(), "downsample.wgsl");374let env_filter_shader = load_embedded_asset!(asset_server.as_ref(), "environment_filter.wgsl");375let copy_shader = load_embedded_asset!(asset_server.as_ref(), "copy.wgsl");376377// First pass for base mip Levels (0-5)378let downsample_first = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {379label: Some("downsampling_first_pipeline".into()),380layout: vec![layouts.downsampling_first.clone()],381push_constant_ranges: vec![],382shader: downsampling_shader.clone(),383shader_defs: {384let mut defs = shader_defs.clone();385if !combine_bind_group {386defs.push(ShaderDefVal::Int("FIRST_PASS".into(), 1));387}388defs389},390entry_point: Some("downsample_first".into()),391zero_initialize_workgroup_memory: false,392});393394let downsample_second = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {395label: Some("downsampling_second_pipeline".into()),396layout: vec![layouts.downsampling_second.clone()],397push_constant_ranges: vec![],398shader: downsampling_shader,399shader_defs: {400let mut defs = shader_defs.clone();401if !combine_bind_group {402defs.push(ShaderDefVal::Int("SECOND_PASS".into(), 1));403}404defs405},406entry_point: Some("downsample_second".into()),407zero_initialize_workgroup_memory: false,408});409410// Radiance map for specular environment maps411let radiance = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {412label: Some("radiance_pipeline".into()),413layout: vec![layouts.radiance.clone()],414push_constant_ranges: vec![],415shader: env_filter_shader.clone(),416shader_defs: shader_defs.clone(),417entry_point: Some("generate_radiance_map".into()),418zero_initialize_workgroup_memory: false,419});420421// Irradiance map for diffuse environment maps422let irradiance = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {423label: Some("irradiance_pipeline".into()),424layout: vec![layouts.irradiance.clone()],425push_constant_ranges: vec![],426shader: env_filter_shader,427shader_defs: shader_defs.clone(),428entry_point: Some("generate_irradiance_map".into()),429zero_initialize_workgroup_memory: false,430});431432// Copy pipeline handles format conversion and populates mip0 when formats differ433let copy_pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {434label: Some("copy_pipeline".into()),435layout: vec![layouts.copy.clone()],436push_constant_ranges: vec![],437shader: copy_shader,438shader_defs: vec![],439entry_point: Some("copy".into()),440zero_initialize_workgroup_memory: false,441});442443let pipelines = GeneratorPipelines {444downsample_first,445downsample_second,446radiance,447irradiance,448copy: copy_pipeline,449};450451// Insert all resources into the render world452commands.insert_resource(layouts);453commands.insert_resource(samplers);454commands.insert_resource(pipelines);455commands.insert_resource(DownsamplingConfig { combine_bind_group });456}457458pub fn extract_generated_environment_map_entities(459query: Extract<460Query<(461RenderEntity,462&GeneratedEnvironmentMapLight,463&EnvironmentMapLight,464)>,465>,466mut commands: Commands,467render_images: Res<RenderAssets<GpuImage>>,468) {469for (entity, filtered_env_map, env_map_light) in query.iter() {470let Some(env_map) = render_images.get(&filtered_env_map.environment_map) else {471continue;472};473474let diffuse_map = render_images.get(&env_map_light.diffuse_map);475let specular_map = render_images.get(&env_map_light.specular_map);476477// continue if the diffuse map is not found478if diffuse_map.is_none() || specular_map.is_none() {479continue;480}481482let diffuse_map = diffuse_map.unwrap();483let specular_map = specular_map.unwrap();484485let render_filtered_env_map = RenderEnvironmentMap {486environment_map: env_map.clone(),487diffuse_map: diffuse_map.clone(),488specular_map: specular_map.clone(),489intensity: filtered_env_map.intensity,490rotation: filtered_env_map.rotation,491affects_lightmapped_mesh_diffuse: filtered_env_map.affects_lightmapped_mesh_diffuse,492};493commands494.get_entity(entity)495.expect("Entity not synced to render world")496.insert(render_filtered_env_map);497}498}499500// A render-world specific version of FilteredEnvironmentMapLight that uses CachedTexture501#[derive(Component, Clone)]502pub struct RenderEnvironmentMap {503pub environment_map: GpuImage,504pub diffuse_map: GpuImage,505pub specular_map: GpuImage,506pub intensity: f32,507pub rotation: Quat,508pub affects_lightmapped_mesh_diffuse: bool,509}510511#[derive(Component)]512pub struct IntermediateTextures {513pub environment_map: CachedTexture,514}515516/// Returns the total number of mip levels for the provided square texture size.517/// `size` must be a power of two greater than zero. For example, `size = 512` → `9`.518#[inline]519fn compute_mip_count(size: u32) -> u32 {520debug_assert!(size.is_power_of_two());52132 - size.leading_zeros()522}523524/// Prepares textures needed for single pass downsampling525pub fn prepare_generated_environment_map_intermediate_textures(526light_probes: Query<(Entity, &RenderEnvironmentMap)>,527render_device: Res<RenderDevice>,528mut texture_cache: ResMut<TextureCache>,529mut commands: Commands,530) {531for (entity, env_map_light) in &light_probes {532let base_size = env_map_light.environment_map.size.width;533let mip_level_count = compute_mip_count(base_size);534535let environment_map = texture_cache.get(536&render_device,537TextureDescriptor {538label: Some("intermediate_environment_map"),539size: Extent3d {540width: base_size,541height: base_size,542depth_or_array_layers: 6, // Cubemap faces543},544mip_level_count,545sample_count: 1,546dimension: TextureDimension::D2,547format: TextureFormat::Rgba16Float,548usage: TextureUsages::TEXTURE_BINDING549| TextureUsages::STORAGE_BINDING550| TextureUsages::COPY_DST,551view_formats: &[],552},553);554555commands556.entity(entity)557.insert(IntermediateTextures { environment_map });558}559}560561/// Shader constants for downsampling algorithm562#[derive(Clone, Copy, ShaderType)]563#[repr(C)]564pub struct DownsamplingConstants {565mips: u32,566inverse_input_size: Vec2,567_padding: u32,568}569570/// Constants for filtering571#[derive(Clone, Copy, ShaderType)]572#[repr(C)]573pub struct FilteringConstants {574mip_level: f32,575sample_count: u32,576roughness: f32,577noise_size_bits: UVec2,578}579580/// Stores bind groups for the environment map generation pipelines581#[derive(Component)]582pub struct GeneratorBindGroups {583pub downsampling_first: BindGroup,584pub downsampling_second: BindGroup,585pub radiance: Vec<BindGroup>, // One per mip level586pub irradiance: BindGroup,587pub copy: BindGroup,588}589590/// Prepares bind groups for environment map generation pipelines591pub fn prepare_generated_environment_map_bind_groups(592light_probes: Query<593(Entity, &IntermediateTextures, &RenderEnvironmentMap),594With<RenderEnvironmentMap>,595>,596render_device: Res<RenderDevice>,597queue: Res<RenderQueue>,598layouts: Res<GeneratorBindGroupLayouts>,599samplers: Res<GeneratorSamplers>,600render_images: Res<RenderAssets<GpuImage>>,601bluenoise: Res<Bluenoise>,602config: Res<DownsamplingConfig>,603mut commands: Commands,604) {605// Skip until the blue-noise texture is available to avoid panicking.606// The system will retry next frame once the asset has loaded.607let Some(stbn_texture) = render_images.get(&bluenoise.texture) else {608return;609};610611assert!(stbn_texture.size.width.is_power_of_two());612assert!(stbn_texture.size.height.is_power_of_two());613let noise_size_bits = UVec2::new(614stbn_texture.size.width.trailing_zeros(),615stbn_texture.size.height.trailing_zeros(),616);617618for (entity, textures, env_map_light) in &light_probes {619// Determine mip chain based on input size620let base_size = env_map_light.environment_map.size.width;621let mip_count = compute_mip_count(base_size);622let last_mip = mip_count - 1;623let env_map_texture = env_map_light.environment_map.texture.clone();624625// Create downsampling constants626let downsampling_constants = DownsamplingConstants {627mips: mip_count - 1, // Number of mips we are generating (excluding mip 0)628inverse_input_size: Vec2::new(1.0 / base_size as f32, 1.0 / base_size as f32),629_padding: 0,630};631632let mut downsampling_constants_buffer = UniformBuffer::from(downsampling_constants);633downsampling_constants_buffer.write_buffer(&render_device, &queue);634635let input_env_map_first = env_map_texture.clone().create_view(&TextureViewDescriptor {636dimension: Some(TextureViewDimension::D2Array),637..Default::default()638});639640// Utility closure to get a unique storage view for a given mip level.641let mip_storage = |level: u32| {642if level <= last_mip {643create_storage_view(&textures.environment_map.texture, level, &render_device)644} else {645// Return a fresh 1×1 placeholder view so each binding has its own sub-resource and cannot alias.646create_placeholder_storage_view(&render_device)647}648};649650// Depending on device limits, build either a combined or split bind group layout651let (downsampling_first_bind_group, downsampling_second_bind_group) =652if config.combine_bind_group {653// Combined layout expects destinations 1–12 in both bind groups654let bind_group = render_device.create_bind_group(655"downsampling_bind_group_combined_first",656&layouts.downsampling_first,657&BindGroupEntries::sequential((658&samplers.linear,659&downsampling_constants_buffer,660&input_env_map_first,661&mip_storage(1),662&mip_storage(2),663&mip_storage(3),664&mip_storage(4),665&mip_storage(5),666&mip_storage(6),667&mip_storage(7),668&mip_storage(8),669&mip_storage(9),670&mip_storage(10),671&mip_storage(11),672&mip_storage(12),673)),674);675676(bind_group.clone(), bind_group)677} else {678// Split path requires a separate view for mip6 input679let input_env_map_second = env_map_texture.create_view(&TextureViewDescriptor {680dimension: Some(TextureViewDimension::D2Array),681base_mip_level: min(6, last_mip),682mip_level_count: Some(1),683..Default::default()684});685686// Split layout (current behavior)687let first = render_device.create_bind_group(688"downsampling_first_bind_group",689&layouts.downsampling_first,690&BindGroupEntries::sequential((691&samplers.linear,692&downsampling_constants_buffer,693&input_env_map_first,694&mip_storage(1),695&mip_storage(2),696&mip_storage(3),697&mip_storage(4),698&mip_storage(5),699&mip_storage(6),700)),701);702703let second = render_device.create_bind_group(704"downsampling_second_bind_group",705&layouts.downsampling_second,706&BindGroupEntries::sequential((707&samplers.linear,708&downsampling_constants_buffer,709&input_env_map_second,710&mip_storage(7),711&mip_storage(8),712&mip_storage(9),713&mip_storage(10),714&mip_storage(11),715&mip_storage(12),716)),717);718719(first, second)720};721722// create a 2d array view of the bluenoise texture723let stbn_texture_view = stbn_texture724.texture725.clone()726.create_view(&TextureViewDescriptor {727dimension: Some(TextureViewDimension::D2Array),728..Default::default()729});730731// Create radiance map bind groups for each mip level732let num_mips = mip_count as usize;733let mut radiance_bind_groups = Vec::with_capacity(num_mips);734735for mip in 0..num_mips {736// Calculate roughness from 0.0 (mip 0) to 0.889 (mip 8)737// We don't need roughness=1.0 as a mip level because it's handled by the separate diffuse irradiance map738let roughness = mip as f32 / (num_mips - 1) as f32;739let sample_count = 32u32 * 2u32.pow((roughness * 4.0) as u32);740741let radiance_constants = FilteringConstants {742mip_level: mip as f32,743sample_count,744roughness,745noise_size_bits,746};747748let mut radiance_constants_buffer = UniformBuffer::from(radiance_constants);749radiance_constants_buffer.write_buffer(&render_device, &queue);750751let mip_storage_view = create_storage_view(752&env_map_light.specular_map.texture,753mip as u32,754&render_device,755);756let bind_group = render_device.create_bind_group(757Some(format!("radiance_bind_group_mip_{mip}").as_str()),758&layouts.radiance,759&BindGroupEntries::sequential((760&textures.environment_map.default_view,761&samplers.linear,762&mip_storage_view,763&radiance_constants_buffer,764&stbn_texture_view,765)),766);767768radiance_bind_groups.push(bind_group);769}770771// Create irradiance bind group772let irradiance_constants = FilteringConstants {773mip_level: 0.0,774// 32 phi, 32 theta = 1024 samples total775sample_count: 1024,776roughness: 1.0,777noise_size_bits,778};779780let mut irradiance_constants_buffer = UniformBuffer::from(irradiance_constants);781irradiance_constants_buffer.write_buffer(&render_device, &queue);782783// create a 2d array view784let irradiance_map =785env_map_light786.diffuse_map787.texture788.create_view(&TextureViewDescriptor {789dimension: Some(TextureViewDimension::D2Array),790..Default::default()791});792793let irradiance_bind_group = render_device.create_bind_group(794"irradiance_bind_group",795&layouts.irradiance,796&BindGroupEntries::sequential((797&textures.environment_map.default_view,798&samplers.linear,799&irradiance_map,800&irradiance_constants_buffer,801&stbn_texture_view,802)),803);804805// Create copy bind group (source env map → destination mip0)806let src_view = env_map_light807.environment_map808.texture809.create_view(&TextureViewDescriptor {810dimension: Some(TextureViewDimension::D2Array),811..Default::default()812});813814let dst_view = create_storage_view(&textures.environment_map.texture, 0, &render_device);815816let copy_bind_group = render_device.create_bind_group(817"copy_bind_group",818&layouts.copy,819&BindGroupEntries::with_indices(((0, &src_view), (1, &dst_view))),820);821822commands.entity(entity).insert(GeneratorBindGroups {823downsampling_first: downsampling_first_bind_group,824downsampling_second: downsampling_second_bind_group,825radiance: radiance_bind_groups,826irradiance: irradiance_bind_group,827copy: copy_bind_group,828});829}830}831832/// Helper function to create a storage texture view for a specific mip level833fn create_storage_view(texture: &Texture, mip: u32, _render_device: &RenderDevice) -> TextureView {834texture.create_view(&TextureViewDescriptor {835label: Some(format!("storage_view_mip_{mip}").as_str()),836format: Some(texture.format()),837dimension: Some(TextureViewDimension::D2Array),838aspect: TextureAspect::All,839base_mip_level: mip,840mip_level_count: Some(1),841base_array_layer: 0,842array_layer_count: Some(texture.depth_or_array_layers()),843usage: Some(TextureUsages::STORAGE_BINDING),844})845}846847/// To ensure compatibility in web browsers, each call returns a unique resource so that multiple missing mip848/// bindings in the same bind-group never alias.849fn create_placeholder_storage_view(render_device: &RenderDevice) -> TextureView {850let tex = render_device.create_texture(&TextureDescriptor {851label: Some("lightprobe_placeholder"),852size: Extent3d {853width: 1,854height: 1,855depth_or_array_layers: 6,856},857mip_level_count: 1,858sample_count: 1,859dimension: TextureDimension::D2,860format: TextureFormat::Rgba16Float,861usage: TextureUsages::STORAGE_BINDING | TextureUsages::TEXTURE_BINDING,862view_formats: &[],863});864865tex.create_view(&TextureViewDescriptor::default())866}867868/// Downsampling node implementation that handles all parts of the mip chain869pub struct DownsamplingNode {870query: QueryState<(871Entity,872Read<GeneratorBindGroups>,873Read<RenderEnvironmentMap>,874)>,875}876877impl FromWorld for DownsamplingNode {878fn from_world(world: &mut World) -> Self {879Self {880query: QueryState::new(world),881}882}883}884885impl Node for DownsamplingNode {886fn update(&mut self, world: &mut World) {887self.query.update_archetypes(world);888}889890fn run(891&self,892_graph: &mut RenderGraphContext,893render_context: &mut RenderContext,894world: &World,895) -> Result<(), NodeRunError> {896let pipeline_cache = world.resource::<PipelineCache>();897let pipelines = world.resource::<GeneratorPipelines>();898899let Some(downsample_first_pipeline) =900pipeline_cache.get_compute_pipeline(pipelines.downsample_first)901else {902return Ok(());903};904905let Some(downsample_second_pipeline) =906pipeline_cache.get_compute_pipeline(pipelines.downsample_second)907else {908return Ok(());909};910911let diagnostics = render_context.diagnostic_recorder();912913for (_, bind_groups, env_map_light) in self.query.iter_manual(world) {914// Copy base mip using compute shader with pre-built bind group915let Some(copy_pipeline) = pipeline_cache.get_compute_pipeline(pipelines.copy) else {916return Ok(());917};918919{920let mut compute_pass =921render_context922.command_encoder()923.begin_compute_pass(&ComputePassDescriptor {924label: Some("lightprobe_copy"),925timestamp_writes: None,926});927928let pass_span = diagnostics.pass_span(&mut compute_pass, "lightprobe_copy");929930compute_pass.set_pipeline(copy_pipeline);931compute_pass.set_bind_group(0, &bind_groups.copy, &[]);932933let tex_size = env_map_light.environment_map.size;934let wg_x = tex_size.width.div_ceil(8);935let wg_y = tex_size.height.div_ceil(8);936compute_pass.dispatch_workgroups(wg_x, wg_y, 6);937938pass_span.end(&mut compute_pass);939}940941// First pass - process mips 0-5942{943let mut compute_pass =944render_context945.command_encoder()946.begin_compute_pass(&ComputePassDescriptor {947label: Some("lightprobe_downsampling_first_pass"),948timestamp_writes: None,949});950951let pass_span =952diagnostics.pass_span(&mut compute_pass, "lightprobe_downsampling_first_pass");953954compute_pass.set_pipeline(downsample_first_pipeline);955compute_pass.set_bind_group(0, &bind_groups.downsampling_first, &[]);956957let tex_size = env_map_light.environment_map.size;958let wg_x = tex_size.width.div_ceil(64);959let wg_y = tex_size.height.div_ceil(64);960compute_pass.dispatch_workgroups(wg_x, wg_y, 6); // 6 faces961962pass_span.end(&mut compute_pass);963}964965// Second pass - process mips 6-12966{967let mut compute_pass =968render_context969.command_encoder()970.begin_compute_pass(&ComputePassDescriptor {971label: Some("lightprobe_downsampling_second_pass"),972timestamp_writes: None,973});974975let pass_span =976diagnostics.pass_span(&mut compute_pass, "lightprobe_downsampling_second_pass");977978compute_pass.set_pipeline(downsample_second_pipeline);979compute_pass.set_bind_group(0, &bind_groups.downsampling_second, &[]);980981let tex_size = env_map_light.environment_map.size;982let wg_x = tex_size.width.div_ceil(256);983let wg_y = tex_size.height.div_ceil(256);984compute_pass.dispatch_workgroups(wg_x, wg_y, 6);985986pass_span.end(&mut compute_pass);987}988}989990Ok(())991}992}993994/// Radiance map node for generating specular environment maps995pub struct FilteringNode {996query: QueryState<(997Entity,998Read<GeneratorBindGroups>,999Read<RenderEnvironmentMap>,1000)>,1001}10021003impl FromWorld for FilteringNode {1004fn from_world(world: &mut World) -> Self {1005Self {1006query: QueryState::new(world),1007}1008}1009}10101011impl Node for FilteringNode {1012fn update(&mut self, world: &mut World) {1013self.query.update_archetypes(world);1014}10151016fn run(1017&self,1018_graph: &mut RenderGraphContext,1019render_context: &mut RenderContext,1020world: &World,1021) -> Result<(), NodeRunError> {1022let pipeline_cache = world.resource::<PipelineCache>();1023let pipelines = world.resource::<GeneratorPipelines>();10241025let Some(radiance_pipeline) = pipeline_cache.get_compute_pipeline(pipelines.radiance)1026else {1027return Ok(());1028};1029let Some(irradiance_pipeline) = pipeline_cache.get_compute_pipeline(pipelines.irradiance)1030else {1031return Ok(());1032};10331034let diagnostics = render_context.diagnostic_recorder();10351036for (_, bind_groups, env_map_light) in self.query.iter_manual(world) {1037let mut compute_pass =1038render_context1039.command_encoder()1040.begin_compute_pass(&ComputePassDescriptor {1041label: Some("lightprobe_radiance_map"),1042timestamp_writes: None,1043});10441045let pass_span = diagnostics.pass_span(&mut compute_pass, "lightprobe_radiance_map");10461047compute_pass.set_pipeline(radiance_pipeline);10481049let base_size = env_map_light.specular_map.size.width;10501051// Radiance convolution pass1052// Process each mip at different roughness levels1053for (mip, bind_group) in bind_groups.radiance.iter().enumerate() {1054compute_pass.set_bind_group(0, bind_group, &[]);10551056// Calculate dispatch size based on mip level1057let mip_size = base_size >> mip;1058let workgroup_count = mip_size.div_ceil(8);10591060// Dispatch for all 6 faces1061compute_pass.dispatch_workgroups(workgroup_count, workgroup_count, 6);1062}1063pass_span.end(&mut compute_pass);1064// End the compute pass before starting the next one1065drop(compute_pass);10661067// Irradiance convolution pass1068// Generate the diffuse environment map1069{1070let mut compute_pass =1071render_context1072.command_encoder()1073.begin_compute_pass(&ComputePassDescriptor {1074label: Some("lightprobe_irradiance_map"),1075timestamp_writes: None,1076});10771078let irr_span =1079diagnostics.pass_span(&mut compute_pass, "lightprobe_irradiance_map");10801081compute_pass.set_pipeline(irradiance_pipeline);1082compute_pass.set_bind_group(0, &bind_groups.irradiance, &[]);10831084// 32×32 texture processed with 8×8 workgroups for all 6 faces1085compute_pass.dispatch_workgroups(4, 4, 6);10861087irr_span.end(&mut compute_pass);1088}1089}10901091Ok(())1092}1093}10941095/// System that generates an `EnvironmentMapLight` component based on the `GeneratedEnvironmentMapLight` component1096pub fn generate_environment_map_light(1097mut commands: Commands,1098mut images: ResMut<Assets<Image>>,1099query: Query<(Entity, &GeneratedEnvironmentMapLight), Without<EnvironmentMapLight>>,1100) {1101for (entity, filtered_env_map) in &query {1102// Validate and fetch the source cubemap so we can size our targets correctly1103let Some(src_image) = images.get(&filtered_env_map.environment_map) else {1104// Texture not ready yet – try again next frame1105continue;1106};11071108let base_size = src_image.texture_descriptor.size.width;11091110// Sanity checks – square, power-of-two, ≤ 81921111if src_image.texture_descriptor.size.height != base_size1112|| !base_size.is_power_of_two()1113|| base_size > 81921114{1115panic!(1116"GeneratedEnvironmentMapLight source cubemap must be square power-of-two ≤ 8192, got {}×{}",1117base_size, src_image.texture_descriptor.size.height1118);1119}11201121let mip_count = compute_mip_count(base_size);11221123// Create a placeholder for the irradiance map1124let mut diffuse = Image::new_fill(1125Extent3d {1126width: 32,1127height: 32,1128depth_or_array_layers: 6,1129},1130TextureDimension::D2,1131&[0; 8],1132TextureFormat::Rgba16Float,1133RenderAssetUsages::all(),1134);11351136diffuse.texture_descriptor.usage =1137TextureUsages::TEXTURE_BINDING | TextureUsages::STORAGE_BINDING;11381139diffuse.texture_view_descriptor = Some(TextureViewDescriptor {1140dimension: Some(TextureViewDimension::Cube),1141..Default::default()1142});11431144let diffuse_handle = images.add(diffuse);11451146// Create a placeholder for the specular map. It matches the input cubemap resolution.1147let mut specular = Image::new_fill(1148Extent3d {1149width: base_size,1150height: base_size,1151depth_or_array_layers: 6,1152},1153TextureDimension::D2,1154&[0; 8],1155TextureFormat::Rgba16Float,1156RenderAssetUsages::all(),1157);11581159// Set up for mipmaps1160specular.texture_descriptor.usage =1161TextureUsages::TEXTURE_BINDING | TextureUsages::STORAGE_BINDING;1162specular.texture_descriptor.mip_level_count = mip_count;11631164// When setting mip_level_count, we need to allocate appropriate data size1165// For GPU-generated mipmaps, we can set data to None since the GPU will generate the data1166specular.data = None;11671168specular.texture_view_descriptor = Some(TextureViewDescriptor {1169dimension: Some(TextureViewDimension::Cube),1170mip_level_count: Some(mip_count),1171..Default::default()1172});11731174let specular_handle = images.add(specular);11751176// Add the EnvironmentMapLight component with the placeholder handles1177commands.entity(entity).insert(EnvironmentMapLight {1178diffuse_map: diffuse_handle,1179specular_map: specular_handle,1180intensity: filtered_env_map.intensity,1181rotation: filtered_env_map.rotation,1182affects_lightmapped_mesh_diffuse: filtered_env_map.affects_lightmapped_mesh_diffuse,1183});1184}1185}118611871188