use crate::components::{DirectionalLight, GraphicsConfig, PostProcessConfig, VolumetricFog};
use crate::ecs::{ActiveRenderQueues, World};
use crate::gfx::render_config as resolve;
use crate::gfx::settings_system::{SettingsSlot, SettingsState};
use crate::gfx::volumetric_fog::FogSettings;
use concinnity_core::render::ops::RenderOps;
pub const LIVE_GRAPHICS_CONFIG_FIELDS: &[&str] =
&["shadow_distance", "shadow_cascades", "shadow_update"];
pub const LIVE_POST_PROCESS_FIELDS: &[&str] = &[
"ambient_intensity",
"exposure_ev",
"bloom_intensity",
"bloom_threshold",
"bloom_knee",
"vignette_strength",
"lut_strength",
"ssao_radius",
"ssao_intensity",
"ssr_intensity",
"ssr_max_distance",
"ssgi_intensity",
"ssgi_max_distance",
"auto_exposure_min_ev",
"auto_exposure_max_ev",
"auto_exposure_speed",
];
pub fn is_available(world: &World) -> bool {
world
.resource::<ActiveRenderQueues>()
.is_some_and(|slot| slot.0.is_some())
&& world
.resource::<SettingsSlot>()
.is_some_and(|slot| slot.0.is_some())
}
pub fn fog_pass_built(world: &World) -> bool {
world
.resource::<SettingsSlot>()
.and_then(|slot| slot.0.as_ref())
.is_some_and(|state| state.fog_built)
}
pub fn apply_directional_lights(world: &mut World, lights: &[DirectionalLight]) -> bool {
let lights = lights.to_vec();
with_live(world, |_, ops| {
ops.record(move |backend| backend.update_directional_lights(&lights));
})
.is_some()
}
pub fn apply_fog(world: &mut World, fog: Option<&VolumetricFog>) -> bool {
let settings = fog.filter(|f| f.enabled).map(|f| {
FogSettings::resolve(
f.color,
f.density,
f.height_falloff,
f.height_reference,
f.max_distance,
f.phase_g,
f.ambient,
)
});
if settings.is_some() && !fog_pass_built(world) {
return false;
}
with_live(world, |_, ops| {
ops.record(move |backend| backend.update_fog_settings(settings));
})
.is_some()
}
pub fn apply_graphics_config(world: &mut World, config: &GraphicsConfig) -> bool {
with_live(world, |state, ops| {
let ceiling = state.ceiling();
let user = state.persisted_graphics().clone();
state.authored_shadow_map_size = config.shadow_map_size;
state.authored_anisotropy = config.anisotropy;
state.shadow_map_size = resolve::shadow_map_size(config.shadow_map_size, &user, &ceiling);
state.anisotropy = resolve::anisotropy(config.anisotropy, &user, &ceiling);
state.authored_shadow_update = config.shadow_update;
let update = resolve::shadow_update(config.shadow_update, &user, &ceiling);
if update != state.shadow_update {
state.shadow_update = update;
ops.record(move |backend| backend.set_shadow_update(update));
}
state.authored_shadow_distance = config.shadow_distance;
let distance = resolve::shadow_distance(config.shadow_distance, &user, &ceiling);
if distance != state.shadow_distance {
state.shadow_distance = distance;
ops.record(move |backend| backend.set_shadow_distance(distance));
}
state.authored_shadow_cascades = config.shadow_cascades;
let cascades = resolve::shadow_cascades(config.shadow_cascades, &user, &ceiling);
if cascades != state.shadow_cascades {
state.shadow_cascades = cascades;
ops.record(move |backend| backend.set_shadow_cascades(cascades));
}
})
.is_some()
}
pub fn apply_post_process_config(world: &mut World, config: &PostProcessConfig) -> bool {
with_live(world, |state, ops| {
let user = state.persisted_graphics().clone();
copy_live_scalars(&mut state.authored_post_config, config);
copy_live_scalars(&mut state.post_config, config);
resolve::overlay_quality_scalars(&mut state.post_config, &user);
let mut params = resolve::post_process_params(Some(config), &user);
params.fxaa = state.post_config.aa_mode.fxaa_flag();
state.post_process = params;
ops.record(move |backend| backend.update_post_process(params));
let quality = crate::gfx::graphics_system::derive_quality_settings(&state.post_config);
ops.record(move |backend| backend.update_quality_params(quality));
let ambient = resolve::ambient_intensity(Some(config), &user);
if ambient != state.ambient_intensity {
state.ambient_intensity = ambient;
ops.record(move |backend| backend.set_ambient_intensity(ambient));
}
})
.is_some()
}
fn copy_live_scalars(dst: &mut PostProcessConfig, src: &PostProcessConfig) {
dst.ambient_intensity = src.ambient_intensity;
dst.exposure_ev = src.exposure_ev;
dst.bloom_intensity = src.bloom_intensity;
dst.bloom_threshold = src.bloom_threshold;
dst.bloom_knee = src.bloom_knee;
dst.vignette_strength = src.vignette_strength;
dst.lut_strength = src.lut_strength;
dst.ssao_radius = src.ssao_radius;
dst.ssao_intensity = src.ssao_intensity;
dst.ssr_intensity = src.ssr_intensity;
dst.ssr_max_distance = src.ssr_max_distance;
dst.ssgi_intensity = src.ssgi_intensity;
dst.ssgi_max_distance = src.ssgi_max_distance;
dst.auto_exposure_min_ev = src.auto_exposure_min_ev;
dst.auto_exposure_max_ev = src.auto_exposure_max_ev;
dst.auto_exposure_speed = src.auto_exposure_speed;
}
fn with_live<R>(
world: &mut World,
f: impl FnOnce(&mut SettingsState, &mut RenderOps) -> R,
) -> Option<R> {
if !is_available(world) {
return None;
}
let mut queues = world.resource_mut::<ActiveRenderQueues>()?.0.take()?;
let mut state = world.resource_mut::<SettingsSlot>()?.0.take()?;
let out = f(&mut state, &mut queues.ops);
if let Some(slot) = world.resource_mut::<SettingsSlot>() {
slot.0 = Some(state);
}
if let Some(slot) = world.resource_mut::<ActiveRenderQueues>() {
slot.0 = Some(queues);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::RenderQueues;
use crate::gfx::mock_backend::{Call, MockBackend, MockState, recording_backend};
use std::sync::{Arc, Mutex};
struct Fixture {
world: World,
backend: MockBackend,
calls: Arc<Mutex<MockState>>,
}
impl Fixture {
fn new() -> Self {
Self::with_state(SettingsState::for_tests())
}
fn with_state(state: SettingsState) -> Self {
let mut world = World::new();
world.insert_resource(ActiveRenderQueues(Some(RenderQueues {
ops: RenderOps::default(),
slots: crate::gfx::render_slots::RenderSlots::new(0, true, &[]),
})));
world.insert_resource(SettingsSlot(Some(state)));
let (calls, backend) = recording_backend();
Self {
world,
backend,
calls,
}
}
fn replay(&mut self) -> Vec<Call> {
let mut queues = self
.world
.resource_mut::<ActiveRenderQueues>()
.and_then(|slot| slot.0.take())
.expect("the queue is parked again");
queues.ops.replay(&mut self.backend);
if let Some(slot) = self.world.resource_mut::<ActiveRenderQueues>() {
slot.0 = Some(queues);
}
self.calls.lock().unwrap().calls.clone()
}
fn state(&mut self) -> &mut SettingsState {
self.world
.resource_mut::<SettingsSlot>()
.and_then(|slot| slot.0.as_mut())
.expect("parked")
}
}
fn graphics_config() -> GraphicsConfig {
GraphicsConfig {
shadow_distance: 120,
shadow_cascades: 2,
..Default::default()
}
}
#[test]
fn a_world_without_a_renderer_takes_nothing() {
let mut world = World::new();
assert!(!is_available(&world));
assert!(!apply_directional_lights(&mut world, &[]));
assert!(!apply_graphics_config(
&mut world,
&GraphicsConfig::default()
));
assert!(!apply_post_process_config(
&mut world,
&PostProcessConfig::default()
));
}
#[test]
fn a_sun_edit_reaches_the_backend() {
let mut f = Fixture::new();
let sun = DirectionalLight {
direction: [0.0, 1.0, 0.0],
color: [1.0, 0.5, 0.25],
intensity: 3.0,
};
assert!(apply_directional_lights(
&mut f.world,
std::slice::from_ref(&sun)
));
assert_eq!(
f.replay(),
vec![Call::UpdateDirectionalLights(vec![(
sun.direction,
sun.color,
sun.intensity
)])]
);
}
#[test]
fn shadow_knobs_push_and_move_their_baselines() {
let mut f = Fixture::new();
assert!(apply_graphics_config(&mut f.world, &graphics_config()));
let calls = f.replay();
assert!(calls.contains(&Call::SetShadowDistance(120)));
assert!(calls.contains(&Call::SetShadowCascades(2)));
let state = f.state();
assert_eq!(state.shadow_distance, 120);
assert_eq!(state.authored_shadow_distance, 120);
assert_eq!(state.shadow_cascades, 2);
assert_eq!(state.authored_shadow_cascades, 2);
}
#[test]
fn an_unchanged_knob_records_no_call() {
let mut f = Fixture::new();
apply_graphics_config(&mut f.world, &graphics_config());
f.replay();
f.calls.lock().unwrap().calls.clear();
apply_graphics_config(&mut f.world, &graphics_config());
assert!(f.replay().is_empty());
}
#[test]
fn a_persisted_override_outranks_the_edited_world() {
let mut state = SettingsState::for_tests();
state.persisted_graphics.shadow_distance = Some(500);
state.shadow_distance = 500;
let mut f = Fixture::with_state(state);
assert!(apply_graphics_config(&mut f.world, &graphics_config()));
let calls = f.replay();
assert!(
!calls
.iter()
.any(|c| matches!(c, Call::SetShadowDistance(_))),
"the overridden row does not move"
);
assert!(calls.contains(&Call::SetShadowCascades(2)), "the rest do");
let state = f.state();
assert_eq!(state.shadow_distance, 500, "the user's value stands");
assert_eq!(
state.authored_shadow_distance, 120,
"the world's value is still recorded as the baseline"
);
}
#[test]
fn the_quality_ceiling_clamps_an_edited_knob() {
let mut state = SettingsState::for_tests();
state.quality_preset = crate::gfx::quality_preset::QualityPreset::Low;
let mut f = Fixture::with_state(state);
let ceiling = f.state().ceiling();
let mut config = graphics_config();
config.shadow_distance = ceiling.shadow_distance + 1_000;
assert!(apply_graphics_config(&mut f.world, &config));
f.replay();
assert_eq!(
f.state().shadow_distance,
ceiling.shadow_distance,
"the ceiling holds"
);
}
#[test]
fn a_post_process_edit_pushes_every_live_knob() {
let mut f = Fixture::new();
let config = PostProcessConfig {
ambient_intensity: 0.25,
exposure_ev: 2.0,
ssgi_intensity: 0.75,
..Default::default()
};
assert!(apply_post_process_config(&mut f.world, &config));
let calls = f.replay();
assert!(calls.contains(&Call::UpdatePostProcess));
assert!(calls.contains(&Call::UpdateQualityParams));
assert!(calls.contains(&Call::SetAmbientIntensity(0.25)));
let state = f.state();
assert_eq!(state.ambient_intensity, 0.25);
assert_eq!(state.post_config.ssgi_intensity, 0.75);
assert_eq!(state.authored_post_config.ssgi_intensity, 0.75);
}
#[test]
fn a_post_process_edit_leaves_the_toggles_alone() {
let mut f = Fixture::new();
let config = PostProcessConfig {
ssao: !PostProcessConfig::default().ssao,
aa_mode: crate::components::AaMode::Fxaa,
..Default::default()
};
apply_post_process_config(&mut f.world, &config);
let state = f.state();
assert_eq!(state.post_config.ssao, PostProcessConfig::default().ssao);
assert_eq!(
state.post_config.aa_mode,
PostProcessConfig::default().aa_mode
);
}
#[test]
fn fog_pushes_resolved_settings_and_disables_with_none() {
let mut f = Fixture::new();
let fog = VolumetricFog {
enabled: true,
density: 0.05,
..Default::default()
};
assert!(apply_fog(&mut f.world, Some(&fog)));
assert!(matches!(
f.replay().as_slice(),
[Call::UpdateFogSettings(Some(_))]
));
f.calls.lock().unwrap().calls.clear();
let off = VolumetricFog {
enabled: false,
..fog
};
assert!(apply_fog(&mut f.world, Some(&off)));
assert_eq!(f.replay(), vec![Call::UpdateFogSettings(None)]);
}
#[test]
fn fog_cannot_be_turned_on_where_the_pass_was_never_built() {
let mut state = SettingsState::for_tests();
state.fog_built = false;
let mut f = Fixture::with_state(state);
assert!(!fog_pass_built(&f.world));
let fog = VolumetricFog {
enabled: true,
..Default::default()
};
assert!(!apply_fog(&mut f.world, Some(&fog)));
assert!(f.replay().is_empty());
assert!(
apply_fog(&mut f.world, None),
"turning fog off never needs the pass"
);
}
#[test]
fn the_live_field_lists_name_real_args() {
let graphics = serde_json::to_value(GraphicsConfig::default()).expect("serialises");
for field in LIVE_GRAPHICS_CONFIG_FIELDS {
assert!(graphics.get(field).is_some(), "GraphicsConfig.{field}");
}
let post = serde_json::to_value(PostProcessConfig::default()).expect("serialises");
for field in LIVE_POST_PROCESS_FIELDS {
assert!(post.get(field).is_some(), "PostProcessConfig.{field}");
}
}
}