diff --git a/crates/benilla-app/src/entities/display.rs b/crates/benilla-app/src/entities/display.rs index b8ffb072d..6ebbecda6 100644 --- a/crates/benilla-app/src/entities/display.rs +++ b/crates/benilla-app/src/entities/display.rs @@ -574,6 +574,61 @@ fn model_dir(model_path: &str) -> &str { mod tests { use super::*; + /// `CreatureDisplayInfo` 4 names `CrystalSpider.mdx`, which does not ship: the model load + /// stands `Spells\ErrorCube.mdx` (`0x707350`, `0x70737f`) rather than drawing nothing. + #[test] + fn a_display_whose_model_file_is_missing_resolves_to_the_error_cube() { + use benilla_assets::{ + m2_url, register_asset_loaders, register_mpq_source, M2Model, ERROR_CUBE, + }; + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let catalog = benilla_formats::load_creature_catalog(&mut chain).expect("creatures"); + let path = catalog.model(4).expect("display 4").model_path; + assert!( + path.to_ascii_lowercase().contains("crystalspider"), + "{path}" + ); + + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + register_mpq_source(&mut app, &data).expect("register mpq source"); + app.add_plugins(bevy::asset::AssetPlugin::default()); + app.init_asset::(); + app.init_asset::(); + app.init_asset::(); + app.init_asset::(); + app.init_asset::(); + register_asset_loaders(&mut app); + let server = app.world().resource::().clone(); + let display = new_creature_display(&catalog, 4, &server); + assert!(display.names_a_model()); + let ModelHandle::M2(handle) = display.handle else { + panic!("display 4 names an M2"); + }; + let cube: Handle = server.load(m2_url(ERROR_CUBE)); + let shape = |app: &App, h: &Handle| { + app.world().resource::>().get(h).map(|m| { + m.submeshes + .iter() + .map(|s| s.geometry.positions.len()) + .collect::>() + }) + }; + let mut got = None; + for _ in 0..3000 { + app.update(); + if let (Some(a), Some(b)) = (shape(&app, &handle), shape(&app, &cube)) { + got = Some((a, b)); + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let (spider, cube) = got.expect("both load"); + assert!(!cube.is_empty(), "the cube has geometry"); + assert_eq!(spider, cube, "the missing model is the cube"); + } + /// Both kinds carry the same empty `parts` once built, so only the handle tells a display that /// named no model from one whose model draws nothing. #[test] diff --git a/crates/benilla-app/src/player/scoped_view.rs b/crates/benilla-app/src/player/scoped_view.rs index 3febad7c8..1c2b3937a 100644 --- a/crates/benilla-app/src/player/scoped_view.rs +++ b/crates/benilla-app/src/player/scoped_view.rs @@ -6,15 +6,14 @@ //! `0x8` makes `SetCameraView` return early), with `[camera+0x40] = n × π/180`; removal restores //! `π/2` and unlocks. The Ornate Spyglass (item 5507, spell 12883) passes 15, a 6× zoom. //! -//! The ratio is applied, not the degrees: `[camera+0x40]` defaults to 90°, while our [`CAM_FOVY`] -//! is the effective vertical field of view, 45° against the reference's measured 44.1°. +//! The ratio is applied to `[camera+0x40]`, which defaults to 90°; the world projection then turns +//! it into the vertical field of view by the frame's aspect (`0x5c3cc0`, [`benilla_world::view`]). -use bevy::camera::{PerspectiveProjection, Projection}; use bevy::prelude::*; use crate::net::{ObjectStore, SelfPlayer}; use crate::ui_action::Spells; -use benilla_world::view::{WorldCamera, CAM_FOVY}; +use benilla_world::view::FovZoom; /// `0x4c`, the aura name the reference's effect walk matches. const SPELL_AURA_FAR_SIGHT: u32 = 76; @@ -43,7 +42,7 @@ pub(super) fn apply_scoped_view( self_q: Query<&ObjectStore, With>, spells: Option>, mut scoped: ResMut, - mut projections: Query<&mut Projection, With>, + mut fov_zoom: ResMut, ) { let catalog = spells.as_ref().map(|s| &s.catalog); scoped.zoom = self_q @@ -64,14 +63,10 @@ pub(super) fn apply_scoped_view( .filter(|°| deg > 0.0) .map(|deg| deg / REFERENCE_DEFAULT_DEGREES); - let Ok(mut projection) = projections.single_mut() else { - return; - }; - let want = CAM_FOVY * scoped.zoom.unwrap_or(1.0); - if let Projection::Perspective(PerspectiveProjection { fov, .. }) = &mut *projection { - if (*fov - want).abs() > f32::EPSILON { - *fov = want; - } + // `stamp_world_fov` turns it into the field of view at the frame's aspect. + let want = scoped.zoom.unwrap_or(1.0); + if fov_zoom.0 != want { + fov_zoom.0 = want; } } @@ -81,25 +76,27 @@ mod tests { #[test] fn the_spyglass_narrows_our_own_field_of_view_six_fold() { + use benilla_world::view::world_fovy; let scoped = ScopedView { zoom: Some(15.0 / REFERENCE_DEFAULT_DEGREES), }; - let fov = CAM_FOVY * scoped.zoom.unwrap(); + let normal = world_fovy(16.0 / 9.0); + let fov = normal * scoped.zoom.unwrap(); assert!( - (fov - CAM_FOVY / 6.0).abs() < 1e-6, - "the spyglass is a 6x zoom off whatever our normal FOV is, not a literal 15 degrees" + (fov - normal / 6.0).abs() < 1e-6, + "the spyglass is a 6x zoom off whatever the normal FOV is, not a literal 15 degrees" ); assert!( - (fov.to_degrees() - 7.5).abs() < 1e-4, - "…which lands at 7.5 degrees vertical for our 45 degree default, not 15" + (fov.to_degrees() - 44.1 / 6.0).abs() < 0.02, + "…which lands at a sixth of the 16:9 field of view, not 15" ); assert!(scoped.active()); let none = ScopedView { zoom: None }; assert_eq!( - CAM_FOVY * none.zoom.unwrap_or(1.0), - CAM_FOVY, - "unscoped restores OUR default, never the reference's stored 90" + none.zoom.unwrap_or(1.0), + 1.0, + "unscoped restores the reference's 90, never a stored default" ); assert!(!none.active()); } diff --git a/crates/benilla-app/src/player/setup.rs b/crates/benilla-app/src/player/setup.rs index cd3ebd544..47beabcaf 100644 --- a/crates/benilla-app/src/player/setup.rs +++ b/crates/benilla-app/src/player/setup.rs @@ -10,7 +10,7 @@ use benilla_assets::coords::wow_to_bevy; use benilla_assets::{RenderConfig, WorldAssets}; use benilla_world::terrain_stream::SPAWN_XY; -use benilla_world::view::{WorldCamera, CAM_FAR, CAM_FOVY, NEARCLIP_DEFAULT}; +use benilla_world::view::{world_fovy, WorldCamera, CAM_FAR, NEARCLIP_DEFAULT}; use super::{ CameraControl, FlyCam, MoveSpeed, Player, PlayerCapsule, CAM_DIST_DEFAULT, CAPSULE_HEIGHT, @@ -102,7 +102,8 @@ pub(super) fn setup_player( // Frame zero only: `view::stamp_near_clip` re-stamps the live `nearclip` every frame, // as `0x511bc0` overwrites the reference camera's `[cam+0x38]`. near: NEARCLIP_DEFAULT, - fov: CAM_FOVY, + // Frame zero only: `view::stamp_world_fov` derives it from the viewport's aspect. + fov: world_fovy(16.0 / 9.0), ..default() }), // A linear `Rgba16Float` target without tonemapping: the shaders light in clamped gamma diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index a9fc4a55d..46c293f31 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -290,7 +290,7 @@ const SORTED_LEAKS: &[(&str, &str)] = &[ ), ("terrain_stream::point_light", "CLOSE/absorb"), ("terrain_stream::spawn_model_entities", "CLOSE/move-engine"), - ("view::CAM_FOVY", "CLOSE/absorb"), + ("view::FovZoom", "CLOSE/absorb"), ("view::FARCLIP_RANGE", "CLOSE/absorb"), ("view::NEARCLIP_DEFAULT", "CLOSE/absorb"), ("wmo_portal::UnitWmoRoom", "CLOSE/absorb"), diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 3a0f4f980..fb4755a12 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -152,9 +152,44 @@ impl MpqAssetReader { } } +/// `CM2Scene::CreateModel` (`0x707350`) opens [`ERROR_CUBE`] when the requested model fails to +/// open (`0x70737f`), for every lane that creates through it (units `0x613e43`, GameObjects +/// `0x5f7bde`, items, spell effects, `Model:SetModel` `0x76cd01`). So a model path whose file is +/// missing reads the cube's bytes, from the archives with open flag 0; any other miss, and the +/// cube's own, stays a miss. +fn or_error_cube( + path: &Path, + read: Result, AssetReaderError>, + cube: impl FnOnce(&str) -> Result>, AssetReaderError>, +) -> Result, AssetReaderError> { + let Err(AssetReaderError::NotFound(_)) = &read else { + return read; + }; + let is_m2 = path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("m2")); + let Some(file) = benilla_formats::m2_file_name(ERROR_CUBE) else { + return read; + }; + if !is_m2 + || path + .to_str() + .is_some_and(|p| p.eq_ignore_ascii_case(&file.replace('\\', "/"))) + { + return read; + } + match cube(&file) { + Ok(Some(bytes)) => Ok(bytes), + _ => read, + } +} + impl AssetReader for MpqAssetReader { async fn read<'a>(&'a self, path: &'a Path) -> Result { - Ok(VecReader::new(self.read_bytes(path)?)) + let bytes = or_error_cube(path, self.read_bytes(path), |cube| { + self.read_candidate(cube) + })?; + Ok(VecReader::new(bytes)) } async fn read_meta<'a>(&'a self, path: &'a Path) -> Result { @@ -193,7 +228,7 @@ impl AssetReader for LooseAssetReader { async fn read<'a>(&'a self, path: &'a Path) -> Result { // The URL holds `/` where the model's name held `\` ([`m2_url`] writes every separator // as `/`), so it is turned back for the index. - let bytes = first_candidate(path, |name| { + let found = first_candidate(path, |name| { let name = name.replace('/', "\\"); match self.loose.find(&name, LooseOpen::Index) { // A loose file that will not open is a miss, with no turn to the chain @@ -201,7 +236,8 @@ impl AssetReader for LooseAssetReader { Some(file) => Ok(file.read()), None => self.mpq.read_candidate(&name), } - })?; + }); + let bytes = or_error_cube(path, found, |cube| self.mpq.read_candidate(cube))?; Ok(VecReader::new(bytes)) } @@ -382,6 +418,35 @@ mod tests { use bevy::camera::primitives::MeshAabb; use bevy::tasks::block_on; + /// `0x70737f`: a model that fails to open stands the error cube; nothing else does. + #[test] + fn a_missing_model_reads_the_error_cube() { + let missing = || Err(AssetReaderError::NotFound("x".into())); + let cube = |name: &str| { + assert_eq!(name, "spells\\errorcube.m2"); + Ok(Some(vec![7u8])) + }; + let m2 = Path::new("creature/crystalspider/crystalspider.m2"); + assert_eq!(or_error_cube(m2, missing(), cube).unwrap(), vec![7u8]); + assert_eq!( + or_error_cube(m2, Ok(vec![1u8]), cube).unwrap(), + vec![1u8], + "a model that opens is its own" + ); + assert!( + or_error_cube(Path::new("creature/x/x.blp"), missing(), cube).is_err(), + "a texture miss is not a model miss" + ); + assert!( + or_error_cube(Path::new("spells/errorcube.m2"), missing(), cube).is_err(), + "no cube behind the cube" + ); + assert!( + or_error_cube(m2, missing(), |_| Ok(None)).is_err(), + "an install without the cube keeps the miss" + ); + } + /// `0x706a50`'s extension rule: the last dot's `.mdx`/`.mdl` in any case is `.m2`, `.m2` /// stands, and anything else stands the error cube from the archives (`0x70737f`). #[test] diff --git a/crates/benilla-world/src/liquid/drift.rs b/crates/benilla-world/src/liquid/drift.rs index f4c89f202..b62f4b3c1 100644 --- a/crates/benilla-world/src/liquid/drift.rs +++ b/crates/benilla-world/src/liquid/drift.rs @@ -715,9 +715,10 @@ mod tests { #[test] fn the_cull_never_clips_a_mote_that_is_on_screen() { for aspect in [4.0 / 3.0, 16.0 / 10.0, 16.0 / 9.0, 21.0 / 9.0, 32.0 / 9.0] { - let (tx, ty) = cull_limits(crate::view::CAM_FOVY, aspect); - let frustum_x = (crate::view::CAM_FOVY * 0.5).tan() * aspect; - let frustum_y = (crate::view::CAM_FOVY * 0.5).tan(); + let fovy = crate::view::world_fovy(aspect); + let (tx, ty) = cull_limits(fovy, aspect); + let frustum_x = (fovy * 0.5).tan() * aspect; + let frustum_y = (fovy * 0.5).tan(); assert!( tx >= frustum_x, "aspect {aspect}: cull {tx} < frustum {frustum_x}" @@ -728,10 +729,17 @@ mod tests { ); } // At the aspects the reference ran at, the limit is its 90° cone exactly. - assert_eq!(cull_limits(crate::view::CAM_FOVY, 4.0 / 3.0), (1.0, 1.0)); - assert_eq!(cull_limits(crate::view::CAM_FOVY, 16.0 / 9.0), (1.0, 1.0)); - let (tx, ty) = cull_limits(crate::view::CAM_FOVY, 32.0 / 9.0); - assert!(tx > 1.0 && ty == 1.0); + assert_eq!( + cull_limits(crate::view::world_fovy(4.0 / 3.0), 4.0 / 3.0), + (1.0, 1.0) + ); + assert_eq!( + cull_limits(crate::view::world_fovy(16.0 / 9.0), 16.0 / 9.0), + (1.0, 1.0) + ); + let (tx, ty) = cull_limits(crate::view::world_fovy(32.0 / 9.0), 32.0 / 9.0); + // The reference's relation keeps the frustum inside its cone at any aspect. + assert_eq!((tx, ty), (1.0, 1.0)); } #[test] diff --git a/crates/benilla-world/src/sun/mod.rs b/crates/benilla-world/src/sun/mod.rs index 3e608167e..80fabd413 100644 --- a/crates/benilla-world/src/sun/mod.rs +++ b/crates/benilla-world/src/sun/mod.rs @@ -6,7 +6,8 @@ //! LightIntBand sub-9, alpha 0xFF) tints every disc and glare but moon02, which has no colour //! writer; the white moon's teal rim is the dome's night bands (sub-3..6) through its feathered //! edge. Every body takes the sky's far-depth pin. The bodies are placed in plain world space, so -//! they project through our camera's 45° fovy, where the reference's is 44.1° at 16:9. +//! they project through the world camera's fovy, which follows the aspect as the reference's does +//! (`0x5c3cc0`). use bevy::pbr::MaterialPlugin; use bevy::prelude::*; diff --git a/crates/benilla-world/src/view.rs b/crates/benilla-world/src/view.rs index e75cba7bb..a11266f22 100644 --- a/crates/benilla-world/src/view.rs +++ b/crates/benilla-world/src/view.rs @@ -1,4 +1,4 @@ -//! The view: the camera the scene renders through ([`WorldCamera`]), its optic ([`CAM_FOVY`]), +//! The view: the camera the scene renders through ([`WorldCamera`]), its optic ([`world_fovy`]), //! and how far the detailed world draws ([`ViewDistance`], the reference's `nearclip` and //! `farclip` pair). `farclip` bounds the per-pixel wall, the CPU cull ([`within_farclip`]) and //! the terrain residency window (`terrain_stream::window`), which derives its reach from it as the @@ -289,24 +289,6 @@ pub fn within_farclip( #[derive(Component)] pub struct WorldCamera; -/// The `$WOW_MSAA` knob as a Bevy `Msaa` level, 4x when unset. `8` falls back to 4x: -/// `Rgba16Float` is only guaranteed `[1, 4]` samples, and there is no adapter to ask here. -pub fn msaa_from_env() -> bevy::render::view::Msaa { - use bevy::render::view::Msaa; - match std::env::var("WOW_MSAA").ok().as_deref() { - Some("off") | Some("0") | Some("1") => Msaa::Off, - Some("2") => Msaa::Sample2, - Some("8") => { - warn!( - "WOW_MSAA=8: Rgba16Float is only guaranteed [1, 4] samples and this adapter \ - reports [1, 2, 4] — falling back to 4x rather than panicking the render thread" - ); - Msaa::Sample4 - } - _ => Msaa::Sample4, - } -} - /// The `nearclip` CVar's registered default, 0.1 (the string at `0x84fb48`, registered at /// `0x68867a`, record `[0xc7f348]`); the camera ctor's 1/9 (`0x50a6c0`) is overwritten from the /// record in the first frame, before it renders. The small near keeps the waterline-crossing band @@ -334,10 +316,54 @@ pub fn stamp_near_clip( } } } -/// The vertical field of view in radians, shared by the projection and every consumer of the near -/// rectangle: 45°, Bevy's `PerspectiveProjection` default; the reference's follows the aspect, -/// 44.1° at 16:9. -pub const CAM_FOVY: f32 = std::f32::consts::FRAC_PI_4; +/// The camera's field-of-view parameter, `[cam+0x40]` = π/2 (the constructor, `0x50a707`). +pub const CAM_FOV: f32 = std::f32::consts::FRAC_PI_2; + +/// The world camera's vertical field of view in radians at `aspect` (width over height): the +/// world projection `0x50ab70` hands `[cam+0x40]` and the frame's aspect to `0x5c3cc0`, whose +/// vertical half-angle is `fov / (2·√(aspect² + 1))` (`0x5c3d0b`-`0x5c3d4c`). 54.0° at 4:3, 44.1° +/// at 16:9. +pub fn world_fovy(aspect: f32) -> f32 { + CAM_FOV / (aspect * aspect + 1.0).sqrt() +} + +/// The held fraction of `[cam+0x40]`: 1 normally; the spyglass (aura 76, `0x50d320`) writes +/// `n × π/180` there, so its zoom scales the field of view before `0x5c3cc0` sees it. +#[derive(Resource, Clone, Copy)] +pub struct FovZoom(pub f32); + +impl Default for FovZoom { + fn default() -> Self { + Self(1.0) + } +} + +/// Derives the world camera's vertical field of view from its viewport's aspect every frame, so a +/// window resize re-derives it as `0x50ab79`-`0x50ab93` re-reads the frame's aspect. +pub fn stamp_world_fov( + zoom: Res, + mut cam: Query<(&Camera, &mut Projection), With>, +) { + for (camera, mut proj) in &mut cam { + let Some(size) = camera + .physical_viewport_size() + .filter(|s| s.x > 0 && s.y > 0) + else { + continue; + }; + let want = world_fovy(size.x as f32 / size.y as f32) * zoom.0; + // `&mut` marks the component changed even when the value is equal, so compare first. + let Projection::Perspective(p) = proj.as_ref() else { + continue; + }; + if (p.fov - want).abs() <= f32::EPSILON { + continue; + } + if let Projection::Perspective(p) = &mut *proj { + p.fov = want; + } + } +} /// The world camera's pose made current within `Update`, for every viewer authority there: Bevy /// propagates `GlobalTransform` in `PostUpdate`, a whole jump late after a teleport snap. @@ -358,7 +384,7 @@ pub(crate) fn publish_camera_pose(mut cam: RootCameraPose) { } } -/// The view lane's plugin: [`stamp_near_clip`] and [`publish_camera_pose`]. +/// The view lane's plugin: [`stamp_near_clip`], [`stamp_world_fov`] and [`publish_camera_pose`]. pub(crate) struct ViewPlugin; impl Plugin for ViewPlugin { @@ -371,6 +397,8 @@ pub(crate) fn plugin(app: &mut App) { // In `Update`, so this frame's `nearclip` write reaches this frame's projection, as `0x511bc0` // runs inside the world-frame driver; initialized here too for a harness with only this plugin. app.init_resource::() + .init_resource::() + .add_systems(Update, stamp_world_fov) // After the frame's script calls, whose `nearclip` callback writes it. .add_systems( Update, @@ -390,6 +418,60 @@ mod tests { use super::*; use bevy::ecs::system::RunSystemOnce; + /// `0x5c3cc0`'s relation: 90° over `√(a² + 1)`. + #[test] + fn the_vertical_fov_follows_the_aspect() { + for (a, deg) in [ + (4.0 / 3.0, 54.0), + (16.0 / 10.0, 47.7), + (16.0 / 9.0, 44.1), + (64.0 / 27.0, 35.0), + ] { + let got = world_fovy(a).to_degrees(); + assert!((got - deg).abs() < 0.06, "aspect {a}: {got}"); + } + } + + /// A resize re-derives the field of view, and the spyglass scales it. + #[test] + fn the_stamp_rederives_on_a_resize_and_scales_by_the_zoom() { + let mut app = App::new(); + app.init_resource::(); + app.add_systems(Update, stamp_world_fov); + let cam = app + .world_mut() + .spawn(( + WorldCamera, + Camera { + viewport: Some(bevy::camera::Viewport { + physical_size: UVec2::new(1600, 1200), + ..default() + }), + ..default() + }, + Projection::from(PerspectiveProjection::default()), + )) + .id(); + let fov = |app: &App| match app.world().get::(cam).unwrap() { + Projection::Perspective(p) => p.fov, + _ => unreachable!(), + }; + app.update(); + assert!((fov(&app) - world_fovy(4.0 / 3.0)).abs() < 1e-6); + app.world_mut() + .get_mut::(cam) + .unwrap() + .viewport + .as_mut() + .unwrap() + .physical_size = UVec2::new(1920, 1080); + app.update(); + assert!((fov(&app) - world_fovy(16.0 / 9.0)).abs() < 1e-6); + app.insert_resource(FovZoom(1.0 / 6.0)); + app.update(); + assert!((fov(&app) - world_fovy(16.0 / 9.0) / 6.0).abs() < 1e-6); + } + /// `0x511bc0`'s near stamp reaches the projection, and an unchanged one stays unmarked. #[test] fn the_near_plane_follows_the_cvar_and_settles() { diff --git a/crates/benilla-world/src/weather/precip/mod.rs b/crates/benilla-world/src/weather/precip/mod.rs index dfe47a383..e81692775 100644 --- a/crates/benilla-world/src/weather/precip/mod.rs +++ b/crates/benilla-world/src/weather/precip/mod.rs @@ -404,7 +404,7 @@ fn push_precip( world_per_px: match proj { Projection::Perspective(p) => (p.fov * 0.5).tan(), // The reference has no orthographic mode; this keeps the sprites sane. - _ => (crate::view::CAM_FOVY * 0.5).tan(), + _ => (crate::view::world_fovy(16.0 / 9.0) * 0.5).tan(), } / SNOW_PX_REF_HEIGHT, }); let spec = |texture: &Handle, blend: EffectBlend, fog: EffectFog| EffectDrawSpec { diff --git a/crates/benilla-world/src/wmo_portal/audit/pin.rs b/crates/benilla-world/src/wmo_portal/audit/pin.rs index 7ac431aa9..074e35166 100644 --- a/crates/benilla-world/src/wmo_portal/audit/pin.rs +++ b/crates/benilla-world/src/wmo_portal/audit/pin.rs @@ -349,7 +349,7 @@ fn wmo_pin_probe() { .unwrap_or_else(|_| panic!("{var} wants a number; got {s:?}")) }) }; - let fovy = scalar("WOW_WMO_PIN_FOVY", crate::view::CAM_FOVY); + let fovy = scalar("WOW_WMO_PIN_FOVY", crate::view::world_fovy(16.0 / 9.0)); let aspect = scalar("WOW_WMO_PIN_ASPECT", 16.0 / 9.0); let clip = Mat4::perspective_rh(fovy, aspect, 0.1, 1000.0) * Mat4::look_at_rh(eye_bevy, look_bevy, Vec3::Y); @@ -523,7 +523,7 @@ fn shadowfang_courtyard_leaves_the_far_room_on_the_scene_fog() { let look_bevy = placed .world_from_local .transform_point3(wow_to_bevy(to_local(look_world))); - let clip = Mat4::perspective_rh(crate::view::CAM_FOVY, 16.0 / 9.0, 0.1, 1000.0) + let clip = Mat4::perspective_rh(crate::view::world_fovy(16.0 / 9.0), 16.0 / 9.0, 0.1, 1000.0) * Mat4::look_at_rh(eye_bevy, look_bevy, Vec3::Y); let pvs = crate::wmo_portal::compute_pvs_traced( &subject.model, @@ -581,8 +581,9 @@ fn shadowfang_courtyard_leaves_the_far_room_on_the_scene_fog() { let inside_bevy = placed .world_from_local .transform_point3(wow_to_bevy(inside)); - let inside_clip = Mat4::perspective_rh(crate::view::CAM_FOVY, 16.0 / 9.0, 0.1, 1000.0) - * Mat4::look_at_rh(inside_bevy, eye_bevy, Vec3::Y); + let inside_clip = + Mat4::perspective_rh(crate::view::world_fovy(16.0 / 9.0), 16.0 / 9.0, 0.1, 1000.0) + * Mat4::look_at_rh(inside_bevy, eye_bevy, Vec3::Y); let inside_pvs = crate::wmo_portal::compute_pvs_traced( &subject.model, inside, @@ -619,7 +620,7 @@ fn shadowfang_sees_true_interiors_that_are_off_the_fog_chain() { .world_from_local .transform_point3(wow_to_bevy(inside)); let look_bevy = placed.world_from_local.transform_point3(wow_to_bevy(look)); - let clip = Mat4::perspective_rh(crate::view::CAM_FOVY, 16.0 / 9.0, 0.1, 1000.0) + let clip = Mat4::perspective_rh(crate::view::world_fovy(16.0 / 9.0), 16.0 / 9.0, 0.1, 1000.0) * Mat4::look_at_rh(inside_bevy, look_bevy, Vec3::Y); let pvs = crate::wmo_portal::compute_pvs_traced( &subject.model, @@ -774,8 +775,9 @@ fn darnassus_pass_two_walks_on_from_the_window() { } let eye_bevy = placed.world_from_local.transform_point3(wow_to_bevy(eye)); let look_bevy = placed.world_from_local.transform_point3(wow_to_bevy(look)); - let clip = Mat4::perspective_rh(crate::view::CAM_FOVY, 16.0 / 9.0, 0.1, 1000.0) - * Mat4::look_at_rh(eye_bevy, look_bevy, Vec3::Y); + let clip = + Mat4::perspective_rh(crate::view::world_fovy(16.0 / 9.0), 16.0 / 9.0, 0.1, 1000.0) + * Mat4::look_at_rh(eye_bevy, look_bevy, Vec3::Y); let mut tap = WalkOn::default(); let pvs = crate::wmo_portal::compute_pvs_traced( &subject.model, diff --git a/crates/benilla-world/src/worldview.rs b/crates/benilla-world/src/worldview.rs index 0dc4cc317..6b11edc09 100644 --- a/crates/benilla-world/src/worldview.rs +++ b/crates/benilla-world/src/worldview.rs @@ -47,9 +47,6 @@ const VIEW_OPEN_Z: f32 = 100.0; /// Near plane in yards: the `nearclip` CVar's default, since the viewer has no CVar table. const NEAR: f32 = crate::view::NEARCLIP_DEFAULT; -/// Vertical FOV in radians (70°), wider than the client camera's [`crate::view::CAM_FOVY`]. -const FOVY: f32 = 1.221_730_5; - /// Builds and runs the world viewer; `build` is the launcher shim's compile-time git stamp. pub fn run(build: BuildId) -> AppExit { // The install resolves from the launcher's folder when its stamp names one, as the client's @@ -273,7 +270,8 @@ fn spawn_view_camera(mut commands: Commands, msaa: Res Projection::from(PerspectiveProjection { far, near: NEAR, - fov: FOVY, + // The client camera's relation, kept current by `view::stamp_world_fov`. + fov: crate::view::world_fovy(16.0 / 9.0), ..default() }), Hdr,