Files
benilla-octo/crates/benilla-app/src/world_backdrop.rs
T
SamandClaude Fable 5.1 edec5b2207 settings live in one registry, and the net drain starts to peel
- the CVars are one engine-side table that outlives every interface reload: a change fires its observer, the eight latched rows wait for RestartGx, and /console gains set, cvar_reset, cvar_default, cvarlist and help (2303)
- every push into the interface runs in one feed phase after the net drain and before the tick, held by a structural test on the built schedule (2304)
- subsystems own their packet handlers, dispatched in packet order; the auction house is the first family out of the 273-arm drain (2305)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:47:09 +07:00

821 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The world as the UI's **backdrop quad** — the seam that put the UI-over-world blend back into
//! gamma bytes (the third and last piece of the composite lane 0161 and 0254 built).
//!
//! ## The seam that was left
//!
//! Both halves of the frame already composite the way the reference's fixed-function device does,
//! and each does it by the same trick. The world (0161): every world shader emits raw gamma bytes,
//! the blur runs on them, and the FFXGlow combine takes the frame's one `srgb_to_linear` so the
//! sRGB present-encode restores the byte. The UI (0254): `ui_quad.wgsl` emits gamma values into an
//! `Rgba8UnormSrgb` target, so the store encodes, the blend's destination read decodes, and every
//! hardware blend is therefore arithmetic on the gamma value — `alphaMode="ADD"` really is
//! `dst + texel·α`, clamped, exactly as EGxBlend 3 does it.
//!
//! Two correct byte lanes, and **the UI-over-world blend fell in the seam between them.** The UI
//! camera composited its finished image onto the swapchain through its output blit, and the
//! swapchain view is sRGB, so that one blend — the only one that mixes UI with world — ran in
//! linear. 0254 named it as a residual and described it as "a small deviation on antialiased UI
//! edges over the world". It is not an edge artefact: it is every translucent UI pixel over the
//! 3D world, at full area.
//!
//! Measured on the chat dock, which is the most translucent surface the client has.
//! `ChatFrameTab`'s body is black at α = 102/255, so a docked tab must leave the scene at **60 %**
//! of its brightness; the unselected tab (frame α 0.5) at 80 %. Against a bare-scene capture of
//! the same pixels (`ui-chat-tabhover` at `$WOW_TABHOVER=9`) they measured **77.5 %** and **89 %** —
//! and a linear composite predicts exactly that, to within a byte, at every point checked
//! (scene 93 → 72 vs 72.5 predicted; 70 → 54 vs 53.9; 95 → 74 vs 74.1). The tab had almost no
//! plate, so the hover glow — an ADD, at full strength — sat on nothing and read as a blue lozenge
//! floating on grass, louder than the tab that was actually selected. That is what the director
//! reported, and it was never a chat bug.
//!
//! ## The fix: put the world *inside* the UI's byte buffer
//!
//! Not a third lane. The world camera renders off-screen, and the **first draw of the UI
//! camera's main pass** is the world's own final pass — the FFXGlow combine ([`FfxBackdrop`],
//! decision 2234) — rendered straight into the UI's byte target as the ground everything else is
//! painted on, inside the very pass that paints it (a pass of its own would cost a tile GPU a
//! load and a store of the whole target). Every UI blend
//! over the world is then the same blend as every UI blend over UI: the one 0254 already
//! verified, in the target it already verified it in. The output blit stops blending entirely (it
//! now carries an opaque frame), which retires the `rgb·a²` hazard 0254 had to patch
//! `PREMULTIPLIED_ALPHA_BLENDING` around.
//!
//! **It was a picture first** (1603): the combine wrote a full-window un-encoded float image that
//! this pass drew as its first quad, the quad re-encoding what it sampled back to the byte the
//! combine had computed. That is one write and one read of every pixel at eight bytes each, and
//! on a bandwidth-bound GPU it is a third of the full-screen chain (2234's rig). The combine now
//! stores that byte itself, in the UI lane's own terms (`ui_quad.wgsl`'s premultiply, no decode),
//! and the picture is gone. What the world camera still carries as a target is a **size-carrier**:
//! a one-byte image at the world's render size that nothing writes, kept because bevy sizes a
//! view's main texture from its target and the camera's logical viewport is held through it
//! ([`retarget_world_camera`]).
//!
//! Nothing about the world lane's arithmetic changes: the combine's byte math is the same, the
//! frame still holds exactly one decode — the UI lane's, at its end — and an opaque world pixel
//! comes out the byte it came out before, minus the float image's own rounding on the way.
//!
//! ## …and that seam turned out to be a dial: **render scale** (decision 1639)
//!
//! Once the world is a picture the UI paints on, the picture does not have to be the window's size.
//! [`RenderScale`] sizes it — the world renders at `window × scale`, the combine still covers the
//! window, and **the UI is untouched at native resolution**, because the combine runs at the UI
//! camera's own size and only *samples* the scaled world, and nothing here can move that. That is
//! the whole reason this is worth having rather than "run the game in a smaller window": text,
//! icons and frame art stay exactly as sharp as they were, and only the 3D pays.
//!
//! **Below 1 it buys frames; above 1 it is supersampling** — and above 1 is the half this machine
//! can measure, since `gxMultisample` defaults to off (1629) and the client therefore ships with no
//! antialiasing of any kind. At exactly 2.0 the combine's plain bilinear read of the scene *is* a
//! 2×2 box average (the destination pixel centre lands on the corner four texels share, weighting
//! each 0.25), so SSAA×2 needs no filter of its own.
//!
//! **The one number that must not move is the camera's LOGICAL viewport**, and
//! [`render_target_for`] is built around holding it fixed — [`retarget_world_camera`] says what
//! reads it (every pick ray in the client) and why getting it wrong is B169 over again.
use bevy::asset::RenderAssetUsages;
use bevy::camera::RenderTarget;
use bevy::prelude::*;
use bevy::render::camera::MipBias;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
use bevy::window::PrimaryWindow;
use crate::ui_pass::PlayerUiCamera;
use benilla_world::ffx_glow::FfxBackdrop;
use benilla_world::view::WorldCamera;
/// The **render scale** — benilla's own CVar `renderScale`, no 1.12 counterpart.
///
/// The era's answer to "my machine is too slow" was `gxResolution`: drop the whole backbuffer, UI
/// and all, and in fullscreen mode-set the display to match. We ship no exclusive mode at all
/// (decision 1627) and our UI is a separate pass over an off-screen world, so we can offer the
/// strictly better version of that trade — **shrink the 3D, keep the interface**. It is the same
/// knob every engine since has grown (Godot's `scaling_3d_scale`, Unity URP's `renderScale`,
/// Unreal's `r.ScreenPercentage`), and it is the standard lever for the one machine class we have a
/// real measurement from: the Steam Deck sitting at 94 % GPU busy (B329).
///
/// **Default 1.0 — off, and it has to be**, so every visual golden in the tree keeps meaning what
/// it meant: at 1.0 [`render_target_for`] returns the window's own physical size and the window's
/// own scale factor, unrounded and unmultiplied, which is bit-for-bit the pre-1639 lane.
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
pub(crate) struct RenderScale(pub(crate) f32);
/// The settable range of [`RenderScale`], shared by the CVar apply and the `$WOW_RENDER_SCALE` env
/// knob so the two cannot drift.
///
/// Wider than a settings row would offer (a slider belongs at 50–200 %, where every engine puts
/// it): this is the clamp that stops an absurd *value*, not the one that shapes the UI. The upper
/// end is deliberately past 2 because supersampling is also the instrument — the only way to price
/// a pixel on a machine whose present is railed at the display's grant (0362, and `crate::video`'s
/// note that macOS honours neither `AutoNoVsync` nor `Immediate`).
pub(crate) const RENDER_SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.25..=4.0;
/// The per-axis ceiling on the backdrop, in physical px — `wgpu::Limits::default()`'s
/// `max_texture_dimension_2d`, which is what Bevy asks the adapter for unless `WgpuSettings` says
/// otherwise.
///
/// Not a render-scale concern in origin: a window wider than this has always been a texture wgpu
/// refuses to create, and before 1639 nothing here looked. The ceiling is applied to the *ratio*
/// rather than to each axis (see [`render_target_for`]) so hitting it shrinks the picture instead of
/// reshaping it.
const MAX_RENDER_AXIS: u32 = 8192;
impl Default for RenderScale {
/// `$WOW_RENDER_SCALE` overrides the default, **session-only** — the A/B lever, the same posture
/// as `$WOW_MSAA` and `$WOW_FARCLIP`: a value pinned into `config.toml` would make a
/// measurement sticky across relaunches.
fn default() -> Self {
let scale = std::env::var("WOW_RENDER_SCALE")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.map_or(1.0, |v| {
v.clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end())
});
Self(scale)
}
}
/// The backdrop's size and the world camera's target scale factor, for a window of `px` physical
/// pixels at `window_factor`, rendered at `scale`.
///
/// **Two invariants, and the second is the load-bearing one.**
///
/// 1. The image keeps the window's aspect, so the ratio — never an individual axis — is what gets
/// clamped to [`MAX_RENDER_AXIS`]. Clamping the axes independently would letterbox the world
/// inside a quad that is still the window's shape.
/// 2. `size.x / factor` — the camera's LOGICAL viewport width — comes out the window's logical
/// width, whatever `scale` is. That is why the factor is derived from the size actually built
/// rather than from `scale`: the `round()` moves the true ratio by up to half a pixel, and it is
/// the logical number every pick ray is denominated in (see [`retarget_world_camera`]).
///
/// At `scale == 1.0` both lines are exact identities in IEEE — `px × 1.0` is `px`, `size.x / px.x`
/// is `1.0`, `window_factor × 1.0` is `window_factor` — so the pre-1639 numbers come back
/// bit-for-bit and no golden can move. A test welds that.
fn render_target_for(px: UVec2, window_factor: f32, scale: f32) -> (UVec2, f32) {
let px = px.max(UVec2::ONE);
let ceiling = |axis: u32| MAX_RENDER_AXIS as f32 / axis as f32;
let scale = scale
.clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end())
.min(ceiling(px.x))
.min(ceiling(px.y));
let size = (px.as_vec2() * scale)
.round()
.as_uvec2()
.clamp(UVec2::ONE, UVec2::splat(MAX_RENDER_AXIS));
(size, window_factor * size.x as f32 / px.x as f32)
}
/// The texture LOD bias a render at `effective` scale owes its mipmapped world textures.
///
/// Rendering smaller doubles every screen-space derivative, so every sampler picks a **coarser**
/// mip — and that blur then gets stretched back up by the resolve, on top of the resolution loss.
/// Every engine that ships a render scale compensates with `log2(scale)`, and every one of them has
/// to do it in the shader, because WebGPU dropped the sampler's LOD bias: `wgpu::SamplerDescriptor`
/// (27.0.1) carries `lod_min_clamp`, `lod_max_clamp` and `anisotropy_clamp`, and nothing else.
///
/// **Clamped at 0 — downscaling is compensated, upscaling is left alone.** Above 1.0 the smaller
/// derivatives already pick a *sharper* mip, and that is not an artefact to correct: it is exactly
/// what makes supersampling work, since the resolve then averages detail that a native-resolution
/// frame could never have resolved. Feeding it `+log2(scale)` would hand back the blurrier mip and
/// throw the win away.
///
/// Bevy carries the plumbing already: [`MipBias`] on the camera is extracted into the view uniform
/// (`View::mip_bias`, defaulting 0.0 when absent), and `pbr_input_from_standard_material` applies it
/// to every sample — so the whole M2/WMO/creature/doodad lane gets this for free and only our own
/// hand-written samplers had to be taught (`terrain.wgsl`, `static_gx.wgsl`, `liquid.wgsl`, and the
/// coverage re-sample in `wow_model.wgsl`).
fn mip_bias(effective: f32) -> f32 {
effective.log2().min(0.0)
}
/// The world camera's target — the size-carrier (see the module doc) — and the claim that makes
/// the world the first draw of the UI camera's main pass.
///
/// Sized in physical pixels **× [`RenderScale`]**; at the default 1.0 that matches the swapchain 1:1
/// and the combine's read of the world is an identity resample, which is the property the
/// composite lane was built on and the reason the scale ships off.
#[derive(Resource)]
pub(crate) struct WorldBackdrop {
/// The image the world camera targets. Nothing writes it (the camera runs `Skip`, its final
/// pass is the UI camera's — [`FfxBackdrop`]) and nothing samples it; it exists to give the
/// view its render size and its logical viewport.
pub(crate) image: Handle<Image>,
/// The size the image was last built at, in physical px — the resize gate.
size: UVec2,
/// What the world camera's target was last stamped with: **which image**, and the scale factor
/// that went with it. Both halves gate the re-stamp — the factor because a stale one is B169,
/// the image because a rebuilt backdrop is a NEW asset (see [`track_render_size`]) and a camera
/// left aiming at the retired one renders into nothing. `None` until the first stamp.
stamped: Option<(AssetId<Image>, f32)>,
}
impl WorldBackdrop {
/// The size the world is actually being rendered at, in physical px — the window's size times
/// [`RenderScale`], after the rounding and the axis ceiling. The number an FPS probe has to
/// print beside its frame time, since nothing else in the line can imply it.
pub(crate) fn render_size(&self) -> UVec2 {
self.size
}
}
/// A fresh world-camera target at `size` physical px: the size-carrier of the module doc. One
/// byte a pixel and a render attachment only — nothing writes it and nothing samples it, so the
/// cheapest format a camera target may have is the honest one, and the CPU copy is dropped once
/// the render world has it.
fn new_world_target(size: UVec2) -> Image {
let mut image = Image::new_fill(
Extent3d {
width: size.x.max(1),
height: size.y.max(1),
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[0],
TextureFormat::R8Unorm,
RenderAssetUsages::RENDER_WORLD,
);
image.texture_descriptor.usage = TextureUsages::RENDER_ATTACHMENT;
image
}
fn window_physical_size(window: &Window) -> UVec2 {
UVec2::new(
window.physical_width().max(1),
window.physical_height().max(1),
)
}
fn setup_backdrop(
mut commands: Commands,
mut images: ResMut<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
scale: Res<RenderScale>,
) {
let (size, _) = windows.single().map_or((UVec2::new(1280, 720), 1.0), |w| {
render_target_for(
window_physical_size(w),
w.resolution.scale_factor(),
scale.0,
)
});
let image = images.add(new_world_target(size));
commands.insert_resource(WorldBackdrop {
image,
size,
// Nothing stamped yet, so `retarget_world_camera`'s first run always fires — before any
// camera exists to point at the image.
stamped: None,
});
}
/// Keep the target at `window × `[`RenderScale`]. A stale-sized target would still *work* (the
/// combine samples whatever size the world rendered at) — what it would break is the pairing: the
/// size and the factor [`retarget_world_camera`] stamps are two halves of one number, and a size
/// that moved without the factor following it is exactly the B169 defect with a different
/// constant.
///
/// Runs before the stamp (the plugin's `.chain()`), so the factor is always computed against the
/// image that now exists.
///
/// ## The rebuild publishes a NEW asset — it does not write through the old handle (decision 1647)
///
/// It used to do exactly that (`*images.get_mut(&handle) = new_backdrop_image(size)`), and **the
/// world froze**: change any graphics setting or resize the window in the world and the 3D stopped
/// dead at the frame of the change while the interface carried on — the director's report of
/// 2026-08-27. Mutating an `Image` behind its handle makes a whole new GPU texture, and the UI
/// pass's material cache, keyed on `AssetId<Image>`, kept its bind group on the texture just
/// thrown away, sampling it forever.
///
/// Since 2234 no material samples this image at all, so that door is closed; the rule stays
/// because it was never that cache's rule but the tree's: **`AssetId<Image>` is the name of a GPU
/// texture, so a new GPU texture gets a new name.** Every consumer then reacts on its own — here
/// [`retarget_world_camera`] re-points the camera — and nothing anywhere can hold a stale view by
/// a name that did not change. The retired asset is **removed**, not merely dropped, for the same
/// reason: a name that goes away is one nothing can keep by mistake, and `ui_pass`'s rebuild
/// forgets materials on exactly that event.
fn track_render_size(
mut backdrop: ResMut<WorldBackdrop>,
mut images: ResMut<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
scale: Res<RenderScale>,
) {
let Ok(window) = windows.single() else {
return;
};
let (size, _) = render_target_for(
window_physical_size(window),
window.resolution.scale_factor(),
scale.0,
);
if size == backdrop.size {
return;
}
let retired = std::mem::replace(&mut backdrop.image, images.add(new_world_target(size)));
images.remove(&retired);
backdrop.size = size;
// Said out loud on every change, because a measurement taken at the wrong scale looks
// exactly like a measurement: the pixels the GPU is actually being asked for are the one
// term no probe line could infer from the window.
info!(
"render scale {:.3}: world renders at {}x{} into a {}x{} window",
scale.0,
size.x,
size.y,
window.physical_width(),
window.physical_height()
);
}
/// Point the world camera at the backdrop instead of the swapchain — **carrying the window's own
/// scale factor**.
///
/// A system rather than a component on the spawn, because there are two spawn sites for the same
/// camera (the real one and `player::setup`'s no-client-data fallback) and neither should have to
/// know about the composite lane. `benilla-worldview` is unaffected — it links `benilla-world`, not
/// this crate, and has no UI camera to composite with, so its world camera keeps the swapchain.
///
/// **The scale factor is the load-bearing half.** A camera's logical↔physical conversions all go
/// through its target's `RenderTargetInfo`: a WINDOW target reports the window's physical size and
/// the window's scale factor, so `logical_viewport_size` is the window's LOGICAL size — which is
/// the space `Window::cursor_position` reports in, and the space every caller here works in
/// (`capture::pick_probe`'s header says it outright: *"`viewport_to_world` works in logical units
/// and a Retina capture is 2× the logical window"*). `From<Handle<Image>>` builds an
/// `ImageRenderTarget` with `scale_factor: 1.0`, and this backdrop is sized in **physical** px — so
/// the moment the world camera was retargeted at it, its logical viewport became the PHYSICAL size
/// and every conversion silently gained a factor of the display's scale.
///
/// On a 2× display that aims every world pick ray at a quarter of the screen: the ray for a cursor
/// at the centre goes to the upper-left quadrant. It hit the whole client at once — the GameObject
/// pick (the cog that "barely appears" and the right-click that "does nothing half the time" —
/// B169's fourth half), the unit pick and its occlusion ray, and the `world_to_viewport` side
/// (chat bubbles, `target::scan`). Stamping the window's real scale factor restores the
/// pre-composite semantics for all of them at once, which is why it lives here and not as a
/// conversion at each of the eight call sites.
///
/// Re-stamped when the factor changes, not only on `Added`: dragging the window between a Retina
/// and a non-Retina display changes it mid-session — and, since 1639, so does moving
/// [`RenderScale`], which multiplies into exactly the same number.
///
/// **Render scale rides here rather than anywhere else precisely because of the paragraph above.**
/// The camera's logical viewport is `image_size / scale_factor`; scaling both by the same ratio
/// leaves it — and therefore the projection (`camera_system` feeds `logical_viewport_size` into
/// `Projection::update`) and every pick ray (`viewport_to_ndc` and `world_to_viewport_core` read
/// `logical_viewport_rect()` and nothing else) — **arithmetically unchanged**. Picking is not
/// "still correct after render scale"; it cannot see render scale at all.
///
/// The camera also carries the [`MipBias`] that a scaled render owes its textures — see
/// [`mip_bias`]. Absent, Bevy's view uniform reads 0.0, so the component is inserted every stamp
/// rather than only when non-zero: a camera left holding last scale's bias is the same class of
/// stale-pairing bug as a stale factor.
fn retarget_world_camera(
mut commands: Commands,
mut backdrop: ResMut<WorldBackdrop>,
windows: Query<&Window, With<PrimaryWindow>>,
added: Query<(), Added<WorldCamera>>,
mut cameras: Query<(Entity, &mut RenderTarget), With<WorldCamera>>,
) {
let (px, window_factor) = windows.single().map_or((UVec2::ONE, 1.0), |w| {
(window_physical_size(w), w.resolution.scale_factor())
});
// The image's real size, not the requested one: `track_render_size` ran first and may have
// clamped, and the factor must describe the texture that exists.
let scale_factor = window_factor * backdrop.size.x as f32 / px.x.max(1) as f32;
// **The image as well as the factor.** A rebuild retires the old asset and publishes a new one
// ([`track_render_size`]), and on a pure window resize the factor does NOT move — so a
// factor-only gate would leave every world camera aiming at an asset that no longer exists.
let want = (backdrop.image.id(), scale_factor);
let moved = backdrop.stamped != Some(want);
if !moved && added.is_empty() {
return;
}
backdrop.stamped = Some(want);
let bias = MipBias(mip_bias(scale_factor / window_factor));
for (entity, mut current) in &mut cameras {
*current = RenderTarget::Image(bevy::camera::ImageRenderTarget {
handle: backdrop.image.clone(),
scale_factor,
});
commands.entity(entity).insert(bias.clone());
}
}
/// Point the player-UI camera's ground pass at the world camera drawing this frame
/// ([`FfxBackdrop`]): the active one, `None` when there is none.
///
/// With no world (the glue screens, the loading screen, a gated camera) the world view's main
/// texture holds a stale or never-written frame, and grounding the interface on it would be
/// worse than the transparent clear the UI camera falls back to. Whether the camera named here
/// actually rendered this frame is the render world's to know, and it checks
/// (`ffx_glow::prepare_backdrops`).
fn claim_backdrop(
cameras: Query<(Entity, &Camera), With<WorldCamera>>,
mut ui: Query<&mut FfxBackdrop, With<PlayerUiCamera>>,
) {
let source = cameras
.iter()
.find_map(|(entity, camera)| camera.is_active.then_some(entity));
for mut backdrop in &mut ui {
// Compare first: the component is extracted every frame regardless, and a write here
// would trip change detection for nothing.
if backdrop.source != source {
backdrop.source = source;
}
}
}
/// Owns the world camera's target (the size-carrier) and the UI camera's claim on the world.
/// See the module doc.
pub(crate) struct WorldBackdropPlugin;
/// Render scale's change callback (1639, 2303). Clamped at the knob's edge like every other
/// numeric row; the backdrop re-sizes on the next frame and the world camera's target factor
/// follows it in the same pass, which is what keeps the pick rays where they were.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut scale: ResMut<RenderScale>) {
if ev.is("renderScale") {
scale.0 = ev
.num()
.clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end());
}
}
impl Plugin for WorldBackdropPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<RenderScale>()
.add_systems(Startup, setup_backdrop)
.add_systems(
Update,
(track_render_size, retarget_world_camera, claim_backdrop).chain(),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::image::TextureFormatPixelInfo as _;
/// **The world camera's target is a size-carrier and nothing else** (decision 2234): one byte
/// a pixel, a render attachment because a camera target must be one, and NOT a texture —
/// nothing samples it. The float image this used to be (1603) carried the world to the UI
/// pass's first quad; the combine carries it now, straight into the UI target, and a future
/// "make the target samplable again" edit is a second copy of the world coming back.
#[test]
fn the_world_target_is_a_size_carrier_only() {
let image = new_world_target(UVec2::new(320, 200));
assert_eq!(image.texture_descriptor.format, TextureFormat::R8Unorm);
let usage = image.texture_descriptor.usage;
assert!(usage.contains(TextureUsages::RENDER_ATTACHMENT));
assert!(
!usage.contains(TextureUsages::TEXTURE_BINDING),
"nothing samples the world's target: the UI camera's ground pass reads the view's \
main texture, not this image"
);
assert_eq!(
image.asset_usage,
RenderAssetUsages::RENDER_WORLD,
"the CPU copy of a texture nothing reads is dropped once the render world has it"
);
}
/// The UI camera's ground pass follows the world camera that is drawing: the active one,
/// `None` while there is none.
#[test]
fn the_ui_camera_claims_the_drawing_world_camera() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_systems(Update, claim_backdrop);
let ui = app
.world_mut()
.spawn((PlayerUiCamera, FfxBackdrop::default()))
.id();
let world_cam = app
.world_mut()
.spawn((
WorldCamera,
Camera {
is_active: true,
..default()
},
))
.id();
let source = |app: &mut App| app.world().get::<FfxBackdrop>(ui).unwrap().source;
app.update();
assert_eq!(source(&mut app), Some(world_cam), "active: claimed");
app.world_mut()
.get_mut::<Camera>(world_cam)
.unwrap()
.is_active = false;
app.update();
assert_eq!(source(&mut app), None, "gated: the claim goes with it");
}
/// **The world camera's target carries the WINDOW's scale factor, not `1.0`** — the pick
/// regression of 2026-08-26 (B169's fourth half).
///
/// `From<Handle<Image>>` builds an `ImageRenderTarget` with `scale_factor: 1.0`, and this
/// backdrop is sized in PHYSICAL px. A camera whose target says "physical size, scale 1"
/// reports that physical size as its LOGICAL viewport — but every caller works in logical
/// units, because that is what `Window::cursor_position` reports in. So on a 2× display the
/// world's pick ray for a centred cursor went to the upper-left quadrant, and the cog "barely
/// appeared" while right-click "did nothing half the time". It hit all eight
/// `viewport_to_world`/`world_to_viewport` sites at once, not just the GameObject pick.
///
/// The scale factor is re-stamped when it CHANGES, not only on `Added`: dragging the window
/// from a Retina display to a non-Retina one changes it mid-session, and a stale factor is the
/// same defect with a different constant.
#[test]
fn the_world_cameras_target_carries_the_windows_scale_factor() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
.init_asset::<Image>();
let window = |sf: f32| {
let mut w = Window::default();
w.resolution.set_scale_factor(sf);
w
};
app.world_mut().spawn((window(2.0), PrimaryWindow));
app.init_resource::<RenderScale>()
.add_systems(Startup, setup_backdrop)
// Chained exactly as the plugin chains them: since 1639 the stamp reads the size the
// resize pass settled on, so a test that ran the stamp alone would be testing a pairing
// that never happens.
.add_systems(Update, (track_render_size, retarget_world_camera).chain());
let cam = app
.world_mut()
.spawn((WorldCamera, RenderTarget::default()))
.id();
app.update();
let stamped = |app: &App, e: Entity| match app.world().entity(e).get::<RenderTarget>() {
Some(RenderTarget::Image(t)) => t.scale_factor,
_ => panic!("the world camera must target the backdrop image"),
};
assert_eq!(
stamped(&app, cam),
2.0,
"a 2× display: the target must report the window's scale factor, or every logical \
cursor position is read as a physical one and the pick ray misses by 2×"
);
// …and it follows the window across displays.
let mut w = app.world_mut().query::<&mut Window>();
w.single_mut(app.world_mut())
.unwrap()
.resolution
.set_scale_factor(1.0);
app.update();
assert_eq!(
stamped(&app, cam),
1.0,
"moving to a 1× display re-stamps: a stale factor is the same defect, inverted"
);
}
/// Physical, not logical: the target is the world's render size. A zero-size window (minimised
/// on some platforms) must still produce a legal texture rather than a device error.
#[test]
fn a_degenerate_size_still_builds_a_legal_texture() {
let image = new_world_target(UVec2::ZERO);
assert_eq!(image.texture_descriptor.size.width, 1);
assert_eq!(image.texture_descriptor.size.height, 1);
assert_eq!(
image.data.as_ref().map(Vec::len),
TextureFormat::R8Unorm.pixel_size().ok()
);
}
/// **Scale 1.0 is bit-for-bit the pre-1639 lane** — the property every visual golden in the
/// tree rests on. Not "within a pixel": the same `UVec2` and the same `f32`, because
/// `px × 1.0`, `size.x / px.x` and `f × 1.0` are all exact in IEEE and a rounding introduced
/// here would move the whole world by a sub-pixel resample.
#[test]
fn scale_one_reproduces_the_windows_own_numbers_exactly() {
for px in [
UVec2::new(1280, 720),
UVec2::new(3200, 1800),
UVec2::new(1601, 901), // odd on both axes — the case a `/ 2` would round
] {
for sf in [1.0, 1.5, 2.0] {
assert_eq!(render_target_for(px, sf, 1.0), (px, sf), "{px} at {sf}×");
}
}
}
/// **The camera's logical viewport does not move** — the whole design, and the invariant that
/// makes render scale invisible to picking. `logical = image_size / scale_factor`
/// (`bevy_camera`'s `to_logical`), and that is what `viewport_to_ndc` and
/// `world_to_viewport_core` read; if it drifts, B169 comes back at the drift's magnitude.
///
/// Sub-pixel is the tolerance because the image is integer: the ratio is derived back out of
/// the size that was actually built, so x is exact and y carries at most one `round()`.
#[test]
fn every_scale_keeps_the_logical_viewport_the_windows_own() {
for px in [UVec2::new(1280, 720), UVec2::new(3200, 1800)] {
for sf in [1.0, 2.0] {
let want = px.as_vec2() / sf;
for scale in [0.25, 0.5, 0.6667, 0.75, 1.0, 1.25, 2.0, 4.0] {
let (size, factor) = render_target_for(px, sf, scale);
let logical = size.as_vec2() / factor;
assert!(
(logical.x - want.x).abs() < 0.001,
"{px} at {sf}× scaled {scale}: logical width {logical:?}, want {want:?}"
);
assert!(
(logical.y - want.y).abs() < 1.0,
"{px} at {sf}× scaled {scale}: logical height {logical:?}, want {want:?}"
);
}
}
}
}
/// The axis ceiling clamps the **ratio**, so the picture keeps the window's aspect instead of
/// being letterboxed inside a quad that is still the window's shape. It binds on an oversized
/// window at scale 1.0 too — a >8192 px window was a texture wgpu refuses to create long before
/// this dial existed, and nothing here used to look.
#[test]
fn the_axis_ceiling_shrinks_the_picture_it_does_not_reshape_it() {
let px = UVec2::new(3840, 2160);
let (size, _) = render_target_for(px, 1.0, 4.0);
assert_eq!(size.x, MAX_RENDER_AXIS);
let aspect = |v: UVec2| v.x as f32 / v.y as f32;
assert!(
(aspect(size) - aspect(px)).abs() < 0.001,
"aspect kept: {size}"
);
// …and the clamp is not render scale's alone.
let huge = UVec2::new(10240, 4320);
let (size, _) = render_target_for(huge, 1.0, 1.0);
assert!(
size.x <= MAX_RENDER_AXIS && size.y <= MAX_RENDER_AXIS,
"{size}"
);
assert!(
(aspect(size) - aspect(huge)).abs() < 0.001,
"aspect kept: {size}"
);
}
/// **The bias compensates downscaling and leaves supersampling alone.** A negative bias where a
/// scale is > 1 would hand back the coarser mip and throw away the only thing SSAA buys; a
/// missing one where it is < 1 is the double blur every engine's render scale corrects.
#[test]
fn the_mip_bias_is_log2_below_one_and_nothing_above_it() {
assert!((mip_bias(0.5) - -1.0).abs() < 1e-6);
assert!((mip_bias(0.25) - -2.0).abs() < 1e-6);
assert_eq!(
mip_bias(1.0),
0.0,
"off must be exactly off — 0.0, not -0.0's cousin"
);
assert_eq!(mip_bias(2.0), 0.0);
assert_eq!(mip_bias(4.0), 0.0);
}
/// **A rebuild publishes a NEW image, and the camera follows it onto the new one** — the
/// 2026-08-27 world-freeze (decision 1647).
///
/// Writing the new size through the OLD handle is what froze the world: `ui_pass` keys its
/// material cache on `AssetId<Image>`, and Bevy prepares a material's bind group once, so the
/// same id handed back the same bind group — still holding the `TextureView` of the texture
/// that had just been thrown away. The world camera drew into the new texture; the UI kept
/// sampling the dead one, forever.
///
/// Three assertions, because the fix has three halves and any one of them alone is still broken:
/// the id must MOVE (or the cache cannot tell), the old asset must be GONE (or the retired
/// image is pinned by whatever still names it), and the camera must be re-stamped onto the new
/// handle — that last one is the half a factor-only gate misses, since a pure window resize
/// does not move the scale factor at all.
#[test]
fn a_rebuilt_backdrop_is_a_new_asset_and_the_camera_follows_it() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
.init_asset::<Image>();
let mut w = Window::default();
w.resolution.set_scale_factor(2.0);
app.world_mut().spawn((w, PrimaryWindow));
app.insert_resource(RenderScale(1.0))
.add_systems(Startup, setup_backdrop)
.add_systems(Update, (track_render_size, retarget_world_camera).chain());
let cam = app
.world_mut()
.spawn((WorldCamera, RenderTarget::default()))
.id();
app.update();
let first = app.world().resource::<WorldBackdrop>().image.id();
// The director's own path: move the render scale while the world is up.
app.insert_resource(RenderScale(0.5));
app.update();
let second = app.world().resource::<WorldBackdrop>().image.id();
assert_ne!(
first, second,
"a rebuilt backdrop is a NEW GPU texture, so it must be a new AssetId — \
the same id hands `ui_pass` back a bind group pointing at the retired texture"
);
assert!(
!app.world().resource::<Assets<Image>>().contains(first),
"the retired image must be removed, not merely dropped: a cached material still \
holds a strong handle to it"
);
match app.world().entity(cam).get::<RenderTarget>() {
Some(RenderTarget::Image(t)) => assert_eq!(
t.handle.id(),
second,
"the camera must be re-stamped onto the new image"
),
_ => panic!("the world camera must target the backdrop image"),
}
}
/// A pure WINDOW resize moves the image without moving the scale factor — the case a
/// factor-only re-stamp gate silently misses, leaving the camera aimed at a removed asset.
#[test]
fn a_window_resize_restamps_the_camera_even_though_the_factor_does_not_move() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
.init_asset::<Image>();
let mut w = Window::default();
w.resolution.set_scale_factor(2.0);
let win = app.world_mut().spawn((w, PrimaryWindow)).id();
app.insert_resource(RenderScale(1.0))
.add_systems(Startup, setup_backdrop)
.add_systems(Update, (track_render_size, retarget_world_camera).chain());
let cam = app
.world_mut()
.spawn((WorldCamera, RenderTarget::default()))
.id();
app.update();
let before = match app.world().entity(cam).get::<RenderTarget>() {
Some(RenderTarget::Image(t)) => (t.handle.id(), t.scale_factor),
_ => panic!("the world camera must target the backdrop image"),
};
app.world_mut()
.entity_mut(win)
.get_mut::<Window>()
.expect("the primary window")
.resolution
.set(640.0, 400.0);
app.update();
let after = match app.world().entity(cam).get::<RenderTarget>() {
Some(RenderTarget::Image(t)) => (t.handle.id(), t.scale_factor),
_ => panic!("the world camera must target the backdrop image"),
};
assert_ne!(before.0, after.0, "the resize must re-point the camera");
assert!(
(before.1 - after.1).abs() < 1e-6,
"at scale 1.0 the factor is the window's own and does not move on a resize — \
which is exactly why the re-stamp cannot be gated on it alone: {} vs {}",
before.1,
after.1
);
assert!(
app.world().resource::<Assets<Image>>().contains(after.0),
"the camera must point at an image that still exists"
);
}
/// End to end on a live `App`: a half-scale world on a 2× display renders into a half-size
/// image, stamps a factor of 1.0 — `image / factor` is still the window's logical size — and
/// carries `MipBias(-1)`.
#[test]
fn a_half_scale_world_halves_the_image_the_factor_and_the_mip() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
.init_asset::<Image>();
let mut w = Window::default();
w.resolution.set_scale_factor(2.0);
let px = window_physical_size(&w);
app.world_mut().spawn((w, PrimaryWindow));
app.insert_resource(RenderScale(0.5))
.add_systems(Startup, setup_backdrop)
.add_systems(Update, (track_render_size, retarget_world_camera).chain());
let cam = app
.world_mut()
.spawn((WorldCamera, RenderTarget::default()))
.id();
app.update();
let backdrop = app.world().resource::<WorldBackdrop>();
assert_eq!(backdrop.size, UVec2::new(px.x / 2, px.y / 2));
match app.world().entity(cam).get::<RenderTarget>() {
Some(RenderTarget::Image(t)) => assert!(
(t.scale_factor - 1.0).abs() < 1e-6,
"half the pixels at half the factor is the same logical viewport: {}",
t.scale_factor
),
_ => panic!("the world camera must target the backdrop image"),
}
let bias = app.world().entity(cam).get::<MipBias>().expect("a bias");
assert!(
(bias.0 - -1.0).abs() < 1e-6,
"half scale owes one mip level: {}",
bias.0
);
}
}