- the texture filter is the reference's policy — two globals, one process-wide mode, no per-texture override — registered as real CVars every sampler reads: the hardcoded trilinear + aniso 8 at five sampler sites had been read off an apitrace of an install whose `Config.wtf` set `anisotropic "16"`, a configured value taken for a default; and `trilinear` ships ON, because the registrar's `"0"` is not what a fresh install renders at — first-launch hardware detection overwrites sixteen video CVars from its hardware table, both rows a real GPU can reach set trilinear, and the reference's own `gx.log` on this machine names that row — while `anisotropic` is not among the sixteen, so anisotropy stays off - the MSAA device clamp ran before the config was read, so a saved `gxMultisample = 8` carried from a machine that offers it still killed the render thread on one that stops at 4; the ceiling is now a property of the write itself, covering the config file, `SetCVar`, the dropdown and any writer added later — and an empty device list is no opinion, so headless runs pass through - one line, one rounding: at a fractional display scale the per-glyph snap's f32 round-trip moved half-pixel ties by an ulp, so caps and ascenders rounded up while x-height letters stayed — the letters left the baseline, the thing the reporter photographed; the line's two anchors now round once and each glyph adds integer device-pixel offsets, so a line cannot be anything but flat at any scale, proven over 15,680 layouts — and `$WOW_DPI`'s size guard, which compared a physical request against the logical size and refused every non-1x run, now reads the unit it is denominated in - a test that panics while holding `ENV_LOCK` no longer poisons the tests behind it — one missing knob resource had reported as twelve red tests, eleven naming the mutex and none naming the bug; there is nothing for the poison to protect, since the guard's `Drop` restores the environment during the panicking test's own unwind Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
739 lines
37 KiB
Rust
739 lines
37 KiB
Rust
//! **The view** — who is looking, with what optics, and how far the detailed world is drawn.
|
||
//!
|
||
//! Three things, one owner. [`WorldCamera`] marks *the* camera the scene is rendered through;
|
||
//! [`CAM_NEAR`] and [`CAM_FOVY`] are its optics; [`ViewDistance`] is the faithful `farclip`.
|
||
//!
|
||
//! The marker and the optics were in `player::camera` until decision 1160's stage zero, and that
|
||
//! was the single largest edge across the engine/game line: **26 engine files** — terrain
|
||
//! streaming, the portal PVS, sun follow, picking, every effect sim — reached into the *player
|
||
//! controller* to ask which camera to read. None of them care about a player; they care about the
|
||
//! viewer, which a world editor and a serverless viewer have without one. `farclip` itself was
|
||
//! promoted out of the debug panel earlier, for the same reason at a smaller scale: it was a
|
||
//! `ModelDebug` field doing double duty, so it read as a debug knob rather than as config, and
|
||
//! subsystems each kept their own idea of it.
|
||
//!
|
||
//! Read by: the hard far-clip **wall** (terrain/model/liquid/WDL/particle shaders, pushed as
|
||
//! `fog_params.w` by `lighting::apply_wow_lighting`), the per-object **cull**
|
||
//! (`model_render::apply_model_visibility`), the particle **draw-set gate**
|
||
//! (`particles::sim::simulate_particles`) — both through [`within_farclip`] — and the terrain
|
||
//! **residency window** (`terrain_stream::window`), which derives its reach from `farclip` the way
|
||
//! the reference does (decision 1513). The player's lever is the Terrain Distance row of the
|
||
//! options window (the `farclip` CVar); `$WOW_FARCLIP` is the headless one.
|
||
|
||
use bevy::prelude::*;
|
||
|
||
/// **The viewer's own body**, as the world needs it — 1160's wire (a), the half that is not about
|
||
/// where to stream.
|
||
///
|
||
/// Three engine lanes read the game's avatar for the same three facts and behind the *same*
|
||
/// predicate (`active && !detached`): the WMO interior probe wants the eye's world point, the
|
||
/// water foam wants a wading body, the precipitation slab wants the commanded planar speed its
|
||
/// tilt keys on. None of them wants a `Player` — they want a body that may or may not be there.
|
||
///
|
||
/// `None` on [`Self::at`] means exactly what each of those sites used to spell out by hand: no
|
||
/// live avatar, or an eye that has been detached from it. A program with no avatar at all leaves
|
||
/// this defaulted and every lane takes its no-body branch, which is what the world viewer needs.
|
||
#[derive(Resource, Clone, Copy)]
|
||
pub struct Viewer {
|
||
/// The avatar's position in **Bevy** space, when one is live and the eye is on it.
|
||
pub at: Option<Vec3>,
|
||
/// Its last-streamed CMovement flags (`MOVEMENTFLAGS`, cached at `unit+0x9e8`). `0` with no
|
||
/// body. Read through [`Self::translating`] / [`Self::turning`] rather than masked at each
|
||
/// site — the bit values are the wire's, and one restatement of them is one too many already.
|
||
pub move_flags: u32,
|
||
/// The **commanded** planar speed in yd/s (`[[player+0x118]+0x84]`): exactly zero with no
|
||
/// direction key held, live rather than a decayed measurement.
|
||
pub planar_speed: f32,
|
||
/// Its collision cylinder height in yards — the foam ring's radius input.
|
||
pub height: f32,
|
||
/// The **first-person feather**: how opaque the viewer's own body is as the camera zooms into
|
||
/// it. `1.0` normally, ramping to `0.0` at full zoom-in. A property of the eye's relationship
|
||
/// to the body, which is why it rides here and not on the body.
|
||
pub self_fade: f32,
|
||
/// **Drunkenness**, `0.0..=1.0` — `PLAYER_BYTES_3` byte 1 clamped at 100. The full-screen haze
|
||
/// is a property of the eye, not of any body in the scene.
|
||
pub drunk: f32,
|
||
/// Is the viewer a **ghost**? While the flag is up the active `LightParams` slot is 4 — the
|
||
/// death profile, applied instantly (decision 0308 §7, byte-verified `death-light.md`).
|
||
pub ghost: bool,
|
||
/// Is a loading cover over the world right now, so the viewer has not actually *seen* anything
|
||
/// yet? The appear ramp arms on this falling edge — the faithful trigger is "the player can
|
||
/// see the entity", and the residency proxy goes true well before the cover drops now that
|
||
/// the clear waits for the whole scene (decision 0737).
|
||
pub world_covered: bool,
|
||
}
|
||
|
||
impl Default for Viewer {
|
||
/// No body, and **nothing covering the world** — the defaults a program with no game boots
|
||
/// with. `self_fade` is `1.0`: absent an eye-to-body relationship, nothing is feathered.
|
||
fn default() -> Self {
|
||
Self {
|
||
at: None,
|
||
move_flags: 0,
|
||
planar_speed: 0.0,
|
||
height: 0.0,
|
||
self_fade: 1.0,
|
||
drunk: 0.0,
|
||
ghost: false,
|
||
world_covered: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Viewer {
|
||
/// Is the body **translating**? The four direction bits (`& 0xf`) — the same test the
|
||
/// reference's water-ripple driver runs (`0x5fa760`).
|
||
pub(crate) fn translating(&self) -> bool {
|
||
self.move_flags & 0xf != 0
|
||
}
|
||
|
||
/// Is it **turning in place**? The two keyboard turn bits (`& 0x30`). Strafe slides without
|
||
/// turning and is covered by [`Self::translating`]; a mouse-look body-step sets no flag at all.
|
||
pub(crate) fn turning(&self) -> bool {
|
||
self.move_flags & 0x30 != 0
|
||
}
|
||
}
|
||
|
||
/// View distance in yards. `farclip` = WoW's `farclip` CVar — the ONE view distance: the far plane of
|
||
/// the detailed world (geometry beyond it is clipped per-pixel, the wall, and the WDL horizon fills
|
||
/// in beyond) **and** the reach of terrain residency (`terrain_stream::window`, decision 1513).
|
||
/// Default **350** — the reference client's own registered default (decision 1624, superseding
|
||
/// 0954's divergence to the clamp's max 777). 777 shipped as "the `Config.wtf` most players ran",
|
||
/// but it is the *maximum*, and it is what every player gets before they touch anything: at 777 the
|
||
/// residency window is 24 chunks per axis against 350's 11 ([`terrain_stream::window::inner_radius`]),
|
||
/// ~4.8x the area streamed, drawn and held resident. The slider still reaches 777.
|
||
#[derive(Resource, Clone, Copy)]
|
||
pub struct ViewDistance {
|
||
pub farclip: f32,
|
||
}
|
||
|
||
/// The settable range of [`ViewDistance::farclip`] — the vanilla `farclip` CVar clamp `[177, 777]`
|
||
/// (validate callback `0x688d40`, wow-re `terrain.md` "Camera-distance CVars"), shared by the CVar
|
||
/// apply, the options row and the `$WOW_FARCLIP` env knob so none can drift. It used to run to 1200
|
||
/// as an A/B lever against the pre-wall "draw everything in the tile window" look; that look is
|
||
/// gone and the window now follows this number, so the headroom went with it (1513).
|
||
pub const FARCLIP_RANGE: std::ops::RangeInclusive<f32> = 177.0..=777.0;
|
||
|
||
/// The world camera's multisampling level — WoW's **`gxMultisample`** CVar.
|
||
///
|
||
/// Held on the reference's own scale: a **sample count**, clamped to [`MSAA_RANGE`] (`[1, 16]`,
|
||
/// the reference's own clamp in the CVar callback at `0x63b250`), where **1 means no
|
||
/// multisampling at all** — not "one sample of MSAA". Both of the reference's device backends read
|
||
/// it that way: D3D9 at `0x599899` leaves `pp.MultiSampleType` at `D3DMULTISAMPLE_NONE` for any
|
||
/// value `<= 1`, and the GL path at `0x59de32` never writes the `WGL_SAMPLE_BUFFERS_ARB` /
|
||
/// `WGL_SAMPLES_ARB` pair at all — the attribute list simply terminates where they would begin.
|
||
/// (wow-re `system/console/scratch/gxmultisample-default.md`, §5-verified 2026-08-26.)
|
||
///
|
||
/// **Default 1 — off — and that is the reference's own default, not a perf choice dressed up as
|
||
/// fidelity.** The reference does not register a literal here: `CVar::Register` at `0x63a950` is
|
||
/// handed a string `snprintf("%d")`'d at runtime from field 21 of the `VideoHardware.dbc` row that
|
||
/// `DetectHardware` (`0x641260`) matched the GPU to. Across the shipped 193-row table that field
|
||
/// only ever holds 1 (144 rows) or 2 (49 rows) — nothing higher exists anywhere in it — and all
|
||
/// three rows reachable by the fallback match hold **1**. Every id in the table is 2004-era, so no
|
||
/// modern GPU matches a specific row and the fallback is what answers: the string registered on
|
||
/// any machine this client actually runs on today is `"1"`. Decision 1629.
|
||
///
|
||
/// **Latched, like the reference's own flag byte** (`CVar::Register` flags `3` = registered |
|
||
/// latched; the callback echoes `"set pending gxRestart"`). The value here is the **pending** one:
|
||
/// changing it persists and `GetCVar` reports it, but the device — here, the camera's `Msaa`
|
||
/// component — keeps what it was born with until the next launch. That is not a limitation we are
|
||
/// working around; it is the reference's behaviour, and it happens to be forced on us anyway,
|
||
/// since swapping MSAA live leaves our post passes MSAA-mismatched and freezes the view.
|
||
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub struct MsaaSetting {
|
||
/// The requested sample count, `[1, 16]`. 1 = no multisampling.
|
||
pub samples: u32,
|
||
}
|
||
|
||
/// The settable range of [`MsaaSetting::samples`] — the reference's own `atoi`-then-clamp `[1, 16]`
|
||
/// at `0x63b250`. Shared by the CVar apply and the `$WOW_MSAA` env knob so the two cannot drift.
|
||
pub const MSAA_RANGE: std::ops::RangeInclusive<u32> = 1..=16;
|
||
|
||
impl Default for MsaaSetting {
|
||
/// `$WOW_MSAA` overrides the default, **session-only** — the A/B lever, the same posture as
|
||
/// `$WOW_FARCLIP` (a value pinned into `config.toml` would make a measurement sticky). The
|
||
/// spellings it has always accepted are kept: `off`/`0`/`1` all mean none.
|
||
fn default() -> Self {
|
||
let samples = match std::env::var("WOW_MSAA").ok().as_deref() {
|
||
Some("off") => Some(1),
|
||
Some(v) => v.parse::<u32>().ok(),
|
||
None => None,
|
||
}
|
||
.map_or(1, |v| v.clamp(*MSAA_RANGE.start(), *MSAA_RANGE.end()));
|
||
Self { samples }
|
||
}
|
||
}
|
||
|
||
impl MsaaSetting {
|
||
/// The sample count as a Bevy [`Msaa`](bevy::render::view::Msaa) level.
|
||
///
|
||
/// wgpu can only express 1/2/4/8, while the reference's range runs to 16, so a request lands on
|
||
/// the **largest expressible level at or below it**. That direction is the reference's own: its
|
||
/// device-init retry loop (`0x63b380`) steps the sample count *down* — `-= 2`, floored at 1 —
|
||
/// and only once it bottoms out does it start giving up depth and colour bits. Nothing anywhere
|
||
/// steps it up, so rounding down can never hand a player more than they asked for.
|
||
pub fn level(self) -> bevy::render::view::Msaa {
|
||
use bevy::render::view::Msaa;
|
||
match self.samples {
|
||
0..=1 => Msaa::Off,
|
||
2..=3 => Msaa::Sample2,
|
||
4..=7 => Msaa::Sample4,
|
||
_ => Msaa::Sample8,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The sample counts this run's GPU actually accepts — the guard between a player's
|
||
/// `gxMultisample` and a wgpu validation error.
|
||
///
|
||
/// **This closes a hazard [`MsaaSetting`] itself opened.** Decision 1629 made the sample count a
|
||
/// real setting a player can type, and nothing between the CVar and the GPU asked whether the GPU
|
||
/// could do it. Bevy does not ask either — `bevy_render`'s `prepare_view_targets` writes
|
||
/// `sample_count: msaa.samples()` into the descriptor unchecked (`view/mod.rs:1142`). wgpu *does*:
|
||
/// `wgpu-core` tests the count against the format's `sample_count_supported` and fails
|
||
/// `CreateTextureError::InvalidSampleCount` (`device/resource.rs:1316`), then tests it again on
|
||
/// every pipeline rendering into that texture (`:3882`, `:3961`, `:4013`). A validation error on a
|
||
/// texture rebuilt every frame is not a graceful degrade; it is the client dying on a value the
|
||
/// player was invited to type. `gxMultisample 8` on a device that stops at 4x is the live case.
|
||
///
|
||
/// **Stepping down is the reference's own answer, not a defensive invention.** Its device-init
|
||
/// retry loop at `0x63b380` walks the sample count *down* (`-= 2`, floored at 1) until the device
|
||
/// accepts a mode, and only once it bottoms out does it begin surrendering depth and colour bits.
|
||
/// A request the hardware cannot meet becoming the nearest one it can is therefore the faithful
|
||
/// behaviour; refusing to boot is what would be unfaithful.
|
||
///
|
||
/// The three formats are every one we multisample into: the world camera's off-screen colour target
|
||
/// (`Rgba16Float` — `benilla_app::world_backdrop`), `Core3d`'s depth (`CORE_3D_DEPTH_FORMAT` =
|
||
/// `Depth32Float`), and the swapchain format the world viewer and the fallback camera render
|
||
/// straight into. A count is granted only if **all three** take it: they are separate capability
|
||
/// bits in wgpu, and a device may well offer 8x colour alongside 4x depth.
|
||
fn supported_sample_counts(adapter: &bevy::render::renderer::RenderAdapter) -> Vec<u32> {
|
||
use bevy::image::BevyDefault as _;
|
||
use bevy::render::render_resource::TextureFormat;
|
||
|
||
let formats = [
|
||
TextureFormat::Rgba16Float,
|
||
TextureFormat::Depth32Float,
|
||
TextureFormat::bevy_default(),
|
||
];
|
||
MSAA_RANGE
|
||
.clone()
|
||
.filter(|&n| {
|
||
formats.iter().all(|f| {
|
||
adapter
|
||
.get_texture_format_features(*f)
|
||
.flags
|
||
.sample_count_supported(n)
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The largest count in `supported` at or below `requested`, floored at 1 — no multisampling, which
|
||
/// no device can refuse. The step-*down* direction is the reference's; see
|
||
/// [`supported_sample_counts`].
|
||
pub fn clamp_to_supported(requested: u32, supported: &[u32]) -> u32 {
|
||
supported
|
||
.iter()
|
||
.copied()
|
||
.filter(|&n| n <= requested)
|
||
.max()
|
||
.unwrap_or(1)
|
||
}
|
||
|
||
/// The multisample formats this run's device accepts, as the Video options dropdown offers them —
|
||
/// `(color_bits, depth_bits, samples)`, ascending.
|
||
///
|
||
/// The reference's distilled triple list (`[0xb4b444]`, count `[0xb4b440]`, built by `0x48c3e0` out
|
||
/// of the D3D `CheckDeviceMultiSampleType` sweep or the GL `wglGetPixelFormatAttribivARB` sweep) —
|
||
/// here, whatever wgpu says it will take. Filled by [`MsaaSupportPlugin`] from the same enumeration
|
||
/// that does the clamping, so the list a player picks from and the ceiling a typed value is clamped
|
||
/// to cannot disagree.
|
||
///
|
||
/// Empty until the adapter exists (and forever, headless): an empty dropdown is the honest answer
|
||
/// for a run with no device, and the Lua walks it zero times.
|
||
#[derive(Resource, Default, Clone, Debug)]
|
||
pub struct MsaaFormats {
|
||
pub formats: Vec<(u32, u32, u32)>,
|
||
}
|
||
|
||
impl MsaaFormats {
|
||
/// `requested`, stepped DOWN to the nearest count this device actually offers.
|
||
///
|
||
/// **The single expression of "the device has the final say"** (decision 1643). It used to
|
||
/// exist only inside [`MsaaSupportPlugin::finish`], which runs once, before the first update —
|
||
/// i.e. against whatever [`MsaaSetting::default`] seeded, and therefore *before*
|
||
/// `config.toml` had been folded in. The doc two paragraphs up already claimed that "the list
|
||
/// a player picks from and the ceiling a typed value is clamped to cannot disagree"; a typed
|
||
/// value was in fact never clamped to it at all, and a saved `gxMultisample = 8` opened on a
|
||
/// GPU that stops at 4 handed the camera an 8 — the 2026-08-26 render-thread kill, one launch
|
||
/// later and on a machine the setting was never chosen on.
|
||
///
|
||
/// **An empty list is no opinion, not a refusal.** Headless, or before the adapter exists,
|
||
/// there is no device to be wrong about and `requested` passes through — the same early-out
|
||
/// `finish` takes when there is no `RenderAdapter`. Clamping to 1 there would turn every
|
||
/// device-less test and every worldview boot into a silent "multisampling off".
|
||
pub fn clamp(&self, requested: u32) -> u32 {
|
||
if self.formats.is_empty() {
|
||
return requested;
|
||
}
|
||
let counts: Vec<u32> = self.formats.iter().map(|&(_, _, n)| n).collect();
|
||
clamp_to_supported(requested, &counts)
|
||
}
|
||
}
|
||
|
||
/// Colour and depth bit counts for the dropdown's label, derived from the formats we actually
|
||
/// render into rather than typed: `MULTISAMPLING_FORMAT_STRING` is
|
||
/// `"%d-bit color %d-bit depth %dx multisample"`, and a number in it should be true.
|
||
///
|
||
/// The colour figure is the **swapchain**'s, not the world camera's off-screen `Rgba16Float`
|
||
/// target. What the dropdown is describing is the mode the window is presented in — the thing the
|
||
/// reference's enumerators were asking the device about — and quoting 64 there would be describing
|
||
/// an internal buffer no player chose.
|
||
fn dropdown_bit_depths() -> (u32, u32) {
|
||
use bevy::image::BevyDefault as _;
|
||
use bevy::render::render_resource::TextureFormat;
|
||
|
||
let bits = |f: TextureFormat| f.block_copy_size(None).unwrap_or(4) * 8;
|
||
(
|
||
bits(TextureFormat::bevy_default()),
|
||
bits(TextureFormat::Depth32Float),
|
||
)
|
||
}
|
||
|
||
/// Clamps [`MsaaSetting`] to what this run's GPU accepts, once, before anything spawns a camera.
|
||
///
|
||
/// **All the work is in `finish`, deliberately** — the same reason as
|
||
/// `benilla_assets::gpu_blp::BlpGpuSupportPlugin`: `RenderAdapter` is inserted into the main world
|
||
/// by `RenderPlugin::finish` (`bevy_render/src/lib.rs:419`), so it does not exist at `build` time
|
||
/// and never exists at all in a headless app. Every plugin's `finish` completes before the runner's
|
||
/// first update, and the cameras read `MsaaSetting` in `Startup` (1629's latch), so this is both
|
||
/// the earliest honest moment and comfortably early enough.
|
||
///
|
||
/// Headless — no adapter, or no setting — changes nothing: such a run builds no swapchain to be
|
||
/// wrong about.
|
||
pub struct MsaaSupportPlugin;
|
||
|
||
impl Plugin for MsaaSupportPlugin {
|
||
fn build(&self, _app: &mut App) {}
|
||
|
||
fn finish(&self, app: &mut App) {
|
||
// Scoped so the adapter borrow ends before the resource is written.
|
||
let supported = match app
|
||
.world()
|
||
.get_resource::<bevy::render::renderer::RenderAdapter>()
|
||
{
|
||
Some(adapter) => supported_sample_counts(adapter),
|
||
None => return,
|
||
};
|
||
// Published whether or not anything needs clamping: this is the dropdown's whole menu.
|
||
let (color_bits, depth_bits) = dropdown_bit_depths();
|
||
app.insert_resource(MsaaFormats {
|
||
formats: supported
|
||
.iter()
|
||
.map(|&s| (color_bits, depth_bits, s))
|
||
.collect(),
|
||
});
|
||
let Some(requested) = app.world().get_resource::<MsaaSetting>().map(|m| m.samples) else {
|
||
return;
|
||
};
|
||
// Through `MsaaFormats::clamp`, not `clamp_to_supported` directly: this seed clamp and the
|
||
// per-write clamp in `cvars::apply_to_knobs` must be the same rule, or the pair drifts
|
||
// exactly the way the missing half already did (1643).
|
||
let granted = app.world().resource::<MsaaFormats>().clamp(requested);
|
||
if granted == requested {
|
||
debug!("msaa: {requested}x accepted (this GPU offers {supported:?})");
|
||
return;
|
||
}
|
||
// At `warn`, not `debug`: the player asked for something and did not get it, and this is
|
||
// the only place that fact exists. A line that survives only in a debug build cannot answer
|
||
// "why won't my setting stick?" from a player's log.
|
||
warn!(
|
||
"msaa: this GPU does not offer {requested}x — using {granted}x (it offers {supported:?})"
|
||
);
|
||
app.world_mut().resource_mut::<MsaaSetting>().samples = granted;
|
||
}
|
||
}
|
||
|
||
/// The world camera's projection far plane in yards — the **horizon** plane, far beyond `farclip`
|
||
/// on purpose so the coarse WDL ring draws behind the wall (decision 0684; the reference's own
|
||
/// `horizonfarclip` is a second plane floored at `farclip + 528`, default 2112). One number, not a
|
||
/// function of anything: the detailed world ends at `farclip` by the wall, never by this plane.
|
||
pub const CAM_FAR: f32 = 3000.0;
|
||
|
||
impl Default for ViewDistance {
|
||
/// `$WOW_FARCLIP` (yd, clamped to [`FARCLIP_RANGE`]) overrides the 350 default. The options row is
|
||
/// the live lever, but a headless capture has no hands — and a horizon or fog report almost always
|
||
/// arrives with the director's slider somewhere other than the default (the 0684 gap was invisible
|
||
/// at 777 and glaring at 320), so reproducing one must not need a human. Read once at startup, like
|
||
/// the other capture-side knobs.
|
||
fn default() -> Self {
|
||
let farclip = std::env::var("WOW_FARCLIP")
|
||
.ok()
|
||
.and_then(|v| v.parse::<f32>().ok())
|
||
.map_or(350.0, |v| {
|
||
v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end())
|
||
});
|
||
Self { farclip }
|
||
}
|
||
}
|
||
|
||
/// Is a world bounding sphere inside the far-clip wall — i.e. does the detailed world still draw it?
|
||
///
|
||
/// **The one spelling of "is it nearer than `farclip`", shared by every CPU-side consumer.** The test is
|
||
/// planar depth along the camera-forward axis (`(center − eye)·fwd`) of the sphere's NEAREST point, which
|
||
/// is deliberately the *same coordinate* the per-pixel wall uses in the shaders (`terrain.wgsl` /
|
||
/// `wow_model.wgsl` / `wow_effect.wgsl` all discard on eye-Z past `fog_params.w`). Agreeing on the
|
||
/// coordinate is what makes an object straddling the boundary **dissolve** through it instead of popping
|
||
/// when its origin crosses.
|
||
///
|
||
/// Radial distance would be the obvious alternative and it is wrong: it disagrees with the wall off-axis,
|
||
/// so a wide object at the edge of the frame pops while its pixels were still being drawn.
|
||
///
|
||
/// ## Why this is not the camera's far plane
|
||
/// The world camera's projection far is ~3000 yd — far *beyond* `farclip` on purpose, so the coarse WDL
|
||
/// horizon can draw behind the wall. So the frustum's own far plane is **not** the reference's far plane,
|
||
/// and a `Frustum::intersects_sphere(.., intersect_far = true)` is not a substitute for this test. In the
|
||
/// reference there is one projection far plane at `farclip` and it bounds the detailed world; here that
|
||
/// bound is this function plus the shaders' per-pixel discard, and nothing else.
|
||
pub fn within_farclip(
|
||
farclip: f32,
|
||
cam_pos: Vec3,
|
||
cam_fwd: Vec3,
|
||
center: Vec3,
|
||
radius: f32,
|
||
) -> bool {
|
||
(center - cam_pos).dot(cam_fwd) - radius <= farclip
|
||
}
|
||
|
||
/// Marks **the world camera** — the one flying the scene. Every "where is the viewer" consumer
|
||
/// (terrain streaming, PVS, sun follow, sound listener, picking, the capture pin, …) filters on this,
|
||
/// NOT on `Camera3d`: since the portrait booths (the portrait booths) there are multiple `Camera3d`s,
|
||
/// and a bare `With<WorldCamera>` query silently reads (or writes!) an off-screen booth camera — exactly
|
||
/// how the capture pin once yanked the booths to the scenario eye and blanked every portrait.
|
||
#[derive(Component)]
|
||
pub struct WorldCamera;
|
||
|
||
/// The camera **near-plane** distance (yd) — the reference's own **1/9**, hardcoded in its camera
|
||
/// ctor (`0x50a6c0`: `+0x38 = 0x3de38e39`; the `nearclip` console cvar stores to a global with zero
|
||
/// readers — dead plumbing; wow-re `water-frame-straddle` §4d). Shared by the projection (the camera spawn)
|
||
/// and the self-avatar fade's `nearclip` ([`crate::model_fade::self_model_fade_alpha`]) so the model
|
||
/// finishes fading exactly as the near plane would begin to slice it — the reference couples the
|
||
/// two the same way (`cam+0x38 ≈ 0.1`, set per frame in the driver `0x511bc0`).
|
||
///
|
||
/// It was 1.0 from 0062 to 0905 "for depth precision" — a rationale that predates knowing the
|
||
/// pipeline: the projection is `perspective_infinite_reverse_rh` on a float depth buffer
|
||
/// ([`crate::capture::depth_probe`]'s tests draw with the real one), where `depth = near/z` makes
|
||
/// relative precision — and our ULP-relative bias ladder ([`crate::sky_order`]) — independent of
|
||
/// the near value. The small near is what keeps the whole waterline-crossing band (the corner-min
|
||
/// submersion probe, `liquid::detect_submersion`) inches tall instead of a yard.
|
||
/// The `$WOW_MSAA` startup knob → a sample count, shared by both world-camera spawners
|
||
/// (`benilla_world::worldview` and the app's `player::setup`) so the two can never disagree.
|
||
///
|
||
/// **8x is refused rather than passed through.** wgpu validates the sample count against the HDR
|
||
/// target's format, and the WebGPU spec guarantees only `[1, 4]` for `Rgba16Float` (this Mac's
|
||
/// adapter reports `[1, 2, 4]`). `WOW_MSAA=8` therefore panicked the render thread inside
|
||
/// `prepare_view_targets` on frame one — an A/B leg that asked for it died with a wgpu validation
|
||
/// error instead of returning an answer, which is how it was found. There is no adapter handle at
|
||
/// camera-spawn time to query, so the honest move is to fall back to 4x and say so loudly: a
|
||
/// usable knob and a legible failure beat a hard crash.
|
||
///
|
||
/// A STARTUP knob, not a live toggle: switching MSAA at runtime leaves the post-process passes
|
||
/// (glow/egui) MSAA-mismatched and freezes the view, so A/B by restarting.
|
||
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,
|
||
}
|
||
}
|
||
|
||
pub const CAM_NEAR: f32 = 1.0 / 9.0;
|
||
/// The camera's vertical field of view (radians) — one constant shared by the projection
|
||
/// (the camera spawn) and every consumer that needs the near rectangle's true shape. 45°, the value the
|
||
/// projection has always used (Bevy's `PerspectiveProjection` default, ≈ the reference's 44.1° —
|
||
/// [`crate::sun`]'s projection note); naming it here just stops the consumers drifting apart.
|
||
pub const CAM_FOVY: f32 = std::f32::consts::FRAC_PI_4;
|
||
|
||
/// **The world camera's world pose, made current before anything in `Update` reads it** — the
|
||
/// set every `Update`-stage viewer authority must order after.
|
||
///
|
||
/// Bevy propagates `GlobalTransform` in `PostUpdate`. For the whole of `Update`, therefore, a
|
||
/// camera's `GlobalTransform` is the pose it had **last** frame, while its `Transform` — written
|
||
/// by the controller in [`crate::schedule::WorldStage::Input`] — is this frame's. Walking, the two
|
||
/// differ by centimetres and nothing shows. On the frame a teleport snaps they differ by the whole
|
||
/// jump, and an authority that decides *what may draw* off the stale one gates a frame drawn from
|
||
/// the new pose with a verdict about the old place (decision 1503).
|
||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct CameraPoseSet;
|
||
|
||
/// Copy the world camera's `Transform` into its `GlobalTransform` (see [`CameraPoseSet`]).
|
||
///
|
||
/// **Root cameras only.** The world camera is spawned unparented (both the fallback and the real
|
||
/// one), and for a root the propagation Bevy will run in `PostUpdate` is exactly this copy — so
|
||
/// this makes the same value available earlier and `PostUpdate` recomputes it identically. A
|
||
/// camera that ever acquires a parent keeps Bevy's propagated value and its old one-frame lag,
|
||
/// which is no worse than before and never silently wrong: the pose it reports is a real pose the
|
||
/// camera had, just not this frame's.
|
||
///
|
||
/// Written through `set_if_neq` so a still camera does not flag `Changed<GlobalTransform>` every
|
||
/// frame for the consumers that key on it.
|
||
/// The root world cameras and their two transforms — see [`publish_camera_pose`].
|
||
type RootCameraPose<'w, 's> =
|
||
Query<'w, 's, (&'static Transform, &'static mut GlobalTransform), RootCamera>;
|
||
/// A world camera Bevy will propagate as a root (no parent to inherit from).
|
||
type RootCamera = (With<WorldCamera>, Without<ChildOf>);
|
||
|
||
pub(crate) fn publish_camera_pose(mut cam: RootCameraPose) {
|
||
for (t, mut g) in &mut cam {
|
||
g.set_if_neq(GlobalTransform::from(*t));
|
||
}
|
||
}
|
||
|
||
/// The view lane's plugin — today, [`publish_camera_pose`] and its ordering contract.
|
||
pub(crate) struct ViewPlugin;
|
||
|
||
impl Plugin for ViewPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
plugin(app);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn plugin(app: &mut App) {
|
||
app.add_systems(
|
||
Update,
|
||
publish_camera_pose
|
||
.in_set(CameraPoseSet)
|
||
// After the controller that writes the camera's `Transform`
|
||
// ([`crate::schedule::WorldStage::Input`]) — the point of the whole system is to
|
||
// publish the pose the controller just chose, not the one before it.
|
||
.after(crate::schedule::WorldStage::Input),
|
||
);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use bevy::ecs::system::RunSystemOnce;
|
||
|
||
/// The contract of [`CameraPoseSet`]: a system ordered after it, in the same `Update`, reads
|
||
/// the pose the controller wrote **this** frame — not the one Bevy will propagate at the end
|
||
/// of it. Written as the frame it fixes: move the camera the way a teleport snap does, and
|
||
/// assert the downstream authority sees the destination.
|
||
#[test]
|
||
fn a_reader_after_the_pose_set_sees_this_frames_camera() {
|
||
#[derive(Resource, Default)]
|
||
struct Seen(Vec3);
|
||
|
||
fn snap_the_camera(mut cam: Query<&mut Transform, With<WorldCamera>>) {
|
||
for mut t in &mut cam {
|
||
t.translation = Vec3::new(500.0, 0.0, 0.0);
|
||
}
|
||
}
|
||
fn read_the_pose(cam: Query<&GlobalTransform, With<WorldCamera>>, mut seen: ResMut<Seen>) {
|
||
for g in &cam {
|
||
seen.0 = g.translation();
|
||
}
|
||
}
|
||
|
||
let mut app = App::new();
|
||
app.init_resource::<Seen>();
|
||
app.configure_sets(Update, crate::schedule::WorldStage::Input);
|
||
app.add_systems(
|
||
Update,
|
||
snap_the_camera.in_set(crate::schedule::WorldStage::Input),
|
||
);
|
||
plugin(&mut app);
|
||
app.add_systems(Update, read_the_pose.after(CameraPoseSet));
|
||
app.world_mut().spawn((
|
||
WorldCamera,
|
||
Transform::default(),
|
||
GlobalTransform::default(),
|
||
));
|
||
|
||
app.update();
|
||
assert_eq!(
|
||
app.world().resource::<Seen>().0,
|
||
Vec3::new(500.0, 0.0, 0.0),
|
||
"an Update-stage viewer authority must see the snapped pose, not the frame-old one"
|
||
);
|
||
}
|
||
|
||
/// A **parented** camera is left to Bevy's propagation: a plain copy of a child's local
|
||
/// `Transform` would be a wrong pose, which is worse than a frame-old right one.
|
||
#[test]
|
||
fn a_parented_camera_is_left_alone() {
|
||
let mut app = App::new();
|
||
plugin(&mut app);
|
||
let parent = app.world_mut().spawn(Transform::default()).id();
|
||
let child = app
|
||
.world_mut()
|
||
.spawn((
|
||
WorldCamera,
|
||
Transform::from_xyz(7.0, 0.0, 0.0),
|
||
GlobalTransform::default(),
|
||
))
|
||
.id();
|
||
app.world_mut().entity_mut(parent).add_child(child);
|
||
|
||
app.world_mut()
|
||
.run_system_once(publish_camera_pose)
|
||
.unwrap();
|
||
assert_eq!(
|
||
app.world()
|
||
.entity(child)
|
||
.get::<GlobalTransform>()
|
||
.unwrap()
|
||
.translation(),
|
||
Vec3::ZERO,
|
||
"the child's local transform is not a world pose"
|
||
);
|
||
}
|
||
/// The wall is planar depth along camera-forward, measured to the sphere's nearest point.
|
||
#[test]
|
||
fn planar_depth_of_the_nearest_point() {
|
||
let eye = Vec3::ZERO;
|
||
let fwd = Vec3::NEG_Z; // Bevy's camera looks down −Z
|
||
let at = |d: f32, r: f32| within_farclip(777.0, eye, fwd, Vec3::new(0.0, 0.0, -d), r);
|
||
assert!(at(700.0, 0.0));
|
||
assert!(at(777.0, 0.0)); // exactly at the wall still draws (the shader discards past it)
|
||
assert!(!at(778.0, 0.0));
|
||
// A big object straddling the wall stays in: its near side is still inside, and the
|
||
// per-pixel wall dissolves the far half. This is the no-pop property.
|
||
assert!(at(800.0, 30.0));
|
||
assert!(!at(900.0, 30.0));
|
||
}
|
||
|
||
/// Off-axis is where radial distance and the shader's eye-Z part company — the wall is a PLANE,
|
||
/// so a point 700 yd forward and 700 yd sideways (radially ~990) is still inside it.
|
||
#[test]
|
||
fn the_wall_is_a_plane_not_a_sphere() {
|
||
let eye = Vec3::ZERO;
|
||
let fwd = Vec3::NEG_Z;
|
||
let off = Vec3::new(700.0, 0.0, -700.0);
|
||
assert!(off.length() > 777.0, "radially outside");
|
||
assert!(
|
||
within_farclip(777.0, eye, fwd, off, 0.0),
|
||
"but inside the planar wall — must match the shader, which discards on eye-Z"
|
||
);
|
||
}
|
||
|
||
/// Behind the camera is trivially inside the wall (negative depth); the lateral frustum planes,
|
||
/// not this test, are what reject it. Pinned so nobody "fixes" this into an abs().
|
||
#[test]
|
||
fn behind_the_camera_is_not_this_tests_job() {
|
||
let eye = Vec3::ZERO;
|
||
assert!(within_farclip(
|
||
777.0,
|
||
eye,
|
||
Vec3::NEG_Z,
|
||
Vec3::new(0.0, 0.0, 5000.0),
|
||
0.0
|
||
));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod msaa_tests {
|
||
use super::*;
|
||
use bevy::render::view::Msaa;
|
||
|
||
/// The env-less default is **off**, and off is spelled 1 (the reference's scale), not 0.
|
||
/// Welded to the `gxMultisample` registration in `benilla-app`'s `cvars.rs` by its own test.
|
||
#[test]
|
||
fn the_default_is_the_references_one_sample_which_means_none() {
|
||
// Not `MsaaSetting::default()` — a test run under `$WOW_MSAA` would read the env and this
|
||
// is a claim about the literal.
|
||
let literal = MsaaSetting { samples: 1 };
|
||
assert_eq!(literal.level(), Msaa::Off);
|
||
assert_eq!(*MSAA_RANGE.start(), 1);
|
||
assert_eq!(*MSAA_RANGE.end(), 16);
|
||
}
|
||
|
||
/// A request lands on the largest level wgpu can express at or below it — the direction the
|
||
/// reference's own device-init retry loop steps (`0x63b380`, `-= 2` floored at 1). Never up:
|
||
/// asking for 3 must not silently buy 4 samples' worth of bandwidth.
|
||
#[test]
|
||
fn an_unsupported_count_steps_down_to_the_nearest_the_device_offers() {
|
||
// The live hazard: a player types 8 on a device that stops at 4x. Stepping down is the
|
||
// reference's own retry direction (`0x63b380`), so this is faithful, not just safe.
|
||
let upto4 = [1, 2, 4];
|
||
assert_eq!(clamp_to_supported(8, &upto4), 4);
|
||
assert_eq!(clamp_to_supported(16, &upto4), 4);
|
||
assert_eq!(clamp_to_supported(4, &upto4), 4);
|
||
assert_eq!(clamp_to_supported(3, &upto4), 2);
|
||
assert_eq!(clamp_to_supported(1, &upto4), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn a_supported_count_is_never_stepped_up_and_never_touched() {
|
||
let all = [1, 2, 4, 8, 16];
|
||
for n in all {
|
||
assert_eq!(clamp_to_supported(n, &all), n, "{n}x should pass through");
|
||
}
|
||
// Between two offered levels it still rounds DOWN — the direction is the whole point.
|
||
assert_eq!(clamp_to_supported(6, &all), 4);
|
||
assert_eq!(clamp_to_supported(15, &all), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn a_device_that_offers_nothing_still_boots_at_one_sample() {
|
||
// `supported_sample_counts` returning empty means some format refused even 1x, which should
|
||
// be impossible — but the floor is 1 (no multisampling) rather than 0 or a panic, because a
|
||
// client that will not start is strictly worse than one that starts unaliased.
|
||
assert_eq!(clamp_to_supported(8, &[]), 1);
|
||
assert_eq!(clamp_to_supported(1, &[]), 1);
|
||
assert_eq!(MsaaSetting { samples: 1 }.level(), Msaa::Off);
|
||
}
|
||
|
||
/// [`MsaaFormats::clamp`] — the one expression of "the device has the final say" (1643),
|
||
/// and both of its halves matter.
|
||
///
|
||
/// A device list steps a request DOWN to something the GPU will actually take, which is what
|
||
/// stops a saved `gxMultisample` from reaching the camera as a wgpu validation error. An
|
||
/// EMPTY list is no opinion rather than a refusal: headless, and before the adapter exists,
|
||
/// there is no device to be wrong about — clamping to 1 there would turn every device-less
|
||
/// run into a silent "multisampling off", including the one this crate's own tests run in.
|
||
#[test]
|
||
fn the_device_list_steps_down_and_an_empty_one_has_no_opinion() {
|
||
let apple = MsaaFormats {
|
||
formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)],
|
||
};
|
||
assert_eq!(apple.clamp(4), 4, "a count it offers is untouched");
|
||
assert_eq!(apple.clamp(3), 2, "down to the nearest offered, never up");
|
||
assert_eq!(apple.clamp(8), 4, "the 2026-08-26 render-thread kill");
|
||
assert_eq!(apple.clamp(16), 4);
|
||
assert_eq!(apple.clamp(1), 1);
|
||
|
||
let headless = MsaaFormats::default();
|
||
assert!(headless.formats.is_empty());
|
||
for n in [1, 2, 4, 8, 16] {
|
||
assert_eq!(headless.clamp(n), n, "no adapter, no opinion");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_sample_count_rounds_down_never_up() {
|
||
let level = |n| MsaaSetting { samples: n }.level();
|
||
assert_eq!(level(1), Msaa::Off);
|
||
assert_eq!(level(2), Msaa::Sample2);
|
||
assert_eq!(level(3), Msaa::Sample2);
|
||
assert_eq!(level(4), Msaa::Sample4);
|
||
assert_eq!(level(7), Msaa::Sample4);
|
||
assert_eq!(level(8), Msaa::Sample8);
|
||
// The reference clamps at 16; anything that reaches here is already in range, and the top
|
||
// of that range is still only eight samples of real hardware.
|
||
assert_eq!(level(16), Msaa::Sample8);
|
||
// 0 cannot arrive through the clamp, but the mapping must not panic if it ever does.
|
||
assert_eq!(level(0), Msaa::Off);
|
||
}
|
||
}
|