From a822d99bd41bc6fa7da7b3f24d2839b96ad04830 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Aug 2026 18:50:55 +0700 Subject: [PATCH] liquids keep time in the shader, and the frame is measured CPU-bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - water and lava keep time in the shader: the 24 Hz CPU tick that re-uploaded every liquid material is gone, −0.28 ms at the Stormwind gates, and lava scrolls smoothly instead of stepping - static transform tracking is pinned on — the adaptive check spent two full scans a frame to say yes in a world where movers are 1–3% of the rows - WOW_MEGA_STATIC merges static geometry per material and 133-yard cell — an experiment lever, priced at −1.15 ms CPU for +0.3 ms GPU at the Stormwind gates - WOW_GPU_MS times the whole frame on the GPU and reads 2.2 ms against 17.4 CPU at the Stormwind gates: the frame is CPU-bound, so the work ahead is on the CPU side Co-Authored-By: Claude Fable 5 --- Cargo.lock | 13 + Cargo.toml | 3 + crates/benilla-app/Cargo.toml | 1 + .../src/capture/probes/live_fps.rs | 36 ++- crates/benilla-app/src/entities/wmo_props.rs | 3 + crates/benilla-app/src/perf/census.rs | 53 ++++ crates/benilla-app/src/perf/gpu.rs | 250 ++++++++++++++++++ crates/benilla-app/src/perf/mod.rs | 15 ++ crates/benilla-app/src/ui_script/input.rs | 30 +++ crates/benilla-assets/src/lib.rs | 6 +- crates/benilla-assets/src/materials.rs | 4 +- crates/benilla-assets/src/model.rs | 66 +++++ crates/benilla-assets/src/shaders/liquid.wgsl | 34 ++- .../benilla-ui/src/script/messageframe/mod.rs | 33 ++- .../src/script/messageframe/scrolling.rs | 53 ++++ crates/benilla-ui/src/script/model.rs | 12 + .../src/widget/kinds/messageframe.rs | 16 ++ crates/benilla-ui/src/widget/kinds/mod.rs | 22 +- crates/benilla-world/src/lib.rs | 1 + crates/benilla-world/src/liquid/mod.rs | 1 - crates/benilla-world/src/liquid/surface.rs | 131 +++------ crates/benilla-world/src/mega_static.rs | 120 +++++++++ .../src/terrain_stream/spawn/assemble.rs | 33 +++ .../src/terrain_stream/spawn/mod.rs | 6 +- crates/benilla-world/src/world_plugins.rs | 13 + 25 files changed, 837 insertions(+), 118 deletions(-) create mode 100644 crates/benilla-app/src/perf/gpu.rs create mode 100644 crates/benilla-world/src/mega_static.rs diff --git a/Cargo.lock b/Cargo.lock index 37cfdb4c..fa7dffae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,6 +618,7 @@ dependencies = [ "smithay-clipboard", "symphonia", "toml", + "wgpu", ] [[package]] @@ -7533,12 +7534,14 @@ dependencies = [ "js-sys", "log", "naga", + "parking_lot", "portable-atomic", "profiling", "raw-window-handle", "smallvec", "static_assertions", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", "wgpu-core", "wgpu-hal", @@ -7571,6 +7574,7 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", @@ -7586,6 +7590,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-wasm" version = "27.0.0" diff --git a/Cargo.toml b/Cargo.toml index 8fd170b6..a16313c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ authors = ["sam "] [workspace.dependencies] anyhow = "1.0.102" bevy = "0.18.1" +# The render app's raw GPU handles (the WOW_GPU_MS meter's query set): bevy 0.18 does not wrap +# wgpu's QuerySet family, so the meter reaches for the same wgpu the workspace already builds. +wgpu = "27" clap = { version = "4.6.1", features = ["derive"] } sha1 = "0.10.6" diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 53bc0706..79482032 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -42,6 +42,7 @@ anyhow.workspace = true arboard = "3" benilla-ui = { path = "../benilla-ui" } bevy.workspace = true +wgpu.workspace = true # Optional: the dev overlays are its only consumers (the panel, the perf pill, the inspector # card), so a player build does not link it at all (`dev` feature, decision 1173). bevy_egui = { version = "0.39", optional = true } diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index 1d0c99ba..1f6f30c0 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -108,6 +108,14 @@ struct LiveFps { /// `drawn=` reading taken indoors cannot be read at all: a big number means either "we claimed a /// room and the cull let everything through" or "we never claimed a room, so nothing was gated" — /// opposite bugs with identical numbers, and the difference cost a measurement (0780). +/// The display stamp's monitor roster + the `WOW_GPU_MS=1` meter + its sample sink, bundled +/// (the 16-SystemParam ceiling, the house's SpawnTables shape). +type ScreenParams<'w, 's> = ( + Query<'w, 's, &'static bevy::window::Monitor>, + Option>, + Local<'s, Vec>, +); + #[derive(SystemParam)] struct SamplePin<'w> { map: Option>, @@ -139,8 +147,12 @@ fn drive_live_fps( mut key_events: MessageWriter, mut exit: MessageWriter, mut occlusions: MessageReader, - monitors: Query<&bevy::window::Monitor>, + // Bundled (the 16-SystemParam ceiling): the monitor roster for the display stamp, and the + // `WOW_GPU_MS=1` meter — the render app's whole-frame GPU clock, sampled per probe frame so + // the leg line carries gpu percentiles beside the cpu ones (absent when the meter is off). + mut screen: ScreenParams, ) { + let (monitors, gpu, gpu_samples) = (&screen.0, &screen.1, &mut screen.2); // Drain every frame so the state is current whichever phase we're in — the window can be // occluded before sampling ever starts (a detached launch spawns behind whatever is open). for o in occlusions.read() { @@ -214,6 +226,12 @@ fn drive_live_fps( } let ms = time.delta_secs() * 1000.0; probe.samples.push(ms); + if let Some(gpu) = gpu { + let ns = gpu.0.load(std::sync::atomic::Ordering::Relaxed); + if ns > 0 { + gpu_samples.push(ns as f32 / 1.0e6); + } + } if probe.samples.len() < probe.frames { return; } @@ -271,6 +289,20 @@ fn drive_live_fps( None => " display=unpositioned".to_string(), } }; + // The GPU meter's percentiles over the same window (WOW_GPU_MS=1; empty otherwise). + let gpu_line = if gpu_samples.is_empty() { + String::new() + } else { + let mut g = std::mem::take(&mut **gpu_samples); + g.sort_by(f32::total_cmp); + let gat = |q: f32| g[(((g.len() - 1) as f32) * q).round() as usize]; + format!( + " gpu_p50={:.2} gpu_p99={:.2} gpu_max={:.2}", + gat(0.50), + gat(0.99), + g[g.len() - 1] + ) + }; // CPU cost per frame across every thread — the load-robust half of the measurement // (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %. let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) { @@ -347,7 +379,7 @@ fn drive_live_fps( seen.mats, seen.meshes, seen.images, seen.uv_anims, seen.tint_anims, ); println!( - "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display} occluded_frames={}{at_pin}{gate}{sky}{ribbons}{culled}", + "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display}{gpu_line} occluded_frames={}{at_pin}{gate}{sky}{ribbons}{culled}", v.len(), at(0.50), at(0.95), diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index 702f42aa..2ae53709 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -281,6 +281,9 @@ pub(super) fn spawn_wmo_gameobject_props( &mut tint_reg, &mut anim_table, card_owner, + // Never diverted into the WOW_MEGA_STATIC bracket: these props parent under a + // MOVING gameobject, and a merged blob bakes world transforms. + None, ); commands.entity(entity).add_children(&ents); // The slot frees itself when the prop despawns — the component hook returns it to diff --git a/crates/benilla-app/src/perf/census.rs b/crates/benilla-app/src/perf/census.rs index 96cc6409..b1ec44d1 100644 --- a/crates/benilla-app/src/perf/census.rs +++ b/crates/benilla-app/src/perf/census.rs @@ -106,3 +106,56 @@ pub(super) fn count_camera_changes( *last = time.elapsed_secs(); } } + +/// `WOW_ROW_BLOAT=` — the consolidation question's premise counter (the drastic-options +/// census, 2026-08-17): once the world holds a real static model row, spawn `n` inert CLONES of +/// it — same mesh handle, same material handle, same component shape — parked 10,000 yd +/// underground so the frustum culls every one. The per-frame walks that scale with TOTAL rows +/// (the visibility reset/sweep pair, the `AssetChanged` tick scans, `PreviousGlobalTransform`, +/// `mark_dirty_trees`) pay for these rows exactly as for real ones, while the O(visible) half +/// (specialize/queue/encode) never sees them — so an interleaved leg A/B (bloat off vs on) reads +/// **d(cpu_ms)/d(rows)** directly. That derivative × the rows a mega-merge would delete is the +/// honest ceiling of the consolidation option, measured before anyone builds it. +/// (Measured the same night it was built: +30k rows = +1.33 cpu_ms at LBRS, ~44 ns/row/frame.) +/// +/// `BloatSource` is one live static row's clonable component set, named for the lint. +type BloatSource<'w, 's> = Query< + 'w, + 's, + ( + &'static Mesh3d, + &'static MeshMaterial3d, + &'static benilla_world::model_render::ModelPart, + &'static bevy::mesh::MeshTag, + &'static bevy::camera::primitives::Aabb, + ), + Without, +>; + +pub(super) fn row_bloat(mut commands: Commands, mut done: Local, source: BloatSource) { + if *done { + return; + } + let Some((mesh, mat, part, tag, aabb)) = source.iter().next() else { + return; // no static row streamed yet — try again next frame + }; + let n: usize = std::env::var("WOW_ROW_BLOAT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + for _ in 0..n { + commands.spawn(( + Mesh3d(mesh.0.clone()), + MeshMaterial3d(mat.0.clone()), + Transform::from_xyz(0.0, -10_000.0, 0.0), + *part, + tag.clone(), + *aabb, + bevy::camera::visibility::NoAutoAabb, + )); + } + eprintln!( + "[row-bloat] spawned {n} inert static rows (cloned a live world row, parked at y=-10000)" + ); + *done = true; +} diff --git a/crates/benilla-app/src/perf/gpu.rs b/crates/benilla-app/src/perf/gpu.rs new file mode 100644 index 00000000..dcf141e5 --- /dev/null +++ b/crates/benilla-app/src/perf/gpu.rs @@ -0,0 +1,250 @@ +//! **`WOW_GPU_MS=1` — the whole-frame GPU meter** (1389's scoped follow-on, built 2026-08-18; +//! the drastic-options census' unknown #2 was "GPU frame time and VRAM headroom — no instrument +//! exists"). +//! +//! Two plain render-graph nodes bracket the camera driver — `begin` writes timestamp 0 into a +//! two-slot query set from its command encoder, `end` writes timestamp 1 — legal on this machine +//! because we hold `TIMESTAMP_QUERY_INSIDE_ENCODERS` (Apple Silicon samples at stage boundaries; +//! bevy's own `RenderDiagnosticsPlugin` needs INSIDE_PASSES and reads zero GPU spans here — the +//! `perf` module header's ⚠ note, measured in 1389). +//! +//! **The resolve rides its own submission, one frame later.** 1389's recorded trap: resolving +//! the query set in the SAME command buffer as the timed work returns zeros on Metal (5 of 6 +//! reads; the 6th a plausible-looking near-miss). So the cleanup system resolves last frame's +//! set into a small ring of MAP_READ buffers via a fresh encoder, and reads the mapping two +//! frames behind. The published number is the freshest mapped delta, nanoseconds in one +//! `AtomicU64` shared with the main world — `FPS_PROBE` samples it per frame under the same env +//! and prints its own percentiles, so a leg gets `gpu_p50/gpu_p99` beside `cpu_ms` with zero +//! cost when the env is off (nothing registers). + +use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; +use std::sync::Arc; + +use bevy::prelude::*; +use bevy::render::graph::CameraDriverLabel; +use bevy::render::render_graph::{ + Node, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel, +}; +use bevy::render::render_resource::{ + Buffer, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, MapMode, +}; +use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue}; +use bevy::render::{Render, RenderApp, RenderSystems}; +use wgpu::{PollType, QuerySet, QuerySetDescriptor, QueryType}; + +/// Is the meter armed? One read; everything registers behind it. +pub(crate) fn enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("WOW_GPU_MS").as_deref() == Ok("1")) +} + +/// The freshest whole-frame GPU duration, nanoseconds. Written by the render app's readback, +/// read by `FPS_PROBE`/whoever in the main world. 0 = no reading yet. +#[derive(Resource, Clone)] +pub(crate) struct GpuMsShared(pub Arc); + +const RING: usize = 4; + +/// Ring-slot states: a copy may only target a FREE slot — submitting into a mapped (or +/// map-pending) buffer is a wgpu validation error, which the first live run demonstrated. +const FREE: u8 = 0; +const PENDING: u8 = 1; +const MAPPED: u8 = 2; + +/// Render-world state: the query set, its resolve buffer, and the mapping ring. +#[derive(Resource)] +struct GpuStamp { + query_set: QuerySet, + /// A 1×1 target the sentinel passes clear — real (if trivial) work, because an EMPTY pass + /// never reaches the Metal encoder and samples nothing (this meter's second zero-read). + sentinel_view: wgpu::TextureView, + resolve: Buffer, + ring: Vec<(Buffer, Arc)>, + frame: usize, + period: f32, + shared: Arc, +} + +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct GpuStampBeginLabel; +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct GpuStampEndLabel; + +struct GpuStampNode { + index: u32, +} + +impl Node for GpuStampNode { + fn run( + &self, + _graph: &mut RenderGraphContext, + render_context: &mut RenderContext, + world: &World, + ) -> Result<(), NodeRunError> { + if let Some(stamp) = world.get_resource::() { + // A SENTINEL PASS, not a bare `write_timestamp`: Apple Silicon samples counters only + // at stage boundaries, and an encoder-level stamp with no stages around it resolves + // to zero (measured on this meter's first live run — 1389's "two sentinel nodes" + // meant exactly this). An empty compute pass is the cheapest stage boundary there is. + let (begin, end) = if self.index == 0 { + (Some(0), None) + } else { + (None, Some(1)) + }; + render_context + .command_encoder() + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("gpu-ms sentinel"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &stamp.sentinel_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: Some(wgpu::RenderPassTimestampWrites { + query_set: &stamp.query_set, + beginning_of_pass_write_index: begin, + end_of_pass_write_index: end, + }), + occlusion_query_set: None, + }); + } + Ok(()) + } +} + +/// Build [`GpuStamp`] on the first render frame — the device does not exist yet at plugin +/// build time (`RenderPlugin` creates it in `finish`), which a startup-eager version learned +/// as a `RenderDevice does not exist` panic on its first live run. +fn init_stamp( + mut commands: Commands, + stamp: Option>, + device: Option>, + queue: Option>, + shared: Res, +) { + if stamp.is_some() { + return; + } + let (Some(device), Some(queue)) = (device, queue) else { + return; + }; + let query_set = device.wgpu_device().create_query_set(&QuerySetDescriptor { + label: Some("gpu-ms stamps"), + ty: QueryType::Timestamp, + count: 2, + }); + let resolve = device.create_buffer(&BufferDescriptor { + label: Some("gpu-ms resolve"), + size: 16, + usage: BufferUsages::QUERY_RESOLVE | BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let ring = (0..RING) + .map(|_| { + ( + device.create_buffer(&BufferDescriptor { + label: Some("gpu-ms read"), + size: 16, + usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + Arc::new(AtomicU8::new(FREE)), + ) + }) + .collect(); + let sentinel = device + .wgpu_device() + .create_texture(&wgpu::TextureDescriptor { + label: Some("gpu-ms sentinel"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + commands.insert_resource(GpuStamp { + query_set, + sentinel_view: sentinel.create_view(&wgpu::TextureViewDescriptor::default()), + resolve, + ring, + frame: 0, + period: queue.get_timestamp_period(), + shared: shared.0.clone(), + }); +} + +/// After the graph's own submission: resolve LAST frame's stamps on a fresh encoder (its own +/// command buffer — the 1389 trap), kick the map, and publish the oldest mapped delta. +fn readback(stamp: Option>, device: Res, queue: Res) { + let Some(mut stamp) = stamp else { return }; + // Copy into a FREE slot only (none free → skip this frame's sample; the meter is a meter). + if let Some(i) = (0..RING).find(|&i| stamp.ring[i].1.load(Ordering::Relaxed) == FREE) { + let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor { + label: Some("gpu-ms resolve"), + }); + encoder.resolve_query_set(&stamp.query_set, 0..2, &stamp.resolve, 0); + encoder.copy_buffer_to_buffer(&stamp.resolve, 0, &stamp.ring[i].0, 0, 16); + queue.submit([encoder.finish()]); + let flag = stamp.ring[i].1.clone(); + flag.store(PENDING, Ordering::Relaxed); + let cb_flag = flag.clone(); + stamp.ring[i] + .0 + .slice(..) + .map_async(MapMode::Read, move |r| { + cb_flag.store(if r.is_ok() { MAPPED } else { FREE }, Ordering::Relaxed); + }); + } + let _ = device.poll(PollType::Poll); + // Publish every landed mapping; slots return to FREE. + for i in 0..RING { + if stamp.ring[i].1.load(Ordering::Relaxed) == MAPPED { + let (t0, t1) = { + let data = stamp.ring[i].0.slice(..).get_mapped_range(); + let words: &[u64] = bytemuck::cast_slice(&data); + (words[0], words[1]) + }; + stamp.ring[i].0.unmap(); + stamp.ring[i].1.store(FREE, Ordering::Relaxed); + if t1 > t0 { + let ns = (t1 - t0) as f64 * f64::from(stamp.period); + stamp.shared.store(ns as u64, Ordering::Relaxed); + } + } + } + stamp.frame += 1; +} + +pub(crate) fn plugin(app: &mut App) { + if !enabled() { + return; + } + let shared = Arc::new(AtomicU64::new(0)); + app.insert_resource(GpuMsShared(shared.clone())); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app.insert_resource(GpuMsShared(shared)); + render_app.add_systems( + Render, + (init_stamp, readback) + .chain() + .in_set(RenderSystems::Cleanup), + ); + let mut graph = render_app.world_mut().resource_mut::(); + graph.add_node(GpuStampBeginLabel, GpuStampNode { index: 0 }); + graph.add_node(GpuStampEndLabel, GpuStampNode { index: 1 }); + graph.add_node_edge(GpuStampBeginLabel, CameraDriverLabel); + graph.add_node_edge(CameraDriverLabel, GpuStampEndLabel); +} diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index 4094e098..06793af8 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -40,6 +40,7 @@ mod census; mod clock; +mod gpu; mod hud; mod journal; #[cfg(target_os = "macos")] @@ -52,6 +53,7 @@ use bevy::prelude::*; use bevy::render::diagnostic::RenderDiagnosticsPlugin; pub(crate) use clock::{process_cpu_secs, system_cpu_ticks}; +pub(crate) use gpu::GpuMsShared; pub(crate) use hud::PerfHud; pub(crate) use journal::FpsJournalPlugin; @@ -114,7 +116,20 @@ impl Plugin for PerfPlugin { app.insert_resource(census::ArchCensusAt(at)); app.add_systems(Last, census::arch_census); } + // `WOW_ROW_BLOAT=` — spawn n inert clones of a live static row (see the system doc): + // the d(cpu_ms)/d(rows) instrument the consolidation option divides by. + if std::env::var("WOW_ROW_BLOAT") + .ok() + .and_then(|v| v.parse::().ok()) + .is_some_and(|n| n > 0) + { + app.add_systems(Update, census::row_bloat); + } #[cfg(target_os = "macos")] stall::plugin(app); + // `WOW_GPU_MS=1` — the whole-frame GPU meter (its module doc owns the design and the + // 1389 resolve-on-a-later-submission trap). Registers nothing when off, so campaign + // anchors never carry its ~0.03 ms sentinel cost uninvited. + gpu::plugin(app); } } diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index 32d86774..39a70793 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -126,7 +126,37 @@ pub(super) fn feed_ui_input( // the VM's space (the inverse of the extract seam's ×s). let s = super::seam_scale(window.height(), ui_scale.0); let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s); + // `WOW_HIT_COST=1` — the hit-test lane's own meter (the resolve-lap report's W9): this + // call rebuilds `order::traversal` + sort + the scroll-clip map EVERY frame the cursor + // is inside the window, moving or not — a real-play cost every parked probe leg is + // structurally blind to (probes park the cursor outside). One aggregate line per + // second, census-meter posture; the number decides whether the epoch-gated skip is + // worth building, before it is built. + static HIT_COST: std::sync::OnceLock = std::sync::OnceLock::new(); + let metering = + *HIT_COST.get_or_init(|| std::env::var("WOW_HIT_COST").as_deref() == Ok("1")); + let t0 = metering.then(std::time::Instant::now); hover.0 = script.mouse_move(x, y); + if let Some(t0) = t0 { + use std::sync::atomic::{AtomicU64, Ordering}; + static ACC_US: AtomicU64 = AtomicU64::new(0); + static CALLS: AtomicU64 = AtomicU64::new(0); + static LAST: AtomicU64 = AtomicU64::new(0); + ACC_US.fetch_add(t0.elapsed().as_micros() as u64, Ordering::Relaxed); + let calls = CALLS.fetch_add(1, Ordering::Relaxed) + 1; + let now_s = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if LAST.swap(now_s, Ordering::Relaxed) != now_s && calls > 1 { + let us = ACC_US.swap(0, Ordering::Relaxed); + let n = CALLS.swap(0, Ordering::Relaxed); + eprintln!( + "[hit-cost] mouse_move_us/frame={:.1} frames={n}", + us as f64 / n.max(1) as f64 + ); + } + } for (btn, name) in [ (MouseButton::Left, "LeftButton"), (MouseButton::Right, "RightButton"), diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 388e7b4b..1e2b0b57 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -32,9 +32,9 @@ pub use world_assets::*; mod model; pub use model::{ - bone_target_id, submesh_to_skinned_mesh, submesh_to_static_mesh, AnimClip, BillboardInfo, - GlobalBone, GlobalSeqChannel, ModelAnimations, ModelAttachment, ModelJoint, ModelMarker, - ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack, + bone_target_id, merged_static_mesh, submesh_to_skinned_mesh, submesh_to_static_mesh, AnimClip, + BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations, ModelAttachment, ModelJoint, + ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT, }; mod adt; diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 440fc507..ac76e006 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -478,7 +478,9 @@ pub struct LiquidExt { /// `secondary` Blinn term — water's own exponent, not the shared row-3 terrain shininess. #[uniform(102)] pub kind: Vec4, - /// `x` = current frame index (set each frame by `animate_liquid`), `y` = frame count; `zw` unused. + /// `x` = reserved (frame 0), `y` = frame count, `z` = scroll flag, `w` = clock enable — + /// the shader derives frame index and scroll from `globals.time` (liquid.wgsl `anim_time`); + /// nothing mutates this uniform after build. #[uniform(102)] pub anim: Vec4, /// **The shared global light** (`lighting::global_light`): the one storage buffer terrain and the diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 6953285a..03937fe6 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -784,6 +784,72 @@ pub(crate) fn build_global_bones( .collect() } +/// Build ONE mesh from many placed static submeshes — the transforms baked into the vertices. +/// The `WOW_MEGA_STATIC` consolidation bracket's builder (2026-08-18 drastic-options census): +/// same attribute recipe as [`submesh_to_static_mesh`], concatenated, with each part's placement +/// transform applied to positions and its rotation to normals (placements scale uniformly, so +/// directions survive). Parts lacking vertex colours pad white when any part has them — one +/// attribute set must cover the whole concatenation. Returns the mesh and its baked min/max for +/// the caller's Aabb. +pub fn merged_static_mesh( + parts: &[(std::sync::Arc, Transform)], +) -> (Mesh, Vec3, Vec3) { + let any_colors = parts + .iter() + .any(|(s, _)| s.vertex_colors.len() == s.positions.len()); + let mut positions: Vec<[f32; 3]> = Vec::new(); + let mut normals: Vec<[f32; 3]> = Vec::new(); + let mut uvs: Vec<[f32; 2]> = Vec::new(); + let mut colors: Vec<[f32; 4]> = Vec::new(); + let mut indices: Vec = Vec::new(); + let (mut mn, mut mx) = (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN)); + for (sub, transform) in parts { + let base = u32::try_from(positions.len()).expect("merged mesh under u32 vertices"); + for p in &sub.positions { + let w = transform.transform_point(wow_to_bevy(*p)); + mn = mn.min(w); + mx = mx.max(w); + positions.push(w.to_array()); + } + if sub.normals.len() == sub.positions.len() { + let flip = sub.billboard_card_faces_away(); + for n in &sub.normals { + let b = transform.rotation * wow_to_bevy(*n); + normals.push(if flip { -b } else { b }.normalize_or_zero().to_array()); + } + } else { + // The lone compute_normals fallback path never merges (its models are rare and the + // bracket tolerates flat shading there): pad up, face normals are close enough for a + // measurement build. + normals.extend(std::iter::repeat_n([0.0, 1.0, 0.0], sub.positions.len())); + } + uvs.extend(sub.uvs.iter().copied()); + if any_colors { + if sub.vertex_colors.len() == sub.positions.len() { + colors.extend(sub.vertex_colors.iter().copied()); + } else { + colors.extend(std::iter::repeat_n( + [1.0, 1.0, 1.0, 1.0], + sub.positions.len(), + )); + } + } + indices.extend(sub.indices.iter().map(|i| base + i)); + } + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::RENDER_WORLD, + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); + if any_colors { + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors); + } + mesh.insert_indices(Indices::U32(indices)); + (mesh, mn, mx) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index c3c5da5e..a4128193 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -40,7 +40,7 @@ mesh_functions, forward_io::Vertex, view_transformations::position_world_to_clip, - mesh_view_bindings::view, + mesh_view_bindings::{view, globals}, } @group(#{MATERIAL_BIND_GROUP}) @binding(100) var frames: texture_2d_array; @@ -49,9 +49,11 @@ struct LiquidParams { // x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess. kind: vec4, - // x = current frame index; y = frame count; z = the v-scroll offset in texture repeats - // (nibble-6/7 WMO magma/slime only — the reference's animated stage-0 texture matrix; see - // `liquid/surface.rs`'s `scrolls`, and `apply_scroll` below); w unused. + // x = reserved (frame 0); y = frame count; z = the SCROLL FLAG (1 only on the nibble-6/7 + // WMO magma/slime lane — the reference's animated stage-0 texture matrix; see + // `liquid/surface.rs`'s `scrolls`, and `apply_scroll` below); w = the clock enable (0 on a + // deterministic run — the whole animation freezes at frame 0 / scroll 0, the 0600 capture + // pin, baked at material build). anim: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(102) var w: LiquidParams; @@ -114,8 +116,28 @@ fn sun_sheen(world_normal: vec3, world_pos: vec3) -> vec3 { // A full repeat every 10 s, so `REPEAT` wrapping makes the sawtooth's reset invisible. Only the // rate and the period are reproducible — the reference's phase comes off `GetTickCount`, i.e. the // machine's uptime, so its absolute value is not a thing to match. +// The liquid clock: `globals.time` (the same wall-elapsed seconds the CPU cycler used to read) +// under the build-time enable. Both animations below are pure functions of it — the CPU-side +// 24 Hz `Assets::get_mut` tick this replaces mutated ~14 materials a tick and its Modified +// fallout (uniform re-uploads, bind-group rebuilds, whole-population `AssetChanged` arming) +// measured 0.28 cpu_ms/frame at the SW pin (2026-08-18 bracket). +fn anim_time() -> f32 { + return w.anim.w * globals.time; +} + +// The 24 fps frame flip — 30 frames over 1.25 s (VERIFIED `FUN_0068aac0`), floor-quantized to +// the tick exactly as the reference's integer frame index is. +fn frame_layer() -> i32 { + return i32(floor(anim_time() * 24.0) % max(w.anim.y, 1.0)); +} + fn apply_scroll(uv: vec2) -> vec2 { - return vec2(uv.x, uv.y + w.anim.z); + // v += (t mod 10) · 0.1 — repeats/s `[0x801620]` = 0.1, period `[0x80e5a0]` = 10.0 (VERIFIED + // wow-re `liquid-uv-scroll-law.md` §5): a sawtooth over exactly one repeat, invisible under + // REPEAT wrapping. CONTINUOUS now, where the CPU tick quantized it to 1/24 s — the reference + // itself rebuilds the matrix per draw off a millisecond clock, so this is the more faithful + // reading, not a new liberty. anim.z is the flag: hard 0 on every non-scrolling lane. + return vec2(uv.x, uv.y + w.anim.z * fract(anim_time() / 10.0)); } // Distance fog — planar eye-Z, GL_LINEAR, gamma space (mirrors terrain.wgsl). Applied to EVERY liquid @@ -183,7 +205,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // Animated frame. For water/ocean this is the DETAIL ripple (RGB ≈ near-black, ALPHA = ripple); // for magma/slime it is the OPAQUE BODY texture. - let detail = textureSample(frames, frames_samp, apply_scroll(in.uv), i32(round(w.anim.x))); + let detail = textureSample(frames, frames_samp, apply_scroll(in.uv), frame_layer()); // Magma / slime (kind.x > 0.5): the animated texture IS the opaque body colour — no depth swatch // (the ADT liquid vertex format carries no colour element at all, and the WMO one is a hard diff --git a/crates/benilla-ui/src/script/messageframe/mod.rs b/crates/benilla-ui/src/script/messageframe/mod.rs index 4a971447..4f63488d 100644 --- a/crates/benilla-ui/src/script/messageframe/mod.rs +++ b/crates/benilla-ui/src/script/messageframe/mod.rs @@ -163,6 +163,9 @@ impl UiScript { pub fn message_lines_needing_measure(&mut self) -> Vec { use std::hash::{Hash, Hasher}; let mut model = self.model_mut(); + // A plain reborrow so the arena borrow inside the loop and the sweep-token field writes + // split by FIELD instead of fighting over the RefMut. + let model = &mut *model; let mut out = Vec::new(); let frames: Vec<(FrameHandle, Rect)> = model .resolved @@ -185,7 +188,7 @@ impl UiScript { .frame(fh) .map(|f| f.effective_scale) .unwrap_or(1.0); - let font = Self::message_frame_font(&model, fh); + let font = Self::message_frame_font(model, fh); let frame_id = model.frame_id(fh); let Some(frame) = model.arena.frame(fh) else { continue; @@ -193,7 +196,30 @@ impl UiScript { let Some(lines) = frame.kind_state.message_lines() else { continue; }; + // The skip token (the W4 fix, 1410's lane one door over): a frame whose line set + // (lines_gen) and measure environment (font/wrap/scale/outline) both match its last + // CLEAN sweep can produce nothing — every line's rows_key was minted under exactly + // these inputs and matched then. Environment changes (a resize, a font swap, a + // SetScale) miss the env hash; text changes miss the generation. The token is only + // stored by a zero-request sweep (below), so an unanswered request keeps + // re-requesting — the same rule the region ledger runs under. + let lines_gen = frame.kind_state.lines_gen().unwrap_or(0); + let env = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + font.path.hash(&mut h); + font.height.map(f32::to_bits).hash(&mut h); + wrap_width.to_bits().hash(&mut h); + (font.outline as u8).hash(&mut h); + scale.to_bits().hash(&mut h); + h.finish() + }; + if model.msg_swept.get(&fh) == Some(&(lines_gen, env)) { + continue; + } + let requests_before = out.len(); for (index, line) in lines.iter().enumerate() { + model.msg_lines_hashed += 1; let mut hasher = std::collections::hash_map::DefaultHasher::new(); line.text.hash(&mut hasher); font.path.hash(&mut hasher); @@ -217,6 +243,11 @@ impl UiScript { key, }); } + if out.len() == requests_before { + model.msg_swept.insert(fh, (lines_gen, env)); + } else { + model.msg_swept.remove(&fh); + } } out } diff --git a/crates/benilla-ui/src/script/messageframe/scrolling.rs b/crates/benilla-ui/src/script/messageframe/scrolling.rs index d15d33c7..04cf955d 100644 --- a/crates/benilla-ui/src/script/messageframe/scrolling.rs +++ b/crates/benilla-ui/src/script/messageframe/scrolling.rs @@ -695,4 +695,57 @@ mod tests { s.run("CF:Clear()").unwrap(); assert_eq!(s.eval::("return CF:GetNumMessages()").unwrap(), 0); } + + /// The W4 skip token: a settled message frame's sweep hashes ZERO lines (the counter is the + /// claim — 0735's counts rule), and each side of the token catches its own change class: + /// text through the generation, the resolved width through the environment hash. The token + /// is only stored by a zero-request sweep, so an UNANSWERED request keeps re-requesting — + /// the region ledger's own rule (1410), inherited. + #[test] + fn a_settled_message_frame_hashes_no_lines_until_something_moves() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + s.run( + "local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\ + f:SetPoint('BOTTOMLEFT', 32, 90)\n\ + f:SetWidth(430)\n\ + f:SetHeight(34)", + ) + .unwrap(); + s.run("CF:AddMessage('one', 1, 1, 1)").unwrap(); + s.run("CF:AddMessage('two', 1, 1, 1)").unwrap(); + s.resolve(); + // Unanswered requests re-request: no token may be stored while keys mismatch. + let first = s.message_lines_needing_measure(); + assert_eq!(first.len(), 2); + let again = s.message_lines_needing_measure(); + assert_eq!(again.len(), 2, "unanswered lines must keep re-requesting"); + let answers: Vec<(u32, u32, u16, u64)> = + again.iter().map(|r| (r.frame, r.index, 1, r.key)).collect(); + s.set_message_line_rows(&answers); + assert!(s.message_lines_needing_measure().is_empty()); + // Settled: the NEXT sweep must skip the frame whole — zero lines hashed. + let hashed_before = s.model_mut().msg_lines_hashed; + assert!(s.message_lines_needing_measure().is_empty()); + assert_eq!( + s.model_mut().msg_lines_hashed, + hashed_before, + "a settled frame's sweep must hash no lines" + ); + // Text change reopens through the GENERATION… + s.run("CF:AddMessage('three', 1, 1, 1)").unwrap(); + let reqs = s.message_lines_needing_measure(); + assert_eq!(reqs.len(), 1, "the new line re-surfaces"); + assert_eq!(reqs[0].text, "three"); + let answers: Vec<(u32, u32, u16, u64)> = + reqs.iter().map(|r| (r.frame, r.index, 1, r.key)).collect(); + s.set_message_line_rows(&answers); + assert!(s.message_lines_needing_measure().is_empty()); + // …and a resolved-width change reopens through the ENVIRONMENT: every line re-keys. + s.run("CF:SetWidth(300)").unwrap(); + s.resolve(); + let reqs = s.message_lines_needing_measure(); + assert_eq!(reqs.len(), 3, "a new wrap width re-measures every line"); + assert!(reqs.iter().all(|r| (r.wrap_width - 300.0).abs() < 0.5)); + } } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index dd574db3..1b6f1122 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -126,6 +126,16 @@ pub(crate) struct Model { /// missed. A candidate the ledger names that the roster walk doesn't is fine (a same-value /// write — the staleness check absorbs it); the reverse is a stale label waiting to ship. pub(crate) measure_dirty: Option>, + /// The message-line sweep's clean tokens: frame → (lines_gen, env hash) at its last + /// ZERO-REQUEST sweep. A frame matching both is skipped whole (`message_lines_needing_measure` + /// hashes none of its lines); a sweep that produced requests stores nothing, so an unanswered + /// request re-requests forever exactly like the region lane's ledger (1410's rule). Stale + /// entries for destroyed frames are inert (generational handles never re-match) and the map + /// holds a handful of message frames, so no destroy hook. + pub(crate) msg_swept: std::collections::HashMap, + /// Lines actually hashed by the sweep — the skip's honest meter (tests assert 0 on a settled + /// second sweep; 0735's counts-never-milliseconds rule). + pub(crate) msg_lines_hashed: u64, /// `WOW_LAYOUT_VERIFY`'s flag that the resolve just taken was the incremental one and owes a /// full-derive re-run to prove it (see `layout::UiScript::resolve_layout`). Always `false` in /// production — nothing reads it unless the verify build set it. @@ -1181,6 +1191,8 @@ impl Model { layout_epoch: 0, layout_touched: None, measure_dirty: None, + msg_swept: std::collections::HashMap::new(), + msg_lines_hashed: 0, layout_verify_recheck: false, layout_derives: 0, layout_scope: super::layout::LayoutScope::default(), diff --git a/crates/benilla-ui/src/widget/kinds/messageframe.rs b/crates/benilla-ui/src/widget/kinds/messageframe.rs index 5e0701c2..bb07fc40 100644 --- a/crates/benilla-ui/src/widget/kinds/messageframe.rs +++ b/crates/benilla-ui/src/widget/kinds/messageframe.rs @@ -54,6 +54,13 @@ pub struct ScrollingMessageState { /// `fadeDuration` — phase-2 fade ramp length (ctor 3.0s). `0` ⇒ the line vanishes instantly at /// phase-1 expiry (no ramp). pub fade_duration: f32, + /// Bumped by every path that can change a line's TEXT set (add, clear, SetMaxLines, and the + /// generic mut door `KindState::message_lines_mut`) — the measure sweep's skip token + /// (`message_lines_needing_measure`): a frame whose generation and measure environment both + /// match its last clean sweep hashes no lines. Fade ticking deliberately does NOT bump (it + /// moves alpha/time, never text), or a fading chat line would keep the sweep hot for its + /// whole 2-minute ride. + pub lines_gen: u64, /// `fadingEnabled` (ctor 1). While false, lines never fade. pub fading_enabled: bool, /// The scrollback offset, counted up from the newest line: `0` = pinned to the bottom (AtBottom, @@ -71,6 +78,7 @@ impl Default for ScrollingMessageState { time_visible: 10.0, fade_duration: 3.0, fading_enabled: true, + lines_gen: 0, scroll_offset: 0, } } @@ -88,6 +96,7 @@ impl ScrollingMessageState { /// dropping the oldest when over `max_lines`. A view scrolled up stays anchored on the same /// content (the ring cursor is a slot, not an offset — msgframe-runtime.md). pub fn add(&mut self, text: String, r: f32, g: f32, b: f32) { + self.lines_gen = self.lines_gen.wrapping_add(1); let line = MessageLine { text, color: [quantize_u8(r), quantize_u8(g), quantize_u8(b)], @@ -113,6 +122,7 @@ impl ScrollingMessageState { /// `SetMaxLines(n)` — **destructive** (`0x787dd0`): frees every line + resets the cursor, then /// sets the new capacity. Not a preserving resize. pub fn set_max_lines(&mut self, n: usize) { + self.lines_gen = self.lines_gen.wrapping_add(1); self.lines.clear(); self.scroll_offset = 0; self.max_lines = n.max(1); @@ -120,6 +130,7 @@ impl ScrollingMessageState { /// `Clear` (`0x7882b0`): retire every line immediately (no fade), reset to the bottom. pub fn clear(&mut self) { + self.lines_gen = self.lines_gen.wrapping_add(1); self.lines.clear(); self.scroll_offset = 0; } @@ -275,6 +286,8 @@ pub enum InsertMode { /// that a message added to a zero-height frame vanishes. #[derive(Clone, Debug, PartialEq)] pub struct MessageFrameState { + /// See [`ScrollingMessageState::lines_gen`] — the same sweep-skip token. + pub lines_gen: u64, /// The display lines, **newest at the back always** — [`InsertMode`] is a *display direction* /// resolved at emit, not a storage order, so "evict the oldest" is one `pop_front` in both /// modes. @@ -304,6 +317,7 @@ impl Default for MessageFrameState { time_visible: 10.0, fade_duration: 3.0, fading_enabled: true, + lines_gen: 0, } } } @@ -314,6 +328,7 @@ impl MessageFrameState { /// starting alpha (this class's fourth numeric really is alpha — the scrolling one's is an id /// and forces `0xFF`), snapshot the frame's fade config, and append. pub fn add(&mut self, text: String, r: f32, g: f32, b: f32, a: f32) { + self.lines_gen = self.lines_gen.wrapping_add(1); self.lines.push_back(MessageLine { text, color: [quantize_u8(r), quantize_u8(g), quantize_u8(b)], @@ -330,6 +345,7 @@ impl MessageFrameState { /// `Clear` — retire every line immediately, no fade. (`_LazyPig` calls `UIErrorsFrame:Clear()` /// eleven times; it is the one non-`AddMessage` verb the corpus actually uses on this class.) pub fn clear(&mut self) { + self.lines_gen = self.lines_gen.wrapping_add(1); self.lines.clear(); } diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 6642b9e1..3a276624 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -151,11 +151,29 @@ impl KindState { } } + /// The message kinds' sweep-skip generation ([`ScrollingMessageState::lines_gen`]); + /// `None` for every other kind. + pub fn lines_gen(&self) -> Option { + match self { + KindState::ScrollingMessage(smf) => Some(smf.lines_gen), + KindState::Message(mf) => Some(mf.lines_gen), + _ => None, + } + } + /// [`Self::message_lines`], mutably — the measure round-trip's write-back half. pub fn message_lines_mut(&mut self) -> Option<&mut VecDeque> { match self { - KindState::ScrollingMessage(smf) => Some(&mut smf.lines), - KindState::Message(mf) => Some(&mut mf.lines), + // Any mut borrow through this door counts as a text change for the measure sweep's + // skip token — conservative, and the door every write-back path uses. + KindState::ScrollingMessage(smf) => { + smf.lines_gen = smf.lines_gen.wrapping_add(1); + Some(&mut smf.lines) + } + KindState::Message(mf) => { + mf.lines_gen = mf.lines_gen.wrapping_add(1); + Some(&mut mf.lines) + } _ => None, } } diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 4039c455..898c4d8c 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -147,6 +147,7 @@ pub mod lighting; pub mod liquid; pub mod map_proj; pub mod mat_anim_table; +pub mod mega_static; pub mod mesh_tag; pub mod model_fade; pub mod model_forms; diff --git a/crates/benilla-world/src/liquid/mod.rs b/crates/benilla-world/src/liquid/mod.rs index a5a8a155..7658b781 100644 --- a/crates/benilla-world/src/liquid/mod.rs +++ b/crates/benilla-world/src/liquid/mod.rs @@ -104,7 +104,6 @@ impl Plugin for LiquidPlugin { .add_systems( Update, ( - surface::animate_liquid, // AFTER the portal pass, which publishes the camera eye's room this frame // (`CameraInteriorClaim`): the submersion verdict is the eye's room applied to // the eye's position, and a frame-old room would flash the wrong atmosphere for diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index de694733..50afa14e 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -1,13 +1,20 @@ //! **The Bevy render glue** — the animated liquid surfaces themselves. //! //! One shared material per (kind, fog block) over a `texture_2d_array` of the kind's frames, the two -//! spawn paths (an ADT chunk's MCLQ and a WMO group's MLIQ), the flat mesh build, and the 24 fps -//! frame cycler. The faithful shading model is `super`'s header and `liquid.wgsl`'s; the *position* -//! side — the grid each spawned surface publishes and the queries against it — is [`super::query`]. +//! spawn paths (an ADT chunk's MCLQ and a WMO group's MLIQ), and the flat mesh build. The faithful +//! shading model is `super`'s header and `liquid.wgsl`'s; the *position* side — the grid each +//! spawned surface publishes and the queries against it — is [`super::query`]. //! -//! The frame-flip is the client's first render animation — a deliberate **one-off** (a frame-index -//! uniform off Bevy real `Time`), NOT a general animation system. Two clocks: animation = -//! wall-clock; day/night = server game-time. +//! **The 24 fps frame-flip and the lava scroll are computed IN the shader off `globals.time`** +//! (liquid.wgsl `anim_time`): both are pure functions of the wall clock and two per-material +//! constants baked at build, so no CPU ever mutates a liquid material again. The 24 Hz +//! `Assets::get_mut` cycler this replaced cost a measured 0.28 cpu_ms/frame at the SW pin (the +//! WOW_NO_LIQUID_ANIM bracket, 2026-08-18, negative in all three rounds): its per-tick Modified +//! chain re-uploaded ~14 material uniforms, rebuilt their Metal bind groups, and armed the +//! whole-population `AssetChanged` walks 24 times a second. A deterministic run bakes the freeze +//! at BUILD time (`anim.w = 0` → the shader clock reads 0 forever), so captures keep frame 0 / +//! scroll 0 bit-exactly — the same pin the old tick enforced (0600). Two clocks, unchanged in +//! spirit: animation = wall clock; day/night = server game-time. use std::collections::HashMap; @@ -24,11 +31,7 @@ use benilla_assets::LockRecover; use benilla_assets::{liquid_frame_array, RenderConfig, WorldAssets}; use benilla_formats::{read_texture_mip_chain, BlpMipChain, LiquidKind, LiquidMesh}; -/// Frame-flip rate — 30 frames over 1.25 s (VERIFIED `FUN_0068aac0`), i.e. 24 fps, real wall-clock. -const ANIM_FPS: f32 = 24.0; - -/// The shared liquid materials, keyed by [`LiquidKey`], plus each one's animated frame count (for -/// the modulo in `animate_liquid`). Read by the terrain streamer (via [`spawn_liquids`] / +/// The shared liquid materials, keyed by [`LiquidKey`]. Read by the terrain streamer (via [`spawn_liquids`] / /// [`spawn_wmo_liquids`]) to material the per-chunk water meshes. Absent when the client has no data /// (no `WorldAssets`). #[derive(Resource, Default)] @@ -61,15 +64,6 @@ struct LiquidKey { scroll: bool, } -/// **Texture repeats per second** the reference slides a scrolling liquid sheet along **v** — -/// `[0x801620]` = 0.1 (VERIFIED wow-re `liquid-uv-scroll-law.md`, six-agent §5). -const SCROLL_REPEATS_PER_SEC: f32 = 0.1; - -/// The scroll's wrap period in seconds — the `fmod` divisor `[0x80e5a0]` = 10.0. With -/// [`SCROLL_REPEATS_PER_SEC`] that makes the offset a **sawtooth over exactly one repeat**, so the -/// wrap is invisible on a `REPEAT`-sampled texture and the phase never grows without bound. -const SCROLL_PERIOD_SECS: f32 = 10.0; - /// Does this surface take the reference's animated stage-0 texture matrix — the lava/slime scroll? /// /// **Only WMO MLIQ surfaces whose type nibble is 6 or 7** (VERIFIED wow-re `liquid-uv-scroll-law.md`: @@ -94,10 +88,6 @@ fn scrolls(nibble: u8) -> bool { struct LiquidEntry { material: Handle, - frame_count: u32, - /// Mirrors [`LiquidKey::scroll`], so the cycler can drive the v-offset without re-deriving the - /// key it is already iterating the values of. - scroll: bool, } impl LiquidAssets { @@ -440,10 +430,21 @@ pub(super) fn setup_liquid( if interior { 1.0 } else { 0.0 }, WATER_SHININESS, ), - // x = frame 0 (index driven by `animate_liquid`); y = frame count; z = the - // v-scroll offset in texture repeats (also `animate_liquid`'s, and left at 0 - // forever on a non-scrolling material); w unused. - anim: Vec4::new(0.0, frame_count as f32, 0.0, 0.0), + // x = reserved (frame 0; the shader derives the live index from its own + // clock); y = frame count; z = the SCROLL FLAG (1 = this lane takes the + // stage-0 v-scroll — [`scrolls`]' nibble-6/7 WMO lane); w = the clock + // enable (0 on a deterministic run bakes the 0600 capture freeze — frame 0, + // scroll 0 — with no tick left to skip). + anim: Vec4::new( + 0.0, + frame_count as f32, + if scroll { 1.0 } else { 0.0 }, + if crate::dev_state::deterministic_run() { + 0.0 + } else { + 1.0 + }, + ), light_buf: world_assets.shared_light.clone(), }, }); @@ -453,11 +454,7 @@ pub(super) fn setup_liquid( interior, scroll, }, - LiquidEntry { - material, - frame_count, - scroll, - }, + LiquidEntry { material }, ); } } @@ -509,74 +506,6 @@ fn load_frame_array( Some((images.add(liquid_frame_array(frames)), loaded)) } -/// Advance every liquid material's frame index at [`ANIM_FPS`] off Bevy **real** `Time` (wall-clock, -/// mirroring the reference's `GetTickCount`-driven cycler — NOT the day/night game clock), and the -/// **v-scroll offset** on the materials that take one ([`scrolls`]). Writes only on the -/// [`ANIM_FPS`] tick edge: `Assets::get_mut` alone marks the asset Modified and feeds the -/// respecialization pipeline (the mark-changed scan + `Changed` sweeps) every frame — the -/// 0353 demand-price law; between ticks the frame index cannot have changed. -/// -/// The scroll rides the same 24 Hz edge rather than getting its own per-frame write. It is a -/// continuous quantity, so that quantises it — but to 1/24 s, i.e. **1/240th of a repeat** per step -/// on a 10 s sawtooth, which is far below what a 256² sheet can show, and it keeps the material -/// upload budget exactly where 0353 put it. (The reference is quantised too, just differently: it -/// rebuilds the matrix per draw off a millisecond clock.) -pub(super) fn animate_liquid( - time: Res