From 4cbca1f48eac09aabf48f4bcf597cbd1b9729369 Mon Sep 17 00:00:00 2001 From: Nils van Lueck Date: Fri, 4 Sep 2026 04:40:55 +0200 Subject: [PATCH] feat(graphics): add adjustable field of view - Add an immediate cab-camera FOV setting to the graphics menu - Document the setting and localize it in English and German --- README.md | 1 + crates/app/src/loading.rs | 5 +++++ crates/app/src/menu.rs | 18 ++++++++++++++++-- crates/app/src/settings.rs | 19 +++++++++++++++++-- crates/i18n/locales/de/main.ftl | 3 +++ crates/i18n/locales/en/main.ftl | 3 +++ 6 files changed, 45 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f3a90a6e..ea7cda3e 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ built world down with it, so the next one starts from an empty world. | Section | Setting | Effect | |---|---|---| | `[graphics]` | `view_distance` | How far terrain is built and drawn [m], 1000 … 12000. The biggest single cost. | +| | `fov` | Vertical field of view of the cab camera [°], 60 … 120. Applies immediately, also while driving. | | | `shadows` | Shadow maps of the sun. | | | `bloom` | Glow around lamps and signals after dark. | | | `shadow_quality` | Edge length of the sun's shadow map: `Low` 1024, `Medium` 2048, `High` 4096 texels. | diff --git a/crates/app/src/loading.rs b/crates/app/src/loading.rs index 131130ed..9bc64f30 100644 --- a/crates/app/src/loading.rs +++ b/crates/app/src/loading.rs @@ -821,6 +821,11 @@ pub(crate) fn load_sky( }, Projection::Perspective(PerspectiveProjection { far: 20_000.0, + fov: settings + .graphics + .fov + .clamp(crate::settings::FOV.0, crate::settings::FOV.1) + .to_radians(), ..default() }), Transform::default(), diff --git a/crates/app/src/menu.rs b/crates/app/src/menu.rs index 539de336..8a27be2d 100644 --- a/crates/app/src/menu.rs +++ b/crates/app/src/menu.rs @@ -365,6 +365,7 @@ impl Entry { #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Setting { ViewDistance, + Fov, TextureQuality, Grass, GrassQuality, @@ -513,6 +514,7 @@ const SETTINGS: [(&str, &[Setting]); 4] = [ "set-graphics", &[ Setting::ViewDistance, + Setting::Fov, Setting::TextureQuality, Setting::Grass, Setting::GrassQuality, @@ -543,6 +545,7 @@ impl Setting { fn key(self) -> &'static str { match self { Setting::ViewDistance => "set-view-distance", + Setting::Fov => "set-fov", Setting::TextureQuality => "set-texture-quality", Setting::Grass => "set-grass", Setting::GrassQuality => "set-grass-quality", @@ -569,11 +572,12 @@ impl Setting { } fn control(self, graphics: &Graphics, audio: &Audio, gameplay: &Gameplay) -> Control { - use settings::{LOOK_SPEED, MAX_FPS, VIEW_DISTANCE, VOLUME}; + use settings::{FOV, LOOK_SPEED, MAX_FPS, VIEW_DISTANCE, VOLUME}; match self { Setting::ViewDistance => { Control::Slider(fraction(graphics.view_distance, VIEW_DISTANCE)) } + Setting::Fov => Control::Slider(fraction(graphics.fov, FOV)), Setting::Volume => Control::Slider(fraction(audio.master, VOLUME)), Setting::MaxFps => Control::Slider(fraction(graphics.max_fps, MAX_FPS)), Setting::LookSpeed => Control::Slider(fraction(gameplay.look_speed, LOOK_SPEED)), @@ -609,6 +613,12 @@ impl Setting { value = i18n::decimal(f64::from(graphics.view_distance), 0) ) } + Setting::Fov => { + t!( + "set-degrees", + value = i18n::decimal(f64::from(graphics.fov), 0) + ) + } Setting::Volume => t!( "set-percent", value = i18n::decimal(f64::from(audio.master) * 100.0, 0) @@ -746,11 +756,14 @@ fn change( gameplay: &mut Gameplay, support: &settings::UpscalingSupport, ) { - use settings::{LOOK_SPEED, MAX_FPS, VIEW_DISTANCE, VOLUME}; + use settings::{FOV, LOOK_SPEED, MAX_FPS, VIEW_DISTANCE, VOLUME}; match setting { Setting::ViewDistance => { graphics.view_distance = step(graphics.view_distance, dir, VIEW_DISTANCE); } + Setting::Fov => { + graphics.fov = step(graphics.fov, dir, FOV); + } Setting::Shadows => graphics.shadows = !graphics.shadows, Setting::Bloom => graphics.bloom = !graphics.bloom, Setting::VolumetricClouds => graphics.volumetric_clouds = !graphics.volumetric_clouds, @@ -4044,6 +4057,7 @@ mod tests { (settings::VIEW_DISTANCE.0..=settings::VIEW_DISTANCE.1) .contains(&graphics.view_distance) ); + assert!((settings::FOV.0..=settings::FOV.1).contains(&graphics.fov)); assert!((settings::VOLUME.0..=settings::VOLUME.1).contains(&audio.master)); assert!((settings::LOOK_SPEED.0..=settings::LOOK_SPEED.1).contains(&gameplay.look_speed)); // The language cycles through system + every shipped language and back. diff --git a/crates/app/src/settings.rs b/crates/app/src/settings.rs index c7d1db75..3ac86d22 100644 --- a/crates/app/src/settings.rs +++ b/crates/app/src/settings.rs @@ -9,7 +9,7 @@ //! //! **Every setting applies the moment it is dialled.** Language, volume, HUD and look //! sensitivity are read where they are used; window mode and vertical sync go onto the -//! window; view distance, shadows, bloom, anti-aliasing, upscaling and the shadow and +//! window; view distance, field of view, shadows, bloom, anti-aliasing, upscaling and the shadow and //! mist quality reach into a running scene through `apply_scene`; the texture quality //! generates the ground textures again into the handles the terrain already holds. A //! setting that needs a restart is an excuse, and it would go stale the moment there is @@ -46,6 +46,8 @@ const APP_ID: &str = "dev.vanlueck.connected-rails"; /// Terrain load and draw radius: smallest, largest, one step of the menu [m]. pub const VIEW_DISTANCE: (f32, f32, f32) = (1_000.0, 12_000.0, 500.0); +/// Vertical field of view of the cab camera: smallest, largest, one step [°]. +pub const FOV: (f32, f32, f32) = (60.0, 120.0, 5.0); /// Master volume: smallest, largest, one step (0 … 1). pub const VOLUME: (f32, f32, f32) = (0.0, 1.0, 0.05); /// Mouse look sensitivity as a factor on the built-in speed. @@ -62,6 +64,8 @@ pub const MAX_FPS: (f32, f32, f32) = (30.0, 250.0, 10.0); pub struct Graphics { /// Terrain load and draw radius [m] — read by `setup` when the run starts. pub view_distance: f32, + /// Vertical field of view of the cab camera [°]. + pub fov: f32, /// Windowed, borderless over the monitor, or exclusive fullscreen. /// /// A settings file from before this was three steps carries `fullscreen = true`; the @@ -115,6 +119,7 @@ impl Default for Graphics { fn default() -> Self { Self { view_distance: 4_000.0, + fov: 90.0, window: WindowStyle::Windowed, vsync: true, max_fps: MAX_FPS.1, @@ -789,7 +794,7 @@ fn apply_window(graphics: Res, mut window: Query<&mut Window, With>, grass_materials: Option>>, cameras: Query<(Entity, Has), With>, + mut projections: Query<&mut bevy::camera::Projection, With>, ) { // The sun's shadow map is a resource of Bevy's own, rebuilt from it every frame. commands.insert_resource(DirectionalLightShadowMap { @@ -846,6 +852,14 @@ fn apply_scene( if let Some(mut streamer) = streamer { streamer.set_load_radius(f64::from(graphics.view_distance)); } + // A hand-edited settings file may hold anything; the menu clamps on every step, + // so only the file needs it here. + let fov = graphics.fov.clamp(FOV.0, FOV.1).to_radians(); + for mut projection in &mut projections { + if let bevy::camera::Projection::Perspective(perspective) = &mut *projection { + perspective.fov = fov; + } + } for (camera, has_bloom) in &cameras { match (graphics.bloom, has_bloom) { (true, false) => { @@ -936,6 +950,7 @@ mod tests { fn defaults_lie_inside_the_ranges() { let graphics = Graphics::default(); assert!((VIEW_DISTANCE.0..=VIEW_DISTANCE.1).contains(&graphics.view_distance)); + assert!((FOV.0..=FOV.1).contains(&graphics.fov)); assert!((VOLUME.0..=VOLUME.1).contains(&Audio::default().master)); assert!((LOOK_SPEED.0..=LOOK_SPEED.1).contains(&Gameplay::default().look_speed)); } diff --git a/crates/i18n/locales/de/main.ftl b/crates/i18n/locales/de/main.ftl index 07c30587..00e374cf 100644 --- a/crates/i18n/locales/de/main.ftl +++ b/crates/i18n/locales/de/main.ftl @@ -1549,6 +1549,8 @@ set-gameplay = Spiel set-stored = Bleibt zwischen zwei Starts in der Einstellungsdatei des Benutzerkontos erhalten. set-view-distance = Sichtweite set-view-distance-hint = Wie weit Gelände gebaut und gezeichnet wird — der größte einzelne Posten. +set-fov = Sichtfeld +set-fov-hint = Vertikales Sichtfeld der Führerstandskamera, in Grad. Wirkt sofort, auch während der Fahrt. set-shadows = Schatten set-shadows-hint = Schattenkarten der Sonne. set-bloom = Lichtschein @@ -1619,6 +1621,7 @@ set-reset = Auf Standard zurücksetzen set-reset-hint = Setzt jede Einstellung dieser Seite auf den Auslieferungszustand. # Einheiten der Werte am rechten Rand einer Einstellungszeile. set-metres = { $value } m +set-degrees = { $value }° set-percent = { $value } % set-factor = { $value } × diff --git a/crates/i18n/locales/en/main.ftl b/crates/i18n/locales/en/main.ftl index d74afc3e..81048201 100644 --- a/crates/i18n/locales/en/main.ftl +++ b/crates/i18n/locales/en/main.ftl @@ -1546,6 +1546,8 @@ set-gameplay = Gameplay set-stored = Kept between runs in the settings file of your user account. set-view-distance = View distance set-view-distance-hint = How far terrain is built and drawn — the biggest single cost. +set-fov = Field of view +set-fov-hint = Vertical field of view of the cab camera, in degrees. Applies right away, while driving too. set-shadows = Shadows set-shadows-hint = Shadow maps of the sun. set-bloom = Bloom @@ -1616,6 +1618,7 @@ set-reset = Reset to defaults set-reset-hint = Puts every setting on this page back to how it shipped. # Units of the values on the right of a settings row. set-metres = { $value } m +set-degrees = { $value }° set-percent = { $value } % set-factor = { $value } ×