The field of view follows the aspect; a missing model draws the cube (#2930)
Three gaps: - The world camera's vertical field of view was a fixed 45°. 1.12.1's camera holds π/2 (`0x50a707`), and its projection takes the vertical half-angle as fov / (2·√(aspect² + 1)) (`0x5c3cc0`). That gives 54.0° at 4:3, 47.7° at 16:10, 44.1° at 16:9 and 35.0° at 21:9. benilla now derives it from the viewport's aspect and re-derives it on a resize. The spyglass keeps its 6× narrowing. - A unit, item, effect or doodad whose model file is missing drew nothing. `CM2Scene::CreateModel` (`0x707350`), which every lane creates through, opens `Spells\ErrorCube` instead (`0x70737f`). benilla's asset readers now do the same for a missing `.m2`. - `view::msaa_from_env` was dead code that defaulted to 4x, and is deleted. Tests: the field of view at each aspect, and `CreatureDisplayInfo` 4 (`CrystalSpider.mdx`, absent) loading the cube. Fixes #124 Fixes #330 Fixes #129 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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::<Image>();
|
||||
app.init_asset::<Mesh>();
|
||||
app.init_asset::<bevy::mesh::skinning::SkinnedMeshInverseBindposes>();
|
||||
app.init_asset::<bevy::animation::AnimationClip>();
|
||||
app.init_asset::<bevy::animation::graph::AnimationGraph>();
|
||||
register_asset_loaders(&mut app);
|
||||
let server = app.world().resource::<AssetServer>().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<M2Model> = server.load(m2_url(ERROR_CUBE));
|
||||
let shape = |app: &App, h: &Handle<M2Model>| {
|
||||
app.world().resource::<Assets<M2Model>>().get(h).map(|m| {
|
||||
m.submeshes
|
||||
.iter()
|
||||
.map(|s| s.geometry.positions.len())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
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]
|
||||
|
||||
@@ -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<SelfPlayer>>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut scoped: ResMut<ScopedView>,
|
||||
mut projections: Query<&mut Projection, With<WorldCamera>>,
|
||||
mut fov_zoom: ResMut<FovZoom>,
|
||||
) {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Vec<u8>, AssetReaderError>,
|
||||
cube: impl FnOnce(&str) -> Result<Option<Vec<u8>>, AssetReaderError>,
|
||||
) -> Result<Vec<u8>, 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<impl Reader + 'a, AssetReaderError> {
|
||||
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<impl Reader + 'a, AssetReaderError> {
|
||||
@@ -193,7 +228,7 @@ impl AssetReader for LooseAssetReader {
|
||||
async fn read<'a>(&'a self, path: &'a Path) -> Result<impl Reader + 'a, AssetReaderError> {
|
||||
// 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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<FovZoom>,
|
||||
mut cam: Query<(&Camera, &mut Projection), With<WorldCamera>>,
|
||||
) {
|
||||
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::<ViewDistance>()
|
||||
.init_resource::<FovZoom>()
|
||||
.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::<FovZoom>();
|
||||
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::<Projection>(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::<Camera>(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() {
|
||||
|
||||
@@ -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<Image>, blend: EffectBlend, fog: EffectFog| EffectDrawSpec {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<crate::view::MsaaSetting>
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user