liquids keep time in the shader, and the frame is measured CPU-bound

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 18:50:55 +07:00
co-authored by Claude Fable 5
parent 19e861bb96
commit a822d99bd4
25 changed files with 837 additions and 118 deletions
Generated
+13
View File
@@ -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"
+3
View File
@@ -15,6 +15,9 @@ authors = ["sam <samuv.eth@gmail.com>"]
[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"
+1
View File
@@ -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 }
@@ -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<Res<'w, crate::perf::GpuMsShared>>,
Local<'s, Vec<f32>>,
);
#[derive(SystemParam)]
struct SamplePin<'w> {
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
@@ -139,8 +147,12 @@ fn drive_live_fps(
mut key_events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
mut exit: MessageWriter<AppExit>,
mut occlusions: MessageReader<bevy::window::WindowOccluded>,
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),
@@ -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
+53
View File
@@ -106,3 +106,56 @@ pub(super) fn count_camera_changes(
*last = time.elapsed_secs();
}
}
/// `WOW_ROW_BLOAT=<n>` — 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<benilla_assets::materials::WowModelMaterial>,
&'static benilla_world::model_render::ModelPart,
&'static bevy::mesh::MeshTag,
&'static bevy::camera::primitives::Aabb,
),
Without<benilla_world::rig_palette::RigPart>,
>;
pub(super) fn row_bloat(mut commands: Commands, mut done: Local<bool>, 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;
}
+250
View File
@@ -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<bool> = 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<AtomicU64>);
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<AtomicU8>)>,
frame: usize,
period: f32,
shared: Arc<AtomicU64>,
}
#[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::<GpuStamp>() {
// 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<Res<GpuStamp>>,
device: Option<Res<RenderDevice>>,
queue: Option<Res<RenderQueue>>,
shared: Res<GpuMsShared>,
) {
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<ResMut<GpuStamp>>, device: Res<RenderDevice>, queue: Res<RenderQueue>) {
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::<RenderGraph>();
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);
}
+15
View File
@@ -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=<n>` — 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::<usize>().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);
}
}
+30
View File
@@ -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<bool> = 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"),
+3 -3
View File
@@ -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;
+3 -1
View File
@@ -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
+66
View File
@@ -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<RenderSubmesh>, 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<u32> = 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::*;
+28 -6
View File
@@ -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<f32>;
@@ -49,9 +49,11 @@
struct LiquidParams {
// x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess.
kind: vec4<f32>,
// 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<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> w: LiquidParams;
@@ -114,8 +116,28 @@ fn sun_sheen(world_normal: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
// 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<f32>) -> vec2<f32> {
return vec2<f32>(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<f32>(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<f32> {
// 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
@@ -163,6 +163,9 @@ impl UiScript {
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
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
}
@@ -695,4 +695,57 @@ mod tests {
s.run("CF:Clear()").unwrap();
assert_eq!(s.eval::<i64>("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));
}
}
+12
View File
@@ -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<Vec<crate::widget::RegionHandle>>,
/// 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<crate::widget::FrameHandle, (u64, u64)>,
/// 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(),
@@ -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();
}
+20 -2
View File
@@ -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<u64> {
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<MessageLine>> {
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,
}
}
+1
View File
@@ -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;
-1
View File
@@ -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
+30 -101
View File
@@ -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<LiquidMaterial>,
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<Mesh3d>` 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<Time>,
liquid: Option<Res<LiquidAssets>>,
mut materials: ResMut<Assets<LiquidMaterial>>,
mut last_ticks: Local<Option<u32>>,
surfaces: Query<(), With<MeshMaterial3d<LiquidMaterial>>>,
) {
let Some(liquid) = liquid else {
return;
};
// No liquid mesh in the world → nothing samples these materials; skip the whole cycle (the
// startup-built kind set otherwise re-uploads ~10 materials at every 24 Hz edge on maps with
// no water at all). The cycler is wall-clock ([`ANIM_FPS`] × elapsed), so when the first
// surface streams in the index resumes at the current wall frame — exactly the reference's
// `GetTickCount`-driven phase.
if surfaces.is_empty() {
return;
}
// Captures pin the cycler to frame 0: the wall-clock at screenshot time varies with load
// times, so any framing with open water diffs differently run to run — the flake substrate's
// baseline redesign caught (MAE 3.97 → 0.009 pinned; decision 0600). One clause, one frame.
let ticks = if crate::dev_state::deterministic_run() {
0
} else {
(time.elapsed_secs() * ANIM_FPS) as u32
};
if *last_ticks == Some(ticks) {
return;
}
*last_ticks = Some(ticks);
// The scroll's sawtooth, off the SAME quantised clock as the frame index so the two can never
// disagree about what time it is: `fmod(t, 10) · 0.1` ∈ [0, 1) repeats. Pinned to 0 in a
// deterministic run for exactly the reason the frame index is (0600).
let scroll = if crate::dev_state::deterministic_run() {
0.0
} else {
(ticks as f32 / ANIM_FPS) % SCROLL_PERIOD_SECS * SCROLL_REPEATS_PER_SEC
};
for entry in liquid.materials.values() {
// Gated per entry: a single-frame liquid (`frame_count` 1) never changes index, and the
// shared cache holds every kind ever built — re-marking them all Modified on each tick
// edge re-uploaded ~10 materials/frame on maps with no water at all.
let frame = (ticks % entry.frame_count.max(1)) as f32;
let offset = if entry.scroll { scroll } else { 0.0 };
benilla_assets::write_gated(
&mut materials,
&entry.material,
|m| m.extension.anim.x != frame || m.extension.anim.z != offset,
|m| {
m.extension.anim.x = frame;
m.extension.anim.z = offset;
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
+120
View File
@@ -0,0 +1,120 @@
//! **`WOW_MEGA_STATIC=1` — the consolidation bracket** (the drastic-options census, 2026-08-18).
//!
//! An EXPERIMENT lever, not a landing: when armed, the placement assembler diverts every fully
//! static batch (no anim host, no billboard, no animated material) into this buffer instead of
//! spawning an entity, and the flush below merges each material's backlog into ONE mesh with the
//! placement transforms baked into the vertices. Grouping by the deduped material handle is what
//! keeps every material semantic (blend, shade selector, batch order, two-sidedness, fog policy)
//! intact — the group IS the batch key.
//!
//! **What the bracket deliberately breaks, and why that is honest:** merged geometry has no
//! per-placement distance fade, no pick identity, no exterior-window gating, no per-group WMO
//! portal visibility, and never despawns with its tile. Those are exactly the constraints a real
//! consolidation must solve (decision 1413 enumerates them); the bracket exists to measure the
//! CEILING — what the frame costs when the static world is a few hundred rows instead of tens of
//! thousands — so the design work knows what it is buying before anyone pays for it. Parked-pin
//! measurements only; the world it draws is visually wrong in motion (pop-in never fades, culling
//! is coarse) and the flag must never default on.
use bevy::camera::primitives::Aabb;
use bevy::camera::visibility::NoAutoAabb;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::mesh_tag::alpha_bits;
use crate::model_render::{ModelKind, ModelPart};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::merged_static_mesh;
use benilla_formats::{ModelBlend, RenderSubmesh};
use std::sync::Arc;
/// Is the bracket armed? Read once; the assembler and the flush both key on it.
pub fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_MEGA_STATIC").as_deref() == Ok("1"))
}
/// One diverted batch: the shared geometry, this placement's transform, and the deduped material
/// it would have drawn with.
pub struct PendingPart {
pub geometry: Arc<RenderSubmesh>,
pub transform: Transform,
pub blend: ModelBlend,
pub kind: ModelKind,
}
/// The divert buffer. Keyed by material handle — the merge group and the draw batch are the same
/// thing by construction.
#[derive(Resource, Default)]
pub struct MegaStaticPending {
pub parts: Vec<(Handle<WowModelMaterial>, PendingPart)>,
}
/// Merge and spawn once the stream has been quiet for [`FLUSH_QUIET_SECS`]. One entity per
/// material group per flush; a later burst (a new tile) makes a new generation of blobs rather
/// than rebuilding the old ones — acceptable for a bracket whose pins are parked.
const FLUSH_QUIET_SECS: f32 = 2.0;
/// (material, spatial cell) → the placements merging into that blob.
type MergeGroups =
std::collections::HashMap<(Handle<WowModelMaterial>, (i32, i32)), Vec<PendingPart>>;
pub fn flush_mega_static(
mut commands: Commands,
mut pending: ResMut<MegaStaticPending>,
mut meshes: ResMut<Assets<Mesh>>,
time: Res<Time>,
// (backlog length at last look, when it last moved) — the quiet timer, kept here so the
// assembler needs no clock.
mut prev: Local<(usize, f32)>,
) {
let (prev_len, moved_at) = &mut *prev;
if pending.parts.len() != *prev_len {
*prev_len = pending.parts.len();
*moved_at = time.elapsed_secs();
return;
}
if pending.parts.is_empty() || time.elapsed_secs() - *moved_at < FLUSH_QUIET_SECS {
return;
}
*prev_len = 0;
let parts = std::mem::take(&mut pending.parts);
let total = parts.len();
// Group key = (material, SPATIAL CELL). Round 1 of this bracket grouped by material alone
// and LOST (+1.38 cpu_ms at SW, drawn 400 → ~830): one blob per material spans the whole
// streamed scene, so its Aabb defeats the frustum cull and every blob's full vertex load
// encodes every frame. The cell restores locality; the distinct-material count (~5.6k at
// SW) remains the blob-count floor either way — the census finding that per-material
// merging alone cannot reach the few-hundred-row regime (1413).
const CELL: f32 = 133.333;
let cell_of = |t: &Transform| {
(
(t.translation.x / CELL).floor() as i32,
(t.translation.z / CELL).floor() as i32,
)
};
let mut by_mat: MergeGroups = std::collections::HashMap::new();
for (mat, p) in parts {
let cell = cell_of(&p.transform);
by_mat.entry((mat, cell)).or_default().push(p);
}
let groups = by_mat.len();
for ((mat, _cell), group) in by_mat {
let (blend, kind) = (group[0].blend, group[0].kind);
let geo: Vec<(Arc<RenderSubmesh>, Transform)> = group
.into_iter()
.map(|p| (p.geometry, p.transform))
.collect();
let (mesh, mn, mx) = merged_static_mesh(&geo);
commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(mat),
Transform::IDENTITY,
ModelPart { kind, blend },
MeshTag(alpha_bits(1.0)),
Aabb::from_min_max(mn, mx),
NoAutoAabb,
));
}
info!("mega-static: merged {total} diverted batches into {groups} blob(s)");
}
@@ -117,6 +117,11 @@ pub fn spawn_model_entities(
// parents that list, and a card is a world root the billboard pass writes absolutely).
// `None` for world-static placements (terrain), whose pivots never move.
card_owner: Option<Entity>,
// `Some` on the world-static streamer path when the `WOW_MEGA_STATIC` bracket may divert
// fully static batches into the merge buffer instead of spawning them
// ([`crate::mega_static`] — an EXPERIMENT lever; its module doc owns the caveats). `None`
// on the moving-prop path (a merged blob cannot ride a gameobject).
mut mega: Option<&mut crate::mega_static::MegaStaticPending>,
// Returns the spawned entities + what the anim host armed for this placement, when the model
// animates: the joint set (bone-indexed) the emitter spawn rides its host bone off (0130 phase
// 4), and the FILE sequence slot its variation roll landed on, which the emitters' rate/gate
@@ -235,6 +240,34 @@ pub fn spawn_model_entities(
light,
seq_owner,
);
// The `WOW_MEGA_STATIC` divert (the consolidation bracket): a fully static batch — no
// anim host, not a billboard card, no per-entity material animation — skips its entity
// entirely; the merge flush draws it inside one blob per MATERIAL, which preserves every
// material semantic by construction (the handle is already deduped over blend, shade,
// batch order, fog policy...). Shared-table UV/tint loops would merge fine too (the
// table drives the material, not the entity), excluded anyway to keep the bracket's
// divert predicate trivially auditable.
if let Some(mega) = mega.as_mut() {
if crate::mega_static::enabled()
&& !animated
&& sub.billboard.is_none()
&& sub.alpha_anim.is_none()
&& seq_owner.is_none()
&& sub.uv_anim.is_none()
&& sub.rgb_anim.is_none()
{
mega.parts.push((
cutout.clone(),
crate::mega_static::PendingPart {
geometry: sub.geometry.clone(),
transform,
blend: sub.blend,
kind,
},
));
continue;
}
}
// The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when
// this is a non-fading interior prop). A MULTIPLY batch (Mod/Mod2x — the weapon-rack
// ARMORREFLECT sheen) also reuses its steady self: its blend equation reads no alpha, so no
@@ -64,6 +64,7 @@ type SpawnTables<'w> = (
Res<'w, crate::terrain_stream::ViewFocus>,
ResMut<'w, ModelForms>,
ResMut<'w, HullWelds>,
ResMut<'w, crate::mega_static::MegaStaticPending>,
);
#[allow(clippy::too_many_arguments)]
@@ -92,7 +93,7 @@ pub(super) fn spawn_loaded_placements(
// model-forms cache (decision 0834).
tables: SpawnTables,
) {
let (mut probes, mut activity, focus, mut forms, mut welds) = tables;
let (mut probes, mut activity, focus, mut forms, mut welds, mut mega) = tables;
let Some(shared_light) = shared_light else {
return;
};
@@ -184,6 +185,7 @@ pub(super) fn spawn_loaded_placements(
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
);
// ADT doodads are exterior scene: from inside a WMO they draw only through a
// portal window (`0x683700`, fed solely by the per-window walk `0x682fa0` — see
@@ -299,6 +301,7 @@ pub(super) fn spawn_loaded_placements(
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
);
// A world WMO placement is exterior scene too (`0x6856c0`, fed by the same
// per-window populate `0x682fa0`): from inside one building, another building
@@ -591,6 +594,7 @@ pub(super) fn spawn_loaded_placements(
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
);
// The slot frees itself when the prop despawns (streaming out) — the component hook
// returns it to the table whoever does the despawn.
+13
View File
@@ -243,6 +243,19 @@ impl Plugin for WorldFoundation {
{
app.insert_resource(bevy::time::Time::<bevy::time::Fixed>::from_hz(hz));
}
// Static transform tracking is ALWAYS on — the adaptive default pays two serial
// full-population scans per frame (`mark_dirty_trees`'s `count()` calls; a `Changed`
// filter count degrades to full iteration) to decide whether >30% of the world moved
// this frame. Ours never has: movers are the streamed units, 1–3% of a world that is
// overwhelmingly static placements — the check can only ever conclude "track", so the
// two scans are its entire price. Pinned, not a knob: no scene benilla renders can be
// on the other side of the threshold.
app.insert_resource(bevy::transform::systems::StaticTransformOptimizations::enabled());
// `WOW_MEGA_STATIC=1` — the consolidation bracket (EXPERIMENT lever; the module doc owns
// the caveats — never a default). The resource + flush register unconditionally: with
// the flag off nothing ever diverts, the buffer stays empty, and the flush early-outs.
app.init_resource::<crate::mega_static::MegaStaticPending>();
app.add_systems(bevy::app::Update, crate::mega_static::flush_mega_static);
// `WOW_UPLOAD_BUDGET=<MB>` — cap the render app's per-frame GPU asset upload (bevy's
// `RenderAssetBytesPerFrame`, unlimited by default; only assets implementing `byte_len`
// participate, which covers meshes and images — exactly the streaming payload). An