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