the performance hunt, day one: the frame sheds 1.85 ms
- UI layout solves what moved, not what exists — hovering a full bag no longer re-solves the whole interface - world albedo textures stop keeping a second copy of themselves in RAM - collision gets a coarse grid for oversized slabs (32x fewer triangles tested), idle units stop re-asking the ground every frame, and anchors spawn on first consumer instead of by the thousand - aura and loot watchers scan when the store changes rather than on the clock, and static-transform tracking and plate reaping join the sweep Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,16 @@
|
||||
//! Prints one line per second: for each watched asset type, the per-frame Added/Modified counts,
|
||||
//! and for images the megabytes those modifications ask the render world to re-upload, plus the
|
||||
//! top offenders by how many frames they were touched in.
|
||||
//!
|
||||
//! The megabytes come from the image's `texture_descriptor`, not from `data.len()`. A
|
||||
//! `RenderAssetUsages::RENDER_WORLD` image has its bytes MOVED into the render world on extract, so
|
||||
//! main-side `data` is `None` from then on — measuring it read 0 for the sprite sheets, the effect
|
||||
//! textures and all three terrain arrays, i.e. for most of the texture set. The meter was blind to
|
||||
//! precisely the assets it exists to catch.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_assets::image_gpu_bytes;
|
||||
use bevy::asset::AssetEvent;
|
||||
use bevy::prelude::*;
|
||||
use bevy::time::Real;
|
||||
@@ -72,9 +79,7 @@ fn watch_images(
|
||||
AssetEvent::Added { .. } => churn.counts.entry("Image").or_default().0 += 1,
|
||||
AssetEvent::Modified { id } => {
|
||||
churn.counts.entry("Image").or_default().1 += 1;
|
||||
let bytes = images
|
||||
.get(id)
|
||||
.map_or(0, |i| i.data.as_ref().map_or(0, Vec::len));
|
||||
let bytes = images.get(id).map_or(0, image_gpu_bytes);
|
||||
churn.image_bytes += bytes as u64;
|
||||
let frame = churn.frames;
|
||||
let slot = churn.images.entry(id).or_default();
|
||||
|
||||
@@ -37,7 +37,11 @@ pub(crate) struct Bowstring {
|
||||
/// this frame's). Two segments: tip → middle → tip.
|
||||
fn draw_bowstrings(
|
||||
bows: Query<(&Bowstring, &GlobalTransform, &InheritedVisibility)>,
|
||||
owners: Query<(&BoneAttach, Has<NockLatch>)>,
|
||||
owners: Query<(
|
||||
&BoneAttach,
|
||||
&benilla_world::rig_anim::RigPose,
|
||||
Has<NockLatch>,
|
||||
)>,
|
||||
joints: Query<&GlobalTransform>,
|
||||
mut gizmos: Gizmos,
|
||||
) {
|
||||
@@ -54,11 +58,12 @@ fn draw_bowstrings(
|
||||
let middle = owners
|
||||
.get(bs.owner)
|
||||
.ok()
|
||||
.filter(|(_, latched)| *latched)
|
||||
.and_then(|(bones, _)| {
|
||||
.filter(|(_, _, latched)| *latched)
|
||||
.and_then(|(bones, pose, _)| {
|
||||
// A pure position read — `posed_point` off the composed pose, no anchor entity
|
||||
// (decision 1355).
|
||||
let &(bone, offset) = bones.points.get(&HAND_ARROW)?;
|
||||
let joint = bones.anchor(bone)?;
|
||||
Some(joints.get(joint).ok()?.transform_point(offset))
|
||||
pose.posed_point(joints.get(pose.joints_root).ok()?, bone, offset)
|
||||
})
|
||||
.unwrap_or_else(|| (top + bottom) / 2.0);
|
||||
gizmos.line(top, middle, STRING_COLOR);
|
||||
|
||||
@@ -142,6 +142,29 @@ fn fire_entity_census(world: &mut World) {
|
||||
}
|
||||
}
|
||||
world.resource_mut::<EntityCensus>().fired = true;
|
||||
|
||||
// The anchor split (0732 slice A's premise check). `RigAnchor` leaves are the single largest
|
||||
// archetype in the scene — 53 % of all entities — and slice A's claim is that most of them ride
|
||||
// nothing. "Rides nothing" is directly observable: an anchor whose entity has no `Children` is
|
||||
// hosting no attachment, no emitter, no ribbon, no card. Counted here rather than inferred from
|
||||
// the model's bone sources, because the model says what COULD attach and the world says what
|
||||
// DID.
|
||||
{
|
||||
let mut q = world.query_filtered::<Option<&bevy::prelude::Children>, bevy::prelude::With<benilla_world::rig_anim::RigAnchor>>();
|
||||
let (mut total, mut childless) = (0u32, 0u32);
|
||||
for kids in q.iter(world) {
|
||||
total += 1;
|
||||
if kids.is_none_or(|k| k.is_empty()) {
|
||||
childless += 1;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"ENTITY_CENSUS_ANCHORS total={total} childless={childless} ({:.1}%) hosting={}",
|
||||
100.0 * f32::from(u16::try_from(childless).unwrap_or(u16::MAX))
|
||||
/ f32::from(u16::try_from(total.max(1)).unwrap_or(u16::MAX)),
|
||||
total - childless
|
||||
);
|
||||
}
|
||||
let components = world.components();
|
||||
let mut rows: Vec<(usize, String)> = world
|
||||
.archetypes()
|
||||
|
||||
@@ -308,6 +308,7 @@ fn drive_bubbles(
|
||||
// The overhead-anchor inputs ([`overhead_anchor`]).
|
||||
anchor_q: (
|
||||
Query<&BoneAttach>,
|
||||
Query<&benilla_world::rig_anim::RigPose>,
|
||||
Query<&OverheadFallback>,
|
||||
Query<&GlobalTransform>,
|
||||
Query<(), With<crate::entities::mount::MountChild>>,
|
||||
@@ -417,6 +418,7 @@ fn drive_bubbles(
|
||||
&anchor_q.1,
|
||||
&anchor_q.2,
|
||||
&anchor_q.3,
|
||||
&anchor_q.4,
|
||||
);
|
||||
let seat_world = Vec3::new(tf.translation.x, anchor.y + LIFT, tf.translation.z);
|
||||
let cam_tf = GlobalTransform::from(*cam_pose);
|
||||
|
||||
@@ -188,6 +188,7 @@ pub(crate) fn float_combat_text(
|
||||
// The overhead-anchor resolution (`0x608640`): the unit's PlayerName attachment joint, else
|
||||
// the bbox fallback.
|
||||
anchors: Query<&BoneAttach>,
|
||||
poses: Query<&benilla_world::rig_anim::RigPose>,
|
||||
fallbacks: Query<&OverheadFallback>,
|
||||
globals: Query<&GlobalTransform>,
|
||||
mounts: Query<(), With<crate::entities::mount::MountChild>>,
|
||||
@@ -211,7 +212,15 @@ pub(crate) fn float_combat_text(
|
||||
};
|
||||
// The spawn snapshot (`0x6c73f0`): the unit's OVERHEAD anchor (`0x608640` — head height,
|
||||
// [`overhead_anchor`]), then the client's `z − 1/3` lift. NOT feet-anchored.
|
||||
let overhead = overhead_anchor(spawn.anchor, tf, &anchors, &fallbacks, &globals, &mounts);
|
||||
let overhead = overhead_anchor(
|
||||
spawn.anchor,
|
||||
tf,
|
||||
&anchors,
|
||||
&poses,
|
||||
&fallbacks,
|
||||
&globals,
|
||||
&mounts,
|
||||
);
|
||||
debug!(
|
||||
"fct: \"{}\" (cat {}) over {:?}",
|
||||
spawn.text, spawn.category, spawn.anchor
|
||||
|
||||
@@ -1061,8 +1061,15 @@ pub(super) fn route_cast_visuals(
|
||||
/// exactly that bug: Stealth's kit 312 carries no models, no anim and no attach at all (its whole
|
||||
/// visual is one proc-14 CharProc), so an effects-only test dropped it and the character showed
|
||||
/// nothing.
|
||||
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
|
||||
pub(crate) fn arm_aura_state_fx(
|
||||
// The slot diff below is a pure function of the store's aura fields, so it re-runs only when
|
||||
// the store was written — `arm_level_up_fx`'s idiom (decision 1357's sibling gate): at the
|
||||
// LBRS pin this was ~800 full aura-slot walks/frame re-deriving an unchanged answer. The
|
||||
// unfiltered twin runs exactly once per DBC-resource arrival: a unit that streamed in before
|
||||
// `SpellVisuals`/`Spells` landed carries standing auras no store write will re-announce.
|
||||
units: Query<(Entity, &ObjectStore)>,
|
||||
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
|
||||
visuals: Option<Res<SpellVisuals>>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
mut fx: MessageWriter<SpellKitFx>,
|
||||
@@ -1070,10 +1077,17 @@ pub(crate) fn arm_aura_state_fx(
|
||||
mut sounds: MessageWriter<SpellKitSound>,
|
||||
mut armed: Local<EntityHashMap<Vec<u32>>>,
|
||||
) {
|
||||
let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed())
|
||||
|| spells.as_ref().is_some_and(|s| s.is_changed());
|
||||
let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else {
|
||||
return; // no client data — no spell visuals (the DBC-resource degrade shape)
|
||||
};
|
||||
for (entity, store) in &units {
|
||||
let scan = if full_sweep {
|
||||
units.iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
changed.iter().collect::<Vec<_>>()
|
||||
};
|
||||
for (entity, store) in scan {
|
||||
let prev = armed.entry(entity).or_default();
|
||||
// Occupied slots, deduped (the same spell re-applied by two casters holds two slots —
|
||||
// one state instance either way).
|
||||
@@ -1160,15 +1174,25 @@ pub(crate) fn arm_aura_state_fx(
|
||||
/// [`LOOT_FX_KEY`] — the client hangs loot art on the same `Effect_C` node type its spell
|
||||
/// visuals use, so sharing the one attach body is the faithful shape.
|
||||
pub(super) fn arm_loot_fx(
|
||||
// Dead+lootable is a pure function of store fields — the same `Changed<ObjectStore>` gate as
|
||||
// `arm_level_up_fx` below and `arm_aura_state_fx` above, with the same one-shot full sweep
|
||||
// when the DBC resource lands after units already streamed in.
|
||||
units: Query<(Entity, &ObjectStore)>,
|
||||
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
|
||||
visuals: Option<Res<SpellVisuals>>,
|
||||
mut fx: MessageWriter<SpellKitFx>,
|
||||
mut armed: Local<EntityHashSet>,
|
||||
) {
|
||||
let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed());
|
||||
let Some(path) = visuals.as_ref().and_then(|v| v.0.loot_art_path()) else {
|
||||
return; // no client data / no such row — no loot art (the DBC-resource degrade shape)
|
||||
};
|
||||
for (entity, store) in &units {
|
||||
let scan = if full_sweep {
|
||||
units.iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
changed.iter().collect::<Vec<_>>()
|
||||
};
|
||||
for (entity, store) in scan {
|
||||
let lootable = store.0.unit_is_dead() && store.0.unit_lootable();
|
||||
if lootable && armed.insert(entity) {
|
||||
fx.write(SpellKitFx::Begin {
|
||||
|
||||
@@ -143,10 +143,15 @@ pub(crate) struct OverheadFallback(pub(crate) f32);
|
||||
/// per-frame by the nameplate and snapshotted at spawn by the floating combat text. Generic over
|
||||
/// the joint-globals query filter so a caller that also mutates `GlobalTransform` elsewhere (the
|
||||
/// nameplate placer) can pass a disjoint query.
|
||||
///
|
||||
/// A pure position read: it computes through `RigPose::posed_point` — the composed pose × the
|
||||
/// rig root's frame — and never touches an anchor entity, so the overhead bone spawns nothing
|
||||
/// (decision 1355).
|
||||
pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
|
||||
entity: Entity,
|
||||
tf: &Transform,
|
||||
anchors: &Query<&BoneAttach>,
|
||||
poses: &Query<&benilla_world::rig_anim::RigPose>,
|
||||
fallbacks: &Query<&OverheadFallback>,
|
||||
globals: &Query<&GlobalTransform, F>,
|
||||
mounts: &Query<(), With<mount::MountChild>>,
|
||||
@@ -162,8 +167,9 @@ pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
|
||||
ATTACH_OVERHEAD
|
||||
};
|
||||
let &(bone, offset) = a.points.get(&slot)?;
|
||||
let joint = a.anchor(bone)?;
|
||||
Some(globals.get(joint).ok()?.transform_point(offset))
|
||||
let pose = poses.get(entity).ok()?;
|
||||
let root = globals.get(pose.joints_root).ok()?;
|
||||
pose.posed_point(root, bone, offset)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
let bbox_z = fallbacks.get(entity).map_or(0.0, |f| f.0);
|
||||
|
||||
@@ -131,8 +131,11 @@ pub(super) struct PartDress<'a> {
|
||||
pub(super) inst_slot: u16,
|
||||
/// Whether the unit built a rig at all — the gate on a part drawing its skinned twin.
|
||||
pub(super) rigged: bool,
|
||||
/// Bone index → anchor entity, for a billboard batch's card to ride its live joint.
|
||||
pub(super) anchors: &'a std::collections::HashMap<u16, Entity>,
|
||||
/// Bone index → anchor entity, for a billboard batch's card to ride its live joint. Resolved
|
||||
/// by the CALLER through `RigPose::anchor_for` for exactly the card bones this dress will
|
||||
/// spawn (decision 1355) — owned, because the resolver needs `&mut` on the pose and this
|
||||
/// context is shared immutably across every part.
|
||||
pub(super) anchors: std::collections::HashMap<u16, Entity>,
|
||||
/// The model's interior fold reference (model-local) — one verdict per unit, so a body can
|
||||
/// never split across the two light laws.
|
||||
pub(super) bake_center: Vec3,
|
||||
@@ -477,7 +480,7 @@ mod tests {
|
||||
let unit = app.world_mut().spawn(Transform::default()).id();
|
||||
let mut fadeable = part(None, false);
|
||||
fadeable.fade_blend = Some(mat(99));
|
||||
let anchors = std::collections::HashMap::new();
|
||||
let anchors: std::collections::HashMap<u16, Entity> = std::collections::HashMap::new();
|
||||
let object = WorldObject {
|
||||
kind: ModelKind::Creature,
|
||||
label: String::new(),
|
||||
@@ -492,7 +495,7 @@ mod tests {
|
||||
object: &object,
|
||||
inst_slot: 0,
|
||||
rigged: false,
|
||||
anchors: &anchors,
|
||||
anchors,
|
||||
bake_center: Vec3::ZERO,
|
||||
idle_aabb: None,
|
||||
now: 0.0,
|
||||
@@ -541,7 +544,7 @@ mod tests {
|
||||
scale_anim: None,
|
||||
seq_translations: Vec::new(),
|
||||
});
|
||||
let anchors = std::collections::HashMap::new();
|
||||
let anchors: std::collections::HashMap<u16, Entity> = std::collections::HashMap::new();
|
||||
let object = WorldObject {
|
||||
kind: ModelKind::Creature,
|
||||
label: String::new(),
|
||||
@@ -556,7 +559,7 @@ mod tests {
|
||||
object: &object,
|
||||
inst_slot: 0,
|
||||
rigged: false,
|
||||
anchors: &anchors,
|
||||
anchors,
|
||||
bake_center: Vec3::ZERO,
|
||||
idle_aabb: None,
|
||||
now: 0.0,
|
||||
|
||||
@@ -40,8 +40,8 @@ mod redress;
|
||||
pub(super) use redress::redress_player_looks;
|
||||
|
||||
/// Set up the skinned instance shared by creatures/players (decision 0019) and animated
|
||||
/// GameObjects (decision 0242): the per-instance consumer-bone anchors, the pose buffer, the
|
||||
/// palette rig slot, and the global-sequence drive.
|
||||
/// GameObjects (decision 0242): the pose buffer, the palette rig slot, and the global-sequence
|
||||
/// drive.
|
||||
///
|
||||
/// The **sequence clock** — the `AnimationPlayer` + graph + [`ModelAnimations`] + the driver that
|
||||
/// owns the choice — is NOT set up here; it is [`arm_sequence_clock`], called by the spawn site
|
||||
@@ -49,15 +49,14 @@ pub(super) use redress::redress_player_looks;
|
||||
/// slot and a skinned twin, and is worth spending only on a model whose bones actually move; the
|
||||
/// clock is worth arming on every instance, because a sequence drives far more than a pose.
|
||||
///
|
||||
/// This lane spawns **no joint
|
||||
/// entities** (decision 0724): the pose lives in a [`benilla_world::rig_anim::RigPose`] array on
|
||||
/// `entity`, and only the CONSUMER bones — attachment points, event markers, emitter/ribbon/
|
||||
/// light hosts, billboard-card bones — get an anchor entity under `joints_root`, re-seated from
|
||||
/// the composed pose each frame it changes. Returns the anchors + the palette rig slot
|
||||
/// (decision 0720), or `None` when the model has no inverse bindposes. Slot `0` = the palette
|
||||
/// table was full: the anchors still exist (emitters/attachments ride them), but parts fall
|
||||
/// back to the static bind-pose mesh. No animations ⇒ the pose just holds bind pose
|
||||
/// (Milestone A).
|
||||
/// This lane spawns **no joint entities** (decision 0724) — the pose lives in a
|
||||
/// [`benilla_world::rig_anim::RigPose`] array — and **no anchor entities either** (decision
|
||||
/// 1355): a bone gets its anchor from `RigPose::anchor_for` the moment something actually
|
||||
/// consumes it, re-seated from the composed pose each frame it changes. Returns the pose (still
|
||||
/// in hand — see [`RigBuild`]) + the palette rig slot (decision 0720), or `None` when the model
|
||||
/// has no inverse bindposes. Slot `0` = the palette table was full: anchors still resolve
|
||||
/// (emitters/attachments ride them), but parts fall back to the static bind-pose mesh. No
|
||||
/// animations ⇒ the pose just holds bind pose (Milestone A).
|
||||
fn setup_skinned_instance(
|
||||
commands: &mut Commands,
|
||||
palettes: &mut benilla_world::rig_palette::RigPalettes,
|
||||
@@ -71,48 +70,13 @@ fn setup_skinned_instance(
|
||||
// rider's frame is the seat anchor instead (decision 0441), a conform-tilted model's its
|
||||
// conform node, while the `AnimationPlayer`/driver components stay on `entity`. Skinned
|
||||
// parts render purely from the palette, so their own parentage is free.
|
||||
let mut pose = benilla_world::rig_anim::RigPose::new(joints_root, &d.skeleton);
|
||||
// The consumer bones: every bone something in the world reaches by entity — an attachment
|
||||
// point (held items, spell effects, the mount seat, overhead anchors), an event marker, an
|
||||
// emitter/ribbon/light host, a billboard card's bone. Everything else is palette-only.
|
||||
let mut bone_set = std::collections::BTreeSet::new();
|
||||
bone_set.extend(d.attachments.iter().map(|a| a.bone));
|
||||
bone_set.extend(d.markers.iter().map(|m| m.bone));
|
||||
bone_set.extend(d.emitters.iter().map(|e| e.def.bone));
|
||||
bone_set.extend(d.ribbons.iter().map(|r| r.def.bone));
|
||||
bone_set.extend(
|
||||
d.lights
|
||||
.iter()
|
||||
.filter_map(|l| u16::try_from(l.def.bone).ok()),
|
||||
);
|
||||
if let Some(parts) = &d.parts {
|
||||
bone_set.extend(
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|p| p.billboard.as_ref().map(|b| b.bone)),
|
||||
);
|
||||
}
|
||||
let mut anchors = std::collections::HashMap::new();
|
||||
for bone in bone_set {
|
||||
let Some(m) = pose.model.get(bone as usize) else {
|
||||
continue; // an out-of-range authored bone reference — no anchor, consumers miss
|
||||
};
|
||||
let (scale, rotation, translation) = m.to_scale_rotation_translation();
|
||||
let anchor = commands
|
||||
.spawn((
|
||||
Transform {
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
},
|
||||
Visibility::default(),
|
||||
benilla_world::rig_anim::RigAnchor { rig: entity, bone },
|
||||
))
|
||||
.id();
|
||||
commands.entity(joints_root).add_child(anchor);
|
||||
pose.anchors.push((bone, anchor));
|
||||
anchors.insert(bone, anchor);
|
||||
}
|
||||
// No anchor entities are spawned here (decision 1355). A consumer bone gets its anchor the
|
||||
// moment something actually consumes it — [`benilla_world::rig_anim::RigPose::anchor_for`],
|
||||
// called by the emitter/ribbon/light arms, the billboard dress, held items, the mount seat,
|
||||
// spell kits and quest markers — and the position-only readers (the overhead anchor, missile
|
||||
// launch points, the bowstring) read `RigPose::posed_point` and never need an entity at all.
|
||||
// The eager population this replaced was 96 % never-consumed at the LBRS pin (decision 1354).
|
||||
let pose = benilla_world::rig_anim::RigPose::new(joints_root, &d.skeleton);
|
||||
// The owned palette rig (decision 0720): the world pass writes this rig's composed frames ×
|
||||
// these bindposes into the slot; every skinned part below tags the slot so the vertex stage
|
||||
// finds its palette. The on-replace hook frees the slot with the visual teardown.
|
||||
@@ -153,11 +117,12 @@ fn setup_skinned_instance(
|
||||
commands.entity(entity).insert(drive);
|
||||
}
|
||||
}
|
||||
// The pose buffer last — the anchors above registered themselves into it. The evaluator
|
||||
// The pose buffer travels in the build result, NOT onto the entity yet: the arms of
|
||||
// `attach_entity_visuals` below still resolve anchors into it (`RigPose::anchor_for` needs
|
||||
// `&mut`), and it lands on the entity in one piece once the build is dressed. The evaluator
|
||||
// (decision 0712) samples the player state straight into `locals`; with no joint entities and
|
||||
// no `AnimationTargetId`s, Bevy's `animate_targets` has nothing of ours to touch.
|
||||
commands.entity(entity).insert(pose);
|
||||
Some(RigBuild { anchors, slot })
|
||||
Some(RigBuild { pose, slot })
|
||||
}
|
||||
|
||||
/// Arm this instance's **sequence clock**: the `AnimationPlayer` + graph + [`ModelAnimations`] that
|
||||
@@ -241,10 +206,12 @@ fn arm_sequence_clock(
|
||||
}
|
||||
}
|
||||
|
||||
/// A collapsed rig's build result (decision 0724): the consumer anchors by bone + the palette
|
||||
/// slot each skinned part tags.
|
||||
/// A collapsed rig's build result (decision 0724): the pose buffer, still in hand so the build's
|
||||
/// own arms can resolve anchors into it (decision 1355 — anchors spawn on first consumer, via
|
||||
/// [`benilla_world::rig_anim::RigPose::anchor_for`]), + the palette slot each skinned part tags.
|
||||
/// The pose lands on the entity at the end of the build.
|
||||
struct RigBuild {
|
||||
anchors: std::collections::HashMap<u16, Entity>,
|
||||
pose: benilla_world::rig_anim::RigPose,
|
||||
slot: u16,
|
||||
}
|
||||
|
||||
@@ -268,9 +235,14 @@ pub(super) fn attach_entity_visuals(
|
||||
Without<VisualAttached>,
|
||||
>,
|
||||
// The mount children's build state (decision 0441): a mounted unit's rider waits on its mount
|
||||
// child's attach — the seat is the mount's attachment-0 joint out of this `BoneAttach`; the
|
||||
// `NetEntity` is the staleness check (the field moved while the unit was still pending).
|
||||
// child's attach — the seat resolves through the mount's attachment-0 point (`BoneAttach`)
|
||||
// into its `RigPose` below; the `NetEntity` is the staleness check (the field moved while
|
||||
// the unit was still pending).
|
||||
mount_children: super::mount::MountChildren,
|
||||
// Already-built rigs' pose buffers — the mount child's, for its seat anchor to resolve on
|
||||
// demand (decision 1355). The unit being built here has no `RigPose` component yet (its pose
|
||||
// is still in `RigBuild`), so the two never alias.
|
||||
mut built_poses: Query<&mut benilla_world::rig_anim::RigPose>,
|
||||
// The ItemDisplayInfo catalog (armor region textures, decision 0074). Read-only here; the
|
||||
// held-item systems in the same chain hold it mutably in their own turns.
|
||||
displays: Option<Res<super::ItemDisplays>>,
|
||||
@@ -366,6 +338,7 @@ pub(super) fn attach_entity_visuals(
|
||||
match super::mount::seat_or_spawn_mount(
|
||||
&mut commands,
|
||||
&mount_children,
|
||||
&mut built_poses,
|
||||
creatures.as_deref(),
|
||||
entity,
|
||||
mount_child.map(|&super::mount::MountChild(c)| c),
|
||||
@@ -454,7 +427,7 @@ pub(super) fn attach_entity_visuals(
|
||||
&& stores
|
||||
.get(entity)
|
||||
.is_ok_and(|s| crate::go_anim::go_animates(s.0.gameobject_type_id()));
|
||||
let skin: Option<RigBuild> = match (net.kind, dm) {
|
||||
let mut skin: Option<RigBuild> = match (net.kind, dm) {
|
||||
// A creature (or player body — decision 0041) with a real skeleton. The `!is_empty`
|
||||
// guard keeps a degenerate boneless model on the static mesh (its skinned twin would
|
||||
// carry joint attributes but have no joints to index — out of bounds).
|
||||
@@ -539,9 +512,11 @@ pub(super) fn attach_entity_visuals(
|
||||
arm_sequence_clock(&mut commands, entity, anims, net.kind, go_state_machine);
|
||||
}
|
||||
}
|
||||
// The bone-riding surface (decision 0072): the instance's joints + the model's attachment
|
||||
// points, so held items (and future bone riders) can hang from the hand/hip/back joints.
|
||||
if let (Some(rb), Some(d)) = (&skin, dm) {
|
||||
// The bone-riding surface (decision 0072): the model's attachment points + event
|
||||
// markers, so held items (and future bone riders) can hang from the hand/hip/back
|
||||
// joints. Pure model data — the anchor *entities* live in `RigPose.anchors` and
|
||||
// spawn on first consumer (decision 1355).
|
||||
if let (Some(_), Some(d)) = (&skin, dm) {
|
||||
// The event markers keep the client's first-match scan order: an ident already
|
||||
// present wins (character models carry six `$CSD` records — the first is the one
|
||||
// `0x7130e0` would return).
|
||||
@@ -550,7 +525,6 @@ pub(super) fn attach_entity_visuals(
|
||||
markers.entry(m.ident).or_insert((m.bone, m.offset));
|
||||
}
|
||||
commands.entity(entity).insert(super::BoneAttach {
|
||||
anchors: rb.anchors.clone(),
|
||||
points: d
|
||||
.attachments
|
||||
.iter()
|
||||
@@ -672,7 +646,28 @@ pub(super) fn attach_entity_visuals(
|
||||
// Everything one part's spawn reads from this unit, gathered once (`attach::dress`) —
|
||||
// the same context the gear-change **re-dress** feeds `spawn_part`, so a body built at
|
||||
// stream-in and a geoset that appears when a belt is swapped are dressed by one law.
|
||||
let no_anchors = std::collections::HashMap::new();
|
||||
//
|
||||
// Billboard-card bones are the one dress input that needs an anchor ENTITY (the card
|
||||
// rides its live joint), so they resolve here — through the same first-consumer
|
||||
// resolver as everything else (decision 1355), over the same geoset predicate as the
|
||||
// spawn loop below, so a hidden variant's card bone spawns nothing.
|
||||
let card_anchors: std::collections::HashMap<u16, Entity> = match skin.as_mut() {
|
||||
Some(rb) => parts
|
||||
.iter()
|
||||
.filter(|part| {
|
||||
!visible_geosets
|
||||
.as_ref()
|
||||
.is_some_and(|vis| !vis.contains(&part.geoset_id))
|
||||
})
|
||||
.filter_map(|part| part.billboard.as_ref().map(|b| b.bone))
|
||||
.filter_map(|bone| {
|
||||
rb.pose
|
||||
.anchor_for(&mut commands, entity, bone)
|
||||
.map(|a| (bone, a))
|
||||
})
|
||||
.collect(),
|
||||
None => std::collections::HashMap::new(),
|
||||
};
|
||||
let dress = PartDress {
|
||||
unit: entity,
|
||||
kind,
|
||||
@@ -680,7 +675,7 @@ pub(super) fn attach_entity_visuals(
|
||||
object: &object,
|
||||
inst_slot,
|
||||
rigged: skin.is_some(),
|
||||
anchors: skin.as_ref().map_or(&no_anchors, |rb| &rb.anchors),
|
||||
anchors: card_anchors,
|
||||
bake_center,
|
||||
idle_aabb,
|
||||
now,
|
||||
@@ -766,9 +761,9 @@ pub(super) fn attach_entity_visuals(
|
||||
{
|
||||
for em in emitters {
|
||||
let owner = skin
|
||||
.as_ref()
|
||||
.and_then(|rb| rb.anchors.get(&em.def.bone))
|
||||
.map_or((entity, [0.0; 3]), |&j| (j, em.bone_pivot));
|
||||
.as_mut()
|
||||
.and_then(|rb| rb.pose.anchor_for(&mut commands, entity, em.def.bone))
|
||||
.map_or((entity, [0.0; 3]), |j| (j, em.bone_pivot));
|
||||
particles::spawn_emitter(
|
||||
&mut commands,
|
||||
em,
|
||||
@@ -802,21 +797,30 @@ pub(super) fn attach_entity_visuals(
|
||||
// GameObject brazier. Same host-bone ride as the emitters, for the same reason: the
|
||||
// reference re-registers each light at its LIVE bone position every frame. (The far more
|
||||
// common carried light is the held torch — that one spawns on the item model, in
|
||||
// `equipment`.)
|
||||
// `equipment`.) The light bones resolve BEFORE the call: the closure can't borrow
|
||||
// `commands` while `spawn_carried_lights` holds it.
|
||||
let light_anchors: std::collections::HashMap<i16, Entity> = model_lights
|
||||
.iter()
|
||||
.filter(|l| l.def.casts())
|
||||
.filter_map(|l| {
|
||||
let bone = u16::try_from(l.def.bone).ok()?;
|
||||
let rb = skin.as_mut()?;
|
||||
rb.pose
|
||||
.anchor_for(&mut commands, entity, bone)
|
||||
.map(|a| (l.def.bone, a))
|
||||
})
|
||||
.collect();
|
||||
super::spawn_carried_lights(&mut commands, model_lights, entity, |bone| {
|
||||
skin.as_ref()
|
||||
.zip(u16::try_from(bone).ok())
|
||||
.and_then(|(rb, b)| rb.anchors.get(&b))
|
||||
.copied()
|
||||
light_anchors.get(&bone).copied()
|
||||
});
|
||||
// Ribbon trails (wisp streamers, trailing quest-object crystals) — the same host-bone
|
||||
// ride as the emitters; the trail self-despawns when its owner joint/entity goes.
|
||||
{
|
||||
for rb in dm.map(|d| d.ribbons.as_slice()).unwrap_or_default() {
|
||||
let (owner, use_pivot) = skin
|
||||
.as_ref()
|
||||
.and_then(|build| build.anchors.get(&rb.def.bone))
|
||||
.map_or((entity, false), |&j| (j, true));
|
||||
.as_mut()
|
||||
.and_then(|build| build.pose.anchor_for(&mut commands, entity, rb.def.bone))
|
||||
.map_or((entity, false), |j| (j, true));
|
||||
// The `+0xc0` enable gate reads THIS instance's playing sequence, live —
|
||||
// a GameObject flips state under its own trails (a trap springs, a door
|
||||
// opens), so there is no spawn-time answer. Passing `None` here (the old
|
||||
@@ -840,6 +844,13 @@ pub(super) fn attach_entity_visuals(
|
||||
);
|
||||
}
|
||||
}
|
||||
// The pose buffer lands last (decision 1355): every build arm above has resolved its
|
||||
// anchors into it, so it arrives on the entity in one piece — the evaluator, the
|
||||
// compose pass and every later `anchor_for` (a weapon equipped in combat, a spell
|
||||
// kit) find the same buffer the build populated.
|
||||
if let Some(rb) = skin {
|
||||
commands.entity(entity).insert(rb.pose);
|
||||
}
|
||||
// Static collision for solid GameObjects (chests, mining veins, doors…): the model-local
|
||||
// collider baked at build time rides the entity's pose, so player + camera collide with it.
|
||||
// Hull-less GameObjects (herbs, small props) carry none — collide-iff-hull. GameObjects only;
|
||||
|
||||
@@ -79,6 +79,9 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
&Children,
|
||||
Option<&benilla_world::rig_palette::RigSkin>,
|
||||
Option<&super::super::BoneAttach>,
|
||||
// The pose buffer: a revealed billboard batch's card bone resolves its anchor on
|
||||
// first demand (`RigPose::anchor_for`, decision 1355).
|
||||
Option<&mut benilla_world::rig_anim::RigPose>,
|
||||
Option<&benilla_world::interior::BodyBakeCenter>,
|
||||
Option<&benilla_world::model_fade::UnitAppearFade>,
|
||||
),
|
||||
@@ -113,7 +116,7 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
let (sections, world_assets, mut images, mut skin_composites, asset_server, mut mats) =
|
||||
skin_build;
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, net, live, mut applied, children, rig, bones, bake_center, unit_fade) in
|
||||
for (entity, net, live, mut applied, children, rig, bones, mut pose, bake_center, unit_fade) in
|
||||
&mut players
|
||||
{
|
||||
// `settled` (decision 0074): every non-empty visible-item entry has resolved through the
|
||||
@@ -206,7 +209,19 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
// Pass 2 — the batches the new gear reveals. They join the unit's own appear-fade clock if
|
||||
// one is still in flight (the login gear cascade lands mid-ramp), exactly as a
|
||||
// late-resolving held item does — never a second ramp of their own, never a pop.
|
||||
let no_anchors = std::collections::HashMap::new();
|
||||
//
|
||||
// A revealed billboard batch's card bone resolves its anchor first (decision 1355), over
|
||||
// exactly the parts the spawn loop below will dress.
|
||||
let card_anchors: std::collections::HashMap<u16, Entity> = match pose.as_mut() {
|
||||
Some(p) => parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, part)| !present[i] && shows(part.geoset_id))
|
||||
.filter_map(|(_, part)| part.billboard.as_ref().map(|b| b.bone))
|
||||
.filter_map(|bone| p.anchor_for(&mut commands, entity, bone).map(|a| (bone, a)))
|
||||
.collect(),
|
||||
None => std::collections::HashMap::new(),
|
||||
};
|
||||
let dress = PartDress {
|
||||
unit: entity,
|
||||
kind: benilla_world::model_render::ModelKind::Creature,
|
||||
@@ -219,7 +234,7 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
},
|
||||
inst_slot: rig.map_or(0, |r| r.slot),
|
||||
rigged: bones.is_some(),
|
||||
anchors: bones.map_or(&no_anchors, |b| &b.anchors),
|
||||
anchors: card_anchors,
|
||||
bake_center: bake_center.map_or(dm.bake_center_local, |c| c.0),
|
||||
idle_aabb: idle_aabb(dm),
|
||||
now,
|
||||
@@ -426,7 +441,6 @@ mod tests {
|
||||
.spawn((Transform::default(), Visibility::default(), ChildOf(joint)))
|
||||
.id();
|
||||
app.world_mut().entity_mut(player).insert(BoneAttach {
|
||||
anchors: HashMap::from([(3u16, joint)]),
|
||||
points: HashMap::new(),
|
||||
markers: HashMap::new(),
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ use crate::net::GuidIndex;
|
||||
use benilla_world::particles::buffer::EffectVertex;
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
use super::{BoneAttach, OverheadFallback};
|
||||
use super::OverheadFallback;
|
||||
|
||||
/// The caster-end anchor's height factor when the model has no `$CSL` marker: the reference's
|
||||
/// `base + (0, 0, modelHeight × modelScale × 0.75)` (`0x6ec73e`–`0x6ec771`, the `0.75f` at
|
||||
@@ -178,14 +178,14 @@ fn jitter(rng: &mut u32) -> f32 {
|
||||
/// `0x61ceb0` read the same table the same way.
|
||||
fn caster_world_pos(
|
||||
caster: Entity,
|
||||
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
|
||||
units: &super::missile::AttachPosQuery,
|
||||
joints: &Query<&GlobalTransform>,
|
||||
heights: &Query<&OverheadFallback>,
|
||||
) -> Option<Vec3> {
|
||||
let (base, bones) = units.get(caster).ok()?;
|
||||
let marker = bones.and_then(|b| {
|
||||
let (base, bones, pose) = units.get(caster).ok()?;
|
||||
let marker = bones.zip(pose).and_then(|(b, p)| {
|
||||
let &(bone, offset) = b.markers.get(b"$CSL")?;
|
||||
Some(joints.get(b.anchor(bone)?).ok()?.transform_point(offset))
|
||||
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
|
||||
});
|
||||
Some(marker.unwrap_or_else(|| {
|
||||
let (scale, _, translation) = base.to_scale_rotation_translation();
|
||||
@@ -437,7 +437,7 @@ pub(crate) fn simulate_chain_beams(
|
||||
mut draw: benilla_world::particles::buffer::WorldEffectDraw,
|
||||
images: Res<Assets<Image>>,
|
||||
world_cam: Query<Entity, With<WorldCamera>>,
|
||||
units: Query<(&GlobalTransform, Option<&BoneAttach>)>,
|
||||
units: super::missile::AttachPosQuery,
|
||||
joints: Query<&GlobalTransform>,
|
||||
heights: Query<&OverheadFallback>,
|
||||
mut beams: Query<(Entity, &mut ChainBeam)>,
|
||||
|
||||
@@ -216,16 +216,14 @@ pub(super) enum ItemModelKind {
|
||||
Quiver,
|
||||
}
|
||||
|
||||
/// The per-instance bone-riding surface, inserted at visual attach (decision 0072): the rig's
|
||||
/// consumer **anchors** by bone (decision 0724 — one anchor entity per bone something reaches,
|
||||
/// not a full joint hierarchy) + the model's attachment points (id → bone + Bevy-space local
|
||||
/// offset). Held items spawn under `anchor(bone)` at the offset; every bone rider (mount seat,
|
||||
/// spell effects, overhead anchors) uses the same two-step lookup.
|
||||
/// The per-instance bone-riding surface, inserted at visual attach (decision 0072): the model's
|
||||
/// attachment points and event markers (id → bone + Bevy-space local offset). **Pure model data**
|
||||
/// — the anchor *entities* live in `RigPose.anchors` and spawn on first consumer (decision 1355):
|
||||
/// a rider that needs a frame to parent under resolves `points`/`markers` here, then
|
||||
/// `RigPose::anchor_for(bone)`; a reader that only needs a position goes straight to
|
||||
/// `RigPose::posed_point(bone, offset)` and touches no entity at all.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct BoneAttach {
|
||||
/// Bone index → its anchor entity. Populated for every bone in `points`/`markers` (and the
|
||||
/// emitter/ribbon/light/billboard hosts) — a `points` hit always resolves.
|
||||
pub(crate) anchors: HashMap<u16, Entity>,
|
||||
/// Attachment id → `(bone index, bevy-space offset from the bone's bind pivot)`.
|
||||
pub(crate) points: HashMap<u16, (u16, Vec3)>,
|
||||
/// Animation-event marker 4CC → the same `(bone, offset)` shape — first record per ident
|
||||
@@ -234,13 +232,6 @@ pub(crate) struct BoneAttach {
|
||||
pub(crate) markers: HashMap<[u8; 4], (u16, Vec3)>,
|
||||
}
|
||||
|
||||
impl BoneAttach {
|
||||
/// The anchor entity standing in for `bone` — the frame a rider parents under / reads.
|
||||
pub(crate) fn anchor(&self, bone: u16) -> Option<Entity> {
|
||||
self.anchors.get(&bone).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// The held-item children currently spawned for a unit: the [`HeldItems`] they were built from (the
|
||||
/// diff key) + the spawned root entity **per slot** — indexed, not a flat list, because the diff is
|
||||
/// per slot (decision 0826): a slot that didn't change keeps its root, and a slot that only changed
|
||||
|
||||
@@ -136,6 +136,10 @@ pub(in crate::entities) fn attach_held_items(
|
||||
// gated on the mesh's own joint attributes. The one exception is an item that rigs itself
|
||||
// (0841) — it carries its own slot instead, and inherits the tint up the `ParentModel` chain.
|
||||
Option<&benilla_world::rig_palette::RigSkin>,
|
||||
// The wearer's pose buffer: the attach joint spawns on first demand from the composed
|
||||
// pose (`RigPose::anchor_for`, decision 1355) — a weapon equipped in combat seats at the
|
||||
// live pose, never the rest pose.
|
||||
Option<&mut benilla_world::rig_anim::RigPose>,
|
||||
)>,
|
||||
held: Option<Res<ItemDisplays>>,
|
||||
time: Res<Time>,
|
||||
@@ -148,7 +152,9 @@ pub(in crate::entities) fn attach_held_items(
|
||||
return;
|
||||
};
|
||||
let now = time.elapsed_secs();
|
||||
for (items, bones, attached, entity, unit_fade, body_center, unit_tf, skin) in &mut units {
|
||||
for (items, bones, attached, entity, unit_fade, body_center, unit_tf, skin, mut pose) in
|
||||
&mut units
|
||||
{
|
||||
// A held item / helm / shoulder resolves and spawns asynchronously (a template round trip, a
|
||||
// model load) — often *after* the body has already armed its appear-fade (decision 0032 is a
|
||||
// per-unit property: the reference fades the whole unit, attachments included, as one). Read
|
||||
@@ -209,7 +215,10 @@ pub(in crate::entities) fn attach_held_items(
|
||||
.get(&n.attach)
|
||||
.zip(ctx.bones.points.get(&w.attach))
|
||||
{
|
||||
if let Some(joint) = ctx.bones.anchor(bone) {
|
||||
if let Some(joint) = pose
|
||||
.as_mut()
|
||||
.and_then(|p| p.anchor_for(&mut commands, entity, bone))
|
||||
{
|
||||
commands.entity(joint).add_child(root);
|
||||
commands
|
||||
.entity(root)
|
||||
@@ -230,7 +239,15 @@ pub(in crate::entities) fn attach_held_items(
|
||||
commands.entity(old).despawn();
|
||||
}
|
||||
*root = wants.and_then(|hs| {
|
||||
spawn_slot(&mut commands, &mut palettes, &ctx, &held, slot_idx, &hs)
|
||||
spawn_slot(
|
||||
&mut commands,
|
||||
&mut palettes,
|
||||
&ctx,
|
||||
pose.as_deref_mut(),
|
||||
&held,
|
||||
slot_idx,
|
||||
&hs,
|
||||
)
|
||||
});
|
||||
}
|
||||
let applied = HeldAttached {
|
||||
@@ -253,6 +270,7 @@ fn spawn_slot(
|
||||
commands: &mut Commands,
|
||||
palettes: &mut benilla_world::rig_palette::RigPalettes,
|
||||
ctx: &WearerCtx,
|
||||
pose: Option<&mut benilla_world::rig_anim::RigPose>,
|
||||
held: &ItemDisplays,
|
||||
slot_idx: usize,
|
||||
hs: &HeldSlot,
|
||||
@@ -263,7 +281,8 @@ fn spawn_slot(
|
||||
let parts = dm.parts.as_ref()?;
|
||||
// Body model has no such attach point (a non-character skeleton) — hold nothing.
|
||||
let &(bone, offset) = bones.points.get(&hs.attach)?;
|
||||
let joint = bones.anchor(bone)?;
|
||||
// The attach joint, spawned on first demand from the wearer's composed pose (decision 1355).
|
||||
let joint = pose?.anchor_for(commands, entity, bone)?;
|
||||
let root = commands
|
||||
.spawn((
|
||||
Transform::from_translation(offset),
|
||||
@@ -766,9 +785,7 @@ mod tests {
|
||||
displays.models.insert((7, KIND), dm);
|
||||
app.insert_resource(displays);
|
||||
|
||||
let joint = app.world_mut().spawn(Transform::default()).id();
|
||||
let bones = BoneAttach {
|
||||
anchors: HashMap::from([(3u16, joint)]),
|
||||
points: HashMap::from([(attach_id::SHOULDER_LEFT, (3u16, Vec3::ZERO))]),
|
||||
markers: HashMap::new(),
|
||||
};
|
||||
@@ -788,8 +805,12 @@ mod tests {
|
||||
)
|
||||
.expect("a fresh palette has room");
|
||||
let wearer_slot = skin.slot;
|
||||
app.world_mut()
|
||||
.spawn((items, bones, Transform::default(), skin));
|
||||
let wearer = app
|
||||
.world_mut()
|
||||
.spawn((items, bones, Transform::default(), skin))
|
||||
.id();
|
||||
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
|
||||
app.world_mut().entity_mut(wearer).insert(pose);
|
||||
app.add_systems(Update, attach_held_items);
|
||||
app.update();
|
||||
|
||||
@@ -836,10 +857,7 @@ mod tests {
|
||||
displays.models.insert((7, HELM_KIND), dm);
|
||||
app.insert_resource(displays);
|
||||
|
||||
// The bone the helm hangs on, and the wearer.
|
||||
let joint = app.world_mut().spawn(Transform::default()).id();
|
||||
let bones = BoneAttach {
|
||||
anchors: HashMap::from([(3u16, joint)]),
|
||||
points: HashMap::from([(attach_id::HELM, (3u16, Vec3::ZERO))]),
|
||||
markers: HashMap::new(),
|
||||
};
|
||||
@@ -865,6 +883,9 @@ mod tests {
|
||||
if let Some(skin) = skin {
|
||||
wearer.insert(skin);
|
||||
}
|
||||
let wearer = wearer.id();
|
||||
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
|
||||
app.world_mut().entity_mut(wearer).insert(pose);
|
||||
app.add_systems(Update, attach_held_items);
|
||||
app.update();
|
||||
|
||||
@@ -1005,9 +1026,7 @@ mod tests {
|
||||
displays.models.insert((7, SHOULDER_KIND), dm);
|
||||
app.insert_resource(displays);
|
||||
|
||||
let joint = app.world_mut().spawn(Transform::default()).id();
|
||||
let bones = BoneAttach {
|
||||
anchors: HashMap::from([(3u16, joint)]),
|
||||
points: HashMap::from([(attach_id::SHOULDER_RIGHT, (3u16, ATTACH))]),
|
||||
markers: HashMap::new(),
|
||||
};
|
||||
@@ -1018,7 +1037,12 @@ mod tests {
|
||||
attach: attach_id::SHOULDER_RIGHT,
|
||||
visual: NO_GLOW,
|
||||
});
|
||||
app.world_mut().spawn((items, bones, Transform::default()));
|
||||
let wearer = app
|
||||
.world_mut()
|
||||
.spawn((items, bones, Transform::default()))
|
||||
.id();
|
||||
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
|
||||
app.world_mut().entity_mut(wearer).insert(pose);
|
||||
app.add_systems(Update, attach_held_items);
|
||||
app.update();
|
||||
|
||||
@@ -1097,9 +1121,7 @@ mod tests {
|
||||
displays.models.insert((7, KIND), dm);
|
||||
app.insert_resource(displays);
|
||||
|
||||
let joint = app.world_mut().spawn(Transform::default()).id();
|
||||
let bones = BoneAttach {
|
||||
anchors: HashMap::from([(3u16, joint)]),
|
||||
points: HashMap::from([(attach_id::HAND_RIGHT, (3u16, Vec3::ZERO))]),
|
||||
markers: HashMap::new(),
|
||||
};
|
||||
@@ -1112,12 +1134,17 @@ mod tests {
|
||||
});
|
||||
// The wearer is mid-login: its own ramp is still pending, so everything attaching to it
|
||||
// must join that ramp rather than open opaque beside it.
|
||||
app.world_mut().spawn((
|
||||
items,
|
||||
bones,
|
||||
Transform::default(),
|
||||
UnitAppearFade::Pending { since: SINCE },
|
||||
));
|
||||
let wearer = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
items,
|
||||
bones,
|
||||
Transform::default(),
|
||||
UnitAppearFade::Pending { since: SINCE },
|
||||
))
|
||||
.id();
|
||||
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
|
||||
app.world_mut().entity_mut(wearer).insert(pose);
|
||||
app.add_systems(Update, attach_held_items);
|
||||
app.update();
|
||||
|
||||
@@ -1190,9 +1217,7 @@ mod tests {
|
||||
displays.models.insert((7, KIND), dm);
|
||||
app.insert_resource(displays);
|
||||
|
||||
let joint = app.world_mut().spawn(Transform::default()).id();
|
||||
let bones = BoneAttach {
|
||||
anchors: HashMap::from([(3u16, joint)]),
|
||||
points: HashMap::from([(attach_id::HAND_RIGHT, (3u16, Vec3::ZERO))]),
|
||||
markers: HashMap::new(),
|
||||
};
|
||||
@@ -1203,12 +1228,17 @@ mod tests {
|
||||
attach: attach_id::HAND_RIGHT,
|
||||
visual: NO_GLOW,
|
||||
});
|
||||
app.world_mut().spawn((
|
||||
items,
|
||||
bones,
|
||||
Transform::default(),
|
||||
UnitAppearFade::Pending { since: SINCE },
|
||||
));
|
||||
let wearer = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
items,
|
||||
bones,
|
||||
Transform::default(),
|
||||
UnitAppearFade::Pending { since: SINCE },
|
||||
))
|
||||
.id();
|
||||
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
|
||||
app.world_mut().entity_mut(wearer).insert(pose);
|
||||
app.add_systems(Update, attach_held_items);
|
||||
app.update();
|
||||
|
||||
@@ -1271,15 +1301,9 @@ mod tests {
|
||||
}
|
||||
app.insert_resource(displays);
|
||||
|
||||
// Three attach points on three bones: the hand, the back (where a stowed weapon rides) and
|
||||
// the shoulder.
|
||||
let (hand, back, shoulder) = (
|
||||
app.world_mut().spawn(Transform::default()).id(),
|
||||
app.world_mut().spawn(Transform::default()).id(),
|
||||
app.world_mut().spawn(Transform::default()).id(),
|
||||
);
|
||||
// Three attach points on three bones: the hand, the back (where a stowed weapon rides)
|
||||
// and the shoulder — their joints spawn on first demand (decision 1355).
|
||||
let bones = BoneAttach {
|
||||
anchors: HashMap::from([(1u16, hand), (2u16, back), (3u16, shoulder)]),
|
||||
points: HashMap::from([
|
||||
(attach_id::HAND_RIGHT, (1u16, HAND_AT)),
|
||||
(SHEATH_BACK, (2u16, BACK_AT)),
|
||||
@@ -1304,6 +1328,8 @@ mod tests {
|
||||
.world_mut()
|
||||
.spawn((items, bones, Transform::default()))
|
||||
.id();
|
||||
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
|
||||
app.world_mut().entity_mut(wearer).insert(pose);
|
||||
app.add_systems(Update, attach_held_items);
|
||||
app.update();
|
||||
(app, wearer)
|
||||
@@ -1367,9 +1393,12 @@ mod tests {
|
||||
.map(|c| c.parent()),
|
||||
app.world()
|
||||
.entity(wearer)
|
||||
.get::<BoneAttach>()
|
||||
.get::<benilla_world::rig_anim::RigPose>()
|
||||
.unwrap()
|
||||
.anchor(2),
|
||||
.anchors
|
||||
.iter()
|
||||
.find(|&&(b, _)| b == 2)
|
||||
.map(|&(_, j)| j),
|
||||
"re-parented onto the sheath point's joint"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -183,37 +183,43 @@ pub(super) struct Missile {
|
||||
weapon_visual: Option<u32>,
|
||||
}
|
||||
|
||||
/// The unit-side inputs every attach/marker **position read** shares: the base frame, the
|
||||
/// attachment/marker tables, and the pose the point is computed from. A pure read — the point
|
||||
/// comes from `RigPose::posed_point` (composed pose × rig root frame), so these reads spawn no
|
||||
/// anchor entity (decision 1355). Shared with the chain-beam lane.
|
||||
pub(super) type AttachPosQuery<'w, 's> = Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
&'static GlobalTransform,
|
||||
Option<&'static BoneAttach>,
|
||||
Option<&'static benilla_world::rig_anim::RigPose>,
|
||||
),
|
||||
>;
|
||||
|
||||
/// A unit's attach-point world position through the given tag cascade, else its base translation.
|
||||
/// `None` only when the unit itself is gone. Shared with the chain-beam lane, whose non-caster
|
||||
/// endpoints anchor through the very same table and cascade (`0x6ec780`; decision 0955).
|
||||
pub(super) fn attach_world_pos(
|
||||
unit: Entity,
|
||||
tags: impl IntoIterator<Item = u16>,
|
||||
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
|
||||
units: &AttachPosQuery,
|
||||
joints: &Query<&GlobalTransform>,
|
||||
) -> Option<Vec3> {
|
||||
let (base, bones) = units.get(unit).ok()?;
|
||||
let point = bones.and_then(|b| {
|
||||
tags.into_iter()
|
||||
.find_map(|tag| b.points.get(&tag).copied())
|
||||
.and_then(|(bone, offset)| b.anchor(bone).map(|j| (j, offset)))
|
||||
let (base, bones, pose) = units.get(unit).ok()?;
|
||||
let point = bones.zip(pose).and_then(|(b, p)| {
|
||||
let (bone, offset) = tags
|
||||
.into_iter()
|
||||
.find_map(|tag| b.points.get(&tag).copied())?;
|
||||
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
|
||||
});
|
||||
Some(
|
||||
match point.and_then(|(j, off)| joints.get(j).ok().map(|g| g.transform_point(off))) {
|
||||
Some(p) => p,
|
||||
None => base.translation(),
|
||||
},
|
||||
)
|
||||
Some(point.unwrap_or_else(|| base.translation()))
|
||||
}
|
||||
|
||||
/// Where a missile is heading **this frame**: a unit target's live dest attach point (so the
|
||||
/// path bends as it moves — the client's re-solve per tick), or the ground shot's fixed point.
|
||||
/// `None` only when a unit target is gone.
|
||||
fn aim_point(
|
||||
aim: Aim,
|
||||
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
|
||||
joints: &Query<&GlobalTransform>,
|
||||
) -> Option<Vec3> {
|
||||
fn aim_point(aim: Aim, units: &AttachPosQuery, joints: &Query<&GlobalTransform>) -> Option<Vec3> {
|
||||
match aim {
|
||||
Aim::Unit {
|
||||
target, dest_tag, ..
|
||||
@@ -235,23 +241,18 @@ fn aim_point(
|
||||
fn launch_world_pos(
|
||||
caster: Entity,
|
||||
fired: Option<[u8; 4]>,
|
||||
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
|
||||
units: &AttachPosQuery,
|
||||
joints: &Query<&GlobalTransform>,
|
||||
) -> Option<Vec3> {
|
||||
let (base, bones) = units.get(caster).ok()?;
|
||||
let point = bones.and_then(|b| {
|
||||
fired
|
||||
let (base, bones, pose) = units.get(caster).ok()?;
|
||||
let point = bones.zip(pose).and_then(|(b, p)| {
|
||||
let (bone, offset) = fired
|
||||
.into_iter()
|
||||
.chain(MARKER_CASCADE)
|
||||
.find_map(|ident| b.markers.get(&ident).copied())
|
||||
.and_then(|(bone, offset)| b.anchor(bone).map(|j| (j, offset)))
|
||||
.find_map(|ident| b.markers.get(&ident).copied())?;
|
||||
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
|
||||
});
|
||||
Some(
|
||||
match point.and_then(|(j, off)| joints.get(j).ok().map(|g| g.transform_point(off))) {
|
||||
Some(p) => p,
|
||||
None => base.translation(),
|
||||
},
|
||||
)
|
||||
Some(point.unwrap_or_else(|| base.translation()))
|
||||
}
|
||||
|
||||
/// One GO's projectiles parked on their caster, waiting for the release keyframe — the client's
|
||||
@@ -380,7 +381,7 @@ fn launch_go(
|
||||
go: &QueuedGo,
|
||||
launch: Vec3,
|
||||
commands: &mut Commands,
|
||||
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
|
||||
units: &AttachPosQuery,
|
||||
joints: &Query<&GlobalTransform>,
|
||||
sounds: &mut MessageWriter<MissileSound>,
|
||||
impacts: &mut MessageWriter<CastEvent>,
|
||||
@@ -459,7 +460,7 @@ pub(super) fn spawn_missiles(
|
||||
fx: Option<ResMut<SpellFx>>,
|
||||
displays: Option<Res<ItemDisplays>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
units: Query<(&GlobalTransform, Option<&BoneAttach>)>,
|
||||
units: AttachPosQuery,
|
||||
joints: Query<&GlobalTransform>,
|
||||
casters: Query<(
|
||||
&AnimDriver,
|
||||
@@ -661,7 +662,7 @@ pub(super) fn move_missiles(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut missiles: Query<(Entity, &mut Missile, &mut Transform)>,
|
||||
units: Query<(&GlobalTransform, Option<&BoneAttach>)>,
|
||||
units: AttachPosQuery,
|
||||
joints: Query<&GlobalTransform>,
|
||||
mut impacts: MessageWriter<CastEvent>,
|
||||
mut defenses: MessageWriter<DefenseAnim>,
|
||||
@@ -734,22 +735,22 @@ mod tests {
|
||||
app.update();
|
||||
}
|
||||
|
||||
/// A caster whose `$CSL` marker joint sits at `hand` (the release launch point).
|
||||
/// A caster whose `$CSL` marker sits at `hand` (the release launch point) — bone 0 of its
|
||||
/// pose is seated there, and the read computes through `posed_point` (decision 1355).
|
||||
fn caster(app: &mut App, hand: Vec3) -> Entity {
|
||||
let joint = app
|
||||
let caster = app
|
||||
.world_mut()
|
||||
.spawn(GlobalTransform::from_translation(hand))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.spawn((
|
||||
GlobalTransform::default(),
|
||||
BoneAttach {
|
||||
anchors: HashMap::from([(0u16, joint)]),
|
||||
points: HashMap::new(),
|
||||
markers: HashMap::from([(*b"$CSL", (0u16, Vec3::ZERO))]),
|
||||
},
|
||||
))
|
||||
.id()
|
||||
.id();
|
||||
let pose = benilla_world::testing::test_rig_pose(caster, &[hand]);
|
||||
app.world_mut().entity_mut(caster).insert(pose);
|
||||
caster
|
||||
}
|
||||
|
||||
fn go(caster: Entity, target: Entity, awaits_release: bool) -> MissileSpawn {
|
||||
|
||||
@@ -156,9 +156,11 @@ pub(super) type MountChildren<'w, 's> = Query<
|
||||
/// Drive one unit's mount side to the point where the rider can be seated. Spawns the child when
|
||||
/// it is missing, drops and retries a child built for a display the field has since moved off, and
|
||||
/// resolves the seat frame once the child's rig is up.
|
||||
#[allow(clippy::too_many_arguments)] // the seat law's full input set, like its callers
|
||||
pub(super) fn seat_or_spawn_mount(
|
||||
commands: &mut Commands,
|
||||
children: &MountChildren,
|
||||
poses: &mut Query<&mut RigPose>,
|
||||
creatures: Option<&super::Creatures>,
|
||||
unit: Entity,
|
||||
child: Option<Entity>,
|
||||
@@ -188,11 +190,16 @@ pub(super) fn seat_or_spawn_mount(
|
||||
let Ok((true, Some(bones), _)) = children.get(child) else {
|
||||
return Seat::Wait; // the mount child is still building
|
||||
};
|
||||
let Some((joint, offset)) = bones
|
||||
.points
|
||||
.get(&0)
|
||||
.and_then(|&(bone, offset)| bones.anchor(bone).map(|j| (j, offset)))
|
||||
else {
|
||||
// The mount's attachment-0 joint, spawned on first demand from the MOUNT's composed pose
|
||||
// (`RigPose::anchor_for`, decision 1355) — registered into the mount's own `RigPose.anchors`,
|
||||
// which is what lets the world pass's patch walk find the seat subtree and cascade the rider.
|
||||
let Some((joint, offset)) = bones.points.get(&0).and_then(|&(bone, offset)| {
|
||||
poses
|
||||
.get_mut(child)
|
||||
.ok()
|
||||
.and_then(|mut p| p.anchor_for(commands, child, bone))
|
||||
.map(|j| (j, offset))
|
||||
}) else {
|
||||
warn!(
|
||||
"MOUNTDISPLAYIDNOMOUNTATTACHMENT: display {mount_display} authors no attachment 0 — \
|
||||
rider stays at the unit matrix"
|
||||
@@ -274,22 +281,26 @@ fn reseat_rig(commands: &mut Commands, rig: &mut RigPose, frame: Entity, conform
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn reseat_mounts(
|
||||
mut commands: Commands,
|
||||
mut units: Query<
|
||||
units: Query<
|
||||
(
|
||||
Entity,
|
||||
&NetEntity,
|
||||
&ObjectStore,
|
||||
Option<&mut RigPose>,
|
||||
Option<&AppliedMount>,
|
||||
Option<&MountChild>,
|
||||
),
|
||||
With<super::VisualAttached>,
|
||||
>,
|
||||
// One pose query for BOTH sides of the seat — the rider's rig (re-rooted onto the seat) and
|
||||
// the mount's (its attachment-0 anchor resolves on demand, decision 1355). Split from the
|
||||
// `units` query because the two sides are different entities out of the same component, and
|
||||
// borrowed strictly in sequence below.
|
||||
mut poses: Query<&mut RigPose>,
|
||||
children: MountChildren,
|
||||
conforms: Query<(), With<super::conform::ConformNode>>,
|
||||
creatures: Option<Res<super::Creatures>>,
|
||||
) {
|
||||
for (entity, net, store, rig, applied, child) in &mut units {
|
||||
for (entity, net, store, applied, child) in &units {
|
||||
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
|
||||
continue;
|
||||
}
|
||||
@@ -307,7 +318,7 @@ pub(super) fn reseat_mounts(
|
||||
// orders it a mount — stamp the field so it can never churn, exactly as the first build
|
||||
// does, and sweep a child if one ever did reach it (the stamp alone would then re-fire
|
||||
// every frame, since the settled test asks about the child too).
|
||||
let Some(mut rig) = rig else {
|
||||
let Ok(seated_root) = poses.get(entity).map(|r| r.joints_root) else {
|
||||
if let Some(&MountChild(child)) = child {
|
||||
if let Ok(mut ec) = commands.get_entity(child) {
|
||||
ec.despawn();
|
||||
@@ -328,9 +339,11 @@ pub(super) fn reseat_mounts(
|
||||
// on its own entity, or on the conform node that tilts it — is where it belongs, and
|
||||
// asking for a ground frame anyway would orphan a second conform node beside the
|
||||
// first.
|
||||
if rig.joints_root != entity && !conforms.contains(rig.joints_root) {
|
||||
if seated_root != entity && !conforms.contains(seated_root) {
|
||||
let ground = ground_frame(&mut commands, entity, net, creatures.as_deref());
|
||||
reseat_rig(&mut commands, &mut rig, ground, false);
|
||||
if let Ok(mut rig) = poses.get_mut(entity) {
|
||||
reseat_rig(&mut commands, &mut rig, ground, false);
|
||||
}
|
||||
}
|
||||
if let Some(child) = child {
|
||||
if let Ok(mut ec) = commands.get_entity(child) {
|
||||
@@ -347,6 +360,7 @@ pub(super) fn reseat_mounts(
|
||||
match seat_or_spawn_mount(
|
||||
&mut commands,
|
||||
&children,
|
||||
&mut poses,
|
||||
creatures.as_deref(),
|
||||
entity,
|
||||
child,
|
||||
@@ -355,8 +369,10 @@ pub(super) fn reseat_mounts(
|
||||
) {
|
||||
Seat::Wait => {}
|
||||
Seat::Frame(anchor) => {
|
||||
let leaving_conform = conforms.contains(rig.joints_root);
|
||||
reseat_rig(&mut commands, &mut rig, anchor, leaving_conform);
|
||||
if let Ok(mut rig) = poses.get_mut(entity) {
|
||||
let leaving_conform = conforms.contains(seated_root);
|
||||
reseat_rig(&mut commands, &mut rig, anchor, leaving_conform);
|
||||
}
|
||||
commands.entity(entity).insert(AppliedMount(live));
|
||||
}
|
||||
Seat::UnitMatrix => {
|
||||
|
||||
@@ -111,15 +111,14 @@ impl Standing {
|
||||
/// give it the attachment-0 anchor the seat hangs from. Returns the child.
|
||||
fn mount_attaches(&mut self) -> Entity {
|
||||
let child = self.mount_child().expect("a mount child was ordered");
|
||||
let joint = self
|
||||
.app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), Visibility::default(), ChildOf(child)))
|
||||
.id();
|
||||
// The seat joint spawns on first demand out of the mount's pose (decision 1355) — the
|
||||
// test provides the pose, not a hand-built joint.
|
||||
let pose =
|
||||
benilla_world::testing::test_rig_pose(child, &[Vec3::ZERO; SEAT_BONE as usize + 1]);
|
||||
self.app.world_mut().entity_mut(child).insert((
|
||||
VisualAttached,
|
||||
pose,
|
||||
BoneAttach {
|
||||
anchors: [(SEAT_BONE, joint)].into_iter().collect(),
|
||||
points: [(0u16, (SEAT_BONE, Vec3::Y))].into_iter().collect(),
|
||||
markers: Default::default(),
|
||||
},
|
||||
@@ -306,7 +305,6 @@ fn a_mount_without_a_seat_leaves_the_body_at_the_unit_matrix() {
|
||||
s.app.world_mut().entity_mut(child).insert((
|
||||
VisualAttached,
|
||||
BoneAttach {
|
||||
anchors: Default::default(),
|
||||
points: Default::default(),
|
||||
markers: Default::default(),
|
||||
},
|
||||
|
||||
@@ -777,13 +777,16 @@ fn reap_matching(
|
||||
/// so the whole effect — meshes and particle emitters alike — rides the animating bone. The one
|
||||
/// exception is the kit's **world-plant slot** ([`benilla_formats::WORLD_EFFECT_TAG`], kit field
|
||||
/// 12): its root is a free world entity at the owner's position/facing/scale, [`WorldPlantFx`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
|
||||
pub(super) fn attach_spell_fx(
|
||||
mut commands: Commands,
|
||||
mut units: Query<(
|
||||
Entity,
|
||||
&mut FxAttached,
|
||||
Option<&BoneAttach>,
|
||||
// The unit's pose buffer: the attach joint spawns on first demand from the composed
|
||||
// pose (`RigPose::anchor_for`, decision 1355).
|
||||
Option<&mut benilla_world::rig_anim::RigPose>,
|
||||
&GlobalTransform,
|
||||
)>,
|
||||
fx: Option<Res<SpellFx>>,
|
||||
@@ -798,7 +801,7 @@ pub(super) fn attach_spell_fx(
|
||||
return;
|
||||
};
|
||||
let now = time.elapsed_secs();
|
||||
for (unit, mut att, bones, unit_gt) in &mut units {
|
||||
for (unit, mut att, bones, mut pose, unit_gt) in &mut units {
|
||||
att.instances.retain_mut(|inst| {
|
||||
// Self-termination (the cast-release flash ran its span).
|
||||
if let Some(expires) = inst.expires {
|
||||
@@ -880,7 +883,9 @@ pub(super) fn attach_spell_fx(
|
||||
.chain(ATTACH_FALLBACKS)
|
||||
.find_map(|tag| b.points.get(&tag).copied().map(|p| (tag, p)))
|
||||
.and_then(|(tag, (bone, offset))| {
|
||||
b.anchor(bone).map(|joint| (tag, joint, offset))
|
||||
pose.as_mut()
|
||||
.and_then(|p| p.anchor_for(&mut commands, unit, bone))
|
||||
.map(|joint| (tag, joint, offset))
|
||||
})
|
||||
});
|
||||
let ground_anchor = point.is_none_or(|(tag, ..)| tag == 0x13);
|
||||
|
||||
@@ -164,7 +164,12 @@ fn spawn_footprints(
|
||||
time: Res<Time>,
|
||||
// GlobalTransform for the same reason as the footstep sounds (0441): a mounted unit's steps
|
||||
// are the MOUNT child's tags, whose local Transform is the seat-relative ~origin.
|
||||
units: Query<(&NetEntity, &GlobalTransform, Option<&BoneAttach>)>,
|
||||
units: Query<(
|
||||
&NetEntity,
|
||||
&GlobalTransform,
|
||||
Option<&BoneAttach>,
|
||||
Option<&benilla_world::rig_anim::RigPose>,
|
||||
)>,
|
||||
// The spawner's ROOT (the rider for a mount child): the pool select, the print scale (the
|
||||
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
|
||||
parents: Query<&ChildOf>,
|
||||
@@ -192,7 +197,7 @@ fn spawn_footprints(
|
||||
let Some(side) = footfall_side(&ev.ident) else {
|
||||
continue;
|
||||
};
|
||||
let Ok((net, transform, attach)) = units.get(ev.entity) else {
|
||||
let Ok((net, transform, attach, pose)) = units.get(ev.entity) else {
|
||||
continue;
|
||||
};
|
||||
// The ink + dims come from the EVENT's model (the mount for a mounted composite); the
|
||||
@@ -223,10 +228,10 @@ fn spawn_footprints(
|
||||
// The planted foot: the event's own marker (bone + offset) through the live joint —
|
||||
// exactly the shape the missile launch points use. No marker/joint = the unit origin.
|
||||
let foot = attach
|
||||
.and_then(|a| {
|
||||
.zip(pose)
|
||||
.and_then(|(a, p)| {
|
||||
let (bone, offset) = a.markers.get(&ev.ident).copied()?;
|
||||
let joint = a.anchor(bone)?;
|
||||
Some(joints.get(joint).ok()?.transform_point(offset))
|
||||
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
|
||||
})
|
||||
.unwrap_or_else(|| transform.translation());
|
||||
// The reference's 50-yd radius about the local player (2500 yd², self always passes).
|
||||
|
||||
@@ -235,6 +235,14 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
let mut app = App::new();
|
||||
// The stamp is plain data from here on — the panel footer and preflight banner read it back.
|
||||
app.insert_resource(build);
|
||||
// Pin Bevy's static-scene transform tracking ON (decision 1356). At the default threshold
|
||||
// `mark_dirty_trees` re-decides per frame by counting every changed-Transform row and every
|
||||
// tree row — two full scans of the very population the tracking exists to skip. This scene
|
||||
// is provably static-heavy (terrain/WMO batches and parked units barely move), so the
|
||||
// auto-tuner can only ever confirm what this line states. The guard it removes bites only
|
||||
// when MOST rows move in one frame (a load burst into a near-empty world), where
|
||||
// dirty-marking briefly costs more than it saves — a loading-band term, accepted knowingly.
|
||||
app.insert_resource(bevy::transform::systems::StaticTransformOptimizations::enabled());
|
||||
// …and one startup line says which build produced this log. Registered HERE, beside the stamp
|
||||
// it prints, rather than in `preflight` where it sat until decision 1179: **which build is
|
||||
// this** is the first thing a bug report from someone else's machine has to establish, and a
|
||||
|
||||
@@ -358,6 +358,7 @@ pub(crate) fn drive_nameplates(
|
||||
// The overhead-anchor inputs ([`overhead_anchor`]) — the spawn-frame seat only.
|
||||
anchor_q: (
|
||||
Query<&BoneAttach>,
|
||||
Query<&benilla_world::rig_anim::RigPose>,
|
||||
Query<&OverheadFallback>,
|
||||
Query<&GlobalTransform>,
|
||||
Query<(), With<crate::entities::mount::MountChild>>,
|
||||
@@ -417,7 +418,7 @@ pub(crate) fn drive_nameplates(
|
||||
}
|
||||
// The billboard facing: screen-aligned (the camera's own rotation), this frame's camera.
|
||||
let facing = cam_tf.rotation;
|
||||
let mut seen: Vec<Entity> = Vec::new();
|
||||
let mut seen = bevy::ecs::entity::EntityHashSet::default();
|
||||
for (entity, net, guid, tf, store, is_self, drawn) in &units {
|
||||
// **A name needs a body to float over.** The director, inside Caverns of Time: the Tanaris
|
||||
// mobs 222 yd overhead had correctly stopped drawing, and their names went on hanging in
|
||||
@@ -530,6 +531,7 @@ pub(crate) fn drive_nameplates(
|
||||
&anchor_q.1,
|
||||
&anchor_q.2,
|
||||
&anchor_q.3,
|
||||
&anchor_q.4,
|
||||
);
|
||||
let scale = height_scale(anchor.y - tf.translation.y);
|
||||
let place = Transform {
|
||||
@@ -537,7 +539,7 @@ pub(crate) fn drive_nameplates(
|
||||
rotation: facing,
|
||||
scale: Vec3::splat(scale),
|
||||
};
|
||||
seen.push(entity);
|
||||
seen.insert(entity);
|
||||
match plates.live.get(&entity) {
|
||||
Some((_, l, c)) if *l == lines && *c == color => {}
|
||||
stale => {
|
||||
@@ -588,6 +590,7 @@ fn place_nameplates(
|
||||
mut plate_tfs: Query<(&mut Transform, &mut GlobalTransform), With<NamePlate>>,
|
||||
anchor_q: (
|
||||
Query<&BoneAttach>,
|
||||
Query<&benilla_world::rig_anim::RigPose>,
|
||||
Query<&OverheadFallback>,
|
||||
Query<&GlobalTransform, Without<NamePlate>>, // disjoint from the plate global write
|
||||
Query<(), With<crate::entities::mount::MountChild>>,
|
||||
@@ -610,11 +613,19 @@ fn place_nameplates(
|
||||
else {
|
||||
continue; // spawned this frame and not yet flushed, or unit despawning — next frame
|
||||
};
|
||||
let raw = overhead_anchor(unit, tf, &anchor_q.0, &anchor_q.1, &anchor_q.2, &anchor_q.3);
|
||||
let raw = overhead_anchor(
|
||||
unit,
|
||||
tf,
|
||||
&anchor_q.0,
|
||||
&anchor_q.1,
|
||||
&anchor_q.2,
|
||||
&anchor_q.3,
|
||||
&anchor_q.4,
|
||||
);
|
||||
// The mounted rock damp: seat the plate at mean + ROCK_KEEP·residual instead of the raw
|
||||
// gallop-riding anchor. Everything is in root-relative offsets, so the damped plate still
|
||||
// tracks a 7 yd/s run with zero world-space lag (the on-foot trailing lesson).
|
||||
let anchor = if anchor_q.3.contains(unit) {
|
||||
let anchor = if anchor_q.4.contains(unit) {
|
||||
let off = raw - tf.translation;
|
||||
let mean = rock.entry(unit).or_insert(off);
|
||||
if (off - *mean).length_squared() > ROCK_SNAP * ROCK_SNAP {
|
||||
@@ -646,7 +657,7 @@ fn place_nameplates(
|
||||
*pglobal = GlobalTransform::from(place);
|
||||
}
|
||||
// Dismounted or despawned units drop their damping state (a re-mount starts fresh).
|
||||
rock.retain(|e, _| anchor_q.3.contains(*e));
|
||||
rock.retain(|e, _| anchor_q.4.contains(*e));
|
||||
}
|
||||
|
||||
/// Registers the plate driver (Update — the entity/mesh churn stays in the schedule the mesh
|
||||
|
||||
@@ -427,34 +427,112 @@ const GROUND_CLAMP_DOWN: f32 = 4.0;
|
||||
/// Z untouched (no pop). Runs right after [`sample_splines`] so a walker's freshly-sampled XY+Z is
|
||||
/// what we re-ground; an idle unit (no spline) is re-grounded in place each frame, which also catches
|
||||
/// it once its terrain collider loads.
|
||||
#[allow(clippy::type_complexity)] // one Bevy system's full input set
|
||||
pub(in crate::net) fn ground_clamp_creatures(
|
||||
world: benilla_world::collision::WorldCollision,
|
||||
mut commands: Commands,
|
||||
mut q: Query<(
|
||||
Entity,
|
||||
&NetEntity,
|
||||
Option<&Spline>,
|
||||
&mut Transform,
|
||||
Option<&mut GroundClamped>,
|
||||
Has<CreatureSwimming>,
|
||||
)>,
|
||||
) {
|
||||
for (net, spline, mut t, swimming) in &mut q {
|
||||
let cost = clamp_cost_enabled();
|
||||
let t0 = cost.then(std::time::Instant::now);
|
||||
let (mut visited, mut skipped, mut held, mut cast, mut hit_n, mut moved) =
|
||||
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
|
||||
|
||||
for (entity, net, spline, mut t, mut clamped, swimming) in &mut q {
|
||||
visited += 1;
|
||||
if net.kind != EntityKind::Unit {
|
||||
skipped += 1;
|
||||
continue; // players + GameObjects own their Z
|
||||
}
|
||||
if spline.is_some_and(|s| !s.grounded) {
|
||||
skipped += 1;
|
||||
continue; // a flying path is authoritative on Z
|
||||
}
|
||||
if swimming {
|
||||
skipped += 1;
|
||||
continue; // in-liquid: the wire Z is the creature's swim depth
|
||||
}
|
||||
// The cast gate (decision 1357): a unit standing exactly where its last cast stood, with
|
||||
// a Y nothing has touched since that cast wrote it, cannot get a different answer — the
|
||||
// ray's inputs are its position and the walking colliders, and a *moving* floor (a lift,
|
||||
// a transport deck) reaches its rider through the wire's own position writes, which break
|
||||
// the bit-equality below and re-arm the cast. A MISS never caches (`hit` gates it), so a
|
||||
// unit standing on a tile whose collider hasn't streamed in keeps asking until it lands —
|
||||
// the original re-ground-in-place behaviour, kept exactly where it is load-bearing.
|
||||
let xz = [t.translation.x, t.translation.z];
|
||||
if let Some(c) = clamped.as_deref() {
|
||||
if c.hit && c.xz == xz && c.y_written == t.translation.y {
|
||||
held += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let origin = t.translation + Vec3::Y * GROUND_CLAMP_UP;
|
||||
let reach = GROUND_CLAMP_UP + GROUND_CLAMP_DOWN;
|
||||
cast += 1;
|
||||
// The one-sided down-ray (decision 0970): a creature grounds like the player grounds — a
|
||||
// face whose winding points away is no floor, or an idle NPC would stand mid-air on the
|
||||
// very shell face the player mover now falls through.
|
||||
if let Some(hit) = world.ray_body(origin, Dir3::NEG_Y, reach) {
|
||||
t.translation.y = origin.y - hit.distance; // the down-ray's hit point Y = feet on the surface
|
||||
let hit = world.ray_body(origin, Dir3::NEG_Y, reach);
|
||||
if let Some(hit) = &hit {
|
||||
hit_n += 1;
|
||||
let y = origin.y - hit.distance; // the down-ray's hit point Y = feet on the surface
|
||||
// Exact bit equality is deliberate, not a sloppy float compare: the question is
|
||||
// "would the write change anything" — Bevy's change detection fires on the
|
||||
// DerefMut regardless of value, so writing an equal Y every frame marked every
|
||||
// standing creature's transform subtree dirty. An epsilon would answer a
|
||||
// different question: a real sub-epsilon ground shift must still land.
|
||||
if y != t.translation.y {
|
||||
moved += 1;
|
||||
t.translation.y = y;
|
||||
}
|
||||
}
|
||||
let state = GroundClamped {
|
||||
xz,
|
||||
y_written: t.translation.y,
|
||||
hit: hit.is_some(),
|
||||
};
|
||||
match clamped.as_deref_mut() {
|
||||
Some(c) => *c = state,
|
||||
None => {
|
||||
commands.entity(entity).insert(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(t0) = t0 {
|
||||
// `WOW_CLAMP_COST=1` — the premise check for gating this sweep (0732 slice S), which
|
||||
// 0732 sized at 0.42 traced against avian's `SpatialQuery::cast_ray`. **0970 replaced
|
||||
// that ray** with a broadphase-plus-BVH gather, so the recorded price measures a
|
||||
// mechanism that no longer exists and the lane is honestly unsized until this prints.
|
||||
//
|
||||
// The two fields that decide the two possible gates, and their kill conditions:
|
||||
// · `cast` vs `visited` — how much of the walk even reaches a ray. A movement gate can
|
||||
// only ever save the `cast` share; if `ms` is small this whole item is dead.
|
||||
// · `moved` vs `hit` — how often the write actually CHANGES Y. Every hit writes
|
||||
// `Transform` unconditionally today, and a write dirties the transform subtree whether
|
||||
// or not the value differs. If `moved` is a small fraction of `hit`, an equality gate
|
||||
// on the write is the cheap half and needs no movement tracking at all.
|
||||
//
|
||||
// Per 0734's law (~10.5 ns per row visit), the walk itself is never the cost here: at ~800
|
||||
// units it is ~8 µs. Only `ms` justifies the slice — quote it, not the counts.
|
||||
eprintln!(
|
||||
"[clamp-cost] visited={visited} skipped={skipped} held={held} cast={cast} hit={hit_n} moved={moved} ms={:.3}",
|
||||
t0.elapsed().as_secs_f32() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the ground-clamp meter is armed (`WOW_CLAMP_COST`). Read once, then a relaxed bool.
|
||||
fn clamp_cost_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_CLAMP_COST").is_some())
|
||||
}
|
||||
|
||||
/// What [`mark_swimming_creatures`] reads per unit: identity, kind, pose, its own collision height
|
||||
@@ -489,6 +567,21 @@ const CREATURE_SWIM_EXIT_BAND: f32 = 1.0 / 36.0;
|
||||
#[derive(Component)]
|
||||
pub(crate) struct CreatureSwimming;
|
||||
|
||||
/// The last ground clamp this unit took (decision 1357) — [`ground_clamp_creatures`]' cast gate.
|
||||
/// All three fields compare by bit: the gate's question is "are the ray's inputs identical to the
|
||||
/// cast that produced this", never "close enough".
|
||||
#[derive(Component)]
|
||||
pub(in crate::net) struct GroundClamped {
|
||||
/// Where the last cast stood.
|
||||
xz: [f32; 2],
|
||||
/// The Y standing after that cast (written or left) — an external write (the wire moving the
|
||||
/// unit, a lift carrying it) breaks equality and re-arms the cast.
|
||||
y_written: f32,
|
||||
/// Only a HIT caches: a miss keeps casting, so a tile whose collider streams in late still
|
||||
/// catches its standing units.
|
||||
hit: bool,
|
||||
}
|
||||
|
||||
/// Maintain [`CreatureSwimming`] on `Unit` creatures from the water over their feet (module docs on
|
||||
/// the INTERIM boundary). Runs chained before [`ground_clamp_creatures`] so a fresh mark exempts the
|
||||
/// clamp the same frame (Bevy inserts the deferred-command sync point for the chain).
|
||||
|
||||
@@ -258,6 +258,9 @@ fn build_markers(
|
||||
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
|
||||
index: Res<GuidIndex>,
|
||||
anchors: Query<&BoneAttach>,
|
||||
// The unit's pose buffer: the overhead joint the marker parents under spawns on first
|
||||
// demand (`RigPose::anchor_for`, decision 1355).
|
||||
mut poses: Query<&mut benilla_world::rig_anim::RigPose>,
|
||||
mounts: Query<(), With<crate::entities::mount::MountChild>>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
@@ -290,11 +293,13 @@ fn build_markers(
|
||||
} else {
|
||||
ATTACH_OVERHEAD
|
||||
};
|
||||
let Some((joint, offset)) = anchor
|
||||
.points
|
||||
.get(&slot)
|
||||
.and_then(|&(bone, offset)| Some((anchor.anchor(bone)?, offset)))
|
||||
else {
|
||||
let Some((joint, offset)) = anchor.points.get(&slot).and_then(|&(bone, offset)| {
|
||||
poses
|
||||
.get_mut(unit)
|
||||
.ok()
|
||||
.and_then(|mut p| p.anchor_for(&mut commands, unit, bone))
|
||||
.map(|joint| (joint, offset))
|
||||
}) else {
|
||||
debug!(
|
||||
"quest_markers: {:#x} has no slot-{slot} attachment — marker never parents (invisible, the client's own behavior)",
|
||||
marker.npc
|
||||
|
||||
@@ -105,11 +105,12 @@ fn mark_place<F: bevy::ecs::query::QueryFilter>(
|
||||
plates: &Nameplates,
|
||||
facing: Quat,
|
||||
attach: &Query<&BoneAttach>,
|
||||
poses: &Query<&benilla_world::rig_anim::RigPose>,
|
||||
fallback: &Query<&OverheadFallback>,
|
||||
globals: &Query<&GlobalTransform, F>,
|
||||
mounts: &Query<(), With<crate::entities::mount::MountChild>>,
|
||||
) -> Transform {
|
||||
let anchor = overhead_anchor(unit, tf, attach, fallback, globals, mounts);
|
||||
let anchor = overhead_anchor(unit, tf, attach, poses, fallback, globals, mounts);
|
||||
// `scale` (the name's world height-law) drives ONLY the seat — the marker sits one
|
||||
// line-pitch above the top of the name block. The quad SIZE is a fixed world unit (§6:
|
||||
// `scale` never enters the marker geometry), not `scale`-scaled.
|
||||
@@ -141,6 +142,7 @@ fn drive_raid_marks(
|
||||
camera: Query<&Transform, With<WorldCamera>>,
|
||||
anchor_q: (
|
||||
Query<&BoneAttach>,
|
||||
Query<&benilla_world::rig_anim::RigPose>,
|
||||
Query<&OverheadFallback>,
|
||||
Query<&GlobalTransform>,
|
||||
Query<(), With<crate::entities::mount::MountChild>>,
|
||||
@@ -203,6 +205,7 @@ fn drive_raid_marks(
|
||||
&anchor_q.1,
|
||||
&anchor_q.2,
|
||||
&anchor_q.3,
|
||||
&anchor_q.4,
|
||||
)
|
||||
});
|
||||
let marker = commands
|
||||
@@ -228,6 +231,7 @@ fn place_raid_marks(
|
||||
mut mark_tfs: Query<(&mut Transform, &mut GlobalTransform), With<RaidMarkBillboard>>,
|
||||
anchor_q: (
|
||||
Query<&BoneAttach>,
|
||||
Query<&benilla_world::rig_anim::RigPose>,
|
||||
Query<&OverheadFallback>,
|
||||
Query<&GlobalTransform, Without<RaidMarkBillboard>>,
|
||||
Query<(), With<crate::entities::mount::MountChild>>,
|
||||
@@ -252,6 +256,7 @@ fn place_raid_marks(
|
||||
&anchor_q.1,
|
||||
&anchor_q.2,
|
||||
&anchor_q.3,
|
||||
&anchor_q.4,
|
||||
);
|
||||
*mtf = place;
|
||||
*mglobal = GlobalTransform::from(place);
|
||||
|
||||
@@ -626,6 +626,12 @@ pub(crate) fn feed_containers(
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
// `WOW_FEED_COST=1` — the premise counter for gating this feed (the 2026-08-15 ledger's #8):
|
||||
// the whole snapshot+diff below runs per frame; before an epoch gate is built, this says what
|
||||
// that actually costs on a real bag population. Printed once a second.
|
||||
let cost_t0 = std::env::var_os("WOW_FEED_COST")
|
||||
.is_some()
|
||||
.then(std::time::Instant::now);
|
||||
// The frame's atomic clock pair (`crate::ui_script::UiClock`): the slot resolves read the
|
||||
// cooldown store and convert triples through ONE coherent instant, so a running cooldown's
|
||||
// pushed start is frame-stable (the resource's own doc).
|
||||
@@ -922,6 +928,21 @@ pub(crate) fn feed_containers(
|
||||
script.set_has_key(key);
|
||||
}
|
||||
|
||||
if let Some(t0) = cost_t0 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SUM_NS: AtomicU64 = AtomicU64::new(0);
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let sum = SUM_NS.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
|
||||
let n = N.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if n.is_multiple_of(60) {
|
||||
let slots: usize = fresh.values().map(|c| c.slots.len()).sum();
|
||||
eprintln!(
|
||||
"[feed-cost] ms={:.3} slots={slots} containers={}",
|
||||
sum as f64 / n as f64 / 1e6,
|
||||
fresh.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
apply_container_source(
|
||||
&mut script,
|
||||
memory,
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
//! that actually change. That is what [`tooltip_change_costs_a_whole_ui_solve`] drives, and with it
|
||||
//! the cost reproduces headlessly and can be measured against a fix.
|
||||
//!
|
||||
//! Run with `cargo test --release -p benilla -- --ignored --nocapture resolve_bench`;
|
||||
//! `WOW_LAYOUT_PROF=1` adds the per-solve shape (rounds, graph size, frame-vs-region split).
|
||||
//! Run with `cargo test --release -p benilla-app --lib -- --ignored --nocapture resolve_bench`;
|
||||
//! `WOW_LAYOUT_PROF=1` adds the per-solve shape — and since decision 1350 its `solved=`/`swept=`
|
||||
//! columns are the ones to read: they are the SCOPE of the solve, and 1350's whole claim is that
|
||||
//! they stay a handful while `frames=`/`anchored=` grow without bound. A solve whose scope tracks
|
||||
//! the graph again is precisely the regression this file exists to catch.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -155,3 +158,98 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
|
||||
"after a settled frame nothing may still be waiting to be measured"
|
||||
);
|
||||
}
|
||||
|
||||
/// The perf half of decision 1350, asserted as a COUNT: a tooltip content change must cost a
|
||||
/// solve whose SCOPE is tooltip-sized, on a UI of 3,000 frames and 8,800 anchored regions.
|
||||
///
|
||||
/// Milliseconds are deliberately not the gate here. This exact cost has now been chased three
|
||||
/// times — 0735 (the measure cache), 0771 (the double solve), and this — and on two of those the
|
||||
/// ms column was contaminated by machine state (0713's stall class) while the work counts were
|
||||
/// clean. The bound is also what makes the regression *reportable*: 1350's pin found the sweep's
|
||||
/// cost tripling with no counter behind it, because `regions_swept` counted the entries the sweep
|
||||
/// skipped as well as the ones it resolved.
|
||||
#[test]
|
||||
fn a_tooltip_content_change_solves_a_tooltip_sized_scope() {
|
||||
let mut s = settled_default_ui();
|
||||
install_changing_tooltip(&s, "ScopeSizeOwner", "scope_size_change");
|
||||
|
||||
// Settle the newly-created owner and the tooltip's first content — a birth is legitimately a
|
||||
// wide solve (a new node has no cached rect to trust).
|
||||
for _ in 0..6 {
|
||||
s.run("scope_size_change()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
for step in 0..10 {
|
||||
s.run("scope_size_change()").unwrap();
|
||||
app_frame(&mut s);
|
||||
let (frames, regions) = s.layout_last_scope();
|
||||
assert!(
|
||||
frames < 100 && regions < 500,
|
||||
"step {step}: a tooltip content change solved {frames} frames and swept {regions} \
|
||||
regions — the scope is tracking the graph, not the change (measured at the time of \
|
||||
writing: 5 frames, 67 regions, against 3,003 and 8,821 in the graph)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scoped resolve's falsifier (decision 1350): on the shipped UI, mid-sweep, a solve that
|
||||
/// touched only the dirty closure must produce **exactly** the rects a from-scratch whole-graph
|
||||
/// solve produces.
|
||||
///
|
||||
/// This is the gate for the whole change, and it is deliberately run on the frames that never
|
||||
/// settle — a hover sweep changes content every frame, so `WOW_LAYOUT_VERIFY`'s settled-frame
|
||||
/// comparison never fires there, and those are the only frames the scope was built for. The
|
||||
/// comparison is `extract()`, not an internal rect map: a stale rect that no quad carries is not a
|
||||
/// bug, and a stale rect that one does carry is the bug in the form the screen would show it.
|
||||
#[test]
|
||||
fn a_scoped_resolve_reproduces_the_whole_graph_solve() {
|
||||
let mut s = settled_default_ui();
|
||||
install_changing_tooltip(&s, "ScopeOwner", "scope_change");
|
||||
|
||||
for step in 0..12 {
|
||||
// A content change, resolved the way the app resolves it — scoped.
|
||||
s.run("scope_change()").unwrap();
|
||||
app_frame(&mut s);
|
||||
let scoped = s.extract();
|
||||
|
||||
// The same model, re-solved from nothing but its inputs.
|
||||
s.force_full_layout_resolve();
|
||||
app_frame(&mut s);
|
||||
let full = s.extract();
|
||||
|
||||
assert!(
|
||||
scoped == full,
|
||||
"step {step}: the scoped resolve and the whole-graph resolve disagree — a node the \
|
||||
scope judged clean did move. {} quads vs {}",
|
||||
scoped.len(),
|
||||
full.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The steady-state cost of the per-frame measure sweep alone: the UI settled, every FontString
|
||||
/// measured, every key a cache hit — what does *asking* cost? It is paid on EVERY frame, quiet
|
||||
/// ones included, which made it a suspect on 1350's successor list — and this bench closed it:
|
||||
/// 0.046 ms/sweep on the full shipped UI in release (2026-08-15; the ~1.0 ms once quoted was a
|
||||
/// dev-build number). Kept as the lane's regression watch: if this climbs toward a milli-
|
||||
/// second, the sweep's per-row clones/hash grew or the cache stopped hitting.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn measure_sweep_steady_state_cost() {
|
||||
let mut s = settled_default_ui();
|
||||
// One more settle so the sweep below is provably pure cache hits.
|
||||
let residual = answer_measures(&mut s);
|
||||
let t = Instant::now();
|
||||
let mut asked = 0usize;
|
||||
const N: u32 = 200;
|
||||
for _ in 0..N {
|
||||
asked += s.fontstrings_needing_measure().len();
|
||||
}
|
||||
println!(
|
||||
"measure sweep steady state: {:.4} ms/sweep ({} sweeps, {} residual requests, {} pre-settle)",
|
||||
ms(t) / f64::from(N),
|
||||
N,
|
||||
asked,
|
||||
residual,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +443,7 @@ fn drive_vplates(
|
||||
// The overhead-anchor inputs ([`overhead_anchor`]).
|
||||
anchor_q: (
|
||||
Query<&BoneAttach>,
|
||||
Query<&benilla_world::rig_anim::RigPose>,
|
||||
Query<&OverheadFallback>,
|
||||
Query<&GlobalTransform>,
|
||||
Query<(), With<crate::entities::mount::MountChild>>,
|
||||
@@ -573,6 +574,7 @@ fn drive_vplates(
|
||||
&anchor_q.1,
|
||||
&anchor_q.2,
|
||||
&anchor_q.3,
|
||||
&anchor_q.4,
|
||||
);
|
||||
let Some(screen) =
|
||||
crate::ui_pass::project_overlay(cam, &cam_tf, anchor + Vec3::Y * PLATE_LIFT, viewport)
|
||||
|
||||
@@ -125,7 +125,16 @@ fn world_art_image(chain: BlpMipChain, wrap: (bool, bool)) -> Image {
|
||||
TextureDimension::D2,
|
||||
mip0,
|
||||
color_format(),
|
||||
RenderAssetUsages::default(),
|
||||
// `RENDER_WORLD`: the render world TAKES the mip chain on extract instead of cloning it and
|
||||
// leaving a main-world copy resident for the asset's life (bevy_render `render_asset.rs` —
|
||||
// the `asset_usage == RENDER_WORLD` branch moves, every other usage clones). This is the
|
||||
// variant carrying every terrain layer, model albedo and WMO albedo in the game, so the
|
||||
// default usage was paying a full mip-chain memcpy on every landing frame and holding a
|
||||
// second copy of the whole world's art in system RAM. Nothing reads a world texture
|
||||
// main-side (the minimap, colliders and clutter all derive from other sources) — same
|
||||
// reasoning, and now the same usage, as the terrain tile arrays (`terrain.rs`) and the
|
||||
// Sprite/Effect/PointSprite variants below.
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
image.data = Some(data);
|
||||
image.texture_descriptor.mip_level_count = levels;
|
||||
|
||||
@@ -27,11 +27,20 @@
|
||||
//! visits them, so every tie-break downstream (`down_ray_claim`'s first-wins, `footprint_sample`'s
|
||||
//! later-wins) resolves exactly as it did before the index existed.
|
||||
|
||||
/// A triangle whose XY AABB touches more cells than this is not binned — it goes to
|
||||
/// [`ColumnGrid::spanning`] and is a candidate for every query. Big floor slabs are the case: at a
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A triangle whose XY AABB touches more cells than this is not binned at the level being built —
|
||||
/// it is handed to the next level down (the coarse grid), and only a face too large for *that*
|
||||
/// becomes a [`ColumnGrid::spanning`] always-tested residue. Big floor slabs are the case: at a
|
||||
/// ~1-triangle-per-cell sizing a 100-yd slab would otherwise be copied into hundreds of cells.
|
||||
const MAX_SPAN_CELLS: u32 = 32;
|
||||
|
||||
/// Below this many oversized faces, a second grid is not worth its own allocation pair and lookup —
|
||||
/// they stay an always-tested list. Well under the per-query counts 1351 measured (~480), so the
|
||||
/// pin's groups all take the coarse level.
|
||||
const MIN_COARSE_TRIS: usize = 24;
|
||||
|
||||
/// Never build cells finer than this (yd) — a degenerate face set (all triangles stacked in one
|
||||
/// spot) would otherwise ask for an unbounded grid.
|
||||
const MIN_CELL: f32 = 0.25;
|
||||
@@ -40,11 +49,11 @@ const MIN_CELL: f32 = 0.25;
|
||||
/// 500-yd dungeon group costs tens of KB of index, not tens of MB.
|
||||
const MAX_CELLS: usize = 1 << 16;
|
||||
|
||||
/// A face set's uniform XY grid — CSR (`starts`/`items`), so one allocation pair per group rather
|
||||
/// than a `Vec` per cell.
|
||||
/// One uniform XY grid over some set of triangles — CSR (`starts`/`items`), so one allocation
|
||||
/// pair per level rather than a `Vec` per cell.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnGrid {
|
||||
/// Grid origin (the face set's XY minimum).
|
||||
struct Level {
|
||||
/// Grid origin (the indexed set's XY minimum).
|
||||
min: [f32; 2],
|
||||
/// 1 / cell size, in cells per yard.
|
||||
inv_cell: f32,
|
||||
@@ -52,12 +61,185 @@ pub struct ColumnGrid {
|
||||
ny: u32,
|
||||
/// CSR row offsets into [`Self::items`], length `nx * ny + 1`.
|
||||
starts: Vec<u32>,
|
||||
/// Triangle indices, ascending within each cell.
|
||||
/// **Global** triangle indices, ascending within each cell.
|
||||
items: Vec<u32>,
|
||||
/// Triangles too large to bin (see [`MAX_SPAN_CELLS`]), ascending — candidates for every query.
|
||||
}
|
||||
|
||||
/// A face set's column index: **two grids and a residue**.
|
||||
///
|
||||
/// One grid cannot serve a dungeon group. Sizing cells at ~1 triangle each is right for the ~11–16k
|
||||
/// small faces, and catastrophic for the handful of 40–100 yd floor slabs sharing the group: a slab
|
||||
/// would be copied into hundreds of cells, so [`MAX_SPAN_CELLS`] sets it aside instead — and
|
||||
/// "aside" used to mean *tested on every query*.
|
||||
///
|
||||
/// 1351 measured what that cost at the LBRS pin: **99.26 %** of all triangles tested per frame were
|
||||
/// those set-aside slabs — 93,624 of 94,322, against 698 that came from a cell list. The index was
|
||||
/// accelerating the 0.7 %.
|
||||
///
|
||||
/// So the oversized set gets its own grid, sized from its own extent and its own count (0711's
|
||||
/// named fix, deferred there and confirmed by 1351). Cells at that level are ~an order of magnitude
|
||||
/// wider, which is exactly what a slab needs to bin. Whatever is still too big for the coarse level
|
||||
/// — a face spanning the whole group — falls to [`Self::spanning`] and keeps the old always-tested
|
||||
/// behaviour, which is now a residue rather than the main term.
|
||||
///
|
||||
/// **Two levels, not recursion.** A recursive grid would need a boxed iterator per query on the
|
||||
/// hottest path in the interior lane; two explicit levels keep [`ColumnGrid::candidates`] a
|
||||
/// three-way merge of three ascending *slices* — no allocation, no indirection, and the
|
||||
/// superset+ascending invariants hold at each level independently.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnGrid {
|
||||
/// The ~1-triangle-per-cell grid over the ordinary faces.
|
||||
fine: Level,
|
||||
/// The coarse grid over the faces `fine` set aside. `None` when there were too few of them to
|
||||
/// be worth a second index — then they are all in [`Self::spanning`], as before.
|
||||
coarse: Option<Level>,
|
||||
/// Faces too large to bin at **either** level, ascending — candidates for every query.
|
||||
spanning: Vec<u32>,
|
||||
}
|
||||
|
||||
/// `WOW_COLUMN_COST=1`: how many triangles a column query actually tests, split by **source** —
|
||||
/// `binned` (the fine level's cell), `coarse` (the coarse level's cell), and `spanning` (the
|
||||
/// residue too large for either, tested on every query).
|
||||
///
|
||||
/// This is 0711's residual, and the counter that confirmed it. That hunt cut the lane from ~1.2 M
|
||||
/// triangle tests/frame to an 88 k remainder, wrote down what the remainder was — big floor slabs,
|
||||
/// too large to bin, merged into every column query — named the fix, and left it. 1351 measured the
|
||||
/// remainder three weeks later: **99.26 %** of tested triangles were that set, and `resolve_ms` was
|
||||
/// still 1.14, byte for byte the number 0711 recorded, because nothing had touched the file.
|
||||
///
|
||||
/// The counter stays as the **regression watch** on the two-level index: `spanning_pct` is now the
|
||||
/// residue's share and should read near zero. If it climbs, some face set has a slab too big for
|
||||
/// even the coarse level and the lane is quietly reverting to 0711's shape.
|
||||
static COLUMN_QUERIES: AtomicU64 = AtomicU64::new(0);
|
||||
static COLUMN_BINNED: AtomicU64 = AtomicU64::new(0);
|
||||
static COLUMN_COARSE: AtomicU64 = AtomicU64::new(0);
|
||||
static COLUMN_SPANNING: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Whether the column-query meter is armed (`WOW_COLUMN_COST`). Read once, then a relaxed bool.
|
||||
pub fn column_cost_enabled() -> bool {
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_COLUMN_COST").is_some())
|
||||
}
|
||||
|
||||
/// Take and zero the column-query counters: `(queries, binned, coarse, spanning)` triangles tested.
|
||||
///
|
||||
/// One caller per frame — the meter that prints them — so the numbers are always per-frame.
|
||||
pub fn take_column_query_stats() -> (u64, u64, u64, u64) {
|
||||
(
|
||||
COLUMN_QUERIES.swap(0, Ordering::Relaxed),
|
||||
COLUMN_BINNED.swap(0, Ordering::Relaxed),
|
||||
COLUMN_COARSE.swap(0, Ordering::Relaxed),
|
||||
COLUMN_SPANNING.swap(0, Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
impl Level {
|
||||
/// Index `ids` (ascending, global triangle indices) into one uniform grid.
|
||||
///
|
||||
/// Returns the level plus the **overflow**: the ids whose XY AABB spans more than
|
||||
/// [`MAX_SPAN_CELLS`] cells at this level's sizing, ascending, for the next level down.
|
||||
/// `None` when the set has no finite extent to grid.
|
||||
fn build(
|
||||
ids: &[u32],
|
||||
xy_aabb: &impl Fn(usize) -> ([f32; 2], [f32; 2]),
|
||||
) -> Option<(Self, Vec<u32>)> {
|
||||
let (mut min, mut max) = ([f32::MAX; 2], [f32::MIN; 2]);
|
||||
for &i in ids {
|
||||
let (lo, hi) = xy_aabb(i as usize);
|
||||
for a in 0..2 {
|
||||
min[a] = min[a].min(lo[a]);
|
||||
max[a] = max[a].max(hi[a]);
|
||||
}
|
||||
}
|
||||
let (w, h) = ((max[0] - min[0]).max(0.0), (max[1] - min[1]).max(0.0));
|
||||
if !w.is_finite() || !h.is_finite() || (w <= 0.0 && h <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
// Aim for ~one triangle per cell, then clamp both ways: never finer than MIN_CELL, never
|
||||
// more than MAX_CELLS cells. Sizing from THIS set's own count and extent is what makes the
|
||||
// coarse level coarse: a few hundred slabs over the same group span give cells ~an order of
|
||||
// magnitude wider than the fine level's, which is what lets a slab bin at all.
|
||||
let target_cells = ids.len().clamp(1, MAX_CELLS) as f32;
|
||||
let area = (w * h).max(f32::MIN_POSITIVE);
|
||||
let mut cell = (area / target_cells).sqrt().max(MIN_CELL);
|
||||
let (mut nx, mut ny) = dims(w, h, cell);
|
||||
while nx as usize * ny as usize > MAX_CELLS {
|
||||
cell *= 2.0;
|
||||
(nx, ny) = dims(w, h, cell);
|
||||
}
|
||||
let inv_cell = 1.0 / cell;
|
||||
let cells = nx as usize * ny as usize;
|
||||
|
||||
// Pass 1 — count per cell, and set the oversized triangles aside for the next level.
|
||||
let mut counts = vec![0u32; cells + 1];
|
||||
let mut overflow = Vec::new();
|
||||
let span_of = |i: u32| -> Option<(u32, u32, u32, u32)> {
|
||||
let (lo, hi) = xy_aabb(i as usize);
|
||||
let x0 = cell_of(lo[0], min[0], inv_cell, nx);
|
||||
let x1 = cell_of(hi[0], min[0], inv_cell, nx);
|
||||
let y0 = cell_of(lo[1], min[1], inv_cell, ny);
|
||||
let y1 = cell_of(hi[1], min[1], inv_cell, ny);
|
||||
let touched = (x1 - x0 + 1) * (y1 - y0 + 1);
|
||||
(touched <= MAX_SPAN_CELLS).then_some((x0, x1, y0, y1))
|
||||
};
|
||||
for &i in ids {
|
||||
match span_of(i) {
|
||||
Some((x0, x1, y0, y1)) => {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
counts[(y * nx + x) as usize] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => overflow.push(i),
|
||||
}
|
||||
}
|
||||
// Prefix sum → CSR starts.
|
||||
let mut starts = vec![0u32; cells + 1];
|
||||
let mut acc = 0u32;
|
||||
for c in 0..cells {
|
||||
starts[c] = acc;
|
||||
acc += counts[c];
|
||||
}
|
||||
starts[cells] = acc;
|
||||
|
||||
// Pass 2 — fill. `ids` is ascending, so each cell fills in ascending triangle order, which
|
||||
// is the order guarantee the tie-breaks downstream rest on.
|
||||
let mut items = vec![0u32; acc as usize];
|
||||
let mut cursor = starts.clone();
|
||||
for &i in ids {
|
||||
if let Some((x0, x1, y0, y1)) = span_of(i) {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
let c = (y * nx + x) as usize;
|
||||
items[cursor[c] as usize] = i;
|
||||
cursor[c] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((
|
||||
Self {
|
||||
min,
|
||||
inv_cell,
|
||||
nx,
|
||||
ny,
|
||||
starts,
|
||||
items,
|
||||
},
|
||||
overflow,
|
||||
))
|
||||
}
|
||||
|
||||
/// This level's own cell list for the column at `(x, y)` — ascending, possibly empty.
|
||||
fn cell(&self, x: f32, y: f32) -> &[u32] {
|
||||
let cx = cell_of(x, self.min[0], self.inv_cell, self.nx);
|
||||
let cy = cell_of(y, self.min[1], self.inv_cell, self.ny);
|
||||
let c = (cy * self.nx + cx) as usize;
|
||||
&self.items[self.starts[c] as usize..self.starts[c + 1] as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl ColumnGrid {
|
||||
/// Build the index for `count` triangles, given each one's XY AABB by index.
|
||||
///
|
||||
@@ -69,112 +251,63 @@ impl ColumnGrid {
|
||||
if count < MIN_TRIS {
|
||||
return None;
|
||||
}
|
||||
let (mut min, mut max) = ([f32::MAX; 2], [f32::MIN; 2]);
|
||||
for i in 0..count {
|
||||
let (lo, hi) = xy_aabb(i);
|
||||
for a in 0..2 {
|
||||
min[a] = min[a].min(lo[a]);
|
||||
max[a] = max[a].max(hi[a]);
|
||||
}
|
||||
}
|
||||
let (w, h) = ((max[0] - min[0]).max(0.0), (max[1] - min[1]).max(0.0));
|
||||
if !w.is_finite() || !h.is_finite() || (w <= 0.0 && h <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
// Aim for ~one triangle per cell, then clamp both ways: never finer than MIN_CELL, never
|
||||
// more than MAX_CELLS cells.
|
||||
let target_cells = count.clamp(1, MAX_CELLS) as f32;
|
||||
let area = (w * h).max(f32::MIN_POSITIVE);
|
||||
let mut cell = (area / target_cells).sqrt().max(MIN_CELL);
|
||||
let (mut nx, mut ny) = dims(w, h, cell);
|
||||
while nx as usize * ny as usize > MAX_CELLS {
|
||||
cell *= 2.0;
|
||||
(nx, ny) = dims(w, h, cell);
|
||||
}
|
||||
let inv_cell = 1.0 / cell;
|
||||
let cells = nx as usize * ny as usize;
|
||||
let all: Vec<u32> = (0..count as u32).collect();
|
||||
let (fine, oversized) = Level::build(&all, &xy_aabb)?;
|
||||
|
||||
// Pass 1 — count per cell, and set the oversized triangles aside.
|
||||
let mut counts = vec![0u32; cells + 1];
|
||||
let mut spanning = Vec::new();
|
||||
let span_of = |i: usize| -> Option<(u32, u32, u32, u32)> {
|
||||
let (lo, hi) = xy_aabb(i);
|
||||
let x0 = cell_of(lo[0], min[0], inv_cell, nx);
|
||||
let x1 = cell_of(hi[0], min[0], inv_cell, nx);
|
||||
let y0 = cell_of(lo[1], min[1], inv_cell, ny);
|
||||
let y1 = cell_of(hi[1], min[1], inv_cell, ny);
|
||||
let touched = (x1 - x0 + 1) * (y1 - y0 + 1);
|
||||
(touched <= MAX_SPAN_CELLS).then_some((x0, x1, y0, y1))
|
||||
// The oversized set gets its own grid when there are enough of them to pay for one. What
|
||||
// *that* level cannot bin stays an always-tested residue — the old behaviour, now applied
|
||||
// to a set 1351 predicts is tiny rather than to 99 % of the traffic.
|
||||
let (coarse, spanning) = if oversized.len() >= MIN_COARSE_TRIS {
|
||||
match Level::build(&oversized, &xy_aabb) {
|
||||
Some((level, residue)) => (Some(level), residue),
|
||||
None => (None, oversized),
|
||||
}
|
||||
} else {
|
||||
(None, oversized)
|
||||
};
|
||||
for i in 0..count {
|
||||
match span_of(i) {
|
||||
Some((x0, x1, y0, y1)) => {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
counts[(y * nx + x) as usize] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => spanning.push(i as u32),
|
||||
}
|
||||
}
|
||||
// Prefix sum → CSR starts.
|
||||
let mut starts = vec![0u32; cells + 1];
|
||||
let mut acc = 0u32;
|
||||
for c in 0..cells {
|
||||
starts[c] = acc;
|
||||
acc += counts[c];
|
||||
}
|
||||
starts[cells] = acc;
|
||||
|
||||
// Pass 2 — fill. Ascending `i` fills each cell in ascending triangle order, which is the
|
||||
// order guarantee the tie-breaks downstream rest on.
|
||||
let mut items = vec![0u32; acc as usize];
|
||||
let mut cursor = starts.clone();
|
||||
for i in 0..count {
|
||||
if let Some((x0, x1, y0, y1)) = span_of(i) {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
let c = (y * nx + x) as usize;
|
||||
items[cursor[c] as usize] = i as u32;
|
||||
cursor[c] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
min,
|
||||
inv_cell,
|
||||
nx,
|
||||
ny,
|
||||
starts,
|
||||
items,
|
||||
fine,
|
||||
coarse,
|
||||
spanning,
|
||||
})
|
||||
}
|
||||
|
||||
/// The triangles that can own the column at `(x, y)` — a superset, in ascending index order.
|
||||
pub fn candidates(&self, x: f32, y: f32) -> ColumnCandidates<'_> {
|
||||
let cx = cell_of(x, self.min[0], self.inv_cell, self.nx);
|
||||
let cy = cell_of(y, self.min[1], self.inv_cell, self.ny);
|
||||
let c = (cy * self.nx + cx) as usize;
|
||||
let (lo, hi) = (self.starts[c] as usize, self.starts[c + 1] as usize);
|
||||
let binned = self.fine.cell(x, y);
|
||||
let coarse = self.coarse.as_ref().map_or(&[][..], |c| c.cell(x, y));
|
||||
if column_cost_enabled() {
|
||||
COLUMN_QUERIES.fetch_add(1, Ordering::Relaxed);
|
||||
COLUMN_BINNED.fetch_add(binned.len() as u64, Ordering::Relaxed);
|
||||
COLUMN_COARSE.fetch_add(coarse.len() as u64, Ordering::Relaxed);
|
||||
COLUMN_SPANNING.fetch_add(self.spanning.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
ColumnCandidates {
|
||||
binned: &self.items[lo..hi],
|
||||
binned,
|
||||
coarse,
|
||||
spanning: &self.spanning,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index size in triangle slots — the memory the acceleration costs, for the load-time log.
|
||||
pub fn slots(&self) -> usize {
|
||||
self.items.len() + self.spanning.len()
|
||||
self.fine.items.len()
|
||||
+ self.coarse.as_ref().map_or(0, |c| c.items.len())
|
||||
+ self.spanning.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ascending merge of one cell's triangles with the always-tested oversized ones. Both inputs are
|
||||
/// ascending, so this is a two-pointer walk and the output order matches a linear scan exactly.
|
||||
/// Ascending merge of the three sources a column can draw from: the fine level's cell, the coarse
|
||||
/// level's cell, and the always-tested residue. All three are ascending, so this is a three-pointer
|
||||
/// walk and the output order matches a linear scan exactly — which is the guarantee every downstream
|
||||
/// tie-break (`down_ray_claim`'s first-wins, `footprint_sample`'s later-wins) rests on.
|
||||
///
|
||||
/// A triangle appears in exactly one source by construction: a face is binned at the fine level, or
|
||||
/// handed down to the coarse level, or in the residue — never two. So the merge cannot duplicate.
|
||||
pub struct ColumnCandidates<'a> {
|
||||
binned: &'a [u32],
|
||||
coarse: &'a [u32],
|
||||
spanning: &'a [u32],
|
||||
}
|
||||
|
||||
@@ -182,16 +315,22 @@ impl Iterator for ColumnCandidates<'_> {
|
||||
type Item = usize;
|
||||
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
let take_binned = match (self.binned.first(), self.spanning.first()) {
|
||||
(Some(b), Some(s)) => b <= s,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => return None,
|
||||
};
|
||||
let src = if take_binned {
|
||||
&mut self.binned
|
||||
} else {
|
||||
&mut self.spanning
|
||||
// Pick the smallest head across the three streams; `None` sorts last.
|
||||
let heads = [
|
||||
self.binned.first().copied(),
|
||||
self.coarse.first().copied(),
|
||||
self.spanning.first().copied(),
|
||||
];
|
||||
let pick = heads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(n, h)| h.map(|v| (n, v)))
|
||||
.min_by_key(|&(_, v)| v)?
|
||||
.0;
|
||||
let src = match pick {
|
||||
0 => &mut self.binned,
|
||||
1 => &mut self.coarse,
|
||||
_ => &mut self.spanning,
|
||||
};
|
||||
let (first, rest) = src.split_first()?;
|
||||
*src = rest;
|
||||
@@ -319,6 +458,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two-level structure actually engages on the dungeon shape, and the residue it leaves is
|
||||
/// small. Without this, a regression that quietly stopped building the coarse level would still
|
||||
/// pass every invariant test above — the index would just be slow again, which is exactly how
|
||||
/// 0711's residual survived ~600 decision records unnoticed.
|
||||
#[test]
|
||||
fn the_coarse_level_takes_the_slabs() {
|
||||
let tris = field(4000);
|
||||
let grid = ColumnGrid::build(tris.len(), |i| aabb(tris[i])).expect("field indexes");
|
||||
|
||||
// `field` plants a slab every 97th triangle; every one of them is far too wide to bin at
|
||||
// the fine level's ~1-triangle-per-cell sizing.
|
||||
let slabs = tris.len().div_ceil(97);
|
||||
let coarse = grid
|
||||
.coarse
|
||||
.as_ref()
|
||||
.expect("the oversized set must get its own grid");
|
||||
assert!(
|
||||
coarse.items.len() >= slabs / 2,
|
||||
"the coarse level binned {} of ~{slabs} slabs — it is not taking them",
|
||||
coarse.items.len()
|
||||
);
|
||||
assert!(
|
||||
grid.spanning.len() * 4 < slabs,
|
||||
"residue {} is not small against ~{slabs} slabs — the coarse cells are too fine",
|
||||
grid.spanning.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The merge draws from three sources; a face must appear in exactly one of them, or a column
|
||||
/// would test it twice and `later-wins` tie-breaks would read the same triangle as two hits.
|
||||
#[test]
|
||||
fn no_triangle_is_in_two_sources() {
|
||||
let tris = field(3000);
|
||||
let grid = ColumnGrid::build(tris.len(), |i| aabb(tris[i])).expect("field indexes");
|
||||
for gx in -10..=10 {
|
||||
for gy in -10..=10 {
|
||||
let (x, y) = (gx as f32 * 11.3, gy as f32 * 10.7);
|
||||
let got: Vec<usize> = grid.candidates(x, y).collect();
|
||||
let mut sorted = got.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(
|
||||
sorted.len(),
|
||||
got.len(),
|
||||
"column ({x}, {y}) yielded a duplicate"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Small face sets opt out — the caller keeps its linear scan rather than paying indirection.
|
||||
#[test]
|
||||
fn tiny_face_sets_are_not_indexed() {
|
||||
|
||||
@@ -49,7 +49,12 @@ pub fn repeat_texture_authored(chain: BlpMipChain, wrap: (bool, bool)) -> Image
|
||||
TextureDimension::D2,
|
||||
mip0,
|
||||
color_texture_format(),
|
||||
RenderAssetUsages::default(),
|
||||
// `RENDER_WORLD` — see [`image_gpu_bytes`] and the `blp.rs` `WorldArt` variant this is the
|
||||
// synchronous twin of: the render world takes the chain rather than cloning it and keeping
|
||||
// a main-world copy. Its callers are `WorldAssets::texture` and the character-skin
|
||||
// composite, and neither reads the result back; a composite is built CPU-side and handed
|
||||
// over whole.
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
image.data = Some(data);
|
||||
image.texture_descriptor.mip_level_count = levels;
|
||||
@@ -124,7 +129,11 @@ pub fn liquid_frame_array(frames: Vec<BlpMipChain>) -> Image {
|
||||
},
|
||||
TextureDimension::D2,
|
||||
color_texture_format(),
|
||||
RenderAssetUsages::default(),
|
||||
// `RENDER_WORLD`: 136 frames of a 256² nine-level RGBA8 pyramid is 47,535,264 B (the figure
|
||||
// `gpu_bytes_count_every_array_layer` pins), built at `Startup` and resident for the whole
|
||||
// process. The default usage held that twice — once on the GPU and once in system RAM,
|
||||
// which nothing ever read.
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
image.data = Some(data);
|
||||
image.texture_descriptor.mip_level_count = mip_level_count;
|
||||
@@ -273,6 +282,41 @@ pub fn sprite_image_tiled(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
image
|
||||
}
|
||||
|
||||
/// The bytes an [`Image`] occupies **on the GPU** — every mip level of every array layer, derived
|
||||
/// from the `texture_descriptor` alone.
|
||||
///
|
||||
/// Read from the descriptor, never from `image.data.len()`: an asset declared
|
||||
/// [`RenderAssetUsages::RENDER_WORLD`] has its bytes *moved* into the render world on extract
|
||||
/// (`bevy_render::render_asset`), so main-side `data` is `None` for its whole life. A meter that
|
||||
/// measures `data` therefore reads 0 for exactly the textures that cost the most — the terrain
|
||||
/// arrays, the sprite sheets, the world albedo. This is the arithmetic bevy_image itself uses, and
|
||||
/// it is block-aware, so it stays right if a format is ever uploaded compressed.
|
||||
pub fn image_gpu_bytes(image: &Image) -> usize {
|
||||
let d = &image.texture_descriptor;
|
||||
let format = d.format;
|
||||
let (bw, bh) = format.block_dimensions();
|
||||
// A copy is defined per block, and a block is only whole: a 1×1 mip of a 4×4-block format still
|
||||
// costs one full block.
|
||||
let Some(block_bytes) = format.block_copy_size(None) else {
|
||||
return 0; // multi-planar/depth-stencil: no single block size, and we upload neither
|
||||
};
|
||||
let volume = d.dimension == TextureDimension::D3;
|
||||
(0..d.mip_level_count.max(1))
|
||||
.map(|i| {
|
||||
let w = (d.size.width >> i).max(1);
|
||||
let h = (d.size.height >> i).max(1);
|
||||
// Array layers do not halve with the mip level; a 3-D texture's depth does.
|
||||
let layers = if volume {
|
||||
(d.size.depth_or_array_layers >> i).max(1)
|
||||
} else {
|
||||
d.size.depth_or_array_layers.max(1)
|
||||
};
|
||||
let blocks = w.div_ceil(bw) * h.div_ceil(bh);
|
||||
(blocks * block_bytes * layers) as usize
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Total bytes for a mip pyramid of `mip_count` levels starting at `top²` RGBA8.
|
||||
pub fn mip_chain_byte_size(top: u32, mip_count: u32) -> usize {
|
||||
(0..mip_count)
|
||||
@@ -299,3 +343,90 @@ pub fn extend_nearest(out: &mut Vec<u8>, src: &[u8], sw: u32, sh: u32, dw: u32,
|
||||
let resized = image::imageops::resize(&buf, dw, dh, image::imageops::FilterType::Nearest);
|
||||
out.extend_from_slice(resized.as_raw());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn descriptor_only(size: Extent3d, mips: u32, format: TextureFormat) -> Image {
|
||||
let mut image = Image::new_uninit(
|
||||
size,
|
||||
TextureDimension::D2,
|
||||
format,
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
image.texture_descriptor.mip_level_count = mips;
|
||||
image
|
||||
}
|
||||
|
||||
/// The arithmetic must agree with [`mip_chain_byte_size`], which the liquid array already
|
||||
/// budgets with — two independent expressions of the same pyramid.
|
||||
#[test]
|
||||
fn gpu_bytes_agree_with_the_rgba8_chain_arithmetic() {
|
||||
for (top, mips) in [(256, 9), (128, 8), (64, 7), (1, 1)] {
|
||||
let image = descriptor_only(
|
||||
Extent3d {
|
||||
width: top,
|
||||
height: top,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mips,
|
||||
TextureFormat::Rgba8Unorm,
|
||||
);
|
||||
assert_eq!(
|
||||
image_gpu_bytes(&image),
|
||||
mip_chain_byte_size(top, mips),
|
||||
"{top}²/{mips}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The number the liquid lane costs, stated once so it cannot drift silently: 136 frames of a
|
||||
/// 256² RGBA8 nine-level pyramid, stacked as array layers.
|
||||
#[test]
|
||||
fn gpu_bytes_count_every_array_layer() {
|
||||
let frames = 136;
|
||||
let image = descriptor_only(
|
||||
Extent3d {
|
||||
width: 256,
|
||||
height: 256,
|
||||
depth_or_array_layers: frames,
|
||||
},
|
||||
9,
|
||||
TextureFormat::Rgba8Unorm,
|
||||
);
|
||||
assert_eq!(image_gpu_bytes(&image), 47_535_264); // 45.33 MiB
|
||||
assert_eq!(
|
||||
image_gpu_bytes(&image),
|
||||
mip_chain_byte_size(256, 9) * frames as usize
|
||||
);
|
||||
}
|
||||
|
||||
/// Block-compressed formats are the point of reading the descriptor rather than a pixel size:
|
||||
/// a 4×4-block format still owes a WHOLE block for its 2×2 and 1×1 mips.
|
||||
#[test]
|
||||
fn gpu_bytes_are_block_aware_and_round_up() {
|
||||
let bc1 = descriptor_only(
|
||||
Extent3d {
|
||||
width: 4,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
3,
|
||||
TextureFormat::Bc1RgbaUnorm,
|
||||
);
|
||||
// 4×4 → 1 block, 2×2 → 1 block, 1×1 → 1 block; BC1 is 8 bytes a block.
|
||||
assert_eq!(image_gpu_bytes(&bc1), 24);
|
||||
// The same pyramid uncompressed is 4 B/texel with no rounding: 64 + 16 + 4.
|
||||
let rgba = descriptor_only(
|
||||
Extent3d {
|
||||
width: 4,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
3,
|
||||
TextureFormat::Rgba8Unorm,
|
||||
);
|
||||
assert_eq!(image_gpu_bytes(&rgba), 84);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +509,15 @@ fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 {
|
||||
/// `width·scale` / `height·scale` — the `layout_767_span` PRIMITIVE (`rf32-composed-resolver.md`):
|
||||
/// operands are `f32` fields, the product narrowed to `f32` at the `fstp [esp]` site, negated for
|
||||
/// LEFT/BOTTOM.
|
||||
///
|
||||
/// **`size` is not always the authored field, and this is the trap** (decision 1349, wow-re
|
||||
/// `region-size-fallback.md`): each caller's `call [eax+0x1c]` is **virtual**. A plain frame lands on
|
||||
/// `0x768420 fld [G+0x50]` — the flat read this signature assumes — but a `CSimpleTexture` lands on
|
||||
/// `0x770720` and a `CSimpleFontString` on `0x772930`, both of which substitute a **content-derived**
|
||||
/// extent when the authored value is exactly `0.0` (the texture's own texel size, one texel per
|
||||
/// FrameXML unit; the string's measured text, floored at one unit). benilla feeds this the authored
|
||||
/// number only, so a zero-authored *texture* axis yields a zero span here where the client yields a
|
||||
/// real one. 1349 §4 carries the change and its scope; do not read this kernel as the whole law.
|
||||
fn size_span(size: f32, scale: f32, negate: bool) -> f32 {
|
||||
let span = (f64::from(size) * f64::from(scale)) as f32;
|
||||
if negate {
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::order::ZTarget;
|
||||
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
|
||||
|
||||
use super::backdrop;
|
||||
use super::types::RegionData;
|
||||
use super::{ExtractedQuad, MeasureRequest, Model, QuadContent, UiScript, SCREEN};
|
||||
|
||||
/// The paint a frame slot's derived quads inherit — its `effective_alpha` and `effective_scale`
|
||||
@@ -94,6 +95,19 @@ impl InputFingerprint {
|
||||
self.f32(i.extent_y);
|
||||
}
|
||||
|
||||
/// Collapse to the single `u64` [`LayoutScope`] compares per node. Never [`NO_NODE`]: a
|
||||
/// present node reading as *absent* would be a false CLEAN — the one direction that ships a
|
||||
/// stale rect rather than a wasted resolve.
|
||||
#[inline]
|
||||
fn finish(self) -> u64 {
|
||||
let h = self.0 ^ self.1.rotate_left(32);
|
||||
if h == NO_NODE {
|
||||
1
|
||||
} else {
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rect(&mut self, r: Rect) {
|
||||
self.f32(r.bottom);
|
||||
@@ -103,6 +117,161 @@ impl InputFingerprint {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentinel in [`LayoutScope::last`]/`now` for "this id was not in that pass's graph". A real
|
||||
/// hash colliding with it costs a spurious DIRTY (a node re-solved for nothing), never a stale
|
||||
/// rect — the same one-sided failure the fingerprint above is built on.
|
||||
const NO_NODE: u64 = 0;
|
||||
|
||||
/// [`LayoutScope::node_of`] holds one roster index per id, and the two rosters are separate
|
||||
/// (`plan` for frames, `regions` for regions) — this bit says which one. Node counts run in the
|
||||
/// thousands, so the top bit is free forever.
|
||||
const REGION_TAG: u32 = 0x8000_0000;
|
||||
|
||||
/// Per-node layout scope — the state that lets a resolve touch only what MOVED (decision 1350,
|
||||
/// the phase 2 decision 0771 left open).
|
||||
///
|
||||
/// 0771 halved the per-content-change cost by folding two whole-UI solves into one; it also said
|
||||
/// plainly that halving it was not fixing it, because *one* solve is still a fixpoint over every
|
||||
/// frame and every region for a change that touches ten FontStrings inside one frame. Two later
|
||||
/// correctness fixes then tripled what a region costs — `0dd6a559a` (a region whose owner has no
|
||||
/// rect resolves from its own anchors) and 1310 (every anchor-less Texture/FontString gets
|
||||
/// implicit anchors at creation) each moved a large population out of the sweep's cheap
|
||||
/// `continue` and into full resolution. Neither is wrong; both are why the whole-UI shape had to
|
||||
/// go.
|
||||
///
|
||||
/// Everything here is indexed **densely by layout id** — frames and regions are minted from one
|
||||
/// monotonic counter (`Model::next_id`), so a single array covers both, and the solver's own rect
|
||||
/// array is indexed the same way.
|
||||
///
|
||||
/// Nothing in here allocates on a steady frame: every buffer is resized once and refilled.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LayoutScope {
|
||||
/// Per id: the input hash the last CONVERGED resolve saw, [`NO_NODE`] if the id was not in
|
||||
/// that graph. A mismatch is a dirty SEED — and a birth is a mismatch for free.
|
||||
last: Vec<u64>,
|
||||
/// How many ids `last` holds a real hash for. Compared against how many of THIS pass's nodes
|
||||
/// were also in the last graph: a shortfall means a node DIED, whose dependents we can no
|
||||
/// longer enumerate (its edges died with it), so that pass falls back to full scope.
|
||||
last_count: usize,
|
||||
/// The screen rect the `last` set was hashed under. It is every clamped frame's extent and the
|
||||
/// root of every chain, so a move is a full-scope event.
|
||||
last_screen: Option<Rect>,
|
||||
/// This pass's hash per id (parallel to `last`); swapped into `last` on convergence.
|
||||
now: Vec<u64>,
|
||||
/// Per id: index into this pass's node roster, or [`u32::MAX`].
|
||||
node_of: Vec<u32>,
|
||||
/// Per id: head index into `dep_to`/`dep_next` of the nodes that DEPEND on it, or
|
||||
/// [`u32::MAX`]. An intrusive list rather than a CSR so the edges are built in one walk.
|
||||
dep_head: Vec<u32>,
|
||||
dep_to: Vec<u32>,
|
||||
dep_next: Vec<u32>,
|
||||
/// Per id: in the dirty closure.
|
||||
dirty: Vec<bool>,
|
||||
/// The closure's worklist.
|
||||
stack: Vec<u32>,
|
||||
}
|
||||
|
||||
/// One anchored, live region as the round loop consumes it — resolved ONCE per call instead of
|
||||
/// re-derived per round (decision 1350). Before this list the sweep walked `region_data` itself
|
||||
/// every round and paid, per region per round, an `arena.region` probe for liveness/owner/kind and
|
||||
/// a `region_to_id` probe to publish the result — and it paid them for the anchor-less entries too,
|
||||
/// which it then `continue`d past.
|
||||
///
|
||||
/// It is also the addressable form the scope needs: a dirty set is a list of INDICES into this,
|
||||
/// and "sweep only what moved" is `for &i in sweep { … regions[i] … }`.
|
||||
struct RegionNode<'a> {
|
||||
rh: RegionHandle,
|
||||
data: &'a RegionData,
|
||||
/// The layout id — the solver's dense array index, and the scope's node key.
|
||||
id: u32,
|
||||
/// The owning frame: supplies the fallback edges for the axes this region's own anchors do
|
||||
/// not pin, and is therefore a layout DEPENDENCY of it.
|
||||
owner: FrameHandle,
|
||||
is_fontstring: bool,
|
||||
}
|
||||
|
||||
impl LayoutScope {
|
||||
/// Grow every per-id array to cover `n` ids and reset the per-pass ones. `last` keeps its
|
||||
/// contents (it is the memory of the previous converged pass); everything else is scratch.
|
||||
fn begin(&mut self, n: usize) {
|
||||
self.last.resize(n, NO_NODE);
|
||||
self.now.clear();
|
||||
self.now.resize(n, NO_NODE);
|
||||
self.node_of.clear();
|
||||
self.node_of.resize(n, u32::MAX);
|
||||
self.dep_head.clear();
|
||||
self.dep_head.resize(n, u32::MAX);
|
||||
self.dirty.clear();
|
||||
self.dirty.resize(n, false);
|
||||
self.dep_to.clear();
|
||||
self.dep_next.clear();
|
||||
self.stack.clear();
|
||||
}
|
||||
|
||||
/// Record that node `from` reads `to`'s rect — so a change at `to` reaches `from`.
|
||||
#[inline]
|
||||
fn edge(&mut self, from: u32, to: u32) {
|
||||
let Some(head) = self.dep_head.get_mut(to as usize) else {
|
||||
return; // an anchor to an id this pass has no node for (the screen root, a dead
|
||||
// target): it cannot become dirty, so it constrains nothing.
|
||||
};
|
||||
let e = self.dep_to.len() as u32;
|
||||
self.dep_to.push(from);
|
||||
self.dep_next.push(*head);
|
||||
*head = e;
|
||||
}
|
||||
|
||||
/// Mark `id` dirty and queue it, if it is not already.
|
||||
#[inline]
|
||||
fn seed(&mut self, id: u32) {
|
||||
if let Some(d) = self.dirty.get_mut(id as usize) {
|
||||
if !*d {
|
||||
*d = true;
|
||||
self.stack.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the dirty set under "depends on": everything that reads a dirty node's rect, and
|
||||
/// everything that reads *those*, must resolve again too.
|
||||
fn close(&mut self) {
|
||||
while let Some(id) = self.stack.pop() {
|
||||
let mut e = self.dep_head[id as usize];
|
||||
while e != u32::MAX {
|
||||
let to = self.dep_to[e as usize];
|
||||
e = self.dep_next[e as usize];
|
||||
self.seed(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything present is dirty — the fallback for the structural cases a per-node diff cannot
|
||||
/// speak about (a death, a screen move, the first pass, a graph left mid-flight by a cycle).
|
||||
fn dirty_all(&mut self) {
|
||||
for (id, &n) in self.node_of.iter().enumerate() {
|
||||
if n != u32::MAX {
|
||||
self.dirty[id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt this pass's hashes as the converged memory.
|
||||
fn commit(&mut self, count: usize, screen: Rect) {
|
||||
std::mem::swap(&mut self.last, &mut self.now);
|
||||
self.last_count = count;
|
||||
self.last_screen = Some(screen);
|
||||
}
|
||||
|
||||
/// Forget it: the next resolve must run in full scope. Called wherever a pass leaves the
|
||||
/// rects mid-flight (the cycle bail), because the memory would then describe a graph that
|
||||
/// never settled.
|
||||
pub(crate) fn invalidate(&mut self) {
|
||||
self.last_count = 0;
|
||||
self.last_screen = None;
|
||||
self.last.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_LAYOUT_VERIFY=1` — the change gate's self-check (decision 0022's instruments-first
|
||||
/// posture). When set, a resolve the gate wants to SKIP runs in full anyway and asserts it
|
||||
/// reproduced the previous rects exactly; a divergence means the fingerprint's read set is
|
||||
@@ -214,12 +383,15 @@ impl UiScript {
|
||||
frame_to_id,
|
||||
id_to_region,
|
||||
region_to_id,
|
||||
next_id,
|
||||
screen,
|
||||
warnings,
|
||||
solver,
|
||||
layout_scope: scope,
|
||||
layout_fingerprint,
|
||||
layout_epoch_resolved,
|
||||
layout_solves,
|
||||
layout_last_scope,
|
||||
layout_rounds,
|
||||
pending_size_changed,
|
||||
..
|
||||
@@ -343,18 +515,57 @@ impl UiScript {
|
||||
// `resolved` / `region_resolved` are the previous pass's OUTPUT carried forward as the
|
||||
// 0294 seed; at convergence seed and output are equal, so re-running against an unchanged
|
||||
// input set is guaranteed to reproduce them — which is precisely what makes the skip safe.
|
||||
//
|
||||
// ── …and, in the same walk, the SCOPE (decision 1350) ─────────────────────────────────
|
||||
// Each node's own inputs are hashed a second time on their own, into
|
||||
// [`LayoutScope::now`], and its anchor targets are recorded as reverse edges. That turns
|
||||
// the gate's one verdict ("something moved") into the far more useful one ("*these* moved,
|
||||
// and here is everything downstream of them"), for the cost of a second accumulator over
|
||||
// a read set we were walking anyway. Everything the aggregate `fp` feeds still feeds it,
|
||||
// byte for byte, so tiers 1 and 2 are untouched.
|
||||
// Sized past `next_id` because the region walk below MINTS ids (see there) — every id it
|
||||
// can mint is one more than the anchored regions it walks.
|
||||
scope.begin(*next_id as usize + region_data.len() + 1);
|
||||
let mut fp = InputFingerprint::default();
|
||||
fp.rect(*screen);
|
||||
fp.feed(plan.len() as u64);
|
||||
for &(_, id, input, over) in &plan {
|
||||
for (i, &(_, id, input, over)) in plan.iter().enumerate() {
|
||||
fp.feed(u64::from(id));
|
||||
fp.input(input);
|
||||
let mut node = InputFingerprint::default();
|
||||
node.input(input);
|
||||
match over {
|
||||
Some(a) => fp.anchors(std::slice::from_ref(&a)),
|
||||
None => fp.feed(u64::MAX),
|
||||
Some(a) => {
|
||||
fp.anchors(std::slice::from_ref(&a));
|
||||
node.anchors(std::slice::from_ref(&a));
|
||||
}
|
||||
None => {
|
||||
fp.feed(u64::MAX);
|
||||
node.feed(u64::MAX);
|
||||
}
|
||||
}
|
||||
if let Some(slot) = scope.now.get_mut(id as usize) {
|
||||
*slot = node.finish();
|
||||
scope.node_of[id as usize] = i as u32;
|
||||
}
|
||||
// A frame reads the rect of every anchor target it has — its OVERRIDE's target when a
|
||||
// ScrollFrame supplies one (the authored anchors are not consulted at all in that
|
||||
// case, exactly as the round loop's `set_frame_anchored` does not consult them).
|
||||
match over {
|
||||
Some(a) => scope.edge(id, a.relative_to),
|
||||
None => {
|
||||
for a in &input.anchors {
|
||||
scope.edge(id, a.relative_to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut fed_regions = 0u64;
|
||||
// The round loop's region roster, built in this same walk (decision 1350) — see
|
||||
// [`RegionNode`]. Only LIVE, anchored regions: an anchor-less one is invisible to the
|
||||
// rounds and a destroyed one has already been dropped from `region_resolved` by the retain
|
||||
// above, so both are exactly what the sweep used to `continue` past.
|
||||
let mut regions: Vec<RegionNode> = Vec::with_capacity(region_data.len());
|
||||
for (&rh, data) in region_data.iter() {
|
||||
// Anchor-less entries are invisible to the rounds — the sweep `continue`s on empty
|
||||
// anchors and the seed retain drops them — so they are not inputs and must not be
|
||||
@@ -364,21 +575,74 @@ impl UiScript {
|
||||
continue;
|
||||
}
|
||||
fed_regions += 1;
|
||||
let live = arena.region(rh);
|
||||
let mut node = InputFingerprint::default();
|
||||
fp.feed(rh.fingerprint_bits());
|
||||
fp.anchors(&data.anchors);
|
||||
node.anchors(&data.anchors);
|
||||
match data.size {
|
||||
Some((w, h)) => {
|
||||
fp.f32(w);
|
||||
fp.f32(h);
|
||||
node.f32(w);
|
||||
node.f32(h);
|
||||
}
|
||||
None => {
|
||||
fp.feed(u64::MAX);
|
||||
node.feed(u64::MAX);
|
||||
}
|
||||
None => fp.feed(u64::MAX),
|
||||
}
|
||||
match data.measured {
|
||||
Some(m) => {
|
||||
fp.f32(m.w);
|
||||
fp.f32(m.h);
|
||||
node.f32(m.w);
|
||||
node.f32(m.h);
|
||||
}
|
||||
None => fp.feed(u64::MAX),
|
||||
None => {
|
||||
fp.feed(u64::MAX);
|
||||
node.feed(u64::MAX);
|
||||
}
|
||||
}
|
||||
if let Some(r) = live {
|
||||
// Mint the layout id here if the region has never needed one. The sweep used to
|
||||
// resolve an id-less region and simply not publish its rect (`if let Some(&id) =
|
||||
// region_to_id.get(..)`) — an EditBox's implicit text FontString is exactly that
|
||||
// shape, created and anchored by `SetTextInsets` and given an id only when extract
|
||||
// or a measure first asks. The scope addresses nodes BY id, so it needs one; and
|
||||
// minting it is pure addressing — it moves no rect, so unlike `Model::region_id`
|
||||
// this does not touch the layout epoch (doing so would re-dirty the very resolve
|
||||
// that is running).
|
||||
let id = match region_to_id.get(&rh) {
|
||||
Some(&id) => id,
|
||||
None => {
|
||||
let id = *next_id;
|
||||
*next_id += 1;
|
||||
region_to_id.insert(rh, id);
|
||||
id_to_region.insert(id, rh);
|
||||
id
|
||||
}
|
||||
};
|
||||
if let Some(slot) = scope.now.get_mut(id as usize) {
|
||||
*slot = node.finish();
|
||||
scope.node_of[id as usize] = regions.len() as u32 | REGION_TAG;
|
||||
}
|
||||
// A region reads its anchor targets' rects, and — for any axis its anchors do not
|
||||
// pin — its OWNER's rect and scale. Both are dependencies; the owner one is the
|
||||
// edge that carries a window moving to every unpinned texture inside it.
|
||||
for a in &data.anchors {
|
||||
scope.edge(id, a.relative_to);
|
||||
}
|
||||
if let Some(&owner_id) = frame_to_id.get(&r.owner) {
|
||||
scope.edge(id, owner_id);
|
||||
}
|
||||
regions.push(RegionNode {
|
||||
rh,
|
||||
data,
|
||||
id,
|
||||
owner: r.owner,
|
||||
is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString),
|
||||
});
|
||||
}
|
||||
// Liveness only. The sweep also reads the region's `owner` and `kind`, but both are
|
||||
// fixed at creation and can never change under a live handle; the owner's contribution
|
||||
@@ -387,7 +651,7 @@ impl UiScript {
|
||||
// frame pass's own output). A DESTROYED region does still matter: `WidgetArena::destroy`
|
||||
// drops the arena entry but leaves `region_data` behind, so membership alone would miss
|
||||
// it and the sweep's `continue` would go unnoticed.
|
||||
fp.feed(u64::from(arena.region(rh).is_some()));
|
||||
fp.feed(u64::from(live.is_some()));
|
||||
}
|
||||
// The count of FED entries (not the map's len — see the vacuous-entry skip above), so an
|
||||
// entry leaving the anchored set is a change even if a sibling enters the same frame.
|
||||
@@ -439,7 +703,95 @@ impl UiScript {
|
||||
if !gate_skips {
|
||||
*layout_solves += 1;
|
||||
}
|
||||
|
||||
// ── The scope: which nodes this solve is actually allowed to touch (decision 1350) ────
|
||||
// A node whose own inputs are unchanged, and none of whose dependencies moved, recomputes
|
||||
// to the rect it already holds — the 0294 seed property, stated per node instead of for
|
||||
// the graph as a whole. So the solve below runs over the dirty CLOSURE and leaves every
|
||||
// other rect at its cached value, feeding it to the solver as an external where a dirty
|
||||
// node anchors to it.
|
||||
//
|
||||
// Four structural cases the per-node diff cannot speak about fall back to full scope,
|
||||
// because getting them wrong ships a stale rect rather than a slow frame:
|
||||
// * no memory yet (the first resolve, or a pass invalidated below);
|
||||
// * the SCREEN moved — the root external and every clamped frame's extent;
|
||||
// * a node DIED: its reverse edges died with it, so its dependents are unreachable.
|
||||
// Counted, not searched: if fewer of this pass's nodes carry a `last` hash than the
|
||||
// last pass had nodes, something left;
|
||||
// * verify, which runs the scoped pass and then the full one and compares.
|
||||
let mut matched = 0usize;
|
||||
let mut scope_nodes = 0usize;
|
||||
for (id, &n) in scope.node_of.iter().enumerate() {
|
||||
if n == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
scope_nodes += 1;
|
||||
if scope.last.get(id).copied().unwrap_or(NO_NODE) != NO_NODE {
|
||||
matched += 1;
|
||||
}
|
||||
}
|
||||
let a_node_died = matched != scope.last_count;
|
||||
let full_scope = scope.last.is_empty()
|
||||
|| scope.last_screen != Some(*screen)
|
||||
|| a_node_died
|
||||
|| gate_skips; // the verify path's re-run: it must reproduce the whole graph
|
||||
if full_scope {
|
||||
scope.dirty_all();
|
||||
} else {
|
||||
for (id, &n) in scope.node_of.iter().enumerate() {
|
||||
if n != u32::MAX && scope.now[id] != scope.last[id] {
|
||||
scope.stack.push(id as u32);
|
||||
scope.dirty[id] = true;
|
||||
}
|
||||
}
|
||||
scope.close();
|
||||
}
|
||||
// The rosters the rounds walk. A frame in the closure is SOLVED; a region in it is SWEPT;
|
||||
// anything a closure node anchors to is SEEDED as an external at its cached rect. A dirty
|
||||
// REGION is seeded as well as swept: the frame pass runs before the sweep that refreshes
|
||||
// it, so it needs last round's value in front of it — which is exactly what the whole-graph
|
||||
// seeding did before.
|
||||
let mut solve_frames: Vec<u32> = Vec::new();
|
||||
let mut sweep_regions: Vec<u32> = Vec::new();
|
||||
let mut seed_frames: Vec<u32> = Vec::new();
|
||||
let mut seed_regions: Vec<u32> = Vec::new();
|
||||
// Is any node that reads `id` in the closure? (Seeding is per-node work too, so a rect
|
||||
// nothing dirty reads must not be seeded.)
|
||||
let read_by_dirty = |scope: &LayoutScope, id: u32| -> bool {
|
||||
let mut e = scope.dep_head[id as usize];
|
||||
while e != u32::MAX {
|
||||
if scope.dirty[scope.dep_to[e as usize] as usize] {
|
||||
return true;
|
||||
}
|
||||
e = scope.dep_next[e as usize];
|
||||
}
|
||||
false
|
||||
};
|
||||
for id in 0..scope.node_of.len() {
|
||||
let n = scope.node_of[id];
|
||||
if n == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let (idx, is_region, dirty) = (n & !REGION_TAG, n & REGION_TAG != 0, scope.dirty[id]);
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let id32 = id as u32;
|
||||
if is_region {
|
||||
if dirty {
|
||||
sweep_regions.push(idx);
|
||||
seed_regions.push(idx);
|
||||
} else if read_by_dirty(scope, id32) {
|
||||
seed_regions.push(idx);
|
||||
}
|
||||
} else if dirty {
|
||||
solve_frames.push(idx);
|
||||
} else if read_by_dirty(scope, id32) {
|
||||
seed_frames.push(idx);
|
||||
}
|
||||
}
|
||||
// The meter: how WIDE this solve is, beside `layout_solves` (how often) and
|
||||
// `layout_rounds` (how deep). The gate asserts on it, because a scope that tracks the
|
||||
// graph is the regression and milliseconds are not evidence of it (decision 0735's lesson,
|
||||
// paid for twice).
|
||||
*layout_last_scope = (solve_frames.len(), sweep_regions.len());
|
||||
let round_cap = plan.len() + region_data.len() + 2;
|
||||
// `WOW_LAYOUT_PROF=1` — the per-solve shape: rounds, the graph's size, and the split
|
||||
// between the frame solve and the region sweep. The numbers that say whether a solve is
|
||||
@@ -453,25 +805,35 @@ impl UiScript {
|
||||
let t_round = prof.then(std::time::Instant::now);
|
||||
let mut changed = false;
|
||||
|
||||
// Seed the solver: the screen root, then every region rect settled so far (regions are
|
||||
// externals to the frame solve — the fixpoint is what closes the loop between them).
|
||||
// Frame ids and region ids come from one monotonic counter, so both live in the
|
||||
// solver's single dense rect array and every anchor-target lookup is an array index.
|
||||
// Seed the solver: the screen root, then every rect this pass is NOT recomputing but
|
||||
// something in it reads (regions are externals to the frame solve — the fixpoint is
|
||||
// what closes the loop between them). Frame ids and region ids come from one monotonic
|
||||
// counter, so both live in the solver's single dense rect array and every anchor-target
|
||||
// lookup is an array index.
|
||||
solver.begin();
|
||||
solver.set_external(SCREEN, *screen);
|
||||
for (&id, rh) in id_to_region.iter() {
|
||||
if let Some(r) = region_resolved.get(rh) {
|
||||
for &i in &seed_regions {
|
||||
let n = ®ions[i as usize];
|
||||
if let Some(r) = region_resolved.get(&n.rh) {
|
||||
solver.set_external(n.id, *r);
|
||||
}
|
||||
}
|
||||
for &i in &seed_frames {
|
||||
let (h, id, _, _) = plan[i as usize];
|
||||
if let Some(r) = resolved.get(&h) {
|
||||
solver.set_external(id, *r);
|
||||
}
|
||||
}
|
||||
for &(_, id, input, over) in &plan {
|
||||
for &i in &solve_frames {
|
||||
let (_, id, input, over) = plan[i as usize];
|
||||
match over {
|
||||
Some(a) => solver.set_frame_anchored(id, input, a),
|
||||
None => solver.set_frame(id, input),
|
||||
}
|
||||
}
|
||||
solver.solve();
|
||||
for &(h, id, _, _) in &plan {
|
||||
for &i in &solve_frames {
|
||||
let (h, id, _, _) = plan[i as usize];
|
||||
match solver.rect(id) {
|
||||
Some(r) => {
|
||||
if resolved.get(&h) != Some(&r) {
|
||||
@@ -503,21 +865,17 @@ impl UiScript {
|
||||
}
|
||||
let t_reg = prof.then(std::time::Instant::now);
|
||||
let mut scratch = LayoutInput::default();
|
||||
for (&rh, data) in region_data.iter() {
|
||||
for &ri in &sweep_regions {
|
||||
let &RegionNode {
|
||||
rh,
|
||||
data,
|
||||
id: region_id,
|
||||
owner,
|
||||
is_fontstring,
|
||||
} = ®ions[ri as usize];
|
||||
if prof {
|
||||
n_regions_swept += 1;
|
||||
}
|
||||
if data.anchors.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((owner, is_fontstring)) = arena.region(rh).map(|r| {
|
||||
(
|
||||
r.owner,
|
||||
matches!(r.kind, crate::widget::RegionKind::FontString),
|
||||
)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
// An owner with NO resolved rect does not disqualify its regions. `owner_rect`
|
||||
// is only the fallback for the axes this region's own anchors do not pin (see the
|
||||
// two `axis(..)` calls below) — a region anchored fully to some OTHER frame needs
|
||||
@@ -579,6 +937,16 @@ impl UiScript {
|
||||
//
|
||||
// `None` = this axis needs the owner and the owner has no rect (above): the region
|
||||
// is unresolvable, like its owner.
|
||||
//
|
||||
// **The owner fallback is OURS, not the client's** (decision 1349, from wow-re's
|
||||
// byte-verified `region-size-fallback.md`): a complete operand enumeration of the
|
||||
// real resolver `[0x7671a0, 0x76761f)` finds no parent pointer in it at all. The
|
||||
// client reaches the same answer for the case that matters — a region with a parent
|
||||
// and NO anchors — at *attach* time instead, via an implicit `SetAllPoints(parent)`
|
||||
// (`0x7701c0` / `0x771480`), and reaches a DIFFERENT answer for every other shape,
|
||||
// because its size getters are virtual and content-derived (see `layout::size_span`).
|
||||
// That is why an authored-zero-width texture with one anchor resolves to its OWNER's
|
||||
// width here and to 8 points there. 1349 §4 carries the replacement and its scope.
|
||||
let axis = |lo: Option<f32>,
|
||||
hi: Option<f32>,
|
||||
owner: Option<(f32, f32)>|
|
||||
@@ -607,9 +975,7 @@ impl UiScript {
|
||||
// Publish into the solver as well as the model: a later region in THIS same sweep
|
||||
// that anchors to this one must see the fresh rect (the sweep has always worked
|
||||
// that way — it read the same `region_resolved` map it was writing).
|
||||
if let Some(&id) = region_to_id.get(&rh) {
|
||||
solver.set_external(id, rect);
|
||||
}
|
||||
solver.set_external(region_id, rect);
|
||||
if region_resolved.get(&rh) != Some(&rect) {
|
||||
region_resolved.insert(rh, rect);
|
||||
changed = true;
|
||||
@@ -621,12 +987,22 @@ impl UiScript {
|
||||
}
|
||||
if !changed {
|
||||
if prof {
|
||||
// `anchored` (live + anchored regions) is reported beside `regions_total`
|
||||
// deliberately: the sweep never touched the difference, so a solve's real
|
||||
// population was invisible in this line — which is exactly why two commits
|
||||
// that moved a large batch of regions from "skipped" to "resolved" (1350's
|
||||
// pin) showed up as a cost with no counter behind it. `solved`/`swept` are
|
||||
// this pass's SCOPE: with the graph settled they should be a handful.
|
||||
eprintln!(
|
||||
"[layout-prof] rounds={} frames={} regions_total={} regions_swept={} \
|
||||
frame_us={} region_us={}",
|
||||
"[layout-prof] rounds={} frames={} regions_total={} anchored={} \
|
||||
solved={} swept={} scope={} regions_swept={} frame_us={} region_us={}",
|
||||
round + 1,
|
||||
plan.len(),
|
||||
region_data.len(),
|
||||
regions.len(),
|
||||
solve_frames.len(),
|
||||
sweep_regions.len(),
|
||||
if full_scope { "full" } else { "dirty" },
|
||||
n_regions_swept,
|
||||
t_frames.as_micros(),
|
||||
t_regions.as_micros()
|
||||
@@ -645,6 +1021,11 @@ impl UiScript {
|
||||
);
|
||||
}
|
||||
*layout_fingerprint = Some(fp);
|
||||
// The scope's memory is adopted only HERE, on the converged path, for the same
|
||||
// reason the fingerprint is: a pass left mid-flight describes a graph that never
|
||||
// settled, and seeding the next pass's "unchanged ⇒ unmoved" argument from it
|
||||
// would be seeding it from a lie.
|
||||
scope.commit(scope_nodes, *screen);
|
||||
// Tier 1 closes only on a `gate_skips` frame: a real solve (`!gate_skips`) hashed
|
||||
// `fp` over now-outgrown seeds — see the gate above. The re-store here is for the
|
||||
// VERIFY path, whose full re-run of a settled frame passed through the state
|
||||
@@ -664,7 +1045,9 @@ impl UiScript {
|
||||
}
|
||||
// The cycle bail (the loop ran out of rounds and warned above): the rects it leaves are
|
||||
// still the ones every reader will see this frame, so the sizes that moved get their
|
||||
// `OnSizeChanged` here exactly as on the converged path.
|
||||
// `OnSizeChanged` here exactly as on the converged path — and the scope forgets, so the
|
||||
// next resolve rebuilds the whole graph rather than trusting a half-solved one.
|
||||
scope.invalidate();
|
||||
queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed);
|
||||
}
|
||||
|
||||
|
||||
@@ -930,6 +930,25 @@ impl UiScript {
|
||||
/// engine deliberately never sees the seam scale — so staleness is the host's to declare; the
|
||||
/// round-trips re-answer on the frames that follow (the same one-frame convergence every
|
||||
/// measure already has).
|
||||
/// Force the next [`UiScript::resolve`] to rebuild the **whole** layout graph rather than the
|
||||
/// dirty closure a scoped resolve would (decision 1350) — and to run at all, rather than stop
|
||||
/// at either change gate.
|
||||
///
|
||||
/// This is the scoped resolve's own falsifier, and it exists so the claim the scope rests on —
|
||||
/// *a node whose own inputs and whose dependencies did not move recomputes to the rect it
|
||||
/// already holds* — is something a test can **disprove** rather than something the engine
|
||||
/// argues. Drive a change, resolve, read the rects; call this, resolve again, read them again;
|
||||
/// they must be identical. `WOW_LAYOUT_VERIFY` makes the same comparison automatically on every
|
||||
/// SETTLED frame; this is for the frames that never settle, which is exactly the hover sweep
|
||||
/// the scope was built for.
|
||||
pub fn force_full_layout_resolve(&mut self) {
|
||||
let mut model = self.model_mut();
|
||||
model.layout_scope.invalidate();
|
||||
model.layout_fingerprint = None;
|
||||
model.layout_epoch_resolved = None;
|
||||
model.touch_layout();
|
||||
}
|
||||
|
||||
pub fn invalidate_text_measures(&mut self) {
|
||||
let mut model = self.model_mut();
|
||||
for d in model.region_data.values_mut() {
|
||||
@@ -1061,6 +1080,17 @@ impl UiScript {
|
||||
self.model_ref().layout_rounds
|
||||
}
|
||||
|
||||
/// The last solve's SCOPE — `(frames solved, regions swept)`, decision 1350's meter.
|
||||
///
|
||||
/// The third axis of a solve's cost, and the one that used to be "all of it": solves says how
|
||||
/// OFTEN, rounds says how DEEP, this says how WIDE. A change that touches ten FontStrings must
|
||||
/// read a handful here however large the UI grows; a scope that tracks the graph is the
|
||||
/// regression, and it is asserted as a COUNT because milliseconds have twice failed to catch
|
||||
/// this class (0735, 0771).
|
||||
pub fn layout_last_scope(&self) -> (usize, usize) {
|
||||
self.model_ref().layout_last_scope
|
||||
}
|
||||
|
||||
/// Is `name` a registered FrameXML template — one `CreateFrame`'s fourth argument or an
|
||||
/// `inherits=` can resolve (decision 1203)?
|
||||
///
|
||||
|
||||
@@ -85,6 +85,10 @@ pub(crate) struct Model {
|
||||
/// change-gated — `tooltip::append_line`, and the gate
|
||||
/// `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`.
|
||||
pub(crate) layout_epoch: u64,
|
||||
/// Per-node input hashes + the dirty-closure scratch — what makes a resolve cost the nodes
|
||||
/// that MOVED rather than the whole graph (decision 1350; see `script::layout::LayoutScope`).
|
||||
/// Tiers 1 and 2 above decide *whether* to solve; this decides *what*.
|
||||
pub(crate) layout_scope: super::layout::LayoutScope,
|
||||
/// The [`Self::layout_epoch`] value the last SETTLED resolve closed on — one whose
|
||||
/// fingerprint matched, proving the input set (seeds included) has stopped moving. `None`
|
||||
/// forces the next resolve through tier 1: the initial state, a cycle-bailed pass, and every
|
||||
@@ -97,6 +101,11 @@ pub(crate) struct Model {
|
||||
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
|
||||
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
|
||||
pub(crate) layout_solves: u64,
|
||||
/// The SCOPE of the last solve that ran: `(frames solved, regions swept)` — decision 1350's
|
||||
/// own meter, and the number its gate asserts on. `layout_solves` says how often the gate let
|
||||
/// a solve through and `layout_rounds` how deep each went; this says how WIDE, which is the
|
||||
/// axis that used to be "the whole UI" and is the one a growing UI silently inflates.
|
||||
pub(crate) layout_last_scope: (usize, usize),
|
||||
/// How many fixpoint ROUNDS those solves ran in total. A solve's cost is rounds × the whole
|
||||
/// graph, so this is what separates "the gate let too much through" from "each pass is doing
|
||||
/// too much" — the two have different fixes.
|
||||
@@ -1087,6 +1096,8 @@ impl Model {
|
||||
solver: LayoutSolver::new(),
|
||||
layout_fingerprint: None,
|
||||
layout_epoch: 0,
|
||||
layout_scope: super::layout::LayoutScope::default(),
|
||||
layout_last_scope: (0, 0),
|
||||
layout_epoch_resolved: None,
|
||||
layout_solves: 0,
|
||||
layout_rounds: 0,
|
||||
|
||||
@@ -615,8 +615,28 @@ pub fn classify_entity_interior(
|
||||
// anchor walk replaced). Cheap enough to leave in: three counters and one `Instant` per frame.
|
||||
static COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
if *COST.get_or_init(|| std::env::var_os("WOW_INTERIOR_COST").is_some()) {
|
||||
// `WOW_COLUMN_COST=1` adds the column-query split to the same line: this lane IS the
|
||||
// grid's caller, so its per-frame traffic belongs beside its per-frame cost. `spanning` was
|
||||
// 0711's residual — the oversized floor slabs tested on every query, 99.26 % of everything
|
||||
// tested when 1351 finally measured it. It is now the coarse level's residue and should
|
||||
// read near zero; `spanning_pct` climbing is the regression watch.
|
||||
let column = if benilla_assets::column_grid::column_cost_enabled() {
|
||||
let (queries, binned, coarse, spanning) =
|
||||
benilla_assets::column_grid::take_column_query_stats();
|
||||
let tested = binned + coarse + spanning;
|
||||
let pct = if tested == 0 {
|
||||
0.0
|
||||
} else {
|
||||
100.0 * spanning as f32 / tested as f32
|
||||
};
|
||||
format!(
|
||||
" col_queries={queries} col_tested={tested} col_binned={binned} col_coarse={coarse} col_spanning={spanning} spanning_pct={pct:.1}"
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
eprintln!(
|
||||
"[interior-cost] anchors={n_anchors} resolved={n_resolved} fade_blocked={n_fade_blocked} parts_written={n_written} resolve_ms={:.2} total_ms={:.2}",
|
||||
"[interior-cost] anchors={n_anchors} resolved={n_resolved} fade_blocked={n_fade_blocked} parts_written={n_written} resolve_ms={:.2} total_ms={:.2}{column}",
|
||||
resolve_us / 1000.0,
|
||||
_t0.elapsed().as_secs_f32() * 1000.0
|
||||
);
|
||||
|
||||
@@ -98,6 +98,30 @@ pub mod testing {
|
||||
pub fn plain_particle_def() -> ParticleEmitterDef {
|
||||
particle_def(ParticleShape::Plane)
|
||||
}
|
||||
|
||||
/// A bind-pose [`crate::rig_anim::RigPose`] for tests that resolve consumer anchors
|
||||
/// (decision 1355: anchors spawn on first demand through `RigPose::anchor_for`, so a test
|
||||
/// wearer/mount/caster needs a pose, not a hand-built joint map): one root-parented joint per
|
||||
/// entry, seated at its local translation.
|
||||
pub fn test_rig_pose(
|
||||
root: bevy::ecs::entity::Entity,
|
||||
joints: &[bevy::math::Vec3],
|
||||
) -> crate::rig_anim::RigPose {
|
||||
let skeleton = benilla_assets::ModelSkeleton {
|
||||
joints: joints
|
||||
.iter()
|
||||
.map(|&t| benilla_assets::ModelJoint {
|
||||
parent: -1,
|
||||
local_translation: t,
|
||||
billboard: None,
|
||||
parent_arm: None,
|
||||
})
|
||||
.collect(),
|
||||
spine_bone: None,
|
||||
head_bone: None,
|
||||
};
|
||||
crate::rig_anim::RigPose::new(root, &skeleton)
|
||||
}
|
||||
}
|
||||
|
||||
pub mod art_scope;
|
||||
|
||||
@@ -139,6 +139,71 @@ impl RigPose {
|
||||
} * local;
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor entity standing in for `bone`, spawned on first demand (decision 1355): a
|
||||
/// consumer that needs an *entity* on a bone — a held item's parent, an emitter's owner
|
||||
/// frame, a quest marker's seat — resolves it here, and only bones something actually
|
||||
/// consumes ever get one. At the LBRS pin, 96 % of the eagerly-spawned population hosted
|
||||
/// nothing (decision 1354).
|
||||
///
|
||||
/// The spawn seats from `model` — the same matrices the compose pass re-seats every anchor
|
||||
/// from — so an anchor created mid-frame (a weapon equipped in combat) stands at the current
|
||||
/// composed pose, never the rest pose. `rig` is the [`RigPose`] holder (`self` cannot know
|
||||
/// its own entity); the anchor parents under [`Self::joints_root`] and dies with it.
|
||||
///
|
||||
/// `None` = the bone is outside this skeleton (an out-of-range authored reference — the
|
||||
/// consumer misses, as it always has).
|
||||
pub fn anchor_for(
|
||||
&mut self,
|
||||
commands: &mut Commands,
|
||||
rig: Entity,
|
||||
bone: u16,
|
||||
) -> Option<Entity> {
|
||||
if let Some(&(_, anchor)) = self.anchors.iter().find(|&&(b, _)| b == bone) {
|
||||
return Some(anchor);
|
||||
}
|
||||
let m = self.model.get(bone as usize)?;
|
||||
let (scale, rotation, translation) = m.to_scale_rotation_translation();
|
||||
let anchor = commands
|
||||
.spawn((
|
||||
Transform {
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
},
|
||||
Visibility::default(),
|
||||
RigAnchor { rig, bone },
|
||||
))
|
||||
.id();
|
||||
commands.entity(self.joints_root).add_child(anchor);
|
||||
self.anchors.push((bone, anchor));
|
||||
Some(anchor)
|
||||
}
|
||||
|
||||
/// The world-space point `offset` under `bone` at the current composed pose — what a
|
||||
/// consumer that only ever *reads* a position (the overhead anchor, a missile launch point,
|
||||
/// the bowstring's nock) gets instead of an anchor entity, so those reads spawn nothing
|
||||
/// (decision 1355). `root_global` is [`Self::joints_root`]'s `GlobalTransform` — exactly the
|
||||
/// frame an anchor's own global would compose through.
|
||||
///
|
||||
/// One deliberate difference from reading a spawned anchor: `model` carries the composed
|
||||
/// pose with the `flags & 0x7` arm folded in but **not** the world pass's camera-billboard
|
||||
/// replacement. No attachment point or event marker sits on a camera-faced bone (those bones
|
||||
/// carry cards, which stay entity-anchored via [`Self::anchor_for`]), so nothing observable
|
||||
/// rides on the difference.
|
||||
pub fn posed_point(
|
||||
&self,
|
||||
root_global: &GlobalTransform,
|
||||
bone: u16,
|
||||
offset: Vec3,
|
||||
) -> Option<Vec3> {
|
||||
let m = self.model.get(bone as usize)?;
|
||||
Some(
|
||||
root_global
|
||||
.affine()
|
||||
.transform_point3(m.transform_point3(offset)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// On every consumer anchor: which rig and bone it stands in for. The body twist's model-frame
|
||||
|
||||
Reference in New Issue
Block a user