Files
benilla-octo/crates/benilla-app/src/ui_pass.rs
T
SamandClaude Opus 5.5 887a4db586 the trainer re-derives its states as the reference does
- the trainer window re-derives every service's state when your money, level, spells or talents change, instead of asking the server for a second list after a purchase (2333, 2336)
- the backpack button reads the stock bag scan: with Bagnon holding the bags it stays unlit, as the reference leaves it (2332)
- three unused dev switches retire, and fifteen others are named as instruments (2335)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:14:17 +07:00

2037 lines
102 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); the world reaches it as the first DRAW of its main pass — the FFXGlow combine,
//! `benilla_world::ffx_glow::FfxBackdrop` ([`crate::world_backdrop`], decision 2234) — and its
//! finished frame reaches the swapchain through [`crate::ui_gamma`]'s decode directly — output
//! mode `Skip`, no blit (decision 2206).
//!
//! ## 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. (A `WOW_UI_DEMO` feeder once
//! filled [`UiQuads`] with synthetic strata to prove the pass in isolation; the widget engine has
//! been the producer since, and the feeder retired — 2335.)
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, MeshTag, 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;
/// 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,
/// Draw the sampled texel as its **luminance** — `Texture:SetDesaturated(1)`, the greyed-out
/// icon every disabled affordance in the reference wears (decision 1327). Folded in the
/// client's GAMMA byte space and applied BEFORE the vertex-colour multiply, so the reference's
/// own `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` lands as greyscale *and* dim.
/// Splits the run like `additive`/`circular`.
pub desaturated: bool,
/// The sampled texture already carries **premultiplied** colour, so the shader must not weight
/// it by its own alpha a second time — the booth render targets ([`crate::portrait`]), and
/// nothing else.
///
/// A booth that clears **transparent** (the `<PlayerModel>` body panes and the dressing room,
/// which composite over the page's own art — decision 1083) builds its target the way any
/// render-to-texture does: opaque geometry writes `a = 1`, alpha batches blend over it, and an
/// **additive particle adds light while contributing no coverage** (`wow_effect.wgsl`'s
/// `(rgb·a, 0)` under a premultiplied state). That buffer is premultiplied by construction —
/// colour is emitted light, alpha is coverage. Sampling it as *straight* alpha and applying the
/// usual `rgb·a` multiplies every effect hanging in EMPTY pane space by zero: the R14
/// pauldrons' fire vanished outright, and a weapon glow survived only where it happened to
/// overlap the model's own opaque pixels — chopped to a hard edge at the silhouette (the
/// director's paper-doll/dressing-room report). The opaque-cleared round portraits hid it,
/// because `a = 1` everywhere makes the extra multiply a no-op — exactly the way opaque panels
/// hid the identical defect one level up, in this camera's own output blend (see
/// [`spawn_player_ui_camera`]'s note on `rgb·a²`).
///
/// Splits the run like `additive`/`circular`; a booth target is its own texture anyway, so no
/// batching is lost.
pub premultiplied: bool,
/// The sampled texture holds the client's **gamma bytes, undecoded** — a SKIP_DECODE upload
/// (`BlpVariant::MapTile`, the minimap tiles: the reference's tile sampler carries
/// `GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT`, so the hardware filters authored bytes).
/// The ordinary arm's `linear_to_srgb` exists to undo the sampler's sRGB decode; on a texture
/// whose sampler did no decode it ENCODES A SECOND TIME, which is how the outdoor minimap
/// washed bright when MapTile moved off `Rgba8UnormSrgb` and only the alpha-test arm learned
/// the new contract. With this set the texel passes through as the authored byte and the
/// vertex-colour multiply lands on it directly — the fixed-function MODULATE, byte for byte.
/// The alpha-test arm ignores the flag: it decodes explicitly for the un-encoded composite
/// target. Splits the run like `additive`/`circular`.
pub gamma_texel: bool,
/// **Alpha TEST** instead of alpha blend, at this reference value on the client's
/// `texel.a × vertexColour.a` — `Some(224.0 / 255.0)` is the WMO-interior minimap tile draw and
/// nothing else so far. A passing fragment writes FULLY OPAQUE (the screen mask still cuts it);
/// a failing one is discarded, so partial coverage never exists on this path.
///
/// The reference's minimap tile draw sets EGxBlend **1**, whose applicator `glDisable`s
/// blending outright, and the `SetRenderState` id-7→id-8 cascade then arms
/// `glAlphaFunc(GL_GEQUAL, 0.87843144)` (`.data 0x85ad20[1] = 224`; wow-re
/// `wmo-interior-minimap-composite.md`). Blending those tiles instead leaves `(1−a)(1−b)` of
/// the black clear at EVERY boundary where two group tiles meet — up to 25% black where the
/// two filtered edges are complementary — which is B141's "odd black lines" (they recoloured
/// with the backing quad, which is how they were caught). Splits the batch run.
pub alpha_test: Option<f32>,
/// **The UV window this quad may sample**, already inset by half a texel — `None` = the
/// sampler's own `ClampToEdge` is the whole story (decision 1608).
///
/// A `SetTexCoord` crop into an ATLAS is not a texture: `CLAMP_TO_EDGE` clamps at the
/// image's edge, not the cell's, so a magnified cell's outermost row of destination pixels
/// samples half a texel PAST its crop and linear-filters in whatever the neighbouring cell
/// authored there. `Interface\Minimap\POIIcons` is the case that forced this: cell 15 (the
/// generic zone-level landmark) is fully transparent, the cell directly above it is the
/// coffin whose bottom row is OPAQUE BLACK, and the world map drew a ~24%-black hairline
/// across the top edge of every zone POI — the director's "black horizontal lines".
///
/// Set it and the fragment clamps into `[u_min, v_min, u_max, v_max]`, which is exactly what
/// a standalone clamped texture of that cell would sample. The producer decides *whether* a
/// crop is a cell (see the extract's `uv_clamp_window`): an axis whose UVs run past `[0,1]`
/// is the reference's TILING idiom and must never be clamped, which is why the window is
/// disabled **per axis** rather than per quad.
///
/// Splits the batch run like `additive`/`circular` — it rides the material, not the vertex.
pub uv_clamp: Option<[f32; 4]>,
/// 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,
desaturated: false,
premultiplied: false,
gamma_texel: false,
alpha_test: None,
uv_clamp: None,
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):
/// 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,
}
/// The **append lane's own z bands** — the world-anchored overlays' slice of the same `z_key`
/// total order the scripted UI packs its `(stratum, level, layer, …)` tuple into
/// ([`benilla_ui::order::ZKey`]).
///
/// The lane's three producers are three *different* client systems that all draw over the world,
/// and their relative order is a fidelity fact each one owns (the floating combat numbers are a
/// world-scene draw under all UI; the plates and the bubbles are frames). What is NOT a fidelity
/// fact is the arithmetic that keeps them apart — and while each producer picked its own small
/// integer, that arithmetic was three private conventions with nothing naming the shared law:
/// combat text at 0, the bubble's four pieces at 0..=3 *on top of it*, the plates at 4..=8. The
/// bands below make the split explicit and, more to the point, give the middle band **room** —
/// a chat bubble is one frame per speaker with its own frame level (decision 1504), which a
/// four-key allocation cannot express. Everything here stays far below the scripted UI's keys
/// (a `ZKey` region carries its is-region bit at `1 << 20`), so the whole lane still paints
/// under the player UI.
pub(crate) mod overlay_z {
/// Floating combat/XP/honor numbers — the client draws them in the world scene, beneath
/// every UI frame ([`crate::combat_text`]).
pub(crate) const WORLD_TEXT: u64 = 0;
/// The chat bubbles' band ([`crate::chat_bubble`]): one **frame level** per live bubble,
/// [`BUBBLE_STRIDE`] keys wide, ordered farthest-camera-distance first.
pub(crate) const BUBBLE: u64 = 1 << 8;
/// Keys per bubble level — the frame's own four pieces (bg, edge, tail, text).
pub(crate) const BUBBLE_STRIDE: u64 = 4;
/// The V-plates ([`crate::vplates`]), above every bubble. Same-unit overlap cannot happen
/// (the plate/bubble mutual exclusion), so this only orders cross-unit stacking.
pub(crate) const VPLATE: u64 = 1 << 16;
/// The highest bubble level the band holds before it would reach [`VPLATE`]. Far past any
/// real population (a bubble needs a speaker within 20 yd), so the clamp is a guard rail,
/// not a policy.
pub(crate) const BUBBLE_MAX_LEVEL: u64 = (VPLATE - BUBBLE) / BUBBLE_STRIDE - 1;
}
/// 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;
pub(crate) 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. `pub(crate)` for one writer: [`crate::world_backdrop`], which
/// points the camera's ground pass at the world camera drawing this frame.
#[derive(Component)]
pub(crate) struct PlayerUiCamera;
/// Marker on each rebuilt batch mesh entity, so [`rebuild_ui_mesh`] can despawn last frame's batches
/// before spawning the new ones. `pub(crate)` for one reader: `FPS_PROBE`'s `ui_batches=`, the
/// live count of texture-identity runs — each is a `Mesh2d` draw in the 2D pass, and that pass
/// node priced at ~0.5 ms a frame on the crowd rig's *alone* leg, ~1.2 with a raid's party
/// frames (1929's trace), so the number a UI change moves is this one, not `quads=`.
#[derive(Component)]
pub(crate) 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, and a run with ONE colour carries it
/// on the batch entity's [`MeshTag`] instead (see [`tint_tag`]) — so **nothing about a colour is
/// in here**, and a material is its identity key alone: the same texture and flags are the same
/// asset for as long as the image lives, and a steady frame re-prepares none.
#[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,
/// Fold the texel to luminance — see [`UiQuad::desaturated`].
#[uniform(7)]
desaturate: u32,
/// The texel is already premultiplied (a booth bake) — see [`UiQuad::premultiplied`].
#[uniform(8)]
premultiplied: u32,
/// Alpha-TEST reference on `texel.a × colour.a`; `<= 0` disables — see [`UiQuad::alpha_test`].
#[uniform(9)]
alpha_ref: f32,
/// The texel is already the client's gamma byte (a SKIP_DECODE upload — the minimap tiles), so
/// the ordinary arm skips its `linear_to_srgb` re-encode — see [`UiQuad::gamma_texel`].
#[uniform(10)]
gamma_texel: 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>>,
/// The half-texel-inset UV window the fragment may sample — `(u_min, v_min, u_max, v_max)`,
/// **per axis**, with `min > max` on an axis disabling that axis. See [`UiQuad::uv_clamp`].
#[uniform(11)]
uv_clamp: Vec4,
}
/// **A run's one colour, packed for the batch entity's [`MeshTag`]** — the per-instance `u32`
/// bevy's Mesh2d uniform carries and re-uploads with the transform every frame, which
/// `ui_quad.wgsl`'s vertex stage unpacks into the run's tint. A run whose quads all share one
/// colour stores WHITE vertices and puts the colour here, so a colour pulse (the stock
/// PlayerFrame status glow, every frame at rest) is one component write on one entity: not an
/// `Assets<Mesh>` write, which arms bevy's asset-changed probes over every `Mesh3d` row in the
/// scene (1982), and not a material write, which re-creates the material's bind group and
/// uniform buffers on the render thread — 2–3 of them a frame at a parked pin priced at ~9 ms
/// on Intel's DX12 driver (2236).
///
/// One BYTE per channel, `×255 + 0.5`: exactly how the reference packs a vertex colour
/// (`CImVector`; `SetVertexColor` at `0x79abd0`, the frame-alpha fold at `0x77fac0` in bytes —
/// wow-re `system/ui/scratch/texture-color-composition.md`), so this is the precision the real
/// client draws with, not a step down from it. Stored COMPLEMENTED: an entity with no `MeshTag`
/// reads 0 in bevy's extract, and the complement makes 0 unpack to opaque white — untinted —
/// so a Mesh2d that draws with this material and never asked for a tint (the minimap's
/// interior tiles) is right without knowing this exists.
fn tint_tag(color: [f32; 4]) -> u32 {
let byte = |v: f32| {
// Round half up on a clamped value — `0x40a2b0`'s `×255.0 + 0.5` then truncate; the
// clamp is the binding's own `[0, 1]` marshal. The cast cannot overflow after it.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let b = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
b
};
!(byte(color[0]) | byte(color[1]) << 8 | byte(color[2]) << 16 | byte(color[3]) << 24)
}
impl UiQuadMaterial {
/// The **WMO-interior minimap tile** material (decision 1466): the texture, an alpha TEST at
/// `alpha_ref`, and nothing else — no tint, no mask, no circle, no desaturate. It is built here
/// rather than through the quad stream because these tiles do not draw at the screen at all:
/// they draw into the minimap's own 256² composite target on its own camera
/// ([`crate::minimap`]'s `composite`), one shared quad mesh per tile under a Transform.
///
/// The forced `(One, OneMinusSrcAlpha)` blend in [`Self::specialize`] is what makes this
/// reproduce the client's *disabled* blending exactly: a fragment that passes the test emits
/// `a = 1`, so the state degenerates to a plain replace — and one that fails emits nothing.
pub(crate) fn interior_tile(texture: Handle<Image>, alpha_ref: f32) -> Self {
Self {
additive: 0,
texture: Some(texture),
circular: 0,
desaturate: 0,
premultiplied: 0,
alpha_ref,
// The alpha-test arm never reads this: it decodes explicitly (its target is the
// un-encoded composite), so the tile's SKIP_DECODE contract is already honoured there.
gamma_texel: 0,
mask_rect: Vec4::new(0.0, 0.0, -1.0, -1.0),
mask: None,
// A tile samples its whole image; there is no cell to stay inside of.
uv_clamp: UV_CLAMP_OFF,
}
}
}
/// The mesh every `UiQuadMaterial` quad that is **not** part of the HUD batch stream draws with:
/// a 1×1 rectangle at the origin, scaled and placed by its own Transform (decision 1466 — a
/// panning composite never rewrites a vertex buffer).
///
/// It has ONE author because a `Material2d` pipeline is keyed on `(view key, MESH LAYOUT)`, and
/// these two quad families have different layouts: `Rectangle` gives POSITION + NORMAL + UV_0
/// (`bevy_mesh` `primitives/dim2.rs`), the HUD's batch mesh POSITION + UV_0 + COLOR
/// ([`rebuild_ui_mesh`]). So `UiQuadMaterial` is **two** pipelines, not one — the claim pipe_warm
/// carried until decision 2262, which is why the composite's compiled live on the first step into
/// a WMO interior. `pipe_warm`'s menagerie warms this exact mesh; drifting it here would take the
/// warm rig with it.
pub(crate) fn tile_quad_mesh() -> Mesh {
Rectangle::new(1.0, 1.0).into()
}
impl Material2d for UiQuadMaterial {
/// Our own vertex stage, in the same file: bevy's carries no per-instance data past the
/// transform, and the run's tint rides the instance tag (see [`tint_tag`]).
fn vertex_shader() -> ShaderRef {
"embedded://benilla_app/shaders/ui_quad.wgsl".into()
}
fn fragment_shader() -> ShaderRef {
"embedded://benilla_app/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
/// [`benilla_world::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;
/// Project a world point for a world-anchored overlay, **with the reference projector's own accept
/// verdict** — `None` means "this one does not draw", and the caller must honour it.
///
/// `0x483ee0` returns a boolean (wow-re `object-layer/scratch/nameplate-offscreen-cull.md`,
/// §5-VERIFIED 2026-08-15). It rejects a point behind the near plane (`483f7a`) **and** a point
/// outside the viewport — the four tests at `484075`/`484086`/`484096`/`4840a6`, against the
/// **WorldFrame**'s own region (`[0xb4b2bc]`, mirrored ÷G44/÷G48 at `0x483970`), so the accept
/// region is exactly the viewport, inclusive. Both plate and worldtext callers **destroy** the
/// thing they were about to place when it comes back false.
///
/// **The trap this exists to remove:** on the viewport rejections the projector has *already
/// written* the out-param (`484065`/`484067`), so a caller that ignores the verdict reads a
/// perfectly plausible off-screen coordinate and sails on — which is exactly how benilla ended up
/// clamping the plates of units nobody can see onto the screen border (1341). Returning `Option`
/// makes the verdict unignorable at the call site.
///
/// The chat bubble deliberately does **not** call this: the reference's bubble seat is the one
/// caller of the three that ignores the boolean, so a bubble simply slides off the edge with its
/// speaker. Bevy's own `Err` covers only the behind-camera half, which is why the viewport test
/// has to be here rather than left to it.
pub(crate) fn project_overlay(
cam: &Camera,
cam_tf: &GlobalTransform,
world: Vec3,
viewport: Vec2,
) -> Option<Vec2> {
// Bevy's `Err` is the near-plane half (`483f7a`); `accepts` is the viewport half.
let p = cam.world_to_viewport(cam_tf, world).ok()?;
accepts(p, viewport).then_some(p)
}
/// The accept REGION: exactly the viewport, **inclusive**. `[0xb4b2bc]` is the WorldFrame, and its
/// region rect is mirrored ÷G44/÷G48 into the compare fields (`0x483970`), so the aspect factors
/// cancel exactly and the test is against the raw screen box.
/// `WOW_PROBE_UI_ONE_TEX=1` — a PRICING lever, never a look: the run split ignores texture
/// identity, so runs break on state flags alone and every run draws with its first quad's
/// texture. What it measures is the draw-count ceiling a UI texture atlas would reach (the
/// module doc's "standard fix"), before that atlas is built.
fn one_texture_probe() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_PROBE_UI_ONE_TEX").is_some())
}
fn accepts(p: Vec2, viewport: Vec2) -> bool {
(0.0..=viewport.x).contains(&p.x) && (0.0..=viewport.y).contains(&p.y)
}
/// 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>()
.init_resource::<UiMeshCost>()
.init_resource::<crate::ui_script::UiCostWanted>()
.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,
// The 2D opaque pass skipped when empty — which on this camera is always (2225).
crate::opaque2d::SkipEmptyOpaque2dPlugin,
))
.add_systems(Startup, (spawn_ui_camera, init_white_texture))
.configure_sets(
Update,
UiQuadAppend.after(benilla_world::schedule::WorldStage::Input),
)
.add_systems(Update, clear_ui_overlays.before(UiQuadAppend))
.add_systems(Update, rebuild_ui_mesh.after(UiQuadAppend))
.add_systems(Last, count_material_events);
}
}
/// 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,
Name::new("player-UI camera"),
Camera2d,
// **No MSAA — named, because silence here means 4×.** `bevy_render`'s `CameraPlugin`
// registers `Camera` → `Msaa` as a required component (`bevy_render/src/camera.rs:56`) and
// `Msaa::default()` is `Sample4`, so a camera that never mentions MSAA does not get "none",
// it gets four samples. Every other non-world camera in the tree says `Msaa::Off` out loud
// (the portrait booths, the minimap composite); this one simply never did, and paid for it.
//
// There is nothing here for multisampling to resolve. The world arrives already resolved —
// since decision 1603 the world camera renders offscreen, and since 2234 this camera's main
// pass draws that finished image straight in first ([`crate::world_backdrop`]), so this
// camera's samples would only re-average a picture whose own MSAA is long since done. What
// it draws itself is
// axis-aligned rects, and the Bevy UI trees riding this camera (decision 0541) antialias
// their own edges analytically in-shader. What it *costs* is a full-window 4× sampled
// colour texture, a full-window 4× multisampled Core2d depth texture (Bevy sizes that one
// at `msaa.samples()` unconditionally — `core_2d::prepare_core_2d_depth_textures` — even
// though `AlphaMode2d::Blend` puts every quad in `Transparent2d`, which never writes it),
// 4× the fill on every quad, and a resolve. Decision 1628.
bevy::render::view::Msaa::Off,
// 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::default(),
// The world is the first draw of this camera's main pass (decision 2234): the FFXGlow
// combine of the world camera [`crate::world_backdrop`] points it at, rendered into this
// target ahead of the quads. `source` is `None` until a world camera draws.
benilla_world::ffx_glow::FfxBackdrop::default(),
// 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,
// **No output blit at all** (decision 2206, [`benilla_world::final_pass`]): the
// lane's final pass — [`crate::ui_gamma`]'s decode — renders straight into the
// swapchain, so bevy's `upscaling` copy of the finished frame is skipped.
//
// It could already have been a plain copy, because this camera carries the world
// too ([`crate::world_backdrop`]) and its target is a whole opaque frame. That was
// the point of the backdrop: the blend that used to happen HERE, against the sRGB
// swapchain view, was the frame's one linear composite, and the only one that mixed
// UI with world; moving the world into the UI's own byte buffer moved that blend
// into this camera's gamma target, where every other UI blend already lives. It
// also retired the hazard 0254 patched around here: `ui_quad.wgsl` writes
// PREMULTIPLIED colour, so the original `ALPHA_BLENDING`'s `SrcAlpha` factor
// weighted it by alpha twice (`rgb·a²`), and a pure-additive quad (a = 0) over the
// world was multiplied clean away. With nothing to blend against, neither factor can
// be wrong — and with no blit, there is no pass left to get it wrong in.
output_mode: CameraOutputMode::Skip,
// 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,
desaturated: bool,
premultiplied: bool,
gamma_texel: bool,
alpha_test: Option<f32>,
mask: Option<UiQuadMask>,
uv_clamp: Option<[f32; 4]>,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
colors: Vec<[f32; 4]>,
indices: Vec<u32>,
}
impl Run {
/// A fresh run carrying `q`'s material identity (every flag that splits a run) under the
/// already-resolved `texture` (the quad's own, or the shared white fallback).
fn new(texture: Handle<Image>, q: &UiQuad) -> Self {
Self {
texture,
additive: q.additive,
circular: q.circular,
desaturated: q.desaturated,
premultiplied: q.premultiplied,
gamma_texel: q.gamma_texel,
alpha_test: q.alpha_test,
mask: q.mask.clone(),
uv_clamp: q.uv_clamp,
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]);
}
}
/// `WOW_UI_COST=1` also counts the frame's [`UiQuadMaterial`] asset events. bevy re-extracts and
/// re-prepares a material — one bind group and one uniform buffer, created on the render thread —
/// for exactly each `Added`/`Modified` event, and nothing else re-prepares one (2236: two or three
/// such events a frame at a parked pin cost 9.5 traced ms on Intel's DX12 driver, which prices one
/// re-prepared material in milliseconds there). So this count is the render side's material work
/// for the frame, read where it is caused — and since a colour left the material for the
/// instance tag ([`tint_tag`]) it reads ZERO on a parked frame, pulsing glow or not: a line here
/// after the interface has settled is a material being built or rebuilt for something other than
/// a new texture, and that is a regression to find, not a cost to price.
fn count_material_events(
mut events: MessageReader<AssetEvent<UiQuadMaterial>>,
materials: Res<Assets<UiQuadMaterial>>,
server: Res<AssetServer>,
mut named: Local<u32>,
) {
if !crate::ui_script::extract::ui_cost_enabled() {
events.clear();
return;
}
let (mut added, mut modified, mut unused) = (0usize, 0usize, 0usize);
for event in events.read() {
match event {
AssetEvent::Added { .. } => added += 1,
AssetEvent::Modified { id } => {
modified += 1;
// The first hundred name themselves: the texture the rewritten material binds.
if *named < 100 {
*named += 1;
if let Some(m) = materials.get(*id) {
let path = m
.texture
.as_ref()
.and_then(|t| server.get_path(t.id()))
.map(|p| p.to_string());
info!("[ui-mat] modified {id:?} texture={path:?}");
}
}
}
AssetEvent::Removed { .. } | AssetEvent::Unused { .. } => unused += 1,
AssetEvent::LoadedWithDependencies { .. } => {}
}
}
if added + modified + unused > 0 {
info!("[ui-mat] added={added} modified={modified} unused={unused}");
}
}
/// 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>>,
/// Each slot's last-written MESH content (base positions, before [`Self::offsets`]) — the
/// per-run skip gate (decision 1361): a slot whose run is bit-identical to what its pooled
/// mesh already holds is not rewritten, so one animating quad no longer drags every batch
/// through the GPU mesh allocator.
stored: Vec<StoredRun>,
/// Each slot's current XY translation from its stored base, carried by the batch entity's
/// `Transform` instead of the mesh (decision 1463): a run that only *panned* — the minimap
/// tile was ~74% of all moving-regime rebuild triggers — moves without an `Assets<Mesh>`
/// write, because ONE Modified event per frame arms `AssetChanged` probes over every
/// `Mesh3d` row in the scene (1370's all-or-nothing fast path, ~0.8 ms/frame at 1461's
/// Goldshire pin).
offsets: Vec<Vec2>,
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
/// Per slot: the material the batch entity currently carries.
bound: Vec<Option<AssetId<UiQuadMaterial>>>,
/// Per slot: the [`MeshTag`] the batch entity currently carries — the run's one colour
/// ([`tint_tag`]), rewritten only when it moves.
tags: Vec<Option<u32>>,
}
/// One pooled batch slot's full identity: the mesh bytes plus everything the entity was last
/// written with (material key, z). Equality is exact — see the skip gate's comment for why a
/// hash was rejected.
#[derive(PartialEq)]
struct StoredRun {
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
colors: Vec<[f32; 4]>,
indices: Vec<u32>,
z_bits: u32,
key: MatKey,
}
impl StoredRun {
/// The candidate's uniform XY offset from this base: `Some(d)` when every vertex is this
/// run's vertex plus one constant delta and everything else is bit-equal — `Some(ZERO)` is
/// exactly the old bit-identical skip case, so this subsumes 1361's gate. `None` means the
/// run genuinely changed shape and must be rebaked. The comparison is exact float
/// arithmetic on purpose: a miss only falls back to the rewrite path, so a widget whose
/// pan doesn't survive `b + d == n` bit-exactly loses the optimization, never correctness.
/// Why [`Self::translation_from`] missed — the pan-gate diagnostic (`WOW_UI_DIFF=1`).
fn translation_miss_reason(&self, new: &StoredRun) -> &'static str {
if self.key != new.key {
"key"
} else if self.z_bits != new.z_bits {
"z"
} else if self.positions.len() != new.positions.len() {
"len"
} else if self.indices != new.indices {
"indices"
} else if self.uvs != new.uvs {
"uvs"
} else if self.colors != new.colors {
"colors"
} else {
"delta-nonuniform"
}
}
fn translation_from(&self, new: &StoredRun) -> Option<Vec2> {
if self.key != new.key
|| self.z_bits != new.z_bits
|| self.positions.len() != new.positions.len()
|| self.positions.is_empty()
|| self.indices != new.indices
|| self.uvs != new.uvs
|| self.colors != new.colors
{
return None;
}
let d = Vec2::new(
new.positions[0][0] - self.positions[0][0],
new.positions[0][1] - self.positions[0][1],
);
self.positions
.iter()
.zip(&new.positions)
.all(|(b, n)| n[0] == b[0] + d.x && n[1] == b[1] + d.y && n[2] == b[2])
.then_some(d)
}
}
/// 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,
bool,
bool,
bool,
u32,
Option<AssetId<Image>>,
[u32; 4],
[u32; 4],
);
/// The [`UiQuadMaterial::uv_clamp`] that clamps NEITHER axis — `min > max` on an axis is the
/// shader's per-axis "off", so `(1,1)` against `(0,0)` disables both.
const UV_CLAMP_OFF: Vec4 = Vec4::new(1.0, 1.0, 0.0, 0.0);
/// 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();
}
// The skip gate must forget with them (decision 1361): a retired slot's entity is gone, so
// "content unchanged" must not skip the respawn when the UI returns. The pan gate's
// offsets ride the same lifecycle (they index the same slots).
pools.stored.clear();
pools.offsets.clear();
}
/// 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.
/// The two asset stores [`rebuild_ui_mesh`] writes, plus the image-removal stream its material
/// cache has to hear — one bundle, because the rebuild is already at clippy's argument ceiling.
type RebuildStores<'w, 's> = (
ResMut<'w, Assets<Mesh>>,
ResMut<'w, Assets<UiQuadMaterial>>,
MessageReader<'w, 's, AssetEvent<Image>>,
);
fn ui_mesh_frozen() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_FREEZE_UI_MESH").is_some())
}
/// `WOW_UI_DIFF` presence — launch-time knob, read once (the rebuild path re-ran getenv per frame).
static UI_DIFF: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var_os("WOW_UI_DIFF").is_some());
/// `WOW_UI_PROBE=1` — launch-time knob, read once (the per-run check re-ran getenv ~90×/frame).
static UI_PROBE: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var("WOW_UI_PROBE").as_deref() == Ok("1"));
/// **What a `quads.dirty` frame costs on the RENDER side** — the meter this pass never had
/// (decision 1625's residual, found the hard way).
///
/// `[ui-cost]` and the hover recorder split the UI *script* pass into tick/resolve/measure/extract/
/// convert, and stop there. Everything below — sorting every quad in the interface, clipping and
/// run-splitting them, and regenerating each run's vertex data — runs in a DIFFERENT system, on
/// exactly the frames a tooltip content change dirties the lane, and no instrument had a clock on
/// it. So the recorder built for the hover symptom could report "the UI phases are flat" on a frame
/// that was doing a full mesh rebuild, which is worse than silence: it reads as an alibi.
///
/// Published as its own resource rather than a field of `UiFrameCost` because the two systems are
/// unordered within `Update` — a field there would be wiped by whichever ran second.
#[derive(Resource, Default, Clone)]
pub(crate) struct UiMeshCost {
/// Did the rebuild actually run this frame, or did the early-out take it?
pub(crate) rebuilt: bool,
/// Total µs across the whole rebuild.
pub(crate) total: u128,
/// Collect + stable sort over every quad in both lanes.
pub(crate) sort: u128,
/// Clip + split into texture-identity runs.
pub(crate) split: u128,
/// Vertex-data regeneration, mesh writes, material lookups, batch entity churn.
pub(crate) write: u128,
/// How many quads were sorted, and how many runs came out — the counts behind the µs.
pub(crate) quads: usize,
pub(crate) runs: usize,
/// How many pooled batch meshes were REWRITTEN this rebuild, rather than left alone or moved
/// by a translation-only nudge (1361's skip gate). This is the number that reaches Bevy: each
/// rewrite re-extracts in `RenderExtractApp`, which is where a hover's real cost turned out to
/// live (decision 1634). `rewrites == runs` every frame means the gate is being defeated for
/// every batch at once, which is what a z coupled to the run count did.
pub(crate) rewrites: usize,
}
fn rebuild_ui_mesh(
mut quads: ResMut<UiQuads>,
mut commands: Commands,
mut stores: RebuildStores,
mut pools: Local<BatchPools>,
white: Option<Res<UiWhiteTexture>>,
windows: Query<&Window, With<PrimaryWindow>>,
// The hide binding and the cost meter's pair, bundled (clippy's argument ceiling — the same
// reason `stores` is a tuple).
mut hide_and_meter: (
Res<crate::ui_hide::UiHidden>,
ResMut<UiMeshCost>,
Res<crate::ui_script::UiCostWanted>,
),
) {
// **Forget the materials of images that no longer exist** — first, before any early return,
// because a missed read is a leak rather than a stale frame.
//
// A cached material holds a strong `Handle<Image>`, and its PREPARED form holds the whole GPU
// texture behind a bind group Bevy never re-prepares. So an entry keyed on a retired asset
// pins that texture for as long as the cache holds the key. The producer that taught this was
// the world backdrop — a full-window `Rgba16Float` image (46 MB at 3200×1800) retired on every
// resize, once a frame while a window is being dragged (decision 1647); since 2234 no quad
// samples the world's target at all, and the hygiene stays for every other image a quad names
// and then loses.
//
// `Removed` only, not `Unused`: `Unused` fires when the last strong handle drops, and this
// cache IS a strong handle, so for a cached texture it can never fire. A producer that retires
// an image removes the asset explicitly (`world_backdrop::track_render_size` still does)
// precisely so this can hear it.
let retired: Vec<AssetId<Image>> = stores
.2
.read()
.filter_map(|e| match e {
AssetEvent::Removed { id } => Some(*id),
_ => None,
})
.collect();
if !retired.is_empty() {
pools.materials.retain(|key, _| !retired.contains(&key.0));
}
let (hidden, mesh_cost, cost_wanted) =
(&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2);
// The meter is off unless something asked (the hover recorder, `WOW_UI_COST=1`) — an unmetered
// rebuild pays one bool test, not six clock reads.
let cost_on = cost_wanted.0 || crate::ui_script::extract::ui_cost_enabled();
**mesh_cost = UiMeshCost::default();
let t_rebuild = cost_on.then(std::time::Instant::now);
let mut t_mark = t_rebuild;
let mut lap = move || -> u128 {
if !cost_on {
return 0;
}
t_mark
.replace(std::time::Instant::now())
.map_or(0, |t| t.elapsed().as_micros())
};
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`]).
//
// **It hides the two LANES, and the world is in neither.** The world reaches the screen as
// the first draw of this camera's main pass ([`crate::world_backdrop`], decision 2234), not
// as a batch, so
// retiring every batch while hidden leaves exactly what the binding's stated point is: "the
// world and nothing else". (Between 1603 and 2234 the world was this pass's first QUAD, and
// the dark path had to keep one batch alive to keep the screen from going black.) While dark
// we still 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.is_changed() {
q.dirty = true;
}
let lanes_hidden = hidden.0;
if lanes_hidden {
q.last_overlays.clone_from(&q.overlays);
// Nothing either lane produces can move a pixel while dark, so only the toggle edge
// reaches the rebuild below.
if !q.dirty {
return;
}
} else if !q.dirty && q.overlays == q.last_overlays {
return;
}
// `WOW_UI_DIFF=1` — WHO re-triggers the rebuild? Names the first differing overlay quad (or
// says the BASE lane's dirty flag did it), once a second. The instrument behind the
// Stormwind mesh-churn hunt: a widget re-emitting a moving/jittering quad every frame drags
// every batch through a full rebuild and the GPU allocator through ~90 frees+reallocs.
if *UI_DIFF {
if q.dirty {
eprintln!("[ui-diff] BASE lane dirty");
} else {
let i = q
.overlays
.iter()
.zip(&q.last_overlays)
.position(|(a, b)| a != b);
match i {
Some(i) => {
let (a, b) = (&q.overlays[i], &q.last_overlays[i]);
eprintln!(
"[ui-diff] overlay {i}/{} differs: tex={:?} rect {:?} -> {:?} uv_changed={} color_changed={}",
q.overlays.len(),
a.texture.as_ref().and_then(|t| t.path()),
b.rect,
a.rect,
a.uv != b.uv,
a.color != b.color,
);
}
None => eprintln!(
"[ui-diff] overlay COUNT changed: {} -> {}",
q.last_overlays.len(),
q.overlays.len()
),
}
}
}
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;
};
let lanes_empty = lanes_hidden || (q.quads.is_empty() && q.overlays.is_empty());
if lanes_empty {
retire_batches(&mut pools, &mut commands);
q.dirty = false;
q.last_overlays.clone_from(&q.overlays);
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> = if lanes_hidden {
Vec::new()
} else {
q.quads.iter().chain(q.overlays.iter()).collect()
};
sorted.sort_by_key(|q| q.z_key);
let n_sorted = sorted.len();
let us_sort = lap();
// 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 *UI_PROBE {
// 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 || one_texture_probe())
&& r.additive == q.additive
&& r.circular == q.circular
&& r.desaturated == q.desaturated
&& r.premultiplied == q.premultiplied
&& r.gamma_texel == q.gamma_texel
&& r.alpha_test == q.alpha_test
&& r.mask == q.mask
&& r.uv_clamp == q.uv_clamp
});
if !same_run {
runs.push(Run::new(texture, q));
}
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.
//
// NB (decision 1634): this z DOES move when the run count moves, and `translation_from` bails on
// `z_bits` first — so it looks like a hover (which adds a run: the ButtonHilight is additive with
// its own texture and can never merge) would defeat 1361's skip gate for every batch at once.
// It was tried, with a constant denominator, and MEASURED: no change to the hover cost, because
// the gate is not in fact being defeated — `mesh_rewrites` reads 2.2 of 96.6 runs on a hover
// sweep. Left exactly as it was; the note is here so the next reader does not re-run the
// experiment.
let us_split = lap();
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;
let mut n_rewrites = 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;
// 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 *UI_PROBE && 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 alpha_ref = run.alpha_test.unwrap_or(0.0);
let uv_clamp = run.uv_clamp.map_or(UV_CLAMP_OFF, Vec4::from_array);
let key: MatKey = (
run.texture.id(),
run.additive,
run.circular,
run.desaturated,
run.premultiplied,
run.gamma_texel,
alpha_ref.to_bits(),
mask.as_ref().map(bevy::asset::Handle::id),
mask_rect.to_array().map(f32::to_bits),
uv_clamp.to_array().map(f32::to_bits),
);
// The per-slot skip gate (decision 1361). A rebuild fires for the WHOLE quad stream the
// moment anything differs — and one continuously-animating quad (the resting blink on
// the player frame, in every city) fires it every frame. The run that quad lives in is
// the only one whose bytes moved; the other ~90 slots are bit-identical to what their
// pooled mesh already holds, and rewriting them anyway put every UI batch through the
// GPU mesh allocator's free+realloc each frame (0.86 ms/frame at the Stormwind pin).
// Full content compare, not a hash — a stale batch drawn over a collision would be a
// rendering bug no one could reproduce.
// A run whose quads all share ONE colour stores white vertices and carries the colour on
// the batch entity's tag ([`tint_tag`]) — so a colour pulse, or a whole string fading,
// is a component write: never a mesh write, never a material write.
let one_colour = run
.colors
.first()
.filter(|&&c| run.colors.iter().all(|&o| o == c))
.copied();
let tag = tint_tag(one_colour.unwrap_or([1.0; 4]));
let colors = match one_colour {
Some(_) => vec![[1.0; 4]; run.colors.len()],
None => run.colors,
};
let stored = StoredRun {
positions: run.positions,
uvs: run.uvs,
colors,
indices: run.indices,
z_bits: z.to_bits(),
key,
};
while pools.bound.len() <= used {
pools.bound.push(None);
pools.tags.push(None);
}
let material_handle = pools
.materials
.entry(key)
.or_insert_with(|| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
texture: Some(run.texture.clone()),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
alpha_ref,
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask: mask.clone(),
uv_clamp,
})
})
.clone();
// The pan gate (1463): a run that matches its slot's base up to one constant XY delta
// moves on the batch entity's `Transform` — no mesh write, no `AssetChanged` arming.
// `Some(ZERO)` is the bit-identical case (1361's old skip gate), where even the
// `Transform` write is skipped unless a previous pan is being undone. The colour rides
// the same way: a moved tag is one component write on the slot's entity.
let pan = pools
.stored
.get(used)
.and_then(|prev| prev.translation_from(&stored).map(|d| (d, prev.z_bits)));
if let Some((d, z_bits)) = pan {
if pools.bound[used] != Some(material_handle.id()) {
pools.bound[used] = Some(material_handle.id());
if let Some(&entity) = pools.entities.get(used) {
commands
.entity(entity)
.insert(MeshMaterial2d(material_handle.clone()));
}
}
if pools.tags[used] != Some(tag) {
pools.tags[used] = Some(tag);
if let Some(&entity) = pools.entities.get(used) {
commands.entity(entity).insert(MeshTag(tag));
}
}
if pools.offsets[used] != d {
pools.offsets[used] = d;
if let Some(&entity) = pools.entities.get(used) {
commands.entity(entity).insert(Transform::from_xyz(
d.x,
d.y,
f32::from_bits(z_bits),
));
}
}
used += 1;
continue;
}
n_rewrites += 1;
// Pan-gate miss diagnostic (`WOW_UI_DIFF=1`, ≤3 lines/s so a startup burst can't
// exhaust it): names the check that sent this slot to the rewrite path.
if *UI_DIFF {
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
static SHOWN: AtomicU32 = AtomicU32::new(0);
static LAST_SEC: AtomicU64 = AtomicU64::new(0);
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let sec = START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_secs();
if LAST_SEC.swap(sec, Ordering::Relaxed) != sec {
SHOWN.store(0, Ordering::Relaxed);
}
if SHOWN.fetch_add(1, Ordering::Relaxed) < 3 {
let why = pools
.stored
.get(used)
.map_or("no-prev", |p| p.translation_miss_reason(&stored));
eprintln!(
"[ui-pan] slot {used} rewrite: {why} (quads={}, key.tex={:?})",
stored.positions.len() / 4,
stored.key.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, stored.positions.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, stored.uvs.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, stored.colors.clone());
mesh.insert_indices(Indices::U32(stored.indices.clone()));
if pools.stored.len() > used {
pools.stored[used] = stored;
} else {
pools.stored.push(stored);
}
// A rebaked slot's mesh holds absolute positions again — its pan resets with it (the
// rewrite arm below writes `Transform::from_xyz(0, 0, z)`).
if pools.offsets.len() > used {
pools.offsets[used] = Vec2::ZERO;
} else {
pools.offsets.push(Vec2::ZERO);
}
let mesh_handle = match pools.meshes.get(used) {
Some(handle) => {
// `WOW_FREEZE_UI_MESH=1` — never rewrite a pooled batch mesh in place (the UI
// freezes at its first-built frame). An EXPERIMENT knob (the 1370 bracket): the
// resting blink's ~2 rewrites/frame are one-asset `Assets<Mesh>` mutations, and
// bevy 0.18's `AssetChanged<Mesh3d>` fast path is all-or-nothing — one modified
// mesh arms a hash probe over every `Mesh3d` row in the scene (~44k at the SW
// pin) in three PostUpdate walks. This lever prices that arming; `WOW_MESH_EVENTS`
// confirms it (events/s → 0). First build (the `None` arm) is untouched.
if !ui_mesh_frozen() {
let _ = meshes.insert(handle.id(), mesh);
}
handle.clone()
}
None => {
let handle = meshes.add(mesh);
pools.meshes.push(handle.clone());
handle
}
};
pools.bound[used] = Some(material_handle.id());
pools.tags[used] = Some(tag);
// 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, the run's colour tag, a fresh z.
match pools.entities.get(used) {
Some(&entity) => {
commands.entity(entity).insert((
Mesh2d(mesh_handle),
MeshMaterial2d(material_handle),
MeshTag(tag),
Transform::from_xyz(0.0, 0.0, z),
));
}
None => {
let entity = commands
.spawn((
UiQuadBatch,
Mesh2d(mesh_handle),
MeshMaterial2d(material_handle),
MeshTag(tag),
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);
pools.stored.truncate(used);
pools.offsets.truncate(used);
if cost_on {
let us_write = lap();
**mesh_cost = UiMeshCost {
rebuilt: true,
total: t_rebuild.map_or(0, |t| t.elapsed().as_micros()),
sort: us_sort,
split: us_split,
write: us_write,
quads: n_sorted,
runs: used,
rewrites: n_rewrites,
};
}
}
#[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>()
// The pass binds images and listens for their removal (the material cache sweep), so
// an app without the Image asset is an under-provisioned harness, not a lighter one.
.init_asset::<Image>()
.init_resource::<UiQuads>()
.init_resource::<UiMeshCost>()
.init_resource::<crate::ui_script::UiCostWanted>()
.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
}
/// The projector's accept region is **exactly the viewport, inclusive** — the WorldFrame's own
/// region mirrored ÷G44/÷G48 (`0x483970`), so the aspect factors cancel. A point on the edge
/// draws (the seat's clamp, not this, is what keeps the rect fully inside); a point outside it
/// does not, however plausible the coordinate the projector already wrote looks. The last row
/// is the measured phantom that bought this: a unit two thirds of a screen below the bottom,
/// whose plate used to be dragged up onto the border (1341).
#[test]
fn the_accept_region_is_the_viewport_inclusive() {
let vp = Vec2::new(1440.0, 810.0);
assert!(accepts(Vec2::new(720.0, 405.0), vp));
assert!(accepts(Vec2::ZERO, vp), "the corner is in view");
assert!(accepts(vp, vp), "so is the far corner");
for out in [
Vec2::new(-0.5, 405.0),
Vec2::new(1440.5, 405.0),
Vec2::new(720.0, -0.5),
Vec2::new(720.0, 810.5),
Vec2::new(1409.0, 1332.0),
] {
assert!(!accepts(out, vp), "{out:?} is not on screen");
}
}
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"
);
}
/// **A removed image takes its cached material with it** (decision 1647).
///
/// The cache holds a strong `Handle<Image>` per entry and its prepared form holds the whole GPU
/// texture behind a bind group Bevy never re-prepares — so an entry keyed on an asset that no
/// longer exists pins that texture indefinitely. Nothing here notices until something retires
/// images in bulk, and the world backdrop did exactly that while it was a quad (1603–2234):
/// one full-window `Rgba16Float` image per resize, once a frame while a window was dragged.
///
/// The removal is seen a few frames late by construction (the loop below names the three
/// lags). That is fine for memory hygiene and would NOT be fine for correctness — which is
/// exactly why the correctness half of 1647 is a new `AssetId` rather than an invalidation.
#[test]
fn a_removed_image_does_not_keep_its_material_alive() {
let mut app = rebuild_app();
let art = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::new_fill(
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[255; 4],
TextureFormat::Rgba8Unorm,
RenderAssetUsages::default(),
));
let art_id = art.id();
{
let mut q = app.world_mut().resource_mut::<UiQuads>();
q.quads.push(UiQuad {
rect: Rect::new(0.0, 0.0, 32.0, 32.0),
texture: Some(art.clone()),
..default()
});
q.dirty = true;
}
app.update();
assert!(
names_image(&app, art_id),
"the textured quad must have produced a material naming that image"
);
// Retire it exactly the way the world backdrop retires a resized target: drop every quad
// that names it, then remove the asset. Only the cache is left holding it.
drop(art);
{
let mut q = app.world_mut().resource_mut::<UiQuads>();
q.quads.retain(|quad| quad.texture.is_none());
q.dirty = true;
}
app.world_mut()
.resource_mut::<Assets<Image>>()
.remove(art_id);
// Three separate one-frame lags stand between the removal and an observably dead material:
// `Assets::asset_events` writes the `Removed` message in `PostUpdate` while the rebuild
// reads it in `Update`; the retired batch entity's own handle drops at that frame's command
// flush; and a dropped handle is only reclaimed by `track_assets` in the NEXT `PreUpdate`.
// None of that matters for the freeze — the correctness half is a new AssetId, not an
// invalidation — so the loop simply spends the frames rather than pretending to be exact.
for _ in 0..4 {
app.update();
}
assert!(
!names_image(&app, art_id),
"a material for a removed image is dead weight: it pins the image AND the GPU \
texture behind its prepared bind group for as long as the cache holds the key"
);
}
/// Does any live `UiQuadMaterial` still name this image?
fn names_image(app: &App, id: AssetId<Image>) -> bool {
app.world()
.resource::<Assets<UiQuadMaterial>>()
.iter()
.any(|(_, m)| m.texture.as_ref().is_some_and(|t| t.id() == id))
}
/// **TOGGLEUI hides the UI, not the world — and the world is not a batch.** The world is the
/// first draw of the UI camera's main pass (`benilla_world::ffx_glow::FfxBackdrop`, 2234), not a
/// quad in either lane, so the dark path is what it was before 1603 put a backdrop quad in
/// here: retire every batch. That leaves "the world and nothing else", which is the binding's
/// stated point, and the lanes come back on top of it exactly as they were.
///
/// (Between 1603 and 2234 this test held ONE batch alive while dark — the backdrop's — because
/// zero batches was then a black screen. A future version that keeps a batch alive while dark
/// is drawing something the binding says must not be drawn.)
#[test]
fn toggleui_retires_every_batch_and_the_world_is_none_of_them() {
let mut app = rebuild_app();
app.update();
let lit = batches(&mut app);
assert!(lit >= 1, "content drawn to begin with");
set_hidden(&mut app, true);
app.update();
assert_eq!(
batches(&mut app),
0,
"dark ⇒ nothing drawn: the world is this camera's own pass, not a batch"
);
set_hidden(&mut app, false);
app.update();
assert_eq!(
batches(&mut app),
lit,
"the UI comes back over the same world"
);
}
/// **The desaturation flag reaches the MATERIAL, and splits the run** (decision 1327).
///
/// Everything upstream of here can be right — the Lua sets it, extract carries it, the quad
/// holds it — and the screen still not change, because the greyscale lives in a shader uniform
/// and a uniform only exists per material. Two quads that differ ONLY in `desaturated` must
/// therefore batch apart; if they merged, whichever of the two came first would decide the look
/// of both, and the visible symptom would be a talent tree that greys in blocks.
#[test]
fn desaturation_splits_the_run_and_lands_on_the_material() {
let mut app = rebuild_app();
// Same texture (the shared white), same blend, adjacent in z — everything that batches
// agrees, so `desaturated` is the only thing that can split them.
let mut quads = app.world_mut().resource_mut::<UiQuads>();
quads.quads.push(UiQuad {
rect: Rect::new(64.0, 0.0, 128.0, 64.0),
z_key: 1,
desaturated: true,
..default()
});
quads.dirty = true;
app.update();
assert_eq!(
batches(&mut app),
2,
"a desaturated quad cannot share a material with a full-colour one"
);
let mut flags: Vec<u32> = app
.world_mut()
.resource::<Assets<UiQuadMaterial>>()
.iter()
.map(|(_, m)| m.desaturate)
.collect();
flags.sort_unstable();
assert_eq!(
flags,
vec![0, 1],
"one material greys and one does not — the uniform the shader branches on"
);
}
/// **The tag is the reference's byte colour, complemented.** `SetVertexColor` quantises
/// `×255 + 0.5` into one byte per channel (wow-re `texture-color-composition.md`), and the
/// complement is what makes bevy's untagged 0 read as opaque white (the minimap's interior
/// tiles never set one).
#[test]
fn tint_tag_is_the_reference_byte_colour_complemented() {
assert_eq!(tint_tag([1.0; 4]), 0, "white is the untagged default");
assert_eq!(
tint_tag([0.0; 4]),
u32::MAX,
"transparent black is a real colour, not a missing tag"
);
// Gold at half alpha: 255, 224 (224.9 truncated), 64 (64.25), 128 (128.0) — the bytes the
// reference packs, r in the low byte the way `unpack4x8unorm` reads them back.
assert_eq!(
tint_tag([1.0, 0.88, 0.25, 0.5]),
!(255 | 224 << 8 | 64 << 16 | 128 << 24)
);
assert_eq!(
tint_tag([2.0, -1.0, 0.5, 1.0]),
!(255 | 128 << 16 | 255 << 24),
"clamped to [0, 1] first, as the Lua binding clamps"
);
}
/// **A colour pulse is one component write** — no `Assets<Mesh>` write and no material
/// write (decision 2236's gate: the `WOW_UI_COST=1` material-event count must be ZERO on a
/// parked frame with a pulsing element, not merely cheaper). The stock PlayerFrame status
/// glow pulses its alpha every frame at rest; on Intel's DX12 driver each material it
/// re-prepared cost milliseconds, and as a vertex colour it had cost an asset-changed sweep
/// over every mesh in the scene. So the colour goes on the batch entity's `MeshTag`, the
/// mesh stays white and untouched, and the material — its identity alone — is never
/// rewritten.
#[test]
fn a_colour_pulse_writes_the_tag_and_neither_asset() {
let mut app = rebuild_app();
// The rebuild's own meter is off unless asked (a player's frame pays one bool for it);
// this test reads its `rewrites`, so it asks.
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
assert_eq!(batches(&mut app), 1);
// Read through a cursor, not the "current update" buffer: `TimePlugin` only lets the
// message buffers swap after a FIXED step (`signal_message_update_system`), and this
// harness ticks faster than one, so frame 1's events are still "current" on frame 2.
let mut cursor = bevy::ecs::message::MessageCursor::<AssetEvent<UiQuadMaterial>>::default();
let mut material_events = |app: &App| {
let (mut added, mut modified) = (0, 0);
for e in cursor.read(
app.world()
.resource::<Messages<AssetEvent<UiQuadMaterial>>>(),
) {
match e {
AssetEvent::Added { .. } => added += 1,
AssetEvent::Modified { .. } => modified += 1,
_ => {}
}
}
(added, modified)
};
assert_eq!(
material_events(&app),
(1, 0),
"the first frame builds the run's one material (and this harness hears it)"
);
let (batch, tag0) = app
.world_mut()
.query_filtered::<(Entity, &MeshTag), With<UiQuadBatch>>()
.single(app.world())
.expect("one batch");
assert_eq!(
**tag0,
tint_tag([1.0; 4]),
"a white quad carries the white tag"
);
// The pulse: the same quad, another colour.
let gold = [1.0, 0.88, 0.25, 0.5];
{
let mut q = app.world_mut().resource_mut::<UiQuads>();
q.quads[0].color = gold;
q.dirty = true;
}
app.update();
let cost = app.world().resource::<UiMeshCost>();
assert!(cost.rebuilt, "the dirty flag ran the rebuild");
assert_eq!(
cost.rewrites, 0,
"no pooled mesh was rewritten for a colour"
);
assert_eq!(
material_events(&app),
(0, 0),
"no material was built or rebuilt for a colour — the count 2236 gates on"
);
assert_eq!(
app.world().resource::<Assets<UiQuadMaterial>>().len(),
1,
"still the one material: colour is not part of its identity"
);
let tag = app
.world()
.get::<MeshTag>(batch)
.expect("the batch keeps its tag");
assert_eq!(**tag, tint_tag(gold), "the colour went to the instance tag");
let mesh_handle = app.world().get::<Mesh2d>(batch).expect("the batch's mesh");
let mesh = app
.world()
.resource::<Assets<Mesh>>()
.get(&mesh_handle.0)
.expect("the pooled mesh");
let Some(bevy::mesh::VertexAttributeValues::Float32x4(colors)) =
mesh.attribute(Mesh::ATTRIBUTE_COLOR)
else {
panic!("the run's vertex colours are Float32x4");
};
assert!(
colors.iter().all(|c| *c == [1.0; 4]),
"the vertices stay white: the colour is the tag's, not the mesh's"
);
}
// 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]);
}
}