Blacksite/crates/game_hot/src/rendering/camera_fx.rs
Rbanh f29712d158
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Initial project import
2026-06-05 21:44:45 -04:00

414 lines
13 KiB
Rust

//! 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<Assets<ScatteringMedium>>,
settings: Res<ProjectSettings>,
profile: Option<Res<ActiveCameraRenderProfile>>,
caps: Option<Res<RenderingCapabilities>>,
solari_stats: Option<Res<SolariRaytracingSceneStats>>,
cameras: Query<Entity, With<ProjectRenderCamera>>,
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::<Exposure>()
.insert(auto_exposure_from_profile(profile));
} else {
commands
.entity(entity)
.remove::<AutoExposure>()
.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<ScatteringMedium>,
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<ScatteringMedium>,
) {
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::<ProjectRenderCamera>()
.remove::<Atmosphere>()
.remove::<AtmosphereSettings>()
.remove::<TemporalAntiAliasing>()
.remove::<ScreenSpaceAmbientOcclusion>()
.remove::<Bloom>()
.remove::<Hdr>()
.remove::<Tonemapping>()
.remove::<Exposure>()
.remove::<AutoExposure>()
.remove::<DistanceFog>()
.remove::<Msaa>();
}
/// Marks the player camera for the shared rendering stack.
#[unsafe(no_mangle)]
pub fn tag_player_camera(
mut commands: Commands,
cameras: Query<Entity, (With<PlayerCamera>, Without<ProjectRenderCamera>)>,
) {
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<ScatteringMedium>,
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::<Atmosphere>()
.remove::<AtmosphereSettings>();
}
if profile.tonemapping_aces && !has_tonemapping {
commands.entity(entity).insert(Tonemapping::AcesFitted);
} else if !profile.tonemapping_aces && has_tonemapping {
commands.entity(entity).remove::<Tonemapping>();
}
if profile.bloom && !has_bloom {
commands.entity(entity).insert(Bloom::NATURAL);
} else if !profile.bloom && has_bloom {
commands.entity(entity).remove::<Bloom>();
}
if profile.ssao && !has_ssao {
commands
.entity(entity)
.insert(ScreenSpaceAmbientOcclusion::default());
} else if !profile.ssao && has_ssao {
commands
.entity(entity)
.remove::<ScreenSpaceAmbientOcclusion>();
}
if profile.taa && !has_taa {
commands
.entity(entity)
.insert(TemporalAntiAliasing::default());
} else if !profile.taa && has_taa {
commands.entity(entity).remove::<TemporalAntiAliasing>();
}
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::<Hdr>();
}
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<Item = Entity>,
) {
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::<Hdr>();
}
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::<ProjectSettings>().cloned() else {
return;
};
let profile = world
.get_resource::<ActiveCameraRenderProfile>()
.cloned()
.unwrap_or_else(|| {
ActiveCameraRenderProfile::from_project(&settings.rendering, settings::GiPath::Forward)
});
let solari_stats = world.get_resource::<SolariRaytracingSceneStats>().copied();
let caps = world
.get_resource::<RenderingCapabilities>()
.cloned()
.unwrap_or_default();
let has_local_shadow_lights = super::viewport_camera::world_has_local_shadow_lights(world);
let cameras: Vec<Entity> = world
.query_filtered::<Entity, With<PlayerCamera>>()
.iter(world)
.collect();
for entity in &cameras {
if world.get::<ProjectRenderCamera>(*entity).is_none() {
world.commands().entity(*entity).insert(ProjectRenderCamera);
}
}
world.resource_scope(|world, mut mediums: Mut<Assets<ScatteringMedium>>| {
for entity in cameras {
apply_camera_render_profile(
&mut world.commands(),
entity,
&profile,
&caps,
solari_stats.as_ref(),
&mut mediums,
has_local_shadow_lights,
);
}
});
}