- the pet action bar is the reference's own `PetActionBarFrame.xml`; our 559-line transcription is deleted, and the reference's bar brings what it had left out — the slide, the Ctrl-held and locked gates on show/hide, the hotkey pass whose string-versus-number comparison leaves every corner empty — with the three pet dialogs moved verbatim into the popup slice; one adapter draws the autocast shine (the reference's `<Model>` draws nothing in this engine, so each button gets the token-texture shine at the reference's own 1.2); the stock file registers four events nothing fired, built rather than listed — `PET_BAR_UPDATE_COOLDOWN` when the ten cooldown triples alone move, `PLAYER_CONTROL_LOST`/`GAINED` on the edge of the control flag (`0x4958e0`'s change-gated fire), `PLAYER_FARSIGHT_FOCUS_CHANGED` on the edge of the farsight descriptor, and `UNIT_FLAGS` on a change of the raw flags (its exact fire law a dispatched question) (1953) - the skills tab is the reference's own `SkillFrame.xml` at its slot between ReputationFrame and HonorFrame; our 874-line transcription is deleted, the `UNLEARN_SKILL` dialog moves verbatim into the popup slice, and `CancelSkillUps` is built — the page's live Close button calls it before hiding, so every close reaches it; it is the reset leg of the temp-point table (`0x4d3e30`, no arguments, no returns) over a table this client keeps empty by construction, so the reset moves nothing, which is the binding's whole effect on this data; `AddSkillUp`, `RemoveSkillUp` and `BuySkillTier` sit on buttons the reference shows only for skill points or step/rank costs no 1.12 server sends and stay named as open gaps; and the expand tab's fit is the reference's load-time law (`GetTextWidth()+45` once, in OnLoad), so the transcription's zero-measure guard and re-fit are gone (1956) - a 45-bot Blackrock raid circling with sound on holds 60 fps with the main thread at 6–7 ms while the HUD's ms readout says 17–18: that number is the sum of every thread's CPU per frame (main, render, compute pool, audio), not the frame time — the raid costs +6 ms spread over four threads with no thread owning it; two suspects refuted by measurement: mimalloc as the process allocator (interleaved: no change on any thread, reverted the same hour) and page faults (59 a frame on the probe, tens of microseconds, not the render thread's 2.1 ms of kernel time, which is the Metal driver's own work and scales with draws); so the probe counts draws per phase family (transparent outnumbers opaque, mostly the buff wave's particles and nameplates) and `WOW_PROBE_SHARED_SKIN=1` prices cross-body instancing before it is built: nothing moved, because bevy instances only identical meshes and a geared body's merged geoset set is its own mesh (1954) - three fixes, each removing work a 15 s sample named: booth cameras skip cluster assignment (no bevy light ever reaches a booth, and the default config rebuilt an empty cluster grid per booth view per frame — 0.2 ms of the raid's main thread), the rig palette's watermark clone reuses the row buffer from two publishes ago instead of calloc'ing the 650 KB table every publish (safe because the GPU only ever receives dirty ranges), and prop probes upload the dirty span rather than every allocated row (circling a city streams props in and out most frames, and each change re-sent hundreds of KB for one row); no bucket of the main thread's profile is above a millisecond — the raid's +2.5 ms there is forty-five bodies' worth of every per-entity lane at ~55 µs a body, and the one lever left that is all ours, one texture array per body folding ~9 draws to ~3, is sized at ~1.5 ms and not started (1954) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
370 lines
15 KiB
Rust
370 lines
15 KiB
Rust
//! **`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::core_pipeline::core_2d::{Opaque2d, Transparent2d};
|
||
use bevy::core_pipeline::core_3d::{AlphaMask3d, Opaque3d, Transparent3d};
|
||
use bevy::pbr::Shadow;
|
||
use bevy::prelude::*;
|
||
use bevy::render::graph::CameraDriverLabel;
|
||
use bevy::render::render_graph::{
|
||
Node, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel,
|
||
};
|
||
use bevy::render::render_phase::{ViewBinnedRenderPhases, ViewSortedRenderPhases};
|
||
use bevy::render::render_resource::{
|
||
Buffer, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, MapMode,
|
||
};
|
||
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
|
||
use bevy::render::{Render, RenderApp, RenderSystems};
|
||
use bevy::ui_render::TransparentUi;
|
||
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>);
|
||
|
||
/// **The wgpu resource census** — live buffers, textures and bind groups, off wgpu-core's own
|
||
/// registry report, refreshed once a frame by the render app and read by `FPS_PROBE` under the
|
||
/// same env (`wgpu_bufs=… wgpu_texs=… wgpu_bgs=…`). It is on the GPU meter because it prices
|
||
/// the GPU *lane's* CPU: every render pass wgpu encodes allocates and clears usage trackers
|
||
/// sized to the device's live buffer and texture counts (`wgpu_core::track::UsageScope`), so a
|
||
/// scene's pass-encode CPU carries a term that scales with residency, not with what is drawn —
|
||
/// a sampled profile of the crowd rig (1929's follow-on) put that term at roughly half of
|
||
/// `encode_render_pass`. The count is the number that lever moves.
|
||
#[derive(Resource, Clone)]
|
||
pub(crate) struct WgpuCensusShared(pub Arc<WgpuCensus>);
|
||
|
||
#[derive(Default)]
|
||
pub(crate) struct WgpuCensus {
|
||
pub buffers: AtomicU64,
|
||
pub textures: AtomicU64,
|
||
pub bind_groups: AtomicU64,
|
||
/// Draw calls the render phases will issue this frame, per family — see [`count_draws`].
|
||
pub draws_opaque: AtomicU64,
|
||
pub draws_transparent: AtomicU64,
|
||
pub draws_shadow: AtomicU64,
|
||
pub draws_ui: AtomicU64,
|
||
}
|
||
|
||
/// Render world, `Cleanup`: refresh the census. wgpu-core's report takes each registry's read
|
||
/// lock and counts live ids — microseconds, and only under the meter's env.
|
||
fn census(instance: Res<bevy::render::renderer::RenderInstance>, shared: Res<WgpuCensusShared>) {
|
||
let Some(report) = instance.generate_report() else {
|
||
return;
|
||
};
|
||
let hub = &report.hub;
|
||
let c = &shared.0;
|
||
c.buffers
|
||
.store(hub.buffers.num_allocated as u64, Ordering::Relaxed);
|
||
c.textures
|
||
.store(hub.textures.num_allocated as u64, Ordering::Relaxed);
|
||
c.bind_groups
|
||
.store(hub.bind_groups.num_allocated as u64, Ordering::Relaxed);
|
||
}
|
||
|
||
/// Render world, `Cleanup`: the frame's draw-call count per phase family, off the phases bevy
|
||
/// just rendered — the number the pass-encode CPU on the render thread scales with (wgpu-core
|
||
/// validates and wgpu-hal encodes per draw, and on Metal a draw is also what the driver's
|
||
/// kernel-side work is measured in). A binned phase issues one draw per bin (Metal has no
|
||
/// multi-draw indirect, so a multidrawable batch set still draws bin by bin) plus one per
|
||
/// unbatchable entity and per non-mesh item; a sorted phase issues one per item whose batch
|
||
/// range survived batching (a merged item's range is emptied into its predecessor's).
|
||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||
fn count_draws(
|
||
shared: Res<WgpuCensusShared>,
|
||
opaque: Option<Res<ViewBinnedRenderPhases<Opaque3d>>>,
|
||
mask: Option<Res<ViewBinnedRenderPhases<AlphaMask3d>>>,
|
||
transparent: Option<Res<ViewSortedRenderPhases<Transparent3d>>>,
|
||
shadow: Option<Res<ViewBinnedRenderPhases<Shadow>>>,
|
||
ui: Option<Res<ViewSortedRenderPhases<TransparentUi>>>,
|
||
two_d: Option<Res<ViewSortedRenderPhases<Transparent2d>>>,
|
||
opaque_2d: Option<Res<ViewBinnedRenderPhases<Opaque2d>>>,
|
||
) {
|
||
fn binned<BPI: bevy::render::render_phase::BinnedPhaseItem>(
|
||
phases: Option<Res<ViewBinnedRenderPhases<BPI>>>,
|
||
) -> u64 {
|
||
phases
|
||
.map(|p| {
|
||
p.0.values()
|
||
.map(|phase| {
|
||
let multi: usize =
|
||
phase.multidrawable_meshes.values().map(|b| b.len()).sum();
|
||
let unbatchable: usize = phase
|
||
.unbatchable_meshes
|
||
.values()
|
||
.map(|u| u.entities.len())
|
||
.sum();
|
||
let non_mesh: usize = phase
|
||
.non_mesh_items
|
||
.values()
|
||
.map(|n| n.entities.len())
|
||
.sum();
|
||
multi + phase.batchable_meshes.len() + unbatchable + non_mesh
|
||
})
|
||
.sum::<usize>() as u64
|
||
})
|
||
.unwrap_or(0)
|
||
}
|
||
fn sorted<SPI: bevy::render::render_phase::SortedPhaseItem>(
|
||
phases: Option<Res<ViewSortedRenderPhases<SPI>>>,
|
||
) -> u64 {
|
||
phases
|
||
.map(|p| {
|
||
p.0.values()
|
||
.map(|phase| {
|
||
phase
|
||
.items
|
||
.iter()
|
||
.filter(|i| !i.batch_range().is_empty())
|
||
.count()
|
||
})
|
||
.sum::<usize>() as u64
|
||
})
|
||
.unwrap_or(0)
|
||
}
|
||
let c = &shared.0;
|
||
c.draws_opaque
|
||
.store(binned(opaque) + binned(mask), Ordering::Relaxed);
|
||
c.draws_transparent
|
||
.store(sorted(transparent), Ordering::Relaxed);
|
||
c.draws_shadow.store(binned(shadow), Ordering::Relaxed);
|
||
c.draws_ui.store(
|
||
sorted(ui) + sorted(two_d) + binned(opaque_2d),
|
||
Ordering::Relaxed,
|
||
);
|
||
}
|
||
|
||
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));
|
||
let counts = Arc::new(WgpuCensus::default());
|
||
app.insert_resource(GpuMsShared(shared.clone()));
|
||
app.insert_resource(WgpuCensusShared(counts.clone()));
|
||
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
|
||
return;
|
||
};
|
||
render_app.insert_resource(GpuMsShared(shared));
|
||
render_app.insert_resource(WgpuCensusShared(counts));
|
||
render_app.add_systems(
|
||
Render,
|
||
(init_stamp, readback, census, count_draws)
|
||
.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);
|
||
}
|