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:
Generated
+13
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user