Files
benilla-octo/crates/benilla-app/src/ui_pass.rs
T
SamandClaude Fable 5 3b6975fdef the pet gets its frame, flooded rooms fill, and macros really fire
- the pet gets its own unit frame beside yours, and an Attack order no longer latches as the standing command
- five rooms that always read bone dry hold their water now, the Deeprun Tram's flooded stretches among them
- macros fire through the client's own chat door, and the macro window fits its tabs and scrolls its icon chooser
- the options window grows a Nameplates page and its first dropdown, and the name-display switches apply live in the world

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:03:43 +07:00

1071 lines
52 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 player-UI quad pass (decision 0068 §2): a custom sorted-quad renderer for the WoW-engine UI,
//! **not** `bevy_ui`'s flexbox/hierarchy z-model — probe B (2026-07-02) measured `bevy_ui` fighting the
//! WoW `(stratum, level, layer, sublayer, decl)` total order and chose a dedicated pass instead. This
//! module is the render **substrate** only: [`UiQuad`]/[`UiQuads`] is the data contract the future
//! widget arena (frames, `FontString`s, the anchor resolver) builds against — nothing here knows about
//! frames, strata, or Lua. A caller flattens its own ordering into `z_key: u64` (a WoW frame's
//! `(stratum, level, layer, sublayer, decl)` tuple packs into one `u64` losslessly) and this pass just
//! draws the resulting flat list back-to-front.
//!
//! ## Camera / compositing order (decision 0025)
//! Three cameras share the window, ordered low→high: the 3D world camera (order 0, untouched) → this
//! module's [`PlayerUiCamera`] (order 1) → the egui dev-overlay camera (bumped from 1 to 2 in
//! `debug_panel::spawn_egui_camera` — the one sanctioned edit outside this file). 0025 established
//! "dev overlays composite over a full-screen world"; this pass slots the *player* UI into that
//! ordering exactly where 0025 already reserved it ("where player-UI arbitration will live later") —
//! **dev overlays stay on top of the player UI, which stays on top of the world.** The camera renders
//! nothing from the 3D world (its own [`RenderLayers`] layer, disjoint from the world camera's default
//! layer 0) and composites over whatever the world camera already painted (`CameraOutputMode::Write`
//! with alpha blending, no clear — same pattern as the egui overlay camera).
//!
//! ## Colour space: the UI gamma composite lane (decision 0254)
//! The reference draws its whole UI through the fixed-function device into an 8-bit backbuffer, so
//! every UI multiply and every UI blend is arithmetic on **gamma bytes**, clamped at each write.
//! This pass reproduces that: [`UiQuad::color`] rides through unconverted, `ui_quad.wgsl` puts its
//! sampled texel back into byte space and premultiplies there, and the hardware blend composes in
//! gamma. [`crate::ui_gamma`] then decodes the finished image to linear exactly once, so the sRGB
//! target's write re-encodes it to the client's byte. This is the UI sibling of decision 0161's
//! world lane, whose one decode lives in the FFXGlow combine.
//!
//! Getting this wrong is not a subtlety: composited in linear, `alphaMode="ADD"` lands at roughly a
//! quarter of the reference's lift (sRGB's power law is superadditive, so a linear add is always
//! weaker than a byte add) — the near-invisible gossip/quest/trainer row highlight that forced 0254.
//!
//! ## Rendering approach
//! Quads are stable-sorted by `z_key` ascending (the WoW total order: lower key = further back). CPU
//! clip (an axis-aligned intersection against [`UiQuad::clip`], reprojecting UVs proportionally) is
//! applied per quad; a quad clipped to nothing emits no geometry. The sorted, clipped list is then
//! split into **contiguous runs of matching texture identity** (texture-less quads use a shared 1×1
//! white image, so they participate in the same run-splitting as textured ones) — each run becomes one
//! `Mesh2d`/`ColorMaterial` entity. Painter's order within a run comes from vertex/index submission
//! order in the single draw call (straight-alpha blending composites strictly in submission order, no
//! depth test); painter's order **across** runs comes from each run's entity `Transform.z` — Bevy's 2D
//! transparent phase sorts draws by ascending world-space z (`bevy_sprite_render`'s
//! `mesh2d::material::queue_material2d_meshes`), so runs are placed at increasing z in the same order
//! they appear in the sorted quad list. This is what makes the *contiguous-run* choice load-bearing
//! rather than a pure optimization: batching is scoped to a run, not deduped globally by texture, so
//! the total order holds **even when two quads share a texture but something else is sandwiched
//! between them in z_key** — that in-between quad still forces a run break, so the shared texture ends
//! up in two (or more) separate draw calls rather than one. The batching consequence: UI content that
//! interleaves textures frequently in z-order (e.g. icon, text, icon, text, …) pays one draw call per
//! transition instead of the two-call minimum a global per-texture dedup would give — correctness over
//! draw-call count. A texture atlas is the standard fix (turns "different texture" into "different UV
//! rect on one atlas", collapsing runs) and is a natural extension once the widget arena needs it.
//!
//! ## Explicitly out of scope (v1)
//! - **Real scissor rects.** [`UiQuad::clip`] is a CPU stand-in (rebuild-time geometry clip); a
//! `ScrollFrame`-driven GPU scissor rect (per 0068 §2, "per-ScrollFrame scissor rects") is a later
//! render-pass change, not a data-contract change — `clip` can stay as an escape hatch for the rare
//! non-rectangular-region case even after real scissor lands.
//! - **Text.** `FontString`s render through `cosmic-text` per 0068 §2; nothing here rasterizes glyphs.
//! - **Extraction from a widget arena.** Nothing yet *produces* [`UiQuad`]s from frames/anchors/strata —
//! that's the widget-arena milestone this pass is built ahead of. The `dev`-gated demo feeder below
//! fills [`UiQuads`] directly to prove the pass in isolation.
use bevy::asset::RenderAssetUsages;
use bevy::camera::visibility::RenderLayers;
use bevy::camera::CameraOutputMode;
use bevy::image::Image;
use bevy::math::Rect;
use bevy::mesh::{Indices, Mesh, PrimitiveTopology};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Extent3d,
TextureDimension, TextureFormat,
};
use bevy::shader::ShaderRef;
use bevy::sprite_render::{Material2d, Material2dKey, Material2dPlugin};
use bevy::window::PrimaryWindow;
use crate::assets::{AssetSet, WorldAssets};
/// Per-corner UVs for a quad: one explicit `(u,v)` sample per **screen** corner, in the
/// [`Run::push_quad`] winding — `[top-left, top-right, bottom-right, bottom-left]`. Deliberately four
/// independent pairs, **not** a `(min,max)` rect, for two reasons that a 2-corner rect can't serve:
///
/// 1. **Mirror/flip preservation.** A WoW `<TexCoords left="1.0" right="0.09375">` (the PlayerFrame
/// ring's horizontal mirror) has `left > right` on purpose; a normalizing `Rect` would silently
/// un-mirror it. Corners carry `TL.u > TR.u` (and top>bottom for a vertical flip) intact.
/// 2. **Rotation.** A backdrop TOP/BOTTOM edge maps atlas-**u** to screen-**Y** and atlas-v to
/// screen-X (the slice is turned 90°). That's not expressible as `u varies only in x, v only in y`
/// — it needs a distinct `(u,v)` at each corner, which this carries and [`clip_quad`] bilinearly
/// reprojects.
///
/// This subsumes the old `{u0,v0,u1,v1}` two-corner form (`ui: mirrored TexCoords`, commit 87c97d7).
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct UvRect {
/// `[top-left, top-right, bottom-right, bottom-left]`, each `[u, v]` — the `push_quad` winding.
pub corners: [[f32; 2]; 4],
}
impl UvRect {
/// The full texture, no crop.
pub const FULL: Self = Self {
corners: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
};
/// From a WoW `[left, right, top, bottom]` `<TexCoords>` tuple: an axis-aligned crop
/// (`TL=(left,top)`, `TR=(right,top)`, `BR=(right,bottom)`, `BL=(left,bottom)`). No
/// normalization — a mirrored (`left>right`) or flipped (`top>bottom`) tuple keeps its
/// orientation, which is the whole point of this type.
pub fn from_tex_coords([left, right, top, bottom]: [f32; 4]) -> Self {
Self {
corners: [[left, top], [right, top], [right, bottom], [left, bottom]],
}
}
/// From explicit per-corner UVs in the `push_quad` winding (`[TL, TR, BR, BL]`) — the backdrop
/// edge/corner pieces, whose rotated slices are not an axis-aligned crop.
pub fn from_corners(corners: [[f32; 2]; 4]) -> Self {
Self { corners }
}
/// From an already-normalized [`Rect`] (e.g. a glyph atlas cell, which is never mirrored):
/// `min→TL`, `max→BR`.
pub fn from_rect(r: Rect) -> Self {
Self::from_tex_coords([r.min.x, r.max.x, r.min.y, r.max.y])
}
}
/// One resolved, screen-space quad — the render substrate's entire input. A future widget arena
/// (frames/anchors/strata) flattens into this; nothing here is frame-shaped.
#[derive(Clone, PartialEq)]
pub(crate) struct UiQuad {
/// Screen pixels, **y-down**, already anchor-resolved (no relative/parent math left to do).
pub rect: Rect,
/// Total paint order — stable-sorted ascending, so **higher draws later (on top)**. A WoW frame's
/// `(stratum, level, layer, sublayer, decl)` tuple is expected to pack into this losslessly.
pub z_key: u64,
/// `None` draws as flat-shaded (via a shared 1×1 white texture — see the module doc's batching
/// note for why texture-less quads still participate in texture-identity run-splitting).
pub texture: Option<Handle<Image>>,
/// The texture sub-rect (atlas cell / crop) as raw corners — see [`UvRect`] for why this is not a
/// [`Rect`] (mirror preservation). Default: the full texture ([`UvRect::FULL`]).
pub uv: UvRect,
/// Straight-alpha (non-premultiplied) vertex color, multiplied against the sampled texel.
/// **Client-space sRGB** — the raw FrameXML/Lua value (the client's FFP wrote these straight to
/// its 8-bit backbuffer). It reaches the shader UNCONVERTED (decision 0254): the quad pass
/// composites in gamma bytes, so this multiply *is* the client's gamma-space `tint × texel`.
/// Producers never pre-convert.
pub color: [f32; 4],
/// WoW `ADD` blend (EGxBlend 3, `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`: highlights, glows) instead
/// of straight alpha. Splits the batch run — see [`ui_quad.wgsl`]'s premultiplied trick for how
/// one pipeline serves both.
pub additive: bool,
/// Mask to the quad's inscribed circle (the live unit portrait — the shader-side twin of the
/// real client's round bake stencil). Splits the run like `additive`; in practice a portrait's
/// render-target texture is unique to it, so no batching is actually lost.
pub circular: bool,
/// CPU-clip stand-in for a real scissor rect (see the module doc). `None` = unclipped.
pub clip: Option<Rect>,
/// Rotate the quad's corners by this many radians **clockwise on screen** about the rect's
/// center, applied at mesh build (the minimap player arrow's facing — decision 0203). The UVs
/// ride their corners, so the art rotates with the geometry. Composes with `clip` by clipping
/// FIRST in the unrotated frame (no current producer sets both; the arrow is never clipped).
pub rotation: f32,
/// Confine the quad to a screen-anchored alpha mask (the minimap's `MinimapMask.blp` circle —
/// decision 0203): the fragment's alpha is multiplied by the mask texture's **alpha** channel
/// (`MinimapMask.blp` is DXT3: white color, the circle ramp in its 8-bit alpha), sampled
/// where the fragment sits within [`UiQuadMask::rect`]; fragments outside the rect drop.
/// Screen-anchored — NOT quad UV space — so a world-anchored tile quad panning under a fixed
/// circular window masks correctly. Splits the run like `additive`/`circular`.
pub mask: Option<UiQuadMask>,
/// Explicit screen-space corners (y-down px, **fan order from corner 0**: the index pattern is
/// `(0,1,2)(0,2,3)`, so any convex 4-gon with corner 0 as the fan apex renders exactly — a
/// triangle repeats its last vertex). When `Some`, `rect`/`rotation`/`clip` are bypassed
/// (`rect` should still bound the corners for the probe/debug view). The cooldown pie's
/// partial-quadrant wedge is the producer (decision 0137 phase 4) — exact geometry computed at
/// extract, no scissor interplay to get wrong.
pub corners: Option<[Vec2; 4]>,
}
/// A screen-anchored alpha mask over a [`UiQuad`] — see [`UiQuad::mask`].
#[derive(Clone, PartialEq)]
pub(crate) struct UiQuadMask {
/// The mask art (its ALPHA channel is the coverage — `MinimapMask.blp` is DXT3 with the
/// circle authored in alpha over a white color plane).
pub texture: Handle<Image>,
/// Where the mask spans on screen, in the same y-down logical px as [`UiQuad::rect`]
/// (the minimap widget's rect). The mask samples 0..1 across this span.
pub rect: Rect,
}
impl Default for UiQuad {
fn default() -> Self {
Self {
rect: Rect::new(0.0, 0.0, 0.0, 0.0),
z_key: 0,
texture: None,
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, 1.0],
additive: false,
circular: false,
clip: None,
rotation: 0.0,
mask: None,
corners: None,
}
}
}
/// The pass's one input resource, two lanes with distinct change protocols:
///
/// - [`Self::quads`] — the BASE lane, owned by a wholesale producer (the widget arena's extract;
/// the dev demo feeder): replaced in full, and the producer sets [`Self::dirty`] only when the
/// new content differs.
/// - [`Self::overlays`] — the APPEND lane ([`UiQuadAppend`]: the minimap fill, V-plates, combat
/// text): cleared at the top of the append window ([`clear_ui_overlays`]) and re-emitted every
/// frame; [`rebuild_ui_mesh`] itself diffs it against last frame's. Appenders never touch
/// `dirty` — when the two lanes lived in one Vec, the extract's change gate compared its fresh
/// output against a Vec still carrying last frame's appends, so it could never hold and the
/// whole UI re-batched every frame (the 0365 live-city churn).
#[derive(Resource, Default)]
pub(crate) struct UiQuads {
pub quads: Vec<UiQuad>,
/// The append lane — see the struct doc. Compared by the rebuild, never flagged.
pub overlays: Vec<UiQuad>,
/// The append lane as of the last rebuild — [`rebuild_ui_mesh`]'s change gate for it.
last_overlays: Vec<UiQuad>,
/// Set by the BASE-lane producer after replacing `quads` with different content; cleared by
/// [`rebuild_ui_mesh`] once it has rebuilt the mesh batches.
pub dirty: bool,
}
/// Clear the append lane at the top of the [`UiQuadAppend`] window — every appender re-emits its
/// frame's quads after this; [`rebuild_ui_mesh`] then diffs the lane to decide if anything changed.
fn clear_ui_overlays(mut quads: ResMut<UiQuads>) {
quads.overlays.clear();
}
/// The dedicated render layer this pass's camera + mesh entities share — disjoint from the world
/// camera's default layer 0 (so neither camera draws the other's content) and distinct from the egui
/// overlay camera's `RenderLayers::none()` (egui paints via its own render node, not `Mesh2d`s, so it
/// doesn't need a mesh-visible layer at all).
const UI_RENDER_LAYER: usize = 1;
fn ui_render_layers() -> RenderLayers {
RenderLayers::layer(UI_RENDER_LAYER)
}
/// Camera order for the player-UI pass: above the (order-0) world camera, below the egui dev-overlay
/// camera (bumped to order 2 in `debug_panel::spawn_egui_camera`). See the module doc's arbitration note.
const UI_CAMERA_ORDER: isize = 1;
/// Marker on the player-UI camera.
#[derive(Component)]
struct PlayerUiCamera;
/// Marker on each rebuilt batch mesh entity, so [`rebuild_ui_mesh`] can despawn last frame's batches
/// before spawning the new ones.
#[derive(Component)]
struct UiQuadBatch;
/// The shared 1×1 opaque-white texture flat-shaded/texture-less quads sample (see the module doc).
#[derive(Resource)]
struct UiWhiteTexture(Handle<Image>);
/// The quad material: a texture + the blend-mode flag, drawn through one PREMULTIPLIED-alpha
/// pipeline (`ui_quad.wgsl`) so straight-alpha and WoW-ADD quads share the pipeline and differ
/// only per-material. Vertex colors carry the per-quad tint.
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub(crate) struct UiQuadMaterial {
#[uniform(0)]
additive: u32,
#[texture(1)]
#[sampler(2)]
texture: Option<Handle<Image>>,
/// Mask to the inscribed circle (live unit portraits) — see [`UiQuad::circular`].
#[uniform(3)]
circular: u32,
/// The screen-anchored mask span in **physical framebuffer px** (`min.xy, max.xy` — the
/// fragment shader compares `@builtin(position)`, which is physical): [`UiQuadMask::rect`]
/// scaled by the window's scale factor at mesh build. `z <= x` (degenerate) disables masking.
#[uniform(4)]
mask_rect: Vec4,
/// The mask art — see [`UiQuadMask`]. `None` binds the fallback image (and `mask_rect` is
/// degenerate, so the shader never reads it).
#[texture(5)]
#[sampler(6)]
mask: Option<Handle<Image>>,
}
impl Material2d for UiQuadMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/ui_quad.wgsl".into()
}
fn alpha_mode(&self) -> bevy::sprite_render::AlphaMode2d {
// Transparent phase (back-to-front by mesh z — the run order). The actual blend state is
// forced premultiplied in `specialize`.
bevy::sprite_render::AlphaMode2d::Blend
}
fn specialize(
descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor,
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
_key: Material2dKey<Self>,
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
if let Some(fragment) = descriptor.fragment.as_mut() {
if let Some(target) = fragment.targets.first_mut().and_then(|t| t.as_mut()) {
// `(One, OneMinusSrcAlpha)` over GAMMA values (decision 0254): the shader hands us
// `(rgb·a, a)` for BLEND and `(rgb·a, 0)` for ADD, so this one state reproduces
// EGxBlend 2 and EGxBlend 3 exactly, clamped at each write like the reference's
// 8-bit backbuffer.
let premultiplied = BlendComponent {
src_factor: BlendFactor::One,
dst_factor: BlendFactor::OneMinusSrcAlpha,
operation: BlendOperation::Add,
};
target.blend = Some(BlendState {
color: premultiplied,
alpha: premultiplied,
});
}
}
Ok(())
}
}
/// Producers that **append** to [`UiQuads`] after the script extract replaced it — the
/// world-anchored text (combat numbers, nameplates). The set lives in **Update, after
/// [`crate::schedule::WorldStage::Input`]** (itself after the script extract's `UiInput`): the
/// camera controller has written this frame's camera `Transform` by then, and the producers
/// project through THAT fresh Transform (`GlobalTransform::from` — the world camera is a root
/// entity), never the stale propagated `GlobalTransform` — the fix for text dragging one frame
/// behind the render when strafing. The set MUST stay in Update: [`rebuild_ui_mesh`] respawns its
/// `Mesh2d` batches, and bevy_sprite_render cannot specialize meshes spawned in PostUpdate
/// ("missing specialization tick" — the whole UI vanishes; a PostUpdate placement was tried and
/// reverted, caught live by the director). The posed BONE transforms the producers read are last
/// frame's — sub-frame head motion, imperceptible next to camera pan.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiQuadAppend;
/// Owns the player-UI camera + the per-frame quad→mesh rebuild. See the module doc for the
/// camera-order arbitration and the batching/ordering approach.
pub(crate) struct PlayerUiPlugin;
impl Plugin for PlayerUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UiQuads>()
.add_plugins((
Material2dPlugin::<UiQuadMaterial>::default(),
// Owned here, not in main.rs: the lane's decode is not optional (see its doc).
crate::ui_gamma::UiGammaPlugin,
))
.add_systems(Startup, (spawn_ui_camera, init_white_texture))
.configure_sets(
Update,
UiQuadAppend.after(crate::schedule::WorldStage::Input),
)
.add_systems(Update, clear_ui_overlays.before(UiQuadAppend))
.add_systems(Update, rebuild_ui_mesh.after(UiQuadAppend));
// Dev-only demo feeder (mirrors the repo's env-var dev-instrument gating — e.g. `$WOW_CAPTURE`,
// `$WOW_TILE_RADIUS` — since the compile-time `dev` cargo feature decision 0026 sets as the
// eventual seam isn't built yet; see `demo_enabled`'s doc comment). Runs once at Startup, after
// the asset chain opens so it can load a real BLP through `WorldAssets::sprite_texture`.
if demo_enabled() {
app.add_systems(Startup, seed_demo_quads.after(AssetSet::Open));
}
}
}
/// `$WOW_UI_DEMO=1` fills [`UiQuads`] once at startup with synthetic content proving the sort, the
/// texture-batching/ordering story, and the CPU clip. Same env-var-gated-instrument idiom as
/// `capture.rs`'s `$WOW_CAPTURE` and `assets::open_world_assets`'s `$WOW_TILE_RADIUS`/`$WOW_TEX_TILES`
/// — decision 0026 phase 1 (a compile-time `dev` cargo feature) is recorded as the target seam but not
/// yet built (0026 §4: "phased, not built now"), so today's instrument-gating convention across the
/// codebase is a runtime env-var check, not `#[cfg(feature = "dev")]`.
fn demo_enabled() -> bool {
std::env::var("WOW_UI_DEMO").as_deref() == Ok("1")
}
/// A full-window overlay camera that composites the player-UI quad pass above the 3D world and below
/// the egui dev overlays. Mirrors `debug_panel::spawn_egui_camera`'s pattern (own render layer, higher
/// order, alpha-blend `Write` output, no clear) — see the module doc for the order arbitration.
fn spawn_ui_camera(mut commands: Commands) {
commands.spawn((
PlayerUiCamera,
Camera2d,
// The gamma composite lane's mandatory decode (decision 0254) — without it the UI presents
// ~2.2× bright, since the quad pass leaves gamma values in the target.
crate::ui_gamma::UiGammaLane,
// Every Bevy UI tree renders HERE (decision 0541) — the glue screens and the loading screen.
// Without the marker, Bevy UI picks the highest-order camera targeting the window, which is
// the egui dev overlay (order 2): the glue screens rode the dev camera, outside the gamma
// lane, and composited in linear — the washed-out login boxes the director caught. Bevy UI
// and egui never contend for it (egui paints through its own render node, not UI nodes).
bevy::ui::IsDefaultUiCamera,
ui_render_layers(),
Camera {
order: UI_CAMERA_ORDER,
output_mode: CameraOutputMode::Write {
// PREMULTIPLIED, not `ALPHA_BLENDING`: `ui_quad.wgsl` writes premultiplied colour
// (rgb·a), so a `SrcAlpha` factor here would weight it by alpha a SECOND time —
// translucent UI over the world came out `rgb·a²`, and a pure-additive quad (a = 0)
// over the world was multiplied clean away. Opaque panels (a = 1) hid it.
blend_state: Some(
bevy::render::render_resource::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
),
clear_color: ClearColorConfig::None,
},
// An overlay must composite ONLY its own pixels. `ClearColorConfig::None` made this
// camera depend on `MsaaWriteback::Auto`, which (for any non-first camera on a target)
// COPIES the world camera's already-final output into this camera's MSAA texture and
// re-emits it through this camera's SDR blit+blend — a whole-image re-encode round-trip
// that tinted the world (a fixed blue film on WMO surfaces, the regression the director
// caught live; it never reproduced in captures). Clear to transparent instead and turn
// writeback off: this camera never touches the world image at all.
clear_color: ClearColorConfig::Custom(Color::NONE),
msaa_writeback: bevy::camera::MsaaWriteback::Off,
..default()
},
));
}
/// Build the shared 1×1 white texture texture-less quads sample (see the module doc's batching note on
/// why they still go through a real texture bind rather than a "no texture" material variant).
fn init_white_texture(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
let image = Image::new_fill(
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[255, 255, 255, 255],
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::RENDER_WORLD,
);
commands.insert_resource(UiWhiteTexture(images.add(image)));
}
/// Clip `rect`/`uv` to `clip`, reprojecting the UV sub-rect proportionally to the clipped edges (an
/// axis-aligned CPU scissor stand-in — see the module doc). Returns `None` if the clip leaves nothing
/// to draw (empty intersection, or a degenerate zero-size source rect).
fn clip_quad(rect: Rect, uv: UvRect, clip: Rect) -> Option<(Rect, UvRect)> {
let clipped = rect.intersect(clip);
if clipped.is_empty() {
return None;
}
let (w, h) = (rect.width(), rect.height());
if w <= 0.0 || h <= 0.0 {
return None;
}
// Fraction along each axis the clipped edges sit at within the original rect, then reproject the
// UV corners by the same fractions — this keeps the texture mapping correct on the visible
// remainder instead of carrying the original (now-too-large) UV rect through unclipped. The
// fractions come from the (always-normalized) screen rect. The reprojection is a **bilinear**
// sample of the four corner UVs at (fx, fy): for an axis-aligned crop it reduces to the old
// separable `u0→u1`/`v0→v1` lerp (so a mirrored UV stays mirrored), and for a rotated backdrop
// edge (u tied to screen-Y) it correctly carries the rotation through the clip.
let t_min_x = (clipped.min.x - rect.min.x) / w;
let t_max_x = (clipped.max.x - rect.min.x) / w;
let t_min_y = (clipped.min.y - rect.min.y) / h;
let t_max_y = (clipped.max.y - rect.min.y) / h;
let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
let [tl, tr, br, bl] = uv.corners;
let bilerp = |fx: f32, fy: f32| -> [f32; 2] {
let top = [lerp(tl[0], tr[0], fx), lerp(tl[1], tr[1], fx)];
let bot = [lerp(bl[0], br[0], fx), lerp(bl[1], br[1], fx)];
[lerp(top[0], bot[0], fy), lerp(top[1], bot[1], fy)]
};
let new_uv = UvRect::from_corners([
bilerp(t_min_x, t_min_y), // top-left
bilerp(t_max_x, t_min_y), // top-right
bilerp(t_max_x, t_max_y), // bottom-right
bilerp(t_min_x, t_max_y), // bottom-left
]);
Some((clipped, new_uv))
}
/// One texture-identity run: contiguous quads (in sorted z_key order) sharing the same texture handle.
/// See the module doc for why runs are contiguous rather than globally deduped by texture.
struct Run {
texture: Handle<Image>,
additive: bool,
circular: bool,
mask: Option<UiQuadMask>,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
colors: Vec<[f32; 4]>,
indices: Vec<u32>,
}
impl Run {
fn new(
texture: Handle<Image>,
additive: bool,
circular: bool,
mask: Option<UiQuadMask>,
) -> Self {
Self {
texture,
additive,
circular,
mask,
positions: Vec::new(),
uvs: Vec::new(),
colors: Vec::new(),
indices: Vec::new(),
}
}
/// Append one screen-space quad (already clipped) as two triangles, in a fixed
/// top-left/top-right/bottom-right/bottom-left winding — cross-quad order within the run is
/// entirely a function of *when* this is called (append order = paint order; see the module doc).
/// `rotation` spins the corners clockwise about the rect center in screen space (see
/// [`UiQuad::rotation`]) — the UVs stay with their corners, so the art rotates in place.
/// `to_world` maps a y-down screen point to the camera's y-up, origin-centred world space.
/// The client-space sRGB `color` rides through unconverted — the quad pass composites in gamma
/// bytes (see [`UiQuad::color`] and decision 0254).
fn push_quad(
&mut self,
rect: Rect,
uv: UvRect,
color: [f32; 4],
rotation: f32,
to_world: impl Fn(Vec2) -> Vec2,
) {
let [tl, tr, br, bl] = uv.corners;
let mut corners = [
(Vec2::new(rect.min.x, rect.min.y), Vec2::from(tl)), // top-left
(Vec2::new(rect.max.x, rect.min.y), Vec2::from(tr)), // top-right
(Vec2::new(rect.max.x, rect.max.y), Vec2::from(br)), // bottom-right
(Vec2::new(rect.min.x, rect.max.y), Vec2::from(bl)), // bottom-left
];
if rotation != 0.0 {
// Clockwise on a y-down screen = the standard CCW rotation matrix in y-down coords.
let (sin, cos) = rotation.sin_cos();
let center = (rect.min + rect.max) * 0.5;
for (p, _) in &mut corners {
let d = *p - center;
*p = center + Vec2::new(d.x * cos - d.y * sin, d.x * sin + d.y * cos);
}
}
self.push_corners(corners, color, to_world);
}
/// Append four explicit screen-space corners (fan order from corner 0 — [`UiQuad::corners`])
/// with their UVs, as the standard `(0,1,2)(0,2,3)` fan.
fn push_corners(
&mut self,
corners: [(Vec2, Vec2); 4],
color: [f32; 4],
to_world: impl Fn(Vec2) -> Vec2,
) {
let base = self.positions.len() as u32;
for (p, t) in corners {
let w = to_world(p);
self.positions.push([w.x, w.y, 0.0]);
self.uvs.push([t.x, t.y]);
self.colors.push(color);
}
self.indices
.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
}
}
/// The rebuild's reused GPU-facing state, kept across frames so a rebuild pays for what CHANGED,
/// never for existence (the 0353 demand-price law, UI lane): batch entities and their mesh assets
/// are pools reused by run index (geometry rewritten in place — no allocator or archetype churn),
/// and materials are cached by their full identity key (same texture/blend/mask ⇒ the same
/// material asset forever, so `prepare_assets` re-prepares nothing on a steady frame). Before
/// this, every rebuild despawned every batch and allocated fresh meshes + materials — ~9 ms/frame
/// of render-side churn in a live city (0365).
#[derive(Default)]
struct BatchPools {
entities: Vec<Entity>,
meshes: Vec<Handle<Mesh>>,
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
}
/// A material's full identity: texture, blend/shape flags, mask texture + mask rect (as bits, so
/// NaN/-0.0 can't split cache entries byte-equal materials would share).
type MatKey = (AssetId<Image>, bool, bool, Option<AssetId<Image>>, [u32; 4]);
/// Retire every pooled batch entity (blank frame / no drawable content). Mesh handles and the
/// material cache stay — assets referenced only by the pool cost nothing to keep and come back
/// for free.
fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) {
for entity in pools.entities.drain(..) {
commands.entity(entity).despawn();
}
}
/// Per frame: when the BASE lane flagged a change ([`UiQuads::dirty`]) or the APPEND lane's
/// content differs from last frame's, stable-sort both lanes by `z_key`, CPU-clip, split into
/// texture-identity runs, and write the runs into the pooled batches ([`BatchPools`]). See the
/// module doc for the full approach (painter's order within/across runs) and its batching
/// consequence.
fn rebuild_ui_mesh(
mut quads: ResMut<UiQuads>,
mut commands: Commands,
// The two asset stores the rebuild writes, bundled (clippy's argument ceiling).
mut stores: (ResMut<Assets<Mesh>>, ResMut<Assets<UiQuadMaterial>>),
mut pools: Local<BatchPools>,
white: Option<Res<UiWhiteTexture>>,
windows: Query<&Window, With<PrimaryWindow>>,
hidden: Res<crate::ui_hide::UiHidden>,
) {
let (meshes, materials) = (&mut stores.0, &mut stores.1);
let q = quads.as_mut();
// TOGGLEUI hides at the *draw*, not at the producers: both lanes keep filling, so the UI comes
// back exactly as it was (see [`crate::ui_hide::UiHidden`]). Retire the batches on the toggle's
// down edge; on the way back up, force one full rebuild — while dark we swallow each frame's
// change flag and keep the append-lane mirror current, so neither lane can hand the rebuild a
// stale "nothing changed" the moment the UI returns. The edge is the resource's own change tick
// (`UiHidden` is written only by the binding and the world-exit reset), not a `Local` mirror.
if hidden.0 {
if hidden.is_changed() {
retire_batches(&mut pools, &mut commands);
}
q.dirty = false;
q.last_overlays.clone_from(&q.overlays);
return;
}
if hidden.is_changed() {
q.dirty = true;
}
if !q.dirty && q.overlays == q.last_overlays {
return;
}
q.dirty = false;
q.last_overlays.clone_from(&q.overlays);
let (Ok(window), Some(white)) = (windows.single(), white) else {
// No window to size against, or the white-texture Startup system hasn't run yet — nothing to
// draw this frame; the resource stays dirty-cleared, matching "we tried and there was nothing
// to show" rather than spinning forever.
retire_batches(&mut pools, &mut commands);
return;
};
if q.quads.is_empty() && q.overlays.is_empty() {
retire_batches(&mut pools, &mut commands);
return;
}
// Screen px (y-down, origin top-left) → the 2D camera's world space (y-up, origin screen-centre) —
// matches the default `OrthographicProjection` (`ScalingMode::WindowSize`, scale 1.0 ⇒ 1 world
// unit = 1 logical px) the camera spawns with.
let (half_w, half_h) = (window.width() * 0.5, window.height() * 0.5);
let to_world = move |p: Vec2| Vec2::new(p.x - half_w, half_h - p.y);
// Stable sort by z_key: the WoW-style total order. Stable so equal-z_key quads keep the producer's
// original relative order (their own decl-order tiebreak, if any). Base lane first, append lane
// after — the same relative order the one-Vec era produced.
let mut sorted: Vec<&UiQuad> = q.quads.iter().chain(q.overlays.iter()).collect();
sorted.sort_by_key(|q| q.z_key);
// Geometry probe (`WOW_UI_PROBE=1`): dump each textured quad's screen rect once — the
// capture-harness companion for diagnosing extracted-vs-rendered geometry by data instead of
// by eyeballing PNGs.
if std::env::var("WOW_UI_PROBE").as_deref() == Ok("1") {
// Fire on every rebuild (rebuilds are change-driven and rare); read the LAST block.
{
info!(
"ui probe: window {}x{} logical",
window.width(),
window.height()
);
for q in &sorted {
info!(
"ui probe: [{:.0},{:.0} {:.0}x{:.0}] tex={} z={:x}",
q.rect.min.x,
q.rect.min.y,
q.rect.width(),
q.rect.height(),
q.texture.as_ref().map_or_else(
|| "-".into(),
|h| {
h.path()
.map_or_else(|| format!("{:?}", h.id()), |p| p.to_string())
}
),
q.z_key
);
}
}
}
// Split into contiguous texture-identity runs, clipping each quad on the way in. A quad clipped to
// nothing is simply never pushed — it does NOT break a run (an invisible quad has no texture to
// conflict with its neighbours).
let mut runs: Vec<Run> = Vec::new();
for q in sorted {
// Explicit-corner quads (the cooldown pie's wedge) carry exact geometry — no clip, no
// rotation, no UV reprojection to apply (see [`UiQuad::corners`]).
let plain = if q.corners.is_none() {
match q.clip {
Some(clip) => match clip_quad(q.rect, q.uv, clip) {
Some(pair) => Some(pair),
None => continue,
},
None => Some((q.rect, q.uv)),
}
} else {
None
};
let texture = q.texture.clone().unwrap_or_else(|| white.0.clone());
let same_run = runs.last().is_some_and(|r| {
r.texture == texture
&& r.additive == q.additive
&& r.circular == q.circular
&& r.mask == q.mask
});
if !same_run {
runs.push(Run::new(texture, q.additive, q.circular, q.mask.clone()));
}
let run = runs.last_mut().unwrap();
match (q.corners, plain) {
(Some(c), _) => {
let [tl, tr, br, bl] = q.uv.corners;
run.push_corners(
[
(c[0], Vec2::from(tl)),
(c[1], Vec2::from(tr)),
(c[2], Vec2::from(br)),
(c[3], Vec2::from(bl)),
],
q.color,
to_world,
);
}
(None, Some((rect, uv))) => run.push_quad(rect, uv, q.color, q.rotation, to_world),
(None, None) => unreachable!("plain is Some when corners is None"),
}
}
// Cross-run order: ascending world-space z (see the module doc — `bevy_sprite_render` sorts its
// `Transparent2d` phase by ascending mesh z, so later runs drawing on top is exactly "higher z").
// Spread runs across a z window comfortably inside the camera's default near/far (±1000) regardless
// of run count, so this never depends on how many runs a given frame happens to produce.
let run_count = runs.len().max(1) as f32;
// An unbounded material key set (a window resize moves every mask rect) resets the cache;
// materials on live batches survive via the entities' own handle clones and simply re-enter
// on their next miss.
if pools.materials.len() > 256 {
pools.materials.clear();
}
let mut used = 0usize;
for (i, run) in runs.into_iter().enumerate() {
if run.indices.is_empty() {
continue;
}
let z = -450.0 + (i as f32 / run_count) * 900.0;
// MAIN_WORLD too (not RENDER_WORLD-only): the pool rewrites this asset in place next
// rebuild, so the main-world copy must survive extraction.
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, run.positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, run.uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, run.colors);
mesh.insert_indices(Indices::U32(run.indices));
let mesh_handle = match pools.meshes.get(used) {
Some(handle) => {
let _ = meshes.insert(handle.id(), mesh);
handle.clone()
}
None => {
let handle = meshes.add(mesh);
pools.meshes.push(handle.clone());
handle
}
};
// The mask span converts to physical framebuffer px here (the shader compares
// `@builtin(position)`); a maskless run gets a degenerate rect, which disables the branch.
let scale = window.scale_factor();
let (mask_rect, mask) = match &run.mask {
Some(m) => (
Vec4::new(
m.rect.min.x * scale,
m.rect.min.y * scale,
m.rect.max.x * scale,
m.rect.max.y * scale,
),
Some(m.texture.clone()),
),
None => (Vec4::new(0.0, 0.0, -1.0, -1.0), None),
};
if std::env::var("WOW_UI_PROBE").as_deref() == Ok("1") && run.mask.is_some() {
info!(
"ui probe: masked run {i}: mask_rect={mask_rect:?} scale={scale} tex={:?}",
run.mask.as_ref().map(|m| m.texture.id())
);
}
let key: MatKey = (
run.texture.id(),
run.additive,
run.circular,
mask.as_ref().map(bevy::asset::Handle::id),
mask_rect.to_array().map(f32::to_bits),
);
let material_handle = pools
.materials
.entry(key)
.or_insert_with(|| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
texture: Some(run.texture),
circular: u32::from(run.circular),
mask_rect,
mask,
})
})
.clone();
// Reuse the batch entity at this slot — same Mesh2d handle (the pooled asset), a material
// handle that only changes when the run's identity does, a fresh z.
match pools.entities.get(used) {
Some(&entity) => {
commands.entity(entity).insert((
Mesh2d(mesh_handle),
MeshMaterial2d(material_handle),
Transform::from_xyz(0.0, 0.0, z),
));
}
None => {
let entity = commands
.spawn((
UiQuadBatch,
Mesh2d(mesh_handle),
MeshMaterial2d(material_handle),
Transform::from_xyz(0.0, 0.0, z),
ui_render_layers(),
))
.id();
pools.entities.push(entity);
}
}
used += 1;
}
// Retire the surplus: batch entities beyond this frame's run count despawn, and their pooled
// mesh assets drop with the truncation (nothing references them anymore).
for entity in pools.entities.drain(used..) {
commands.entity(entity).despawn();
}
pools.meshes.truncate(used);
}
/// Deliberately-overlapping synthetic content proving the sort: 5 z strata × 40 quads each, offset both
/// within a stratum (neighbours overlap 35px) and across strata (each stratum offset 20px from the
/// last, so a later, higher-`z_key` stratum's field visibly paints over the previous one's — the actual
/// thing this pass exists to get right). Plus one real-BLP-textured quad and one CPU-clipped quad,
/// both given the highest z_keys — see the module doc: this ordering is what makes the shared "white"
/// texture identity get split into two separate runs (the icon quad sits between them in z_key).
const STRATA: usize = 5;
const PER_STRATUM: usize = 40;
const QUAD_SIZE: f32 = 80.0;
const COLS: usize = 10;
fn strata_color(s: usize) -> [f32; 4] {
// Red → orange → yellow → green → blue, alpha < 1 so the overlaps are visibly additive-ish
// (provable by eye: a later stratum's quad should read as a *blend* over the earlier one where
// clipped by nothing else, and fully opaque-looking where nothing underlies it).
const COLORS: [[f32; 3]; STRATA] = [
[0.85, 0.15, 0.15],
[0.90, 0.55, 0.10],
[0.85, 0.80, 0.10],
[0.15, 0.75, 0.25],
[0.15, 0.45, 0.90],
];
let c = COLORS[s % STRATA];
[c[0], c[1], c[2], 0.85]
}
fn seed_demo_quads(
mut quads: ResMut<UiQuads>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
let mut out = Vec::with_capacity(STRATA * PER_STRATUM + 2);
for s in 0..STRATA {
let stratum_offset = s as f32 * 20.0;
for i in 0..PER_STRATUM {
let (col, row) = (i % COLS, i / COLS);
let x = 40.0 + stratum_offset + col as f32 * 45.0;
let y = 40.0 + stratum_offset + row as f32 * 45.0;
out.push(UiQuad {
rect: Rect::new(x, y, x + QUAD_SIZE, y + QUAD_SIZE),
z_key: (s as u64) * 1000 + i as u64,
color: strata_color(s),
..default()
});
}
}
// Real BLP through the same UI-art path the loading screen uses (`sprite_texture`: sRGB, clamp,
// no mip chain — one texture mapped to one quad, not tiling world art).
if let Some(mut assets) = world_assets {
if let Some(icon) =
assets.sprite_texture("Interface\\Icons\\INV_Misc_QuestionMark.blp", &mut images)
{
out.push(UiQuad {
rect: Rect::new(560.0, 40.0, 560.0 + 64.0, 40.0 + 64.0),
z_key: (STRATA as u64) * 1000 + 500,
texture: Some(icon),
..default()
});
}
}
// CPU-clip demo: a 160×160 quad clipped to its left half — proves the intersect + UV-reprojection
// path (not just a quad that already happened to fit).
let clip_rect = Rect::new(700.0, 40.0, 700.0 + 160.0, 40.0 + 160.0);
out.push(UiQuad {
rect: clip_rect,
z_key: (STRATA as u64) * 1000 + 600,
color: [0.2, 0.8, 1.0, 1.0],
clip: Some(Rect::new(
clip_rect.min.x,
clip_rect.min.y,
clip_rect.min.x + 80.0,
clip_rect.max.y,
)),
..default()
});
quads.quads = out;
quads.dirty = true;
}
#[cfg(test)]
mod tests {
use super::*;
/// The rebuild in isolation: real resources, no renderer (the batches are plain entities until
/// something draws them).
fn rebuild_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
.init_asset::<Mesh>()
.init_asset::<UiQuadMaterial>()
.init_resource::<UiQuads>()
.init_resource::<crate::ui_hide::UiHidden>()
.insert_resource(UiWhiteTexture(Handle::default()))
.add_systems(Update, rebuild_ui_mesh);
app.world_mut().spawn((Window::default(), PrimaryWindow));
// One flat quad in the BASE lane — enough content for exactly one batch.
let mut quads = app.world_mut().resource_mut::<UiQuads>();
quads.quads.push(UiQuad {
rect: Rect::new(0.0, 0.0, 64.0, 64.0),
..default()
});
quads.dirty = true;
app
}
fn batches(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<(), With<UiQuadBatch>>()
.iter(app.world())
.count()
}
fn set_hidden(app: &mut App, hidden: bool) {
app.world_mut().resource_mut::<crate::ui_hide::UiHidden>().0 = hidden;
}
/// TOGGLEUI at the draw: hiding retires the batches, and un-hiding brings the SAME content back
/// — the case the two lanes' change protocol would otherwise swallow, since nothing about the
/// quads themselves changed while the UI was dark (`dirty` false, the append lane identical),
/// so the rebuild would have taken its early-out and left the screen bare.
#[test]
fn toggleui_retires_the_batches_and_restores_them() {
let mut app = rebuild_app();
app.update();
assert_eq!(batches(&mut app), 1, "one batch drawn to begin with");
set_hidden(&mut app, true);
app.update();
assert_eq!(batches(&mut app), 0, "hidden ⇒ nothing drawn");
// Still nothing after further frames: the producers keep filling both lanes, and the
// rebuild must keep taking its dark path rather than re-spawning on the next flagged frame.
app.world_mut().resource_mut::<UiQuads>().dirty = true;
app.update();
assert_eq!(batches(&mut app), 0, "hidden stays hidden");
set_hidden(&mut app, false);
app.update();
assert_eq!(
batches(&mut app),
1,
"the UI comes back on the same content"
);
}
// The mirror-preservation guarantee, and the reason [`UvRect`] exists instead of
// `bevy::math::Rect`: PlayerFrameTexture's `<TexCoords left="1.0" right="0.09375">`
// (ref-PlayerFrame.xml l.55-57) must reach the vertex buffer with `u0 > u1`. `Rect::from_corners`
// normalizes min/max and would silently un-mirror the ring art.
#[test]
fn mirrored_tex_coords_emit_unnormalized_corners() {
// [left, right, top, bottom] as extraction hands them over — left > right (horizontal mirror).
let uv = UvRect::from_tex_coords([1.0, 0.09375, 0.0, 0.78125]);
let [tl, tr, br, bl] = uv.corners;
assert!(
tl[0] > tr[0],
"mirror preserved: TL.u {} > TR.u {}",
tl[0],
tr[0]
);
// The top-LEFT screen vertex samples u=1.0 and the top-RIGHT samples u=0.09375 — i.e. the
// texture is flipped horizontally on the quad (push_quad maps corners[0]→TL, corners[1]→TR).
assert_eq!(tl, [1.0, 0.0]);
assert_eq!(tr, [0.09375, 0.0]);
assert_eq!(br, [0.09375, 0.78125]);
assert_eq!(bl, [1.0, 0.78125]);
}
// clip_quad reprojects UVs by the clipped screen-edge fractions. The bilinear reduces to the
// separable corner lerp for an axis-aligned crop, so a mirrored source UV must survive clipping
// still mirrored (the pin's "verify, don't assume").
#[test]
fn clip_preserves_mirrored_uv() {
let uv = UvRect::from_tex_coords([1.0, 0.09375, 0.0, 0.78125]);
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
// Clip to the left half of the quad.
let clip = Rect::new(0.0, 0.0, 50.0, 100.0);
let (crect, cuv) =
clip_quad(rect, uv, clip).expect("left half is a non-empty intersection");
assert_eq!(crect, Rect::new(0.0, 0.0, 50.0, 100.0));
let [tl, tr, ..] = cuv.corners;
assert!(
tl[0] > tr[0],
"mirror survives clip: TL.u {} > TR.u {}",
tl[0],
tr[0]
);
// Left half of a mirrored range [1.0 .. 0.09375] samples [1.0 .. midpoint].
assert_eq!(tl[0], 1.0);
assert!((tr[0] - 0.546_875).abs() < 1e-6); // lerp(1.0, 0.09375, 0.5)
// The un-clipped vertical axis is untouched.
assert_eq!(tl[1], 0.0);
assert_eq!(cuv.corners[3][1], 0.78125); // BL.v
}
// A rotated backdrop TOP-edge UV (atlas-u tied to screen-Y, atlas-v to screen-X reversed —
// backdrop-mechanism.md §3): clipping to the left half must reproject the *v* axis (screen-X),
// leaving *u* (screen-Y) untouched. This is the case the old separable-lerp clip could not do
// and the four-corner bilinear must.
#[test]
fn clip_reprojects_rotated_uv() {
// TOP edge with widthRun = 4: TL=(0.25,4) TR=(0.25,0) BR=(0.375,0) BL=(0.375,4).
let uv = UvRect::from_corners([[0.25, 4.0], [0.25, 0.0], [0.375, 0.0], [0.375, 4.0]]);
let rect = Rect::new(0.0, 0.0, 100.0, 16.0);
// Clip to the left half in screen-X (v axis), full height (u axis untouched).
let clip = Rect::new(0.0, 0.0, 50.0, 16.0);
let (crect, cuv) = clip_quad(rect, uv, clip).expect("left half intersects");
assert_eq!(crect, Rect::new(0.0, 0.0, 50.0, 16.0));
let [tl, tr, br, bl] = cuv.corners;
// u (screen-Y) unchanged: top row still 0.25, bottom row still 0.375.
assert!((tl[0] - 0.25).abs() < 1e-6 && (tr[0] - 0.25).abs() < 1e-6);
assert!((bl[0] - 0.375).abs() < 1e-6 && (br[0] - 0.375).abs() < 1e-6);
// v (screen-X, reversed): left edge still 4.0; right edge now the midpoint 2.0.
assert!((tl[1] - 4.0).abs() < 1e-6, "left v kept: {}", tl[1]);
assert!((tr[1] - 2.0).abs() < 1e-6, "right v halved: {}", tr[1]);
}
}