The last real SuperWoW feature, and one piece of work closed three things: `CREATE_CHATBUBBLE` (which could not honestly be fired, because its payload can only be the bubble), `C_ChatBubbles.GetAllChatBubbles` (which answered an empty list, truthfully, for the same reason) and pfUI's whole bubble skin, which walks that list. `benilla-ui`'s `script/chat_bubble.rs` is a pool of real frames — anonymous WorldFrame children with a backdrop (`0x4b0a35` IS `SetBackdrop`), a tail texture and a FontString, which is what the reference's own `CGChatBubbleFrame` is and how addons of the era found bubbles before SuperWoW published the event. The app keeps everything a bubble's existence depends on — the spawn gate, the lifetime, the fade, the range re-test, the stacking sort and the text measurement that sizes the frame — and hands it over as `BubbleState`: the same split `vplates.rs` already makes for the plates, so the engine decides what is on screen, the widget layer decides what Lua sees, and neither can disagree about a bubble because there is one feed. Three things improved by the move rather than being preserved by it: - The bubbles sit at frame levels 2, 3, 4… over the V-plates at level 1, which is the reference's own order (`0x4b12d5`-`0x4b1312`). The quad band had them UNDERNEATH — a deviation `overlay_z` carried in a comment. That band is gone and the lane is down to one tenant. - The art is loaded, tiled and bleed-inset by the UI pass like any other frame's, so this file drops its own texture resource and its own half-texel inset helper. - The system moved BEFORE the paint. It sat in `UiQuadAppend`, the window *after* the extract: right for an appender, a frame late for a feed. The test that matters transcribes pfUI's own `ScanBubbles` loop — it finds the FontString with the line and its chat-type colour, blanks the one Texture region and parents its own frame to the bubble — and then checks the EXTRACT for drawn quads: both backdrop halves, the tail, and the text at the em the app measured the box with. A test that only checked the widgets would have passed with nothing on screen. Two architectural gates caught this and both were answered rather than silenced. The one-shot gate: `drive_bubbles` now holds the VM and drains a queue, so it is EXEMPT with the argument — the queue's product is app-side state, the widget sync is idempotent and binds on the first frame there is a WorldFrame, and gating the system instead would pile lines up and spawn them all at once with fresh lifetimes. The VM wall: its ceiling rises by one with the reason, which is that this file is `vplates.rs`'s exact twin. `CREATE_CHATBUBBLE` fires per APPEARANCE, not per pool slot ever built — the opposite of `NAME_PLATE_CREATED`'s rule, because a recycled frame has been re-anchored, re-sized and re-texted since it was last seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
1650 lines
65 KiB
Rust
1650 lines
65 KiB
Rust
//! The player-UI quad pass: a sorted-quad renderer for the WoW UI, not `bevy_ui`. Producers
|
||
//! flatten a frame's `(stratum, level, layer, sublayer, decl)` order into [`UiQuad::z_key`].
|
||
//!
|
||
//! [`PlayerUiCamera`] (order 1) sits over the offscreen world camera (0) and under the egui dev
|
||
//! overlay (2). Its main pass draws the world first ([`crate::world_backdrop`]), then the quads,
|
||
//! and [`crate::ui_gamma`]'s decode writes the swapchain with no blit.
|
||
//!
|
||
//! The reference draws its UI through the fixed-function device into an 8-bit backbuffer, so every
|
||
//! UI multiply and blend is gamma-byte arithmetic, clamped at each write. So is this pass, decoded
|
||
//! to linear once at the end; composited in linear, an `ADD` quad lands at about a quarter of the
|
||
//! reference's lift.
|
||
//!
|
||
//! Quads are stable-sorted by `z_key`, CPU-clipped and split into contiguous runs of one texture
|
||
//! and material state, one `Mesh2d` draw each. A run never spans a quad of another state, so the
|
||
//! total order holds and interleaved textures pay one draw per switch.
|
||
|
||
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;
|
||
|
||
/// One `(u, v)` per screen corner, in [`Run::push_quad`]'s winding (top-left, top-right,
|
||
/// bottom-right, bottom-left), not a `(min, max)` rect: a `<TexCoords>` with `left > right` (the
|
||
/// PlayerFrame ring) must stay mirrored, and a backdrop top or bottom edge maps atlas u to screen
|
||
/// Y, which only per-corner UVs express.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
pub(crate) struct UvRect {
|
||
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 `<TexCoords>` `[left, right, top, bottom]` tuple, unnormalized, so a mirror survives.
|
||
pub fn from_tex_coords([left, right, top, bottom]: [f32; 4]) -> Self {
|
||
Self {
|
||
corners: [[left, top], [right, top], [right, bottom], [left, bottom]],
|
||
}
|
||
}
|
||
|
||
/// From explicit corners in the `push_quad` winding: the backdrop's rotated edge slices.
|
||
pub fn from_corners(corners: [[f32; 2]; 4]) -> Self {
|
||
Self { corners }
|
||
}
|
||
|
||
/// From an already-normalized [`Rect`], such as a glyph atlas cell: `min` to TL, `max` to 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 pass's whole input. `texture`, the flags from `additive`
|
||
/// to `uv_clamp`, and `mask` are material state: a quad differing in any of them starts a new run.
|
||
#[derive(Clone, PartialEq)]
|
||
pub(crate) struct UiQuad {
|
||
/// Screen pixels, y-down, anchor-resolved.
|
||
pub rect: Rect,
|
||
/// Paint order, stable-sorted ascending: higher draws on top.
|
||
pub z_key: u64,
|
||
/// `None` samples the shared 1×1 white.
|
||
pub texture: Option<Handle<Image>>,
|
||
pub uv: UvRect,
|
||
/// Straight-alpha tint in client-space sRGB, the raw FrameXML/Lua value, never converted.
|
||
pub color: [f32; 4],
|
||
/// WoW `ADD` blend (EGxBlend 3, `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`) instead of straight alpha.
|
||
pub additive: bool,
|
||
/// Mask to the inscribed circle: the live unit portrait.
|
||
pub circular: bool,
|
||
/// Draw the texel's luminance (`Texture:SetDesaturated(1)`), folded in gamma bytes before the
|
||
/// tint, so `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)`
|
||
/// (`Blizzard_TalentUI.lua:215`) lands grey and dim.
|
||
pub desaturated: bool,
|
||
/// The texture is already premultiplied: a booth render target ([`crate::portrait`]), where
|
||
/// an additive particle adds colour with no coverage and a second alpha weight would erase it.
|
||
pub premultiplied: bool,
|
||
/// The texture holds undecoded gamma bytes (`BlpVariant::MapTile`: the reference's tile
|
||
/// sampler sets `GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT`), so the tint multiplies the
|
||
/// authored byte and the ordinary arm's `linear_to_srgb` must not encode it again.
|
||
pub gamma_texel: bool,
|
||
/// Alpha test against this value on `texel.a × tint.a` instead of blending: a pass writes
|
||
/// opaque, a fail discards. `Some(224.0 / 255.0)` is the WMO-interior minimap tile, drawn by
|
||
/// the reference with EGxBlend 1 (blending off) and `glAlphaFunc(GL_GEQUAL, 0.87843144)`
|
||
/// (`0x85ad20[1] = 224`); blended, two tiles' seam keeps `(1−a)(1−b)` of the black clear.
|
||
pub alpha_test: Option<f32>,
|
||
/// The half-texel-inset window `[u_min, v_min, u_max, v_max]` sampled, so an atlas cell clamps
|
||
/// at its own edge instead of filtering in its neighbour (`ClampToEdge` stops at the image's).
|
||
/// Per axis: UVs past `[0, 1]` are the reference's tiling idiom and never clamp.
|
||
pub uv_clamp: Option<[f32; 4]>,
|
||
/// CPU clip standing in for a scissor rect.
|
||
pub clip: Option<Rect>,
|
||
/// Radians clockwise on screen about the rect centre (the minimap player arrow), the UVs
|
||
/// riding their corners; `clip` applies first, unrotated.
|
||
pub rotation: f32,
|
||
/// A screen-anchored alpha mask (the minimap's `MinimapMask.blp` circle), so a tile panning
|
||
/// under the fixed circle masks correctly; outside [`UiQuadMask::rect`] the fragment drops.
|
||
pub mask: Option<UiQuadMask>,
|
||
/// Explicit screen corners (y-down px) drawn as the fan `(0,1,2)(0,2,3)` (the cooldown pie's
|
||
/// wedge), bypassing `rotation`, `clip` and `rect`, which should still bound them.
|
||
pub corners: Option<[Vec2; 4]>,
|
||
}
|
||
|
||
/// A screen-anchored alpha mask over a [`UiQuad`].
|
||
#[derive(Clone, PartialEq)]
|
||
pub(crate) struct UiQuadMask {
|
||
/// The mask art, whose alpha is the coverage (`MinimapMask.blp`: DXT3, the circle in alpha).
|
||
pub texture: Handle<Image>,
|
||
/// The span it covers, in [`UiQuad::rect`]'s y-down logical px; it samples 0..1 across it.
|
||
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 input in two lanes. [`Self::quads`], the base lane, is replaced whole by the script
|
||
/// extract, which sets [`Self::dirty`] only when it changed. [`Self::overlays`], the append lane
|
||
/// ([`UiQuadAppend`]), is cleared and re-emitted every frame and diffed by [`rebuild_ui_mesh`]
|
||
/// against the last frame's; its appenders never touch `dirty`.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct UiQuads {
|
||
pub quads: Vec<UiQuad>,
|
||
pub overlays: Vec<UiQuad>,
|
||
/// The append lane as of the last rebuild.
|
||
last_overlays: Vec<UiQuad>,
|
||
pub dirty: bool,
|
||
}
|
||
|
||
/// The append lane's z bands, far below the scripted UI's keys (a [`benilla_ui::order::ZKey`]
|
||
/// region sets `1 << 20`), so the whole lane paints under the player UI.
|
||
pub(crate) mod overlay_z {
|
||
/// Floating combat, XP and honor numbers, drawn by the client in the world scene under all UI.
|
||
///
|
||
/// **The lane's only remaining tenant.** The chat bubbles used to hold a band above this one
|
||
/// (and the V-plates a band above that, as a ceiling for it): all three are widgets now —
|
||
/// plates in `vplates.rs`, bubbles in `chat_bubble.rs` — so their order comes from frame
|
||
/// strata and levels, where the reference's does. A band for one tenant is just 0, and the
|
||
/// bubbles' own ordering against the plates improved by the move: as frames they take levels
|
||
/// 2, 3, 4… over the plates' 1, which is the reference's own order, where the band layout had
|
||
/// them underneath.
|
||
pub(crate) const WORLD_TEXT: u64 = 0;
|
||
}
|
||
|
||
/// Clear the append lane at the top of the [`UiQuadAppend`] window, before every appender.
|
||
fn clear_ui_overlays(mut quads: ResMut<UiQuads>) {
|
||
quads.overlays.clear();
|
||
}
|
||
|
||
/// The render layer this pass's camera and batches share, disjoint from the world camera's 0.
|
||
const UI_RENDER_LAYER: usize = 1;
|
||
|
||
pub(crate) fn ui_render_layers() -> RenderLayers {
|
||
RenderLayers::layer(UI_RENDER_LAYER)
|
||
}
|
||
|
||
/// Above the world camera (0), below the egui dev overlay (2, `debug_panel::spawn_egui_camera`).
|
||
const UI_CAMERA_ORDER: isize = 1;
|
||
|
||
/// Marker on the player-UI camera, whose backdrop [`crate::world_backdrop`] points at the world
|
||
/// camera drawing this frame.
|
||
#[derive(Component)]
|
||
pub(crate) struct PlayerUiCamera;
|
||
|
||
/// Marker on each pooled batch entity, one `Mesh2d` draw per run; the FPS probe's `ui_batches=`
|
||
/// counts them.
|
||
#[derive(Component)]
|
||
pub(crate) struct UiQuadBatch;
|
||
|
||
/// The shared 1×1 opaque white that texture-less quads sample.
|
||
#[derive(Resource)]
|
||
struct UiWhiteTexture(Handle<Image>);
|
||
|
||
/// The quad material: a texture and the [`UiQuad`] flags as uniforms, drawn through one
|
||
/// premultiplied-alpha pipeline (`ui_quad.wgsl`). It holds no colour, so it is its identity key
|
||
/// alone 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>>,
|
||
#[uniform(3)]
|
||
circular: u32,
|
||
#[uniform(7)]
|
||
desaturate: u32,
|
||
#[uniform(8)]
|
||
premultiplied: u32,
|
||
/// `<= 0` disables the alpha test.
|
||
#[uniform(9)]
|
||
alpha_ref: f32,
|
||
#[uniform(10)]
|
||
gamma_texel: u32,
|
||
/// The mask span in physical px (the shader compares `@builtin(position)`); `z <= x` disables.
|
||
#[uniform(4)]
|
||
mask_rect: Vec4,
|
||
#[texture(5)]
|
||
#[sampler(6)]
|
||
mask: Option<Handle<Image>>,
|
||
/// `(u_min, v_min, u_max, v_max)`; `min > max` on an axis disables that axis.
|
||
#[uniform(11)]
|
||
uv_clamp: Vec4,
|
||
}
|
||
|
||
/// A run's one colour for the batch entity's [`MeshTag`], unpacked by `ui_quad.wgsl`'s vertex
|
||
/// stage, so a colour pulse is a component write, never a mesh or material write. One byte per
|
||
/// channel, `×255 + 0.5`, as the reference packs a vertex colour (`CImVector`; `SetVertexColor`
|
||
/// at `0x79abd0`, the frame-alpha fold at `0x77fac0`). Complemented, so an entity with no tag
|
||
/// (the minimap's interior tiles) unpacks to opaque white.
|
||
fn tint_tag(color: [f32; 4]) -> u32 {
|
||
let byte = |v: f32| {
|
||
// `0x40a2b0`: `×255.0 + 0.5`, truncated, after the binding's `[0, 1]` clamp; no overflow.
|
||
#[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: the texture and an alpha test, nothing else, for the
|
||
/// minimap's own composite target ([`crate::minimap`]). A passing fragment emits `a = 1`, so
|
||
/// the forced blend of [`Self::specialize`] is a plain replace: the reference's blending off.
|
||
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 decodes explicitly, so the tile's SKIP_DECODE holds without this.
|
||
gamma_texel: 0,
|
||
mask_rect: Vec4::new(0.0, 0.0, -1.0, -1.0),
|
||
mask: None,
|
||
uv_clamp: UV_CLAMP_OFF,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The mesh every `UiQuadMaterial` quad outside the batches draws with: a 1×1 rectangle placed by
|
||
/// its Transform. Its layout (POSITION + NORMAL + UV_0) keys a second pipeline beside the batch
|
||
/// mesh's (POSITION + UV_0 + COLOR), which `pipe_warm` warms by drawing this same mesh.
|
||
pub(crate) fn tile_quad_mesh() -> Mesh {
|
||
Rectangle::new(1.0, 1.0).into()
|
||
}
|
||
|
||
impl Material2d for UiQuadMaterial {
|
||
/// Our own vertex stage, which unpacks the run's tint from the instance tag ([`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 {
|
||
// The transparent phase, sorted by mesh z (the run order); `specialize` sets the blend.
|
||
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: the shader emits `(rgb·a, a)` for
|
||
// BLEND and `(rgb·a, 0)` for ADD, so this one state is EGxBlend 2 and EGxBlend 3,
|
||
// 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. In Update after
|
||
/// [`benilla_world::schedule::WorldStage::Input`], the world-anchored ones (combat numbers,
|
||
/// nameplates) project through this frame's camera `Transform` (`GlobalTransform::from`; the
|
||
/// camera is a root entity), not the propagated one a frame behind. Never PostUpdate:
|
||
/// [`rebuild_ui_mesh`] spawns `Mesh2d` batches, and bevy_sprite_render cannot specialize one
|
||
/// spawned there (the whole UI vanishes).
|
||
#[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 verdict:
|
||
/// `None` does not draw. `0x483ee0` rejects a point behind the near plane (`0x483f7a`) or outside
|
||
/// the WorldFrame's region (`[0xb4b2bc]`; `0x484075`, `0x484086`, `0x484096`, `0x4840a6`), which
|
||
/// `0x483970` mirrors ÷G44/÷G48 so the aspect cancels: exactly the viewport, inclusive. A viewport
|
||
/// rejection has already written a plausible out-param (`0x484065`, `0x484067`), so the verdict
|
||
/// must be honoured; plate and world-text callers destroy what they were placing. The chat bubble
|
||
/// does not call this: its seat ignores the verdict, and a bubble slides off with its speaker.
|
||
pub(crate) fn project_overlay(
|
||
cam: &Camera,
|
||
cam_tf: &GlobalTransform,
|
||
world: Vec3,
|
||
viewport: Vec2,
|
||
) -> Option<Vec2> {
|
||
// Bevy's `Err` is the near-plane half (`0x483f7a`); `accepts` is the viewport half.
|
||
let p = cam.world_to_viewport(cam_tf, world).ok()?;
|
||
accepts(p, viewport).then_some(p)
|
||
}
|
||
|
||
/// `WOW_PROBE_UI_ONE_TEX=1`, for pricing only (it draws wrong): runs split on state flags alone,
|
||
/// each drawn with its first quad's texture, the draw count a UI texture atlas would reach.
|
||
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 and the per-frame quad-to-mesh rebuild.
|
||
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(),
|
||
// The lane's decode is not optional, so this plugin owns it.
|
||
crate::ui_gamma::UiGammaPlugin,
|
||
// Skips the 2D opaque pass when empty, which on this camera is always.
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// The full-window player-UI camera: the world, then the quads, then the gamma decode.
|
||
fn spawn_ui_camera(mut commands: Commands) {
|
||
commands.spawn((
|
||
PlayerUiCamera,
|
||
Name::new("player-UI camera"),
|
||
Camera2d,
|
||
// No MSAA, named because silence means 4×: `Camera` requires `Msaa`, whose default is
|
||
// `Sample4`. The world arrives resolved, and the quads are axis-aligned.
|
||
bevy::render::view::Msaa::Off,
|
||
// The lane's mandatory decode: the quad pass leaves gamma bytes in the target.
|
||
crate::ui_gamma::UiGammaLane::default(),
|
||
// The world as this pass's first draw; `source` is `None` until a world camera draws.
|
||
benilla_world::ffx_glow::FfxBackdrop::default(),
|
||
// Bevy UI (the glue and loading screens) renders here, in the gamma lane; unmarked, it
|
||
// would take the highest-order camera, the egui overlay.
|
||
bevy::ui::IsDefaultUiCamera,
|
||
ui_render_layers(),
|
||
Camera {
|
||
order: UI_CAMERA_ORDER,
|
||
// No output blit ([`benilla_world::final_pass`]): the decode writes the swapchain.
|
||
output_mode: CameraOutputMode::Skip,
|
||
// Clear to transparent, writeback off: `MsaaWriteback::Auto` would re-emit an earlier
|
||
// camera's output through this one, a re-encode round trip that tints the world.
|
||
clear_color: ClearColorConfig::Custom(Color::NONE),
|
||
msaa_writeback: bevy::camera::MsaaWriteback::Off,
|
||
..default()
|
||
},
|
||
));
|
||
}
|
||
|
||
/// Build the shared 1×1 white texture that texture-less quads sample.
|
||
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` to `clip`, reprojecting the UVs to the clipped edges; `None` when nothing is left.
|
||
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;
|
||
}
|
||
// A bilinear sample of the four corner UVs at the clipped edges' fractions: an axis-aligned
|
||
// crop stays a separable lerp (a mirror survives), and a rotated backdrop edge keeps its turn.
|
||
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),
|
||
bilerp(t_max_x, t_min_y),
|
||
bilerp(t_max_x, t_max_y),
|
||
bilerp(t_min_x, t_max_y),
|
||
]);
|
||
Some((clipped, new_uv))
|
||
}
|
||
|
||
/// One run: contiguous quads, in `z_key` order, sharing a texture and every material flag.
|
||
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 with `q`'s material flags under the resolved `texture` (its own, or the white).
|
||
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 clipped quad as two triangles in the TL, TR, BR, BL winding; append order is
|
||
/// paint order within the run. `rotation` spins the corners clockwise about the rect centre,
|
||
/// UVs riding along; `to_world` maps y-down screen px into the camera's y-up, centred space.
|
||
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)),
|
||
(Vec2::new(rect.max.x, rect.min.y), Vec2::from(tr)),
|
||
(Vec2::new(rect.max.x, rect.max.y), Vec2::from(br)),
|
||
(Vec2::new(rect.min.x, rect.max.y), Vec2::from(bl)),
|
||
];
|
||
if rotation != 0.0 {
|
||
// Clockwise on a y-down screen is the standard CCW matrix in y-down coordinates.
|
||
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 screen corners with their UVs as the fan `(0,1,2)(0,2,3)`.
|
||
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 logs the frame's [`UiQuadMaterial`] asset events: bevy re-prepares a
|
||
/// material (a bind group and a uniform buffer, on the render thread) for exactly each `Added` or
|
||
/// `Modified`. It reads zero on a settled frame, pulsing glow or not; a line there is a material
|
||
/// rebuilt for something other than a new texture, a regression.
|
||
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 state across frames, so a rebuild pays only for what changed: batch entities and
|
||
/// meshes pooled by run index and rewritten in place, materials cached by their identity key.
|
||
#[derive(Default)]
|
||
struct BatchPools {
|
||
entities: Vec<Entity>,
|
||
meshes: Vec<Handle<Mesh>>,
|
||
/// Each slot's last-written mesh (base positions): an unchanged run is not rewritten.
|
||
stored: Vec<StoredRun>,
|
||
/// Each slot's pan from its base, on the entity's `Transform`: a run that only moved (the
|
||
/// minimap tiles) costs no `Assets<Mesh>` write, whose `AssetChanged` probe sweeps every
|
||
/// `Mesh3d` in the scene.
|
||
offsets: Vec<Vec2>,
|
||
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
|
||
/// Per slot: the material the batch entity carries.
|
||
bound: Vec<Option<AssetId<UiQuadMaterial>>>,
|
||
/// Per slot: the batch entity's [`MeshTag`], the run's one colour ([`tint_tag`]).
|
||
tags: Vec<Option<u32>>,
|
||
}
|
||
|
||
/// One pooled slot's full identity: the mesh bytes and what the entity was last written with
|
||
/// (material key, z). Compared in full, never hashed: a collision would draw a stale batch.
|
||
#[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 {
|
||
/// Why [`Self::translation_from`] missed, for `WOW_UI_DIFF=1`. That gate is `Some(d)` when
|
||
/// every vertex moved by one XY delta and all else is bit-equal (`Some(ZERO)`: unchanged); its
|
||
/// exact float compare can only cost a rewrite, never correctness.
|
||
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, floats by their bits so byte-equal materials share one entry.
|
||
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` turns an axis off.
|
||
const UV_CLAMP_OFF: Vec4 = Vec4::new(1.0, 1.0, 0.0, 0.0);
|
||
|
||
/// Despawn every pooled batch entity; mesh handles and the material cache stay, free to keep.
|
||
fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) {
|
||
for entity in pools.entities.drain(..) {
|
||
commands.entity(entity).despawn();
|
||
}
|
||
// The skip and pan gates forget with them: a retired slot's entity is gone, so unchanged
|
||
// content must not skip the respawn when the UI returns.
|
||
pools.stored.clear();
|
||
pools.offsets.clear();
|
||
}
|
||
|
||
/// The two asset stores [`rebuild_ui_mesh`] writes and the image-removal stream its material
|
||
/// cache hears, bundled under 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())
|
||
}
|
||
|
||
static UI_DIFF: std::sync::LazyLock<bool> =
|
||
std::sync::LazyLock::new(|| std::env::var_os("WOW_UI_DIFF").is_some());
|
||
|
||
static UI_PROBE: std::sync::LazyLock<bool> =
|
||
std::sync::LazyLock::new(|| std::env::var("WOW_UI_PROBE").as_deref() == Ok("1"));
|
||
|
||
/// A rebuild's render-side cost, phases in µs: the sort, clip, split and mesh writes that the
|
||
/// script-side `[ui-cost]` phases do not see. Its own resource, not a `UiFrameCost` field: the two
|
||
/// systems are unordered in `Update`, and whichever ran second would wipe a shared field.
|
||
#[derive(Resource, Default, Clone)]
|
||
pub(crate) struct UiMeshCost {
|
||
pub(crate) rebuilt: bool,
|
||
pub(crate) total: u128,
|
||
pub(crate) sort: u128,
|
||
pub(crate) split: u128,
|
||
pub(crate) write: u128,
|
||
pub(crate) quads: usize,
|
||
pub(crate) runs: usize,
|
||
/// Pooled meshes rewritten rather than left or panned, each re-extracted in
|
||
/// `RenderExtractApp`; `rewrites == runs` every frame means the skip gate is defeated.
|
||
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 under clippy's argument ceiling.
|
||
mut hide_and_meter: (
|
||
Res<crate::ui_hide::UiHidden>,
|
||
ResMut<UiMeshCost>,
|
||
Res<crate::ui_script::UiCostWanted>,
|
||
),
|
||
) {
|
||
// Hear the image stream first, before any early return, since a missed read both leaks and
|
||
// freezes (below).
|
||
let (mut retired, mut reshaped): (Vec<AssetId<Image>>, Vec<AssetId<Image>>) =
|
||
(Vec::new(), Vec::new());
|
||
for event in stores.2.read() {
|
||
match event {
|
||
AssetEvent::Removed { id } => retired.push(*id),
|
||
AssetEvent::Modified { id } => reshaped.push(*id),
|
||
_ => {}
|
||
}
|
||
}
|
||
// Forget the materials of removed images: a cached material holds a strong image handle and,
|
||
// prepared, its GPU texture. `Removed`, not `Unused`: this cache is itself a strong handle, so
|
||
// `Unused` never fires for a cached image, and a producer retiring an image removes the asset
|
||
// explicitly.
|
||
if !retired.is_empty() {
|
||
pools.materials.retain(|key, _| !retired.contains(&key.0));
|
||
}
|
||
// Re-prepare the materials of modified images. A modified image is a *new* GPU texture behind
|
||
// the same asset id — `GpuImage::prepare_asset` creates one every time it runs — and nothing
|
||
// re-prepares a material because an image it names changed: `PreparedMaterial2d` is prepared
|
||
// from `AssetEvent<UiQuadMaterial>` alone. So the cached material's bind group goes on holding
|
||
// the dead view and the quad samples a texture nothing renders into any more.
|
||
//
|
||
// That is a frozen picture, and it is how a booth pane froze: a unit pane's target is resized
|
||
// between 256 and 512 as the pane it is lent to changes size
|
||
// ([`crate::portrait::resize_pane_targets`]), so the first window to claim a slot bound a live
|
||
// texture and every later one bound a corpse — the camera rendering all the while, the bakes
|
||
// restaging all the while, into a texture no longer on screen.
|
||
//
|
||
// Touching the material re-extracts it, and `as_bind_group` reads the image's current view.
|
||
// Only the two resize sites emit this event, so a steady frame does no work here.
|
||
if !reshaped.is_empty() {
|
||
for (key, material) in &pools.materials {
|
||
if reshaped.contains(&key.0) || key.7.is_some_and(|mask| reshaped.contains(&mask)) {
|
||
// The touch is the point; the value is not wanted.
|
||
let _ = stores.1.get_mut(material.id());
|
||
}
|
||
}
|
||
}
|
||
let (hidden, mesh_cost, cost_wanted) =
|
||
(&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2);
|
||
// The meter is off unless asked (the hover recorder, `WOW_UI_COST=1`): one bool test.
|
||
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 the producers: both lanes keep filling, so the UI returns as
|
||
// it was ([`crate::ui_hide::UiHidden`]). The world is in neither lane (it is this camera's
|
||
// first draw), so retiring every batch leaves the world. While dark, the change flag is still
|
||
// swallowed and the append-lane mirror kept current, so neither lane reports a stale
|
||
// "unchanged" on return. The edge is `UiHidden`'s own change tick.
|
||
if hidden.is_changed() {
|
||
q.dirty = true;
|
||
}
|
||
let lanes_hidden = hidden.0;
|
||
if lanes_hidden {
|
||
q.last_overlays.clone_from(&q.overlays);
|
||
// Nothing moves a pixel while dark, so only the toggle edge rebuilds.
|
||
if !q.dirty {
|
||
return;
|
||
}
|
||
} else if !q.dirty && q.overlays == q.last_overlays {
|
||
return;
|
||
}
|
||
// `WOW_UI_DIFF=1`: names what re-triggered the rebuild, the base lane's dirty flag or the
|
||
// first differing overlay quad.
|
||
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, or the white texture not built yet: nothing to draw, and the flag stays
|
||
// cleared rather than spinning.
|
||
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, top-left origin) to the camera's world space (y-up, centred): the default
|
||
// `OrthographicProjection`, one unit per logical px.
|
||
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, so equal keys keep producer order, the base lane before the append lane.
|
||
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();
|
||
|
||
// `WOW_UI_PROBE=1`: every quad's screen rect on each rebuild; the last block is current.
|
||
if *UI_PROBE {
|
||
{
|
||
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 runs, clipping each quad on the way in; a quad clipped to nothing is
|
||
// never pushed and does not break a run.
|
||
let mut runs: Vec<Run> = Vec::new();
|
||
for q in sorted {
|
||
// Explicit corners (the cooldown pie's wedge) are exact: no clip, rotation or reprojection.
|
||
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"),
|
||
}
|
||
}
|
||
|
||
// Across runs, ascending mesh z (`bevy_sprite_render`'s `Transparent2d` sort), spread over
|
||
// ±450, inside the camera's ±1000 near/far whatever the run count.
|
||
let us_split = lap();
|
||
let run_count = runs.len().max(1) as f32;
|
||
// The key set is unbounded (a resize moves every mask rect), so it resets; live batches keep
|
||
// their materials through their own handles, which re-enter on the 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;
|
||
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: a rebuild covers the whole stream, but a slot whose run matches
|
||
// its pooled mesh in full is not rewritten. A one-colour run stores white vertices and
|
||
// puts the colour on the batch's tag ([`tint_tag`]), so a pulse or a fade skips too.
|
||
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: a run matching its slot's base up to one XY delta moves on the entity's
|
||
// `Transform`, with no mesh write; the unchanged case, `Some(ZERO)`, writes it only to
|
||
// undo an earlier pan. A changed material or tag is one component write.
|
||
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;
|
||
// `WOW_UI_DIFF=1`, at most 3 lines a second: the check that sent this slot to a rewrite.
|
||
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.
|
||
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 mesh holds absolute positions, so its pan resets (the entity gets `(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` freezes the UI at its first build, pricing the hash probe
|
||
// one modified mesh arms over every `Mesh3d` row (bevy 0.18's `AssetChanged`).
|
||
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);
|
||
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;
|
||
}
|
||
// Entities past this frame's run count despawn; their meshes drop with the truncation.
|
||
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.
|
||
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 hears their removal, so the harness needs the Image asset.
|
||
.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));
|
||
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 WorldFrame's region mirrored ÷G44/÷G48 (`0x483970`) is the viewport, inclusive. The
|
||
/// last row is a measured unit two thirds of a screen below the bottom edge.
|
||
#[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;
|
||
}
|
||
|
||
/// Un-hiding brings back the same content, though neither lane changed while dark and the
|
||
/// rebuild would otherwise take its early-out.
|
||
#[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");
|
||
// A flagged frame while dark still takes the dark path.
|
||
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 cache's strong handle would otherwise pin the image and its GPU texture for good.
|
||
#[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"
|
||
);
|
||
|
||
// Drop every quad that names it, then remove the asset: only the cache still holds 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 one-frame lags: `Removed` is written in `PostUpdate` and read in `Update`, the
|
||
// retired batch's handle drops at that frame's command flush, and `track_assets` reclaims
|
||
// it the next `PreUpdate`.
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// A resized image is a new GPU texture behind the same id, and only a material event
|
||
/// re-prepares a material: without the touch the quad samples the dead one for good.
|
||
#[test]
|
||
fn a_resized_image_re_prepares_the_material_that_samples_it() {
|
||
let mut app = rebuild_app();
|
||
let art = app
|
||
.world_mut()
|
||
.resource_mut::<Assets<Image>>()
|
||
.add(Image::new_fill(
|
||
Extent3d {
|
||
width: 256,
|
||
height: 256,
|
||
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();
|
||
|
||
// A cursor, not the current buffer: this harness ticks faster than the message swap.
|
||
let mut cursor = bevy::ecs::message::MessageCursor::<AssetEvent<UiQuadMaterial>>::default();
|
||
let mut touched = |app: &App| {
|
||
cursor
|
||
.read(
|
||
app.world()
|
||
.resource::<Messages<AssetEvent<UiQuadMaterial>>>(),
|
||
)
|
||
.filter(|e| matches!(e, AssetEvent::Modified { .. }))
|
||
.count()
|
||
};
|
||
touched(&app);
|
||
|
||
// The booth's resize, as `resize_pane_targets` makes it.
|
||
app.world_mut()
|
||
.resource_mut::<Assets<Image>>()
|
||
.get_mut(art_id)
|
||
.expect("the image is still there")
|
||
.resize(Extent3d {
|
||
width: 512,
|
||
height: 512,
|
||
depth_or_array_layers: 1,
|
||
});
|
||
// `Modified` is written in `PostUpdate` and read in `Update`, so the touch is next frame's.
|
||
app.update();
|
||
app.update();
|
||
assert!(
|
||
touched(&app) > 0,
|
||
"a material naming the resized image must be re-prepared, or its bind group keeps \
|
||
the texture the image no longer has"
|
||
);
|
||
|
||
// And a frame that resizes nothing touches nothing: this runs before every early return.
|
||
app.update();
|
||
assert_eq!(
|
||
touched(&app),
|
||
0,
|
||
"a steady frame re-prepares no material (the UI cost meter asserts this too)"
|
||
);
|
||
}
|
||
|
||
/// 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))
|
||
}
|
||
|
||
/// The world is the UI camera's first draw (`benilla_world::ffx_glow::FfxBackdrop`), not a
|
||
/// batch, so dark leaves the world and nothing else; a batch alive while dark is a bug.
|
||
#[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 greyscale is a per-material uniform: merged, the first quad would decide both (a talent
|
||
/// tree greying 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: only `desaturated` differs.
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// `SetVertexColor` quantises `×255 + 0.5` into one byte per channel (`0x79abd0`).
|
||
#[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), r in the low
|
||
// byte as `unpack4x8unorm` reads it.
|
||
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 (the stock PlayerFrame status glow, every frame at rest) must leave the
|
||
/// `WOW_UI_COST=1` material-event count at zero: no mesh write, no material write.
|
||
#[test]
|
||
fn a_colour_pulse_writes_the_tag_and_neither_asset() {
|
||
let mut app = rebuild_app();
|
||
// The meter is off unless asked, and this test reads `rewrites`.
|
||
app.world_mut()
|
||
.resource_mut::<crate::ui_script::UiCostWanted>()
|
||
.0 = true;
|
||
app.update();
|
||
assert_eq!(batches(&mut app), 1);
|
||
// A cursor, not the current buffer: `TimePlugin` swaps message buffers only after a fixed
|
||
// step (`signal_message_update_system`), and this harness ticks faster, 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"
|
||
);
|
||
|
||
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 — a colour is a tag, not a material"
|
||
);
|
||
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"
|
||
);
|
||
}
|
||
|
||
// PlayerFrameTexture's `<TexCoords left="1.0" right="0.09375">` (`PlayerFrame.xml:56`) must
|
||
// reach the vertex buffer mirrored; a normalizing `Rect` would un-mirror the ring.
|
||
#[test]
|
||
fn mirrored_tex_coords_emit_unnormalized_corners() {
|
||
// `[left, right, top, bottom]` as the extract hands them over.
|
||
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]
|
||
);
|
||
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]);
|
||
}
|
||
|
||
#[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);
|
||
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]
|
||
);
|
||
assert_eq!(tl[0], 1.0);
|
||
assert!((tr[0] - 0.546_875).abs() < 1e-6); // lerp(1.0, 0.09375, 0.5)
|
||
assert_eq!(tl[1], 0.0);
|
||
assert_eq!(cuv.corners[3][1], 0.78125); // BL.v
|
||
}
|
||
|
||
// A backdrop top edge maps atlas u to screen Y and v to screen X reversed (`0x77f0c0`), so
|
||
// clipping the left half reprojects v and leaves u.
|
||
#[test]
|
||
fn clip_reprojects_rotated_uv() {
|
||
// Top edge with widthRun = 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);
|
||
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;
|
||
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);
|
||
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]);
|
||
}
|
||
}
|