Blacksite/crates/settings/src/lib.rs

387 lines
13 KiB
Rust

//! Serializable project settings loaded from `assets/project.ron`.
mod audio;
mod plugin;
mod rendering;
pub use audio::*;
pub use plugin::{
save_project_settings, sync_sim_tuning, ProjectSettingsChanged, ProjectSettingsIo,
ProjectSettingsPlugin, SimTuning,
};
pub use rendering::{
effective_auto_exposure, resolve_effective_render_stack, resolve_gi_path,
write_effective_render_stack, ActiveCameraRenderProfile, ActiveVolumeContribution,
EffectiveRenderStack, ExposureMode, GiMode, GiPath, RenderFallbackReason,
RenderingCapabilities, VolumeContribution,
};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
pub const DEFAULT_PROJECT_PATH: &str = "assets/project.ron";
pub const SETTINGS_VERSION: u32 = 1;
pub const PROJECT_TEMPLATE_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Reflect, PartialEq, Eq)]
#[reflect(Clone)]
pub enum ProjectKind {
#[default]
Game,
Sandbox,
}
/// Marker for cameras that receive the shared project rendering stack.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct ProjectRenderCamera;
/// Root project manifest and tunable sections.
#[derive(Resource, Debug, Clone, Serialize, Deserialize, Reflect)]
#[reflect(Resource, Clone)]
pub struct ProjectSettings {
pub version: u32,
#[serde(default)]
pub project_id: String,
pub name: String,
#[serde(default)]
pub project_kind: ProjectKind,
#[serde(default = "default_project_template_version")]
pub template_version: u32,
#[serde(default)]
pub capabilities: Vec<String>,
pub default_level: String,
pub asset_roots: Vec<String>,
pub rendering: RenderingSettings,
#[serde(default)]
pub audio: AudioSettings,
pub physics: PhysicsSettings,
pub input: InputSettings,
}
impl Default for ProjectSettings {
fn default() -> Self {
Self {
version: SETTINGS_VERSION,
project_id: "blacksite-default".into(),
name: "Bevy FPS Foundation".into(),
project_kind: ProjectKind::Game,
template_version: PROJECT_TEMPLATE_VERSION,
capabilities: vec![
"editor".into(),
"gameplay".into(),
"rendering".into(),
"audio".into(),
],
default_level: "assets/levels/editor_scene.scn.ron".into(),
asset_roots: vec![
"assets/models".into(),
"assets/textures".into(),
"assets/materials".into(),
"assets/levels".into(),
"assets/audio".into(),
],
rendering: RenderingSettings::default(),
audio: AudioSettings::default(),
physics: PhysicsSettings::default(),
input: InputSettings::default(),
}
}
}
const fn default_project_template_version() -> u32 {
PROJECT_TEMPLATE_VERSION
}
/// Camera post-processing and world lighting defaults.
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
#[reflect(Clone)]
pub struct RenderingSettings {
#[serde(default)]
pub gi_mode: GiMode,
pub hdr: bool,
#[serde(default)]
pub exposure_mode: ExposureMode,
pub exposure_ev100: f32,
#[serde(default = "default_auto_exposure_speed_brighten")]
pub auto_exposure_speed_brighten: f32,
#[serde(default = "default_auto_exposure_speed_darken")]
pub auto_exposure_speed_darken: f32,
pub tonemapping_aces: bool,
pub bloom: bool,
pub taa: bool,
pub ssao: bool,
pub atmosphere: bool,
pub fog_color: [f32; 3],
pub fog_density: f32,
pub ambient_color: [f32; 3],
pub ambient_brightness: f32,
pub sun_illuminance: f32,
pub shadow_cascades: u32,
pub shadow_first_cascade: f32,
pub shadow_max_distance: f32,
}
fn default_auto_exposure_speed_brighten() -> f32 {
3.0
}
fn default_auto_exposure_speed_darken() -> f32 {
1.0
}
impl Default for RenderingSettings {
fn default() -> Self {
Self {
gi_mode: GiMode::default(),
hdr: true,
exposure_mode: ExposureMode::default(),
exposure_ev100: 13.0,
auto_exposure_speed_brighten: default_auto_exposure_speed_brighten(),
auto_exposure_speed_darken: default_auto_exposure_speed_darken(),
tonemapping_aces: true,
bloom: true,
taa: true,
ssao: true,
atmosphere: true,
fog_color: [0.66, 0.75, 0.86],
fog_density: 0.0012,
ambient_color: [0.6, 0.7, 0.85],
ambient_brightness: 60.0,
sun_illuminance: 100_000.0,
shadow_cascades: 4,
shadow_first_cascade: 6.0,
shadow_max_distance: 220.0,
}
}
}
/// Gameplay movement and capsule constants (mirrors former `sim::tuning`).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Reflect)]
#[reflect(Clone)]
pub struct PhysicsSettings {
pub player_radius: f32,
pub player_length: f32,
pub eye_offset_stand: f32,
pub eye_offset_crouch: f32,
pub gravity: f32,
pub walk_speed: f32,
pub run_speed: f32,
pub crouch_speed: f32,
pub jump_speed: f32,
pub ground_accel: f32,
pub air_accel: f32,
pub ground_check_dist: f32,
pub skin: f32,
pub max_slide_iter: u32,
pub ground_normal_y: f32,
pub coyote_time: f32,
pub jump_buffer: f32,
}
impl Default for PhysicsSettings {
fn default() -> Self {
Self {
player_radius: 0.4,
player_length: 1.0,
eye_offset_stand: 0.6,
eye_offset_crouch: 0.1,
gravity: 22.0,
walk_speed: 6.0,
run_speed: 9.5,
crouch_speed: 3.0,
jump_speed: 7.5,
ground_accel: 70.0,
air_accel: 16.0,
ground_check_dist: 0.2,
skin: 0.02,
max_slide_iter: 4,
ground_normal_y: 0.5,
coyote_time: 0.12,
jump_buffer: 0.12,
}
}
}
/// Input sensitivities and editor fly camera speeds.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Reflect)]
#[reflect(Clone)]
pub struct InputSettings {
pub yaw_sensitivity: f32,
pub pitch_sensitivity: f32,
pub editor_fly_speed: f32,
pub editor_look_sensitivity: f32,
pub editor_pan_speed: f32,
}
impl Default for InputSettings {
fn default() -> Self {
Self {
yaw_sensitivity: 0.003,
pitch_sensitivity: 0.0022,
editor_fly_speed: 14.0,
editor_look_sensitivity: 0.003,
editor_pan_speed: 0.015,
}
}
}
/// Loads settings from disk, falling back to defaults.
pub fn load_project_settings_from_path(path: &str) -> ProjectSettings {
load_project_settings_with_source(path).0
}
/// Loads settings plus the exact source text used when the file was readable.
pub fn load_project_settings_with_source(path: &str) -> (ProjectSettings, Option<String>) {
match std::fs::read_to_string(path) {
Ok(contents) => match ron::from_str(&contents) {
Ok(settings) => (settings, Some(contents)),
Err(error) => {
warn!("Failed to parse {path}: {error}; using defaults");
(ProjectSettings::default(), Some(contents))
}
},
Err(error) => {
warn!("Could not read {path}: {error}; using defaults");
(ProjectSettings::default(), None)
}
}
}
/// Serializes settings to RON.
pub fn save_project_settings_to_string(settings: &ProjectSettings) -> Result<String, ron::Error> {
ron::ser::to_string_pretty(settings, ron::ser::PrettyConfig::default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_round_trip() {
let settings = ProjectSettings::default();
let text = save_project_settings_to_string(&settings).expect("serialize");
let parsed: ProjectSettings = ron::from_str(&text).expect("deserialize");
assert_eq!(parsed.name, settings.name);
assert_eq!(parsed.project_id, settings.project_id);
assert_eq!(parsed.project_kind, settings.project_kind);
assert_eq!(parsed.template_version, settings.template_version);
assert_eq!(parsed.capabilities, settings.capabilities);
assert_eq!(parsed.audio, settings.audio);
assert_eq!(parsed.physics.walk_speed, settings.physics.walk_speed);
assert_eq!(parsed.rendering.gi_mode, settings.rendering.gi_mode);
}
#[test]
fn settings_load_returns_the_exact_source_for_revision_guards() {
let root = std::env::temp_dir().join(format!(
"blacksite-settings-source-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = root.join("project.ron");
std::fs::create_dir_all(&root).unwrap();
let source = save_project_settings_to_string(&ProjectSettings::default()).unwrap();
std::fs::write(&path, &source).unwrap();
let (settings, loaded_source) = load_project_settings_with_source(path.to_str().unwrap());
assert_eq!(settings.name, ProjectSettings::default().name);
assert_eq!(loaded_source.as_deref(), Some(source.as_str()));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn legacy_manifest_defaults_project_identity_fields() {
let legacy = r#"(
version: 1,
name: "Legacy",
default_level: "assets/levels/main.scn.ron",
asset_roots: [],
rendering: (
gi_mode: Auto, hdr: true, exposure_mode: Manual, exposure_ev100: 13.0,
auto_exposure_speed_brighten: 3.0, auto_exposure_speed_darken: 1.0,
tonemapping_aces: true, bloom: true, taa: true, ssao: true,
atmosphere: true, fog_color: (0.0, 0.0, 0.0), fog_density: 0.0,
ambient_color: (0.0, 0.0, 0.0), ambient_brightness: 0.0,
sun_illuminance: 100000.0, shadow_cascades: 4,
shadow_first_cascade: 6.0, shadow_max_distance: 220.0,
),
physics: (
player_radius: 0.4, player_length: 1.0, eye_offset_stand: 0.6,
eye_offset_crouch: 0.1, gravity: 22.0, walk_speed: 6.0,
run_speed: 9.5, crouch_speed: 3.0, jump_speed: 7.5,
ground_accel: 70.0, air_accel: 16.0, ground_check_dist: 0.2,
skin: 0.02, max_slide_iter: 4, ground_normal_y: 0.5,
coyote_time: 0.12, jump_buffer: 0.12,
),
input: (
yaw_sensitivity: 0.003, pitch_sensitivity: 0.0022,
editor_fly_speed: 14.0, editor_look_sensitivity: 0.003,
editor_pan_speed: 0.015,
),
)"#;
let parsed: ProjectSettings = ron::from_str(legacy).unwrap();
assert!(parsed.project_id.is_empty());
assert_eq!(parsed.project_kind, ProjectKind::Game);
assert_eq!(parsed.template_version, PROJECT_TEMPLATE_VERSION);
assert!(parsed.capabilities.is_empty());
assert_eq!(parsed.audio, AudioSettings::default());
assert!(parsed.audio.validate().is_ok());
}
#[test]
fn project_defaults_include_audio_capability_and_asset_root() {
let settings = ProjectSettings::default();
assert!(settings.capabilities.iter().any(|item| item == "audio"));
assert!(settings
.asset_roots
.iter()
.any(|item| item == "assets/audio"));
assert!(settings.audio.validate().is_ok());
}
#[test]
fn gi_mode_defaults_auto() {
assert_eq!(RenderingSettings::default().gi_mode, GiMode::Auto);
}
#[test]
fn exposure_mode_auto_round_trip() {
let mut settings = ProjectSettings::default();
settings.rendering.exposure_mode = ExposureMode::Auto;
settings.rendering.auto_exposure_speed_brighten = 4.0;
settings.rendering.auto_exposure_speed_darken = 0.5;
let text = save_project_settings_to_string(&settings).expect("serialize");
let parsed: ProjectSettings = ron::from_str(&text).expect("deserialize");
assert_eq!(parsed.rendering.exposure_mode, ExposureMode::Auto);
assert_eq!(parsed.rendering.auto_exposure_speed_brighten, 4.0);
assert_eq!(parsed.rendering.auto_exposure_speed_darken, 0.5);
}
#[test]
fn effective_auto_exposure_requires_hdr_and_compute() {
let mut profile = ActiveCameraRenderProfile {
exposure_mode: ExposureMode::Auto,
hdr: true,
..Default::default()
};
let caps = RenderingCapabilities {
auto_exposure_supported: true,
..Default::default()
};
assert!(effective_auto_exposure(&profile, &caps));
profile.hdr = false;
assert!(!effective_auto_exposure(&profile, &caps));
profile.hdr = true;
let caps = RenderingCapabilities::default();
assert!(!effective_auto_exposure(&profile, &caps));
}
}