//! Assembles the high-fidelity rendering stack on project cameras. use bevy::anti_alias::taa::TemporalAntiAliasing; use bevy::camera::Exposure; use bevy::core_pipeline::tonemapping::Tonemapping; use bevy::pbr::{Atmosphere, AtmosphereSettings, ScatteringMedium, ScreenSpaceAmbientOcclusion}; use bevy::post_process::auto_exposure::AutoExposure; use bevy::post_process::bloom::Bloom; use bevy::prelude::*; use bevy::render::view::Hdr; use settings::{ effective_auto_exposure, ActiveCameraRenderProfile, GiPath, ProjectRenderCamera, ProjectSettings, RenderingCapabilities, RenderingSettings, }; use super::auto_exposure::auto_exposure_from_profile; use sim::PlayerCamera; use super::fullscreen_effects::{strip_fullscreen_effects, sync_fullscreen_effect}; use super::solari::{strip_gi_path, sync_gi_path, SolariRaytracingSceneStats}; use super::viewport_camera::effective_viewport_gi_path; /// Environment variable name for HDR override (matches game launch). pub const HDR_ENV_VAR: &str = "BEVY_FPS_HDR"; fn hdr_enabled_from_env() -> bool { std::env::var(HDR_ENV_VAR) .map(|value| { !matches!( value.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no" ) }) .unwrap_or(true) } /// True when the camera should run auto exposure this frame (mode, HDR pass, compute). pub fn camera_auto_exposure_active( profile: &ActiveCameraRenderProfile, caps: &RenderingCapabilities, ) -> bool { effective_auto_exposure(profile, caps) && hdr_enabled_profile(profile) } /// Whether the HDR render pass is active for this profile (respects `BEVY_FPS_HDR`). pub fn hdr_enabled_profile(profile: &ActiveCameraRenderProfile) -> bool { if std::env::var(HDR_ENV_VAR).is_ok() { hdr_enabled_from_env() } else { profile.hdr } } /// True when the camera render target must be HDR for either authored HDR or Solari storage writes. pub fn hdr_required_for_effective_gi( profile: &ActiveCameraRenderProfile, effective_gi: GiPath, ) -> bool { hdr_required_for_render_stack(hdr_enabled_profile(profile), effective_gi) } pub fn hdr_required_for_render_stack(hdr_enabled: bool, effective_gi: GiPath) -> bool { hdr_enabled || effective_gi == GiPath::SolariDeferred } /// Applies the shared project rendering profile to all [`ProjectRenderCamera`] entities. #[unsafe(no_mangle)] pub fn setup_project_camera_effects( mut commands: Commands, mut mediums: ResMut>, settings: Res, profile: Option>, caps: Option>, solari_stats: Option>, cameras: Query>, points: Query<&PointLight>, spots: Query<&SpotLight>, ) { let default_caps = RenderingCapabilities::default(); let gi_caps = caps.as_deref().unwrap_or(&default_caps); let active = profile.map(|p| p.clone()).unwrap_or_else(|| { settings::ActiveCameraRenderProfile::from_project( &settings.rendering, settings::resolve_gi_path(settings.rendering.gi_mode, gi_caps), ) }); let has_local_shadow_lights = super::viewport_camera::has_local_shadow_lights(&points, &spots); for entity in &cameras { apply_camera_render_profile( &mut commands, entity, &active, gi_caps, solari_stats.as_deref(), &mut mediums, has_local_shadow_lights, ); } } /// Inserts or removes manual [`Exposure`] vs [`AutoExposure`] for the active profile. pub fn sync_exposure( commands: &mut Commands, entity: Entity, profile: &ActiveCameraRenderProfile, caps: &RenderingCapabilities, ) { if camera_auto_exposure_active(profile, caps) { commands .entity(entity) .remove::() .insert(auto_exposure_from_profile(profile)); } else { commands .entity(entity) .remove::() .insert(Exposure { ev100: profile.exposure_ev100, }); } } /// Inserts post-processing on a camera entity from a resolved render profile. pub fn apply_camera_render_profile( commands: &mut Commands, entity: Entity, profile: &ActiveCameraRenderProfile, caps: &RenderingCapabilities, solari_stats: Option<&SolariRaytracingSceneStats>, mediums: &mut Assets, has_local_shadow_lights: bool, ) { commands.entity(entity).insert(Msaa::Off); let effective_gi = effective_viewport_gi_path(profile.gi_path, caps, solari_stats, has_local_shadow_lights); sync_exposure(commands, entity, profile, caps); commands.entity(entity).insert(DistanceFog { color: Color::srgb( profile.fog_color[0], profile.fog_color[1], profile.fog_color[2], ), falloff: FogFalloff::Exponential { density: profile.fog_density, }, ..default() }); if profile.atmosphere { let medium = mediums.add(ScatteringMedium::default()); commands .entity(entity) .insert(Atmosphere::earthlike(medium)); commands .entity(entity) .insert(AtmosphereSettings::default()); } if profile.tonemapping_aces { commands.entity(entity).insert(Tonemapping::AcesFitted); } if profile.bloom { commands.entity(entity).insert(Bloom::NATURAL); } if profile.ssao { commands .entity(entity) .insert(ScreenSpaceAmbientOcclusion::default()); } if profile.taa { commands .entity(entity) .insert(TemporalAntiAliasing::default()); } if hdr_required_for_effective_gi(profile, effective_gi) { commands.entity(entity).insert(Hdr); } sync_gi_path(commands, entity, effective_gi); sync_fullscreen_effect(commands, entity, profile.fullscreen_effect.as_deref()); } /// Legacy entry: applies project settings directly (no volume blend). pub fn apply_project_camera_fx( commands: &mut Commands, entity: Entity, rendering: &RenderingSettings, mediums: &mut Assets, ) { let profile = ActiveCameraRenderProfile::from_project(rendering, settings::GiPath::Forward); apply_camera_render_profile( commands, entity, &profile, &RenderingCapabilities::default(), None, mediums, false, ); } /// Removes the high-cost post-processing stack from a camera entity. pub fn strip_project_camera_fx(commands: &mut Commands, entity: Entity) { strip_fullscreen_effects(commands, entity); strip_gi_path(commands, entity); commands .entity(entity) .remove::() .remove::() .remove::() .remove::() .remove::() .remove::() .remove::() .remove::() .remove::() .remove::() .remove::() .remove::(); } /// Marks the player camera for the shared rendering stack. #[unsafe(no_mangle)] pub fn tag_player_camera( mut commands: Commands, cameras: Query, Without)>, ) { for entity in &cameras { commands.entity(entity).insert(ProjectRenderCamera); } } /// Toggles optional post-processing components without stripping the whole stack. #[allow(clippy::too_many_arguments)] pub fn sync_optional_rendering_fx( commands: &mut Commands, entity: Entity, profile: &ActiveCameraRenderProfile, caps: &RenderingCapabilities, solari_stats: Option<&SolariRaytracingSceneStats>, mediums: &mut Assets, has_atmosphere: bool, has_tonemapping: bool, has_bloom: bool, has_ssao: bool, has_taa: bool, has_hdr: bool, has_auto_exposure: bool, has_local_shadow_lights: bool, ) { let effective_gi = effective_viewport_gi_path(profile.gi_path, caps, solari_stats, has_local_shadow_lights); if profile.atmosphere && !has_atmosphere { let medium = mediums.add(ScatteringMedium::default()); commands .entity(entity) .insert(Atmosphere::earthlike(medium)); commands .entity(entity) .insert(AtmosphereSettings::default()); } else if !profile.atmosphere && has_atmosphere { commands .entity(entity) .remove::() .remove::(); } if profile.tonemapping_aces && !has_tonemapping { commands.entity(entity).insert(Tonemapping::AcesFitted); } else if !profile.tonemapping_aces && has_tonemapping { commands.entity(entity).remove::(); } if profile.bloom && !has_bloom { commands.entity(entity).insert(Bloom::NATURAL); } else if !profile.bloom && has_bloom { commands.entity(entity).remove::(); } if profile.ssao && !has_ssao { commands .entity(entity) .insert(ScreenSpaceAmbientOcclusion::default()); } else if !profile.ssao && has_ssao { commands .entity(entity) .remove::(); } if profile.taa && !has_taa { commands .entity(entity) .insert(TemporalAntiAliasing::default()); } else if !profile.taa && has_taa { commands.entity(entity).remove::(); } let wants_hdr = hdr_required_for_effective_gi(profile, effective_gi); if wants_hdr && !has_hdr { commands.entity(entity).insert(Hdr); } else if !wants_hdr && has_hdr { commands.entity(entity).remove::(); } let wants_auto = camera_auto_exposure_active(profile, caps); if wants_auto != has_auto_exposure { sync_exposure(commands, entity, profile, caps); } else if wants_auto { commands .entity(entity) .insert(auto_exposure_from_profile(profile)); } else { commands.entity(entity).insert(Exposure { ev100: profile.exposure_ev100, }); } sync_gi_path(commands, entity, effective_gi); sync_fullscreen_effect(commands, entity, profile.fullscreen_effect.as_deref()); } /// Patches exposure/fog/HDR when the active profile changes at runtime. pub fn patch_camera_effects( profile: &ActiveCameraRenderProfile, caps: &RenderingCapabilities, solari_stats: Option<&SolariRaytracingSceneStats>, has_local_shadow_lights: bool, commands: &mut Commands, cameras: impl IntoIterator, ) { for entity in cameras { let effective_gi = effective_viewport_gi_path( profile.gi_path, caps, solari_stats, has_local_shadow_lights, ); sync_exposure(commands, entity, profile, caps); commands.entity(entity).insert(DistanceFog { color: Color::srgb( profile.fog_color[0], profile.fog_color[1], profile.fog_color[2], ), falloff: FogFalloff::Exponential { density: profile.fog_density, }, ..default() }); if hdr_required_for_effective_gi(profile, effective_gi) { commands.entity(entity).insert(Hdr); } else { commands.entity(entity).remove::(); } sync_gi_path(commands, entity, effective_gi); sync_fullscreen_effect(commands, entity, profile.fullscreen_effect.as_deref()); } } #[cfg(test)] mod tests { use super::*; #[test] fn solari_forces_hdr_render_target() { assert!(hdr_required_for_render_stack(false, GiPath::SolariDeferred)); assert!(hdr_required_for_render_stack(true, GiPath::Forward)); assert!(!hdr_required_for_render_stack(false, GiPath::Forward)); } } /// Re-applies player camera FX tags after a hot reload. pub fn refresh_player_camera_fx(world: &mut World) { let Some(settings) = world.get_resource::().cloned() else { return; }; let profile = world .get_resource::() .cloned() .unwrap_or_else(|| { ActiveCameraRenderProfile::from_project(&settings.rendering, settings::GiPath::Forward) }); let solari_stats = world.get_resource::().copied(); let caps = world .get_resource::() .cloned() .unwrap_or_default(); let has_local_shadow_lights = super::viewport_camera::world_has_local_shadow_lights(world); let cameras: Vec = world .query_filtered::>() .iter(world) .collect(); for entity in &cameras { if world.get::(*entity).is_none() { world.commands().entity(*entity).insert(ProjectRenderCamera); } } world.resource_scope(|world, mut mediums: Mut>| { for entity in cameras { apply_camera_render_profile( &mut world.commands(), entity, &profile, &caps, solari_stats.as_ref(), &mut mediums, has_local_shadow_lights, ); } }); }