//! Post-process volume blending and active camera profile resolution. use bevy::prelude::*; use settings::{ resolve_effective_render_stack, resolve_gi_path, write_effective_render_stack, ActiveCameraRenderProfile, ActiveVolumeContribution, EffectiveRenderStack, GiPath, ProjectSettings, RenderingCapabilities, RenderingSettings, VolumeContribution, }; use shared::{ inspector_component_active, InspectorOrder, LevelObject, PostProcessVolumeDesc, PostProcessVolumeOverrides, COMPONENT_POST_PROCESS_VOLUME, }; use super::solari::SolariRaytracingSceneStats; use super::viewport_camera::has_local_shadow_lights; /// Edge blend weight for a point in volume local space. pub fn volume_edge_weight(local: Vec3, half_extents: Vec3, blend_distance: f32) -> f32 { let dist = half_extents - local.abs(); if dist.x < 0.0 || dist.y < 0.0 || dist.z < 0.0 { return 0.0; } if blend_distance <= 0.0 { return 1.0; } let min_dist = dist.x.min(dist.y).min(dist.z); (min_dist / blend_distance).clamp(0.0, 1.0) } /// Applies volume overrides onto project rendering defaults. pub fn resolve_volume_overrides( project: &RenderingSettings, desc: &PostProcessVolumeDesc, ) -> ActiveCameraRenderProfile { let gi_path = GiPath::Forward; let mut profile = ActiveCameraRenderProfile::from_project(project, gi_path); apply_overrides(&mut profile, &desc.overrides); profile.fullscreen_effect = desc.fullscreen_effect.clone(); profile } fn apply_overrides( profile: &mut ActiveCameraRenderProfile, overrides: &PostProcessVolumeOverrides, ) { if let Some(v) = overrides.exposure_ev100 { profile.exposure_ev100 = v; } if let Some(v) = overrides.fog_color { profile.fog_color = [v.r, v.g, v.b]; } if let Some(v) = overrides.fog_density { profile.fog_density = v; } if let Some(v) = overrides.bloom { profile.bloom = v; } if let Some(v) = overrides.taa { profile.taa = v; } if let Some(v) = overrides.ssao { profile.ssao = v; } if let Some(v) = overrides.tonemapping_aces { profile.tonemapping_aces = v; } if let Some(v) = overrides.atmosphere { profile.atmosphere = v; } if let Some(v) = overrides.hdr { profile.hdr = v; } } #[derive(Debug, Clone)] pub struct VolumeSample { entity: Entity, weight: f32, priority: i32, desc: PostProcessVolumeDesc, } fn blend_profiles( project: &RenderingSettings, gi_path: GiPath, samples: &[VolumeSample], ) -> ActiveCameraRenderProfile { let mut profile = ActiveCameraRenderProfile::from_project(project, gi_path); if samples.is_empty() { return profile; } let mut sorted: Vec<&VolumeSample> = samples.iter().filter(|s| s.weight > 0.0).collect(); sorted.sort_by(|a, b| { b.priority .cmp(&a.priority) .then_with(|| a.entity.index().cmp(&b.entity.index())) }); for sample in &sorted { if sample.desc.fullscreen_effect.is_some() { profile.fullscreen_effect = sample.desc.fullscreen_effect.clone(); break; } } for sample in &sorted { if sample.desc.overrides.exposure_ev100.is_some() { profile.exposure_ev100 = resolve_volume_overrides(project, &sample.desc).exposure_ev100; profile.exposure_mode = settings::ExposureMode::Manual; break; } } for sample in &sorted { if sample.desc.overrides.fog_density.is_some() { profile.fog_density = resolve_volume_overrides(project, &sample.desc).fog_density; break; } } for sample in &sorted { if sample.desc.overrides.fog_color.is_some() { profile.fog_color = resolve_volume_overrides(project, &sample.desc).fog_color; break; } } // Edge blend for scalars without explicit overrides: weighted by spatial weight. let mut total_w = 0.0f32; let mut exposure = 0.0f32; let mut fog_density = 0.0f32; let mut fog_color = [0.0f32; 3]; let any_exposure_override = sorted .iter() .any(|s| s.desc.overrides.exposure_ev100.is_some()); let any_fog_density_override = sorted .iter() .any(|s| s.desc.overrides.fog_density.is_some()); let any_fog_color_override = sorted.iter().any(|s| s.desc.overrides.fog_color.is_some()); for sample in &sorted { let resolved = resolve_volume_overrides(project, &sample.desc); let w = sample.weight; if !any_exposure_override { exposure += resolved.exposure_ev100 * w; } if !any_fog_density_override { fog_density += resolved.fog_density * w; } if !any_fog_color_override { fog_color[0] += resolved.fog_color[0] * w; fog_color[1] += resolved.fog_color[1] * w; fog_color[2] += resolved.fog_color[2] * w; } total_w += w; } if total_w > 0.0 { if !any_exposure_override { profile.exposure_ev100 = exposure / total_w; } if !any_fog_density_override { profile.fog_density = fog_density / total_w; } if !any_fog_color_override { profile.fog_color = fog_color.map(|c| c / total_w); } } for sample in &sorted { let o = &sample.desc.overrides; if let Some(v) = o.hdr { profile.hdr = v; break; } } for sample in &sorted { if let Some(v) = sample.desc.overrides.bloom { profile.bloom = v; break; } } for sample in &sorted { if let Some(v) = sample.desc.overrides.taa { profile.taa = v; break; } } for sample in &sorted { if let Some(v) = sample.desc.overrides.ssao { profile.ssao = v; break; } } for sample in &sorted { if let Some(v) = sample.desc.overrides.tonemapping_aces { profile.tonemapping_aces = v; break; } } for sample in &sorted { if let Some(v) = sample.desc.overrides.atmosphere { profile.atmosphere = v; break; } } profile } /// Resolves the active camera render profile from project settings and scene volumes. pub fn resolve_active_camera_render_profile( camera_world: Vec3, project: &RenderingSettings, caps: &RenderingCapabilities, volumes: &[(Entity, &PostProcessVolumeDesc, &GlobalTransform)], ) -> (ActiveCameraRenderProfile, Vec) { let gi_path = resolve_gi_path(project.gi_mode, caps); let mut samples = Vec::new(); for (entity, desc, global) in volumes { let local = global.affine().inverse().transform_point3(camera_world); let weight = volume_edge_weight(local, desc.half_extents, desc.blend_distance); if weight > 0.0 { samples.push(VolumeSample { entity: *entity, weight, priority: desc.priority, desc: (*desc).clone(), }); } } let mut contributions: Vec = samples .iter() .map(|s| VolumeContribution { entity_bits: s.entity.to_bits(), weight: s.weight, label: s .desc .label .clone() .unwrap_or_else(|| format!("Volume {}", s.entity.index())), override_count: s.desc.overrides.override_count(), }) .collect(); contributions.sort_by(|a, b| { b.weight .partial_cmp(&a.weight) .unwrap_or(std::cmp::Ordering::Equal) }); let profile = blend_profiles(project, gi_path, &samples); (profile, contributions) } /// Bevy system: updates [`ActiveCameraRenderProfile`] and [`ActiveVolumeContribution`]. pub fn sync_active_camera_render_profile( mut commands: Commands, settings: Res, volumes: Query< ( Entity, &PostProcessVolumeDesc, &GlobalTransform, Option<&InspectorOrder>, ), With, >, cameras: Query<&GlobalTransform, With>, mut profile: ResMut, mut contributions: ResMut, mut caps: ResMut, solari_stats: Res, points: Query<&PointLight>, spots: Query<&SpotLight>, mut last_request: Local>, mut last_stack: Local>, ) { let gi_path = resolve_gi_path(settings.rendering.gi_mode, &caps); let request_key = (settings.rendering.gi_mode, gi_path, caps.rt_supported); if *last_request != Some(request_key) { info!( "Rendering profile request: gi_mode={:?} requested={:?} rt_supported={}", settings.rendering.gi_mode, gi_path, caps.rt_supported ); *last_request = Some(request_key); } let local_shadow_lights = has_local_shadow_lights(&points, &spots); let Some(camera_tf) = cameras.iter().next() else { let resolved = ActiveCameraRenderProfile::from_project(&settings.rendering, gi_path); *profile = resolved.clone(); contributions.set_entries(Vec::new()); publish_effective_stack( &mut commands, &mut caps, &solari_stats, resolved.gi_path, local_shadow_lights, &mut last_stack, ); return; }; let volume_list: Vec<_> = volumes .iter() .filter(|(_, _, _, order)| { inspector_component_active(*order, COMPONENT_POST_PROCESS_VOLUME) }) .map(|(entity, desc, transform, _)| (entity, desc, transform)) .collect(); let (resolved, entries) = resolve_active_camera_render_profile( camera_tf.translation(), &settings.rendering, &caps, &volume_list, ); *profile = resolved.clone(); publish_effective_stack( &mut commands, &mut caps, &solari_stats, resolved.gi_path, local_shadow_lights, &mut last_stack, ); contributions.set_entries(entries); } fn publish_effective_stack( commands: &mut Commands, caps: &mut RenderingCapabilities, solari_stats: &SolariRaytracingSceneStats, requested_gi_path: GiPath, local_shadow_lights: bool, last_stack: &mut Local>, ) { let stack = resolve_effective_render_stack( requested_gi_path, caps, solari_stats.solari_ready(), local_shadow_lights, ); write_effective_render_stack(caps, stack); commands.insert_resource(stack); if **last_stack != Some(stack) { info!( "Rendering effective stack: requested={:?} effective={:?} fallback={:?} rt_supported={} solari_ready={} local_shadow_lights={} tagged_meshes={} render_instances={} render_directional_lights={} bind_group_ready={} emissive_meshes={}", stack.requested_gi_path, stack.effective_gi_path, stack.fallback_reason, caps.rt_supported, stack.solari_ready, stack.local_shadow_lights, solari_stats.tagged_meshes, solari_stats.render_instances, solari_stats.render_directional_lights, solari_stats.render_bind_group_ready, solari_stats.emissive_meshes, ); **last_stack = Some(stack); } } /// Alias used by [`SolariRenderingPlugin`] and editor ordering. pub use sync_active_camera_render_profile as resolve_active_camera_render_profile_system; /// Returns entities whose post-process volumes contain `world_pos` with weight > 0. pub fn sample_volumes_at( world_pos: Vec3, volumes: &[(Entity, &PostProcessVolumeDesc, &GlobalTransform)], ) -> Vec { volumes .iter() .filter_map(|(entity, desc, global)| { let local = global.affine().inverse().transform_point3(world_pos); let weight = volume_edge_weight(local, desc.half_extents, desc.blend_distance); (weight > 0.0).then_some(*entity) }) .collect() } #[cfg(test)] mod tests { use super::*; use shared::ColorDesc; fn default_project() -> RenderingSettings { RenderingSettings::default() } #[test] fn edge_weight_center_is_one() { let w = volume_edge_weight(Vec3::ZERO, Vec3::ONE, 0.5); assert!((w - 1.0).abs() < 1e-5); } #[test] fn edge_weight_outside_is_zero() { let w = volume_edge_weight(Vec3::new(2.0, 0.0, 0.0), Vec3::ONE, 0.5); assert_eq!(w, 0.0); } #[test] fn inherit_vs_override_exposure() { let project = default_project(); let mut desc = PostProcessVolumeDesc::default(); desc.overrides.exposure_ev100 = Some(8.0); let resolved = resolve_volume_overrides(&project, &desc); assert!((resolved.exposure_ev100 - 8.0).abs() < 1e-5); assert_eq!(resolved.bloom, project.bloom); } #[test] fn priority_wins_at_overlap_center() { let project = default_project(); let low = VolumeSample { entity: Entity::from_raw_u32(1).unwrap(), weight: 1.0, priority: 0, desc: PostProcessVolumeDesc { overrides: PostProcessVolumeOverrides { exposure_ev100: Some(5.0), ..Default::default() }, ..Default::default() }, }; let high = VolumeSample { entity: Entity::from_raw_u32(2).unwrap(), weight: 1.0, priority: 10, desc: PostProcessVolumeDesc { overrides: PostProcessVolumeOverrides { exposure_ev100: Some(15.0), ..Default::default() }, ..Default::default() }, }; let profile = blend_profiles(&project, GiPath::Forward, &[low, high]); assert!((profile.exposure_ev100 - 15.0).abs() < 1e-4); } #[test] fn volume_exposure_override_forces_manual_mode() { let mut project = default_project(); project.exposure_mode = settings::ExposureMode::Auto; let sample = VolumeSample { entity: Entity::from_raw_u32(1).unwrap(), weight: 1.0, priority: 0, desc: PostProcessVolumeDesc { overrides: PostProcessVolumeOverrides { exposure_ev100: Some(9.0), ..Default::default() }, ..Default::default() }, }; let profile = blend_profiles(&project, GiPath::Forward, &[sample]); assert_eq!(profile.exposure_mode, settings::ExposureMode::Manual); assert!((profile.exposure_ev100 - 9.0).abs() < 1e-5); } #[test] fn solari_stays_active_when_rt_is_supported() { let mut project = default_project(); project.gi_mode = settings::GiMode::Solari; let caps = RenderingCapabilities { rt_supported: true, ..Default::default() }; assert_eq!( resolve_gi_path(project.gi_mode, &caps), GiPath::SolariDeferred ); } #[test] fn fog_color_override() { let project = default_project(); let mut desc = PostProcessVolumeDesc::default(); desc.overrides.fog_color = Some(ColorDesc::srgb(0.1, 0.2, 0.3)); let resolved = resolve_volume_overrides(&project, &desc); assert!((resolved.fog_color[0] - 0.1).abs() < 1e-5); } }