diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 9326e714..9ae98595 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -28,7 +28,7 @@ mod model; pub use model::{ bone_target_id, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations, ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, - PoseNode, PoseSource, PoseTrack, + PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT, }; mod adt; mod terrain; diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index a3516ab4..3fe7dfd1 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -175,20 +175,43 @@ pub(crate) fn build_submesh_mesh(sub: &RenderSubmesh) -> Mesh { mesh } -/// Build the **skinned** twin of [`build_submesh_mesh`]: the same baked geometry plus the per-vertex -/// `JOINT_INDEX` (`Uint16x4` — M2 bone indices, used directly as joint indices) + `JOINT_WEIGHT` -/// (`Float32x4`, normalised). These two attributes are the entire trigger for Bevy's `SKINNED` shader -/// path (decision 0019). When the submesh carries no skin (WMO / a boneless batch) this is identical -/// to the static mesh — harmless, but the creature path is the only consumer regardless. +/// The skinned twin's per-vertex joint indices (`Uint16x4` — M2 bone indices, used directly as +/// palette indices). **Deliberately NOT `Mesh::ATTRIBUTE_JOINT_INDEX`** (decision 0720): Bevy's +/// `SKINNED` pipeline path triggers on the standard attributes being in the mesh LAYOUT — while +/// its draw-time skin bind group resolves per ENTITY registration — so a mesh carrying them can +/// only ever render through Bevy's skin lane. Our own attribute ids keep Bevy's `is_skinned()` +/// false everywhere; `WowModelExt::specialize` sees them in the layout and compiles the +/// `WOW_RIG_SKIN` path instead, which skins from the owned palette region of the shared light +/// buffer (`rig_palette`). Shader locations 10/11 (Bevy's builtin set ends at 7). +pub const ATTRIBUTE_WOW_JOINT_INDEX: bevy::mesh::MeshVertexAttribute = + bevy::mesh::MeshVertexAttribute::new( + "Wow_JointIndex", + 988_540_917, + bevy::render::render_resource::VertexFormat::Uint16x4, + ); +/// The skinned twin's per-vertex joint weights (`Float32x4`, normalised) — see +/// [`ATTRIBUTE_WOW_JOINT_INDEX`]. +pub const ATTRIBUTE_WOW_JOINT_WEIGHT: bevy::mesh::MeshVertexAttribute = + bevy::mesh::MeshVertexAttribute::new( + "Wow_JointWeight", + 988_540_918, + bevy::render::render_resource::VertexFormat::Float32x4, + ); + +/// Build the **skinned** twin of [`build_submesh_mesh`]: the same baked geometry plus the +/// per-vertex [`ATTRIBUTE_WOW_JOINT_INDEX`] + [`ATTRIBUTE_WOW_JOINT_WEIGHT`]. These two +/// attributes are the entire trigger for the owned-palette skinning path (decisions 0019/0720). +/// When the submesh carries no skin (WMO / a boneless batch) this is identical to the static +/// mesh — harmless, but the creature path is the only consumer regardless. pub(crate) fn build_skinned_submesh_mesh(sub: &RenderSubmesh) -> Mesh { let mut mesh = build_submesh_mesh(sub); if sub.joints.len() == sub.positions.len() && !sub.joints.is_empty() { mesh.insert_attribute( - Mesh::ATTRIBUTE_JOINT_INDEX, + ATTRIBUTE_WOW_JOINT_INDEX, VertexAttributeValues::Uint16x4(sub.joints.clone()), ); mesh.insert_attribute( - Mesh::ATTRIBUTE_JOINT_WEIGHT, + ATTRIBUTE_WOW_JOINT_WEIGHT, VertexAttributeValues::Float32x4(sub.weights.clone()), ); } diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index 5558d06a..b1429b3a 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -35,10 +35,9 @@ #import bevy_pbr::{ pbr_fragment::pbr_input_from_standard_material, pbr_functions::alpha_discard, - forward_io::{Vertex, VertexOutput, FragmentOutput}, + forward_io::{VertexOutput, FragmentOutput}, mesh_view_bindings::view, view_transformations::position_world_to_clip, - skinning, mesh_functions, } @@ -131,6 +130,13 @@ struct WowLight { // [7·slot .. 7·slot+7) over the fragment normal. Only this shader declares the region — the // other shaders mirror the buffer PREFIX and bind the same (larger) buffer. prop_probes: array, 57344>, + // The owned skin palette (decision 0720; rig_palette.rs mirrors both sizes). `rig_table`: + // one base bone index per rig slot (2048 = mesh_tag's 11-bit rig field; the instance's slot + // rides its MeshTag bits 19-29). `palettes`: 3 vec4 rows per bone — the rows of + // `world_from_joint × inverse_bindpose`, the same matrix Bevy's skin lane would feed + // `skin_model` — blended in the vertex stage below (WOW_RIG_SKIN). + rig_table: array, + palettes: array>, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; @@ -230,28 +236,113 @@ fn mcnk_cell_anchor(P: vec3) -> vec3 { return vec3((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half); } -// Custom vertex stage — bevy 0.18's `mesh.wgsl` vertex verbatim (same defs: SKINNED skinning, -// VERTEX_* attributes; morph targets omitted — no model mesh authors them), plus the per-vertex -// point-light evaluation on the post-skin world position/normal. A `MaterialExtension` swaps the -// whole stage, so the mirror must track bevy's on upgrades. +// The vertex input — bevy 0.18's `forward_io::Vertex` fields at bevy's shader locations (the +// VERTEX_* defs come from the base mesh-pipeline specialize, driven by what the mesh authors; +// tangents / morphs never — no model mesh has them), PLUS the owned-palette joint attributes at +// locations 10/11 under WOW_RIG_SKIN (`WowModelExt::specialize` sets the def and appends the +// attributes to the buffer layout when the mesh carries `ATTRIBUTE_WOW_JOINT_INDEX` — decision +// 0720; Bevy's `forward_io::Vertex` only declares joints under its own SKINNED path, which no +// benilla mesh triggers anymore). +struct WowVertex { + @builtin(instance_index) instance_index: u32, +#ifdef VERTEX_POSITIONS + @location(0) position: vec3, +#endif +#ifdef VERTEX_NORMALS + @location(1) normal: vec3, +#endif +#ifdef VERTEX_UVS_A + @location(2) uv: vec2, +#endif +#ifdef VERTEX_UVS_B + @location(3) uv_b: vec2, +#endif +#ifdef VERTEX_COLORS + @location(5) color: vec4, +#endif +#ifdef WOW_RIG_SKIN + @location(10) joint_indices: vec4, + @location(11) joint_weights: vec4, +#endif +} + +#ifdef WOW_RIG_SKIN +// The owned-palette skin model (decision 0720): the instance's rig slot from its MeshTag rig +// field (bits 19-29) → the rig's base bone index → the four indexed bones' palette rows blended +// by the vertex weights. Returns `world_from_local` exactly like Bevy's `skin_model` — the rows +// ARE `world_from_joint × inverse_bindpose`, so the reconstruction below is the same matrix +// Bevy's lane produced, and it REPLACES the mesh's world matrix (never composes with it). +fn wow_skin_model(instance_index: u32, indices: vec4, weights: vec4) -> mat4x4 { + let tag = mesh_functions::get_tag(instance_index); + let base = wow_light.rig_table[(tag >> 19u) & 0x7ffu]; + let b0 = 3u * (base + indices.x); + let b1 = 3u * (base + indices.y); + let b2 = 3u * (base + indices.z); + let b3 = 3u * (base + indices.w); + let r0 = weights.x * wow_light.palettes[b0] + + weights.y * wow_light.palettes[b1] + + weights.z * wow_light.palettes[b2] + + weights.w * wow_light.palettes[b3]; + let r1 = weights.x * wow_light.palettes[b0 + 1u] + + weights.y * wow_light.palettes[b1 + 1u] + + weights.z * wow_light.palettes[b2 + 1u] + + weights.w * wow_light.palettes[b3 + 1u]; + let r2 = weights.x * wow_light.palettes[b0 + 2u] + + weights.y * wow_light.palettes[b1 + 2u] + + weights.z * wow_light.palettes[b2 + 2u] + + weights.w * wow_light.palettes[b3 + 2u]; + // r0/r1/r2 are the affine's ROWS; a wgsl matrix is column-major. + return mat4x4( + vec4(r0.x, r1.x, r2.x, 0.0), + vec4(r0.y, r1.y, r2.y, 0.0), + vec4(r0.z, r1.z, r2.z, 0.0), + vec4(r0.w, r1.w, r2.w, 1.0), + ); +} + +// bevy_pbr::skinning's normal math verbatim (inverse-transpose via the adjugate), on our matrix. +fn inverse_transpose_3x3m(in: mat3x3) -> mat3x3 { + let x = cross(in[1], in[2]); + let y = cross(in[2], in[0]); + let z = cross(in[0], in[1]); + let det = dot(in[2], z); + return mat3x3(x / det, y / det, z / det); +} + +fn wow_skin_normals(world_from_local: mat4x4, normal: vec3) -> vec3 { + return normalize( + inverse_transpose_3x3m(mat3x3( + world_from_local[0].xyz, + world_from_local[1].xyz, + world_from_local[2].xyz + )) * normal + ); +} +#endif + +// Custom vertex stage — bevy 0.18's `mesh.wgsl` vertex verbatim (VERTEX_* attributes; morph +// targets omitted — no model mesh authors them) with the owned-palette skinning in place of +// Bevy's SKINNED path (decision 0720), plus the per-vertex point-light evaluation on the +// post-skin world position/normal. A `MaterialExtension` swaps the whole stage, so the mirror +// must track bevy's on upgrades. @vertex -fn vertex(vertex: Vertex) -> WowVsOut { +fn vertex(vertex: WowVertex) -> WowVsOut { var out: WowVsOut; let mesh_world_from_local = mesh_functions::get_world_from_local(vertex.instance_index); -#ifdef SKINNED - var world_from_local = skinning::skin_model( +#ifdef WOW_RIG_SKIN + var world_from_local = wow_skin_model( + vertex.instance_index, vertex.joint_indices, - vertex.joint_weights, - vertex.instance_index + vertex.joint_weights ); #else var world_from_local = mesh_world_from_local; #endif #ifdef VERTEX_NORMALS -#ifdef SKINNED - out.world_normal = skinning::skin_normals(world_from_local, vertex.normal); +#ifdef WOW_RIG_SKIN + out.world_normal = wow_skin_normals(world_from_local, vertex.normal); #else out.world_normal = mesh_functions::mesh_normal_local_to_world( vertex.normal, @@ -367,13 +458,16 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp // constant `tex0.a < 224/255` (STABLE silhouette, never grows/snaps) — with blend on and source // alpha = `tex0.a × fade`. On the blend twin (`model_flags.y`) `AlphaMode::Blend` does no discard, // so we re-apply that hard cutout here on the UNFADED alpha; `specialize` keeps depth-write ON. - // The exterior payload is TYPED (mesh_tag.rs, decision 0173): bits 0-15 = fade alpha u16 (a whole - // payload of 0 = the untagged ⇒ opaque sentinel), bits 16-23 = the per-instance ground-shade byte - // (0 lit → 255 MCSH-shadowed — decoded at the doodad sun below; entities ramp it, statics leave 0). - // On an interior-mode material (interior z, not WMO x) the payload carries the SH-probe SLOT in - // bits 16-29 instead — static MODD props at spawn, and every indoor entity on the footprint-bake - // law (decision 0354: units keep the probe lane indoors; the day/night state is the exterior - // material at the intensity-1.0 shade byte, not a mode of its own). + // The payload is TYPED (mesh_tag.rs, decisions 0173/0720): bits 0-5 = the fade alpha (6-bit + // fraction; a whole payload of 0 = the untagged ⇒ opaque sentinel), bits 19-29 = the skin + // rig slot (vertex-stage concern — the fragment never reads it, but it rides every payload, + // so the 0-sentinel test uses the WHOLE masked payload as before). Between them the exterior + // payload carries the per-instance ground-shade byte in bits 6-13 (0 lit → 255 MCSH-shadowed + // — decoded at the doodad sun below; entities ramp it, statics leave 0). On an interior-mode + // material (interior z, not WMO x) bits 6-18 carry the SH-probe SLOT instead — static MODD + // props at spawn, and every indoor entity on the footprint-bake law (decision 0354: units + // keep the probe lane indoors; the day/night state is the exterior material at the + // intensity-1.0 shade byte, not a mode of its own). // Tag bits 31/30 are standalone flags (mesh_tag.rs), split off before the payload decode so the // 0-sentinel and both payload modes read the masked value: bit 31 = hover/target HIGHLIGHT, // bit 30 = INTERIOR FOG — the instance's model stands in a WMO interior, so it fogs with the @@ -383,11 +477,11 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp let raw_tag = mesh_functions::get_tag(in.instance_index); let highlighted = (raw_tag & 0x80000000u) != 0u; let interior_fogged = (raw_tag & 0x40000000u) != 0u; - // Bits 0-15 are the fade alpha in BOTH payload modes (the interior-probe slot rides bits - // 16-29 since 0355), so a feathering indoor entity keeps its probe AND its alpha ramp. + // Bits 0-5 are the fade alpha in BOTH payload modes, so a feathering indoor entity keeps its + // probe AND its alpha ramp — and a skinned part keeps its rig slot through either. let fade_tag = raw_tag & 0x3fffffffu; - let alpha16 = f32(fade_tag & 0xffffu) / 65535.0; - let obj_fade = select(alpha16, 1.0, fade_tag == 0u); + let alpha6 = f32(fade_tag & 0x3fu) / 63.0; + let obj_fade = select(alpha6, 1.0, fade_tag == 0u); if (m.model_flags.y > 0.5 && base_color.a < VANILLA_ALPHA_KEY) { discard; } @@ -447,7 +541,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp // binary's `0x69e770`; statics leave it 0), 0.5..0.85 = fixed intensity 1.0 (an exterior WMO // MODD prop — the 2.5 site is one a MODD prop never reaches, §8b), <0.5 = statically // MCSH-shadowed (0.5). - let inst_shade = select(f32((fade_tag >> 16u) & 0xffu) / 255.0, 0.0, interior_prop); + let inst_shade = select(f32((fade_tag >> 6u) & 0xffu) / 255.0, 0.0, interior_prop); let mat_shade = select(0.0, 1.0, m.sun_scale.x < 0.5); let shade_t = max(mat_shade, inst_shade); let mid_band = m.sun_scale.x >= 0.5 && m.sun_scale.x < 0.85; @@ -519,7 +613,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp // slot. Evaluated here per fragment over the same basis — note the SH lobe's soft wrap (side-on // ≈ 0.088·C) is the reference's authored response, deliberately NOT a hard max(N·L,0). // Units/GameObjects never reach this lane (base CGLight — plain day/night ×1.0, §8/§9). - let probe = 7u * ((fade_tag >> 16u) & 0x3fffu); + let probe = 7u * ((fade_tag >> 6u) & 0x1fffu); let lit_m2_interior = clamp( vec3( dot(wow_light.prop_probes[probe + 0u], n1) diff --git a/crates/benilla/src/billboard.rs b/crates/benilla/src/billboard.rs index d4bfb060..0edf0c4c 100644 --- a/crates/benilla/src/billboard.rs +++ b/crates/benilla/src/billboard.rs @@ -225,6 +225,12 @@ pub struct BillboardJointRig { } impl BillboardJointRig { + /// The host root — the collapsed-rig world pass's do-not-enter set reads it (a nested rig + /// with its own billboard output owns its interior, whichever lane the outer rig is on). + pub(crate) fn root(&self) -> Entity { + self.root + } + /// Build for a spawned rig — `None` when the skeleton authors no billboard bone and no /// ignore-parent-rotation bone (the common case: ordinary rigs cost nothing). `root` is the /// host entity the joints hang under (the model-space frame). @@ -481,7 +487,14 @@ impl Plugin for BillboardPlugin { ) .add_systems( PostUpdate, - (billboard_joint_palette, face_billboards) + ( + billboard_joint_palette, + // The collapsed-rig world pass (decision 0724): palette rows + replaced-subtree + // anchor re-seats, between the entity lane's joint rewrite and the card facing + // (cards following a unit's billboard-bone anchor read the replaced frame). + crate::creature_anim::finalize_rig_worlds, + face_billboards, + ) .chain() .in_set(BillboardPlace), ); diff --git a/crates/benilla/src/bowstring.rs b/crates/benilla/src/bowstring.rs index 9190ebfe..d71a1c33 100644 --- a/crates/benilla/src/bowstring.rs +++ b/crates/benilla/src/bowstring.rs @@ -57,7 +57,7 @@ fn draw_bowstrings( .filter(|(_, latched)| *latched) .and_then(|(bones, _)| { let &(bone, offset) = bones.points.get(&HAND_ARROW)?; - let joint = *bones.joints.get(bone as usize)?; + let joint = bones.anchor(bone)?; Some(joints.get(joint).ok()?.transform_point(offset)) }) .unwrap_or_else(|| (top + bottom) / 2.0); diff --git a/crates/benilla/src/capture/mod.rs b/crates/benilla/src/capture/mod.rs index 8e158e52..33b55b4d 100644 --- a/crates/benilla/src/capture/mod.rs +++ b/crates/benilla/src/capture/mod.rs @@ -11,6 +11,13 @@ //! Captures contain Blizzard-derived imagery (rendered terrain/models), so they live under the //! gitignored `target/visual/` and are never committed. //! +//! ## Why the clock is frozen (decision 0723) +//! "Deterministic" is the whole claim: a golden diff is evidence only if two runs of one unchanged +//! build agree. Pinning the camera, the game clock and the clutter seed is not enough, because the +//! sims integrate in *seconds* while the settle counts *frames* — see [`CAPTURE_FRAME_DT`] and +//! [`hold_clock`], which together make a capture a pure function of the build. `scripts/visual.sh +//! selfcheck` is the tripwire that keeps it that way. +//! //! ## Running one capture by hand //! **Run through Cargo — never the built binary directly:** //! ```text @@ -28,9 +35,11 @@ //! prints the scenario names. `scripts/visual.sh` wraps all of this. use std::path::Path; +use std::time::Duration; use bevy::prelude::*; use bevy::render::view::screenshot::{save_to_disk, Capturing, Screenshot}; +use bevy::time::TimeUpdateStrategy; use benilla_assets::coords::wow_to_bevy; @@ -192,15 +201,59 @@ pub(crate) struct CaptureMode; /// alone is not enough: tile *entities* exist well before their pipelines are warm.) const SETTLE_FRAMES: u32 = 150; -/// The effective settle window: `$WOW_CAPTURE_SETTLE` overrides [`SETTLE_FRAMES`] for scenes that -/// need longer to reach steady state before the shot (e.g. snow — flakes sink at 2-6.5 yd/s from -/// ~22 yd up, so the precipitation column takes ~600 frames to fill). +/// The effective settle window: `$WOW_CAPTURE_SETTLE` overrides [`SETTLE_FRAMES`] for scenes whose +/// assets take longer to arrive. This buys **real** frames (loading, pipeline warm-up, spawning) — +/// for *sim* time, which is what a precipitation column or a particle pool needs, raise +/// [`age_frames`] instead; the two were the same knob until decision 0723 split them. fn settle_frames() -> u32 { std::env::var("WOW_CAPTURE_SETTLE") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(SETTLE_FRAMES) } + +/// Steps of [`CAPTURE_FRAME_DT`] the sims run, clock released, once the scene is built and +/// quiescent — the shot's *sim age*. 150 = 2.5 s: past the 2 s spawn appear-fade +/// (`model_fade::APPEAR_FADE_SECS`) and long past a flame pool's particle lifetime, so the scene is +/// at steady state and not mid-transient. +/// +/// Because it is counted in frozen frames it is a **duration**, identically on any machine — which +/// is the whole point. `$WOW_CAPTURE_AGE` overrides it for a scene that needs longer to fill (snow: +/// flakes sink at 2-6.5 yd/s from ~22 yd up, so the column wants ~11 s ≈ 660). +const AGE_FRAMES: u32 = 150; + +/// The effective aging window — see [`AGE_FRAMES`]. +fn age_frames() -> u32 { + std::env::var("WOW_CAPTURE_AGE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(AGE_FRAMES) +} + +/// Consecutive frames the world entity count must hold before [`Phase::Settling`] calls the scene +/// quiescent. Residency is not enough on its own: it counts terrain *tiles*, while a tile's doodads +/// and WMOs only get *registered* there and spawn later still, as their M2s finish loading under +/// `terrain_stream`'s wall-clock `SPAWN_BUDGET`. Every one of those carries the torches and braziers +/// whose flames this is all about, so a capture that starts its clock at residency still ages each +/// flame from whichever frame its model happened to land on. Waiting for the count to stop moving +/// is the generic form of "everything that was going to spawn has spawned". +const QUIESCE_FRAMES: u32 = 30; +/// The frozen capture clock's frame step: every capture frame advances the game clock by exactly +/// this, whatever the frame really cost. Anything integrated in *seconds* — particle pools, ribbon +/// trails, animation, weather, fades — therefore reaches the shutter in the same state every run. +/// +/// Without it the harness is only **frame**-pinned: [`settle_frames`] counts frames while the sims +/// advance on `time.delta_secs()`, i.e. real wall-clock, so two runs spend different real time per +/// frame (streaming, pipeline warm-up, whatever else the machine is doing) and photograph the flames +/// in different places. Measured on this tree before the change — the golden six captured twice from +/// one **unchanged** build: MAE up to 0.009 with max pixel deltas of 180, the same band a real render +/// change lands in. That noise floor is why decision 0721 could not read its own flame pixels, and +/// why 0719 read signal out of some that were partly run-to-run churn. +/// +/// 1/60 s, so every settle window keeps the duration its comment claims (150 frames ≈ 2.5 s) instead +/// of buying however much sim time the machine happened to grant. +const CAPTURE_FRAME_DT: Duration = Duration::from_nanos(16_666_667); + /// Hard cap on how long to wait for the screenshot save to land, so a capture never hangs the harness. const SAVE_TIMEOUT_FRAMES: u32 = 120; /// A couple of grace frames after the save completes before exiting, so the file is flushed. @@ -214,8 +267,12 @@ const PROBE_WARMUP_FRAMES: u32 = 60; enum Phase { /// Waiting for terrain streaming to report the scene fully resident. Streaming, - /// Resident; counting settle frames before the shot. + /// Resident; counting settle frames — and waiting for the scene to stop growing — before the + /// shot. The game clock is held here (see [`hold_clock`]). Settling(u32), + /// Scene built and quiescent, clock released: running exactly [`age_frames`] steps of + /// [`CAPTURE_FRAME_DT`] so every second-driven thing is at a known, machine-independent age. + Aging(u32), /// fxview only: scene settled, fixture armed; waiting for the effect to attach and run its /// requested age before the shot. FxAging, @@ -240,6 +297,16 @@ struct CaptureCtx { /// frame-time stats + scene counts and exit. The repeatable perf instrument: same scenario, same /// settle, numbers instead of pixels. 0 = normal capture. probe_frames: u32, + /// The frozen clock is in force ([`CAPTURE_FRAME_DT`]) — always, except under a perf probe, + /// whose entire measurement *is* the real frame cost. + frozen_clock: bool, + /// World entity count at the previous frame, and how many consecutive frames it has held — + /// the scene-quiescence test [`Phase::Settling`] waits on. See [`QUIESCE_FRAMES`]. + last_entities: usize, + stable_frames: u32, + /// Frames spent in [`Phase::Aging`] including restarts — the backstop that shoots anyway if a + /// scene never goes quiescent, so the harness cannot hang on one. + aging_total: u32, /// Probe samples (frame ms). probe_samples: Vec, /// Process CPU seconds at the first sampled frame — the window baseline for the probe line's @@ -431,16 +498,29 @@ impl Plugin for CapturePlugin { std::process::exit(2); } } + let probe_frames = std::env::var("WOW_FPS_PROBE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + // The frozen capture clock ([`CAPTURE_FRAME_DT`]) — never under a perf probe, which reads + // `time.delta_secs()` AS its measurement and would report a flawless constant 16.67 ms for + // any scene, however slow. + let frozen_clock = probe_frames == 0; + if frozen_clock { + app.insert_resource(TimeUpdateStrategy::ManualDuration(CAPTURE_FRAME_DT)) + .add_systems(Startup, hold_clock); + } app.insert_resource(CaptureMode) .insert_resource(CaptureCtx { scenario, out, phase: Phase::Streaming, ui_seeded: false, - probe_frames: std::env::var("WOW_FPS_PROBE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(0), + probe_frames, + frozen_clock, + last_entities: 0, + stable_frames: 0, + aging_total: 0, probe_samples: Vec::new(), probe_cpu_start: None, }) @@ -519,6 +599,19 @@ fn pin_scene( } } +/// Hold the game clock at zero until the scene is resident; [`drive_capture`] releases it. +/// +/// The fixed frame step alone is not enough, because it only makes each frame the same *size* — a +/// torch flame still ages from the frame its tile happened to spawn on, and tiles spawn under a +/// wall-clock budget (`terrain_stream`'s `SPAWN_BUDGET`) against asset I/O that finishes on a +/// different frame every run. Held through streaming, every emitter in the scene is zero-age at +/// residency, so at the shutter each has run exactly `settle_frames()` steps no matter when it +/// appeared. Frame-driven work (streaming itself, the spawn budget, the screenshot save) is on the +/// real clock and runs on regardless. +fn hold_clock(mut clock: ResMut>) { + clock.pause(); +} + /// Drive the capture lifecycle: wait for streaming, settle, screenshot, exit. #[allow(clippy::too_many_arguments)] fn drive_capture( @@ -536,6 +629,7 @@ fn drive_capture( wfx_req: Option>, mut fx_state: Option>, game_time: Res