414 lines
13 KiB
Rust
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,
|
|
);
|
|
}
|
|
});
|
|
}
|