Arriving bodies composite off the main thread and show once dressed (#732)

A crowd of arriving players was composited in one frame, on the main thread, under the archive lock: 40 geared looks took 76–115 ms.

In 1.12.1 an unforced full composite never waits on its section loads (`0x44b430`), and a unit whose first composite is pending is not drawn: ShouldRender `0x607da0` feeds the draw flag `0x710c50`, and the flag goes to the mount model when mounted. It appears dressed once the composite is done.

Now an arriving body composites on the async pool, one task per look, reading outside the lock. The body, and its mount, are built once the atlas lands. Arriving now costs the main thread 0.26–0.45 ms for 40 looks. The appear fade runs from arrival, as `FadeTo` is armed at create.

A re-dress, a rig heal and the glue and dressing-room previews stay in the frame. Baked NPC skins are unchanged.

Fixes #609

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 20:14:10 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 15e83f2483
commit 4772489aad
17 changed files with 1588 additions and 231 deletions
+45 -27
View File
@@ -28,6 +28,10 @@ use display::{
mod attach;
use attach::{attach_entity_visuals, build_dressup_preview, build_glue_pet, build_glue_preview};
/// Composited body skins by look, built off the main thread.
mod skin_composite;
use skin_composite::{land_skin_composites, SkinComposites, SkinKey, SkinSections};
/// The dynamic point lights an entity's own model carries, such as a held torch.
mod carried_light;
use carried_light::spawn_carried_lights;
@@ -371,34 +375,28 @@ struct GameObjects {
#[derive(Resource)]
struct Characters(CharacterGeosets);
/// The `CharSections` skin lookup; without it a player's body skin stays untextured.
#[derive(Resource)]
struct SkinSections(CharSections);
/// Character-creation data (body displays, race and class combos, appearance ranges).
#[derive(Resource)]
pub(crate) struct CharCreate(pub(crate) CharCreateCatalog);
/// Composited body skins by look, so every player wearing a look shares one 256² atlas.
#[derive(Resource, Default)]
struct SkinComposites(benilla_assets::SpatialCache<SkinKey, Handle<Image>>);
/// When a net entity arrived, on the appear fade's clock: the reference stamps a unit's fade as its
/// create block is processed (`0x465c50` → `0x5fb880`, a player's through `0x5debe0` →
/// `0x613af0` at `0x5fb956` → `0x614f80`), before its model or composite is ready, so the ramp runs
/// from here however long the visual waits. A mount child carries its rider's, as the reference
/// writes the unit's one fade onto the mount model (`0x614ae6`–`0x614af4`, `0x614b9e`–`0x614bba`).
#[derive(Component, Clone, Copy, Debug, PartialEq)]
pub(crate) struct Arrival(pub(crate) f32);
/// What decides a composited body skin: race and sex pick the `CharSections` rows, the dials pick
/// the variations, and `equip` holds the worn armour display ids by body slot − 2.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(super) struct SkinKey {
pub(super) race: u8,
pub(super) sex: u8,
pub(super) skin: u8,
pub(super) face: u8,
pub(super) facial_hair: u8,
pub(super) hair_style: u8,
pub(super) hair_color: u8,
pub(super) equip: [u32; 8],
/// The guild emblem: two guilds' members wear one tabard display but must not share an atlas.
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
/// The tabard designer's preview: the emblem paints over an empty tabard slot.
pub(super) tabard_preview: bool,
/// Stamp each net entity's [`Arrival`] the frame it streams in.
fn stamp_arrivals(
mut commands: Commands,
time: Res<Time>,
arrived: Query<Entity, (Added<NetEntity>, Without<Arrival>)>,
) {
let now = time.elapsed_secs();
for entity in &arrived {
commands.entity(entity).insert(Arrival(now));
}
}
/// Marks a net entity whose visual is attached; the `waterfx` rig pre-marks its dummy unit.
@@ -436,9 +434,9 @@ fn evict_display_caches(
items.as_ref().map_or(0, |i| i.models.len()),
fx.models.len(),
glows.as_ref().map_or(0, |g| g.models.len()),
composites.0.len(),
composites.done.len(),
);
composites.0.clear();
composites.clear();
fx.models.clear();
if let Some(mut c) = creatures {
c.models.clear();
@@ -461,7 +459,10 @@ fn scope_entity_art(
mut scope: benilla_world::art_scope::ArtScope,
mut composites: ResMut<SkinComposites>,
) {
scope.apply(&mut composites.0, benilla_world::art_scope::ArtSlot::Skins);
scope.apply(
&mut composites.done,
benilla_world::art_scope::ArtSlot::Skins,
);
}
/// A built body's armed-idle box in model space, which [`publish_world_units`] restates as
@@ -590,6 +591,21 @@ impl Plugin for EntitiesPlugin {
.add_message::<live_display::DisplaySwapped>()
.add_systems(Startup, setup_entities.after(AssetSet::Open))
.add_systems(Update, (evict_display_caches, scope_entity_art))
// An arrival is stamped the frame it streams in, before its visual is asked for.
.add_systems(
Update,
stamp_arrivals
.after(WorldStage::Net)
.before(EntityVisualsSet),
)
// Finished body composites land before the frame's bodies ask for their atlas.
.add_systems(
Update,
land_skin_composites
.after(evict_display_caches)
.before(EntityVisualsSet)
.after(WorldStage::Net),
)
// After the net stage, whose Commands create the entity; until then its readers take the
// constructor default.
.add_systems(Update, stamp_collision_heights.after(WorldStage::Net))
@@ -826,7 +842,9 @@ fn setup_entities(
Err(e) => warn!("character geosets unavailable, players show every geoset: {e:#}"),
}
match CharSections::load(&mut chain) {
Ok(sections) => commands.insert_resource(SkinSections(sections)),
Ok(sections) => {
commands.insert_resource(SkinSections::new(sections, world_assets.chain.clone()))
}
Err(e) => warn!("char sections unavailable, player bodies stay untextured: {e:#}"),
}
match CharCreateCatalog::load(&mut chain) {
@@ -0,0 +1,455 @@
//! Bodies arriving together, against the real install: each composite runs off the main thread and
//! its body is built only once it can draw dressed, as the reference withholds a unit whose first
//! composite is pending (`0x607da0` → `0x477860`, `0x481749` → `0x710c50`).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use benilla_formats::{load_item_display_catalog, open_chain, CharSections, NpcAppearance};
use benilla_protocol::ObjectFields;
use super::super::skin_composite::{land_skin_composites, SkinKey};
use super::super::{Equipment, ItemDisplays};
use super::*;
/// The one character-body display every arrival wears.
const BODY: u32 = 42;
/// Worn display ids by bodyslot − 2: tier-2 sets at their 1.12 displays.
const SETS: [[u32; 8]; 4] = [
[10840, 33983, 33990, 33986, 33989, 33982, 33984, 0],
[0, 33650, 31110, 31115, 31111, 31127, 33651, 0],
[0, 33667, 33665, 33672, 34269, 33666, 33668, 0],
[0, 34081, 34078, 34084, 34083, 34079, 34082, 0],
];
/// One synthetic batch that can feather, so a built body has a child to show and joins its
/// appear ramp.
fn body_part() -> super::super::EntityPart {
super::super::EntityPart {
mesh: Handle::default(),
geometry: Arc::new(benilla_formats::RenderSubmesh::default()),
aabb: None,
skinned_mesh: None,
welded_billboard: false,
material: Handle::default(),
material_interior: None,
material_interior_bake: None,
material_interior_bake_blend: None,
fade_blend: Some(Handle::default()),
zfill: None,
blend: benilla_formats::ModelBlend::Opaque,
additive: false,
two_sided: false,
geoset_id: 0,
char_slot: None,
billboard: None,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
/// A loaded character-body display; `npc` gives it a creature-display look.
fn body_display(npc: Option<NpcAppearance>) -> DisplayModel {
DisplayModel {
handle: ModelHandle::M2(Handle::default()),
parts: Some(vec![body_part()]),
is_character_body: true,
npc_appearance: npc,
..super::super::display::empty_shell()
}
}
/// The attach chain over the real tables and chain: finished composites land, then bodies build.
fn app() -> Option<App> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = open_chain(&data).expect("open chain");
let tables = CharSections::load(&mut chain).expect("CharSections");
let items = load_item_display_catalog(&mut chain).expect("ItemDisplayInfo");
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<Image>()
.init_asset::<Mesh>()
.init_asset::<benilla_assets::materials::WowModelMaterial>()
.init_resource::<benilla_world::model_render::ModelMaterials>()
.init_resource::<super::super::corpse::BonesModels>()
.init_resource::<SkinComposites>()
.init_resource::<merge::MergedFormsCache>()
.init_resource::<benilla_world::doodad_anim::UvAnimMaterials>()
.init_resource::<benilla_world::doodad_anim::TintAnimMaterials>()
.init_resource::<benilla_world::mat_anim_table::MatAnimTable>()
.init_resource::<benilla_world::rig_palette::RigPalettes>()
.init_resource::<benilla_world::collision::ColliderEpoch>()
.insert_resource(CubeAssets {
mesh: Handle::default(),
player_mesh: Handle::default(),
player_mat: Handle::default(),
npc_mat: Handle::default(),
})
.insert_resource(SkinSections::new(tables, Arc::new(Mutex::new(chain))))
.insert_resource(ItemDisplays::icons_for_tests(items))
.add_systems(
Update,
(
super::super::stamp_arrivals,
land_skin_composites,
attach_entity_visuals,
)
.chain(),
);
Some(app)
}
/// A player's look: race, sex, face, hair style and its worn set.
#[derive(Clone, Copy)]
struct Look {
race: u8,
sex: u8,
face: u8,
hair: u8,
equip: [u32; 8],
}
impl Look {
fn key(&self) -> SkinKey {
SkinKey {
race: self.race,
sex: self.sex,
skin: 0,
face: self.face,
facial_hair: 0,
hair_style: self.hair,
hair_color: 0,
equip: self.equip,
emblem: None,
tabard_preview: false,
}
}
}
/// A player arriving in `look`, its gear settled.
fn arrive(app: &mut App, look: Look) -> Entity {
let bytes_0 = u32::from(look.race) | u32::from(look.sex) << 16;
let bytes = u32::from(look.face) << 8 | u32::from(look.hair) << 16;
app.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Player,
display_id: Some(BODY),
scale: 1.0,
},
Equipment {
bodyslots: look.equip,
settled: true,
..Default::default()
},
// UNIT_FIELD_BYTES_0 (36): race, gender; PLAYER_BYTES (193): face, hair style.
ObjectStore(ObjectFields::from_pairs(&[(36, bytes_0), (193, bytes)])),
Transform::default(),
Visibility::default(),
))
.id()
}
fn built(app: &App, e: Entity) -> bool {
app.world().entity(e).contains::<VisualAttached>()
}
fn children(app: &App, e: Entity) -> usize {
app.world().get::<Children>(e).map_or(0, |c| c.len())
}
/// A crowd arriving in one frame costs that frame no composite: every body waits, drawing
/// nothing, while its look composites off the main thread, and each is built on a frame its
/// finished atlas is already cached. A look already cached builds at once.
#[test]
fn a_crowd_composites_off_the_main_thread_and_each_body_waits_for_its_atlas() {
let Some(mut app) = app() else { return };
app.world_mut().insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([(BODY, body_display(None))]),
});
let races = [1u8, 2, 3, 4, 5, 6, 7, 8];
let looks: Vec<Look> = (0..12)
.map(|i| Look {
race: races[i % races.len()],
sex: (i / races.len()) as u8,
face: (i % 3) as u8,
hair: (i % 4) as u8,
equip: SETS[i % SETS.len()],
})
.collect();
let crowd: Vec<Entity> = looks.iter().map(|&l| arrive(&mut app, l)).collect();
app.update();
let composites = app.world().resource::<SkinComposites>();
assert_eq!(
composites.done.len(),
0,
"the arrival frame composited nothing on the main thread"
);
assert_eq!(composites.running(), crowd.len(), "one composite per look");
for &e in &crowd {
assert!(!built(&app, e), "a body waits for its composite");
assert_eq!(children(&app, e), 0, "and draws nothing meanwhile");
}
let deadline = Instant::now() + Duration::from_secs(20);
while crowd.iter().any(|&e| !built(&app, e)) {
assert!(Instant::now() < deadline, "every composite lands");
std::thread::sleep(Duration::from_millis(1));
app.update();
for (&e, look) in crowd.iter().zip(&looks) {
if built(&app, e) {
let atlas = app
.world_mut()
.resource_mut::<SkinComposites>()
.done
.fetch(&look.key());
assert!(
matches!(atlas, Some(Some(_))),
"a body is built only over its finished atlas"
);
assert!(children(&app, e) > 0, "and draws once built");
}
}
}
assert_eq!(app.world().resource::<SkinComposites>().running(), 0);
// A look already composited is not waited on.
let twin = arrive(&mut app, looks[0]);
app.update();
assert!(built(&app, twin), "a cached look builds on its first frame");
assert_eq!(app.world().resource::<SkinComposites>().running(), 0);
}
/// A body that waited on its composite joins the appear ramp its arrival started: the reference's
/// fade is stamped by the create block's appear handler (`0x613af0` → `0x614f80`) and ramps on the
/// wall clock whatever ShouldRender answers (`0x614a90` at `0x60800c`).
#[test]
fn a_body_that_waited_for_its_atlas_fades_from_its_arrival() {
let Some(mut app) = app() else { return };
app.world_mut().insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([(BODY, body_display(None))]),
});
let look = Look {
race: 1,
sex: 0,
face: 0,
hair: 0,
equip: SETS[0],
};
let player = arrive(&mut app, look);
app.update();
let arrived = app
.world()
.get::<super::super::Arrival>(player)
.expect("stamped the frame it arrived")
.0;
assert!(!built(&app, player), "the body waits for its atlas");
let deadline = Instant::now() + Duration::from_secs(20);
while !built(&app, player) {
assert!(Instant::now() < deadline, "the composite lands");
std::thread::sleep(Duration::from_millis(1));
app.update();
}
let built_at = app.world().resource::<Time>().elapsed_secs();
assert!(
built_at > arrived,
"the body was built frames after it arrived"
);
assert_eq!(
app.world()
.get::<benilla_world::model_fade::UnitAppearFade>(player)
.copied(),
Some(benilla_world::model_fade::UnitAppearFade::Pending {
since: built_at,
arrived,
}),
"its ramp runs from its arrival, its backstop from its build"
);
}
/// benilla's own palette heal rebuilds a body that was drawn: its composite is forced, so even a
/// look the cache has dropped rebuilds in the same frame and never drops out.
#[test]
fn a_healed_rig_rebuilds_its_body_in_the_same_frame() {
use bevy::ecs::system::RunSystemOnce;
let Some(mut app) = app() else { return };
app.world_mut().insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([(BODY, body_display(None))]),
});
let look = Look {
race: 2,
sex: 1,
face: 1,
hair: 2,
equip: SETS[1],
};
let player = arrive(&mut app, look);
let deadline = Instant::now() + Duration::from_secs(20);
while !built(&app, player) {
assert!(Instant::now() < deadline, "the composite lands");
std::thread::sleep(Duration::from_millis(1));
app.update();
}
// The scope sweep dropped the look, then the rig starved and heals.
app.world_mut()
.resource_mut::<SkinComposites>()
.done
.clear();
app.world_mut()
.entity_mut(player)
.insert((crate::net::Guid(7), benilla_world::rig_palette::RigStarved));
app.world_mut()
.run_system_once(super::super::live_display::heal_rig_starved)
.expect("the heal runs");
assert!(!built(&app, player), "the heal tore the visual down");
app.update();
assert!(built(&app, player), "and it rebuilt in the same frame");
assert!(children(&app, player) > 0, "drawing");
assert_eq!(app.world().resource::<SkinComposites>().running(), 0);
}
/// A creature display with a baked atlas loads it as is and is never waited on (`0x477866`); one
/// without composites like a player and waits.
#[test]
fn a_baked_npc_skin_never_waits_on_the_compositor() {
let Some(mut app) = app() else { return };
let npc = |bake_name: Option<&str>| NpcAppearance {
race: 1,
sex: 0,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
equipment: [0; 10],
bake_name: bake_name.map(str::to_owned),
};
app.world_mut().insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([
(7, body_display(Some(npc(Some("HumanMaleGuard.blp"))))),
(8, body_display(Some(npc(None)))),
]),
});
let mut unit = |display: u32| {
app.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Unit,
display_id: Some(display),
scale: 1.0,
},
Transform::default(),
Visibility::default(),
))
.id()
};
let (baked, live) = (unit(7), unit(8));
app.update();
assert!(built(&app, baked), "a baked skin builds on its first frame");
assert!(
!built(&app, live),
"a composited NPC skin waits for its atlas"
);
assert_eq!(app.world().resource::<SkinComposites>().running(), 1);
}
/// What a crowd of 40 distinct looks costs the main thread: its arrival frame's requests, each
/// frame's landing, against compositing the same crowd on this thread.
#[test]
#[ignore = "instrument: run by hand — cargo test -p benilla-app --lib arrival_tests::crowd_main_thread_cost -- --ignored --nocapture"]
fn crowd_main_thread_cost() {
use super::super::skin_composite::SkinComposites;
let data = benilla_formats::wow_data_or_skip!();
bevy::tasks::AsyncComputeTaskPool::get_or_init(bevy::tasks::TaskPool::new);
let mut chain = open_chain(&data).expect("open chain");
let tables = CharSections::load(&mut chain).expect("CharSections");
let items = load_item_display_catalog(&mut chain).expect("ItemDisplayInfo");
let sections = SkinSections::new(tables, Arc::new(Mutex::new(chain)));
let races = [1u8, 2, 3, 4, 5, 6, 7, 8];
let keys: Vec<SkinKey> = (0..40)
.map(|i| {
Look {
race: races[i % races.len()],
sex: (i / races.len() % 2) as u8,
face: (i % 3) as u8,
hair: (i % 4) as u8,
equip: SETS[i % SETS.len()],
}
.key()
})
.collect();
let plan = |key: &SkinKey| {
let worn: [Option<&benilla_formats::ItemDisplay>; 8] = std::array::from_fn(|i| {
(key.equip[i] != 0)
.then(|| items.get(key.equip[i]))
.flatten()
});
sections.tables.composite_plan(
key.race,
key.sex,
key.skin,
key.face,
key.facial_hair,
key.hair_style,
key.hair_color,
worn,
key.emblem,
key.tabard_preview,
)
};
let ms = |d: Duration| d.as_secs_f64() * 1e3;
let mut images = Assets::<Image>::default();
let mut lane = SkinComposites::default();
let t = Instant::now();
for key in &keys {
lane.request(*key, &sections, || plan(key));
}
let arrival = t.elapsed();
let (mut landing, mut worst, mut frames) = (Duration::ZERO, Duration::ZERO, 0);
let wall = Instant::now();
while lane.running() > 0 {
std::thread::sleep(Duration::from_millis(1));
let t = Instant::now();
if lane.land(&mut images) > 0 {
frames += 1;
}
let d = t.elapsed();
landing += d;
worst = worst.max(d);
}
println!(
"LANE arrival frame: {:.3} ms for {} looks; landing {:.3} ms over {frames} landing frames \
(worst {:.3} ms); all landed {:.1} ms after arrival",
ms(arrival),
keys.len(),
ms(landing),
ms(worst),
ms(wall.elapsed())
);
let mut sync = SkinComposites::default();
let t = Instant::now();
for key in &keys {
sync.force(*key, &sections, || plan(key), &mut images);
}
println!(
"LANE the same crowd composited on this thread: {:.1} ms",
ms(t.elapsed())
);
}
@@ -7,9 +7,9 @@ use bevy::prelude::*;
use crate::net::{NetEntity, ObjectStore};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::{repeat_texture_authored, LockRecover, WorldAssets};
use super::super::{DisplayModel, EntityPart, SkinKey, SkinSections};
use super::super::skin_composite::BodyAtlas;
use super::super::{DisplayModel, EntityPart, SkinComposites, SkinKey, SkinSections};
/// A character-model body's appearance, from the wire or from its display's
/// CreatureDisplayInfoExtra row; it drives both the geoset selection and the skin materials.
@@ -214,24 +214,131 @@ fn shared_skin_probe() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_PROBE_SHARED_SKIN").is_some())
}
/// Build a character body's per-appearance materials: the body atlas (composited live, or an NPC's
/// baked BLP), the hair, the cape and the extra skin, each at its batches' own blend and
/// sidedness from `parts`. Without the tables or the light buffer, parts keep their built material.
#[allow(clippy::type_complexity)]
pub(super) fn build_char_skin_materials(
/// The composite a look wears: `None` for a baked NPC atlas, which is never composited.
pub(super) fn skin_key(
look: &CharLook,
// The armor ids paint only a live composite; a baked NPC atlas already carries its gear.
equip: [u32; 8],
cloak: u32,
// Painted over a tabard whose display asks for it; `None` leaves the tabard's own art.
emblem: Option<benilla_formats::GuildEmblem>,
tabard_preview: bool,
) -> Option<SkinKey> {
let BodySkin::Composite { face } = look.body else {
return None;
};
// `WOW_PROBE_SHARED_SKIN` prices a shared body material and is never a look: one key for every
// body lets Bevy's batcher instance them.
if shared_skin_probe() {
return Some(SkinKey {
race: 1,
sex: 0,
skin: 0,
face: 0,
facial_hair: 0,
hair_style: 0,
hair_color: 0,
equip: [0; 8],
emblem: None,
tabard_preview: false,
});
}
Some(SkinKey {
race: look.race,
sex: look.sex,
skin: look.skin,
face,
facial_hair: look.facial_hair,
hair_style: look.hair_style,
hair_color: look.hair_color,
equip,
emblem,
tabard_preview,
})
}
/// What `key`'s composite reads, from the tables and the worn displays' rows.
fn composite_plan(
key: &SkinKey,
sections: &SkinSections,
displays: Option<&super::super::ItemDisplays>,
) -> Option<benilla_formats::CompositePlan> {
let mut worn: [Option<&benilla_formats::ItemDisplay>; 8] = [None; 8];
if let Some(catalog) = displays.map(|d| &d.catalog) {
for (i, id) in key.equip.iter().enumerate() {
if *id != 0 {
worn[i] = catalog.get(*id);
}
}
}
sections.tables.composite_plan(
key.race,
key.sex,
key.skin,
key.face,
key.facial_hair,
key.hair_style,
key.hair_color,
worn,
key.emblem,
key.tabard_preview,
)
}
/// A world body's atlas. A baked NPC skin loads as is and is never waited on (the fork at
/// `0x477866`); a composited one is cached, or running off the main thread, started here on a
/// miss. Without the tables the body stays untextured.
pub(super) fn body_atlas(
look: &CharLook,
key: Option<SkinKey>,
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
world_assets: Option<&WorldAssets>,
parts: &[EntityPart],
composites: &mut SkinComposites,
asset_server: &AssetServer,
) -> BodyAtlas {
if let BodySkin::Baked(name) = &look.body {
return BodyAtlas::Ready(Some(asset_server.load::<Image>(baked_npc_url(name))));
}
match (key, sections) {
(Some(key), Some(sections)) => {
composites.request(key, sections, || composite_plan(&key, sections, displays))
}
_ => BodyAtlas::Ready(None),
}
}
/// [`body_atlas`] composited on this thread on a miss ([`SkinComposites::force`]): the previews, whose
/// composite the reference forces, a re-dress of a standing body and a rig-heal rebuild.
pub(super) fn forced_body_atlas(
look: &CharLook,
key: Option<SkinKey>,
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
composites: &mut SkinComposites,
asset_server: &AssetServer,
images: &mut Assets<Image>,
skin_cache: &mut benilla_assets::SpatialCache<SkinKey, Handle<Image>>,
) -> Option<Handle<Image>> {
if let BodySkin::Baked(name) = &look.body {
return Some(asset_server.load::<Image>(baked_npc_url(name)));
}
let (key, sections) = (key?, sections?);
composites.force(
key,
sections,
|| composite_plan(&key, sections, displays),
images,
)
}
/// Build a character body's per-appearance materials: the body atlas ([`body_atlas`]), the hair,
/// the cape and the extra skin, each at its batches' own blend and sidedness from `parts`. Without
/// the tables or the light buffer, parts keep their built material.
pub(super) fn build_char_skin_materials(
look: &CharLook,
body_tex: Option<Handle<Image>>,
cloak: u32,
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
parts: &[EntityPart],
asset_server: &AssetServer,
mats: &mut benilla_world::model_render::M2BatchMaterials,
) -> CharSkinMaterials {
@@ -250,80 +357,6 @@ pub(super) fn build_char_skin_materials(
)
};
// The composite reads its BLPs synchronously off the shared chain, once per look behind the
// cache.
let body_tex: Option<Handle<Image>> = match &look.body {
BodySkin::Baked(name) => Some(asset_server.load::<Image>(baked_npc_url(name))),
BodySkin::Composite { face } => world_assets.and_then(|world| {
let mut key = SkinKey {
race: look.race,
sex: look.sex,
skin: look.skin,
face: *face,
facial_hair: look.facial_hair,
hair_style: look.hair_style,
hair_color: look.hair_color,
equip,
emblem,
tabard_preview,
};
// `WOW_PROBE_SHARED_SKIN` prices a shared body material and is never a look: one key
// for every body lets Bevy's batcher instance them.
if shared_skin_probe() {
key = SkinKey {
race: 1,
sex: 0,
skin: 0,
face: 0,
facial_hair: 0,
hair_style: 0,
hair_color: 0,
equip: [0; 8],
emblem: None,
tabard_preview: false,
};
}
match skin_cache.fetch(&key) {
Some(handle) => Some(handle),
None => {
let catalog = displays.map(|d| &d.catalog);
let mut worn: [Option<&benilla_formats::ItemDisplay>; 8] = [None; 8];
if let Some(catalog) = catalog {
for (i, id) in equip.iter().enumerate() {
if *id != 0 {
worn[i] = catalog.get(*id);
}
}
}
let chain = &mut world.chain.lock_recover();
let composed = sections
.0
.composite_body(
chain,
key.race,
key.sex,
key.skin,
key.face,
key.facial_hair,
key.hair_style,
key.hair_color,
worn,
key.emblem,
key.tabard_preview,
)
.ok()??;
// Through the upload gate like every texture: a no-op on this RGBA8
// composite, but it keeps the format and the bytes in agreement.
let handle = images.add(repeat_texture_authored(
benilla_assets::for_upload(composed),
(true, true),
));
skin_cache.insert(key, handle.clone());
Some(handle)
}
}
}),
};
// A single-sided and a two-sided set, chosen per batch by its own M2 `0x04`: the robe skirt
// (geoset 1302) is authored two-sided, the closed body is not.
let body = body_tex.map(|tex| {
@@ -342,7 +375,7 @@ pub(super) fn build_char_skin_materials(
// The hair texture (M2 type 6) also dresses the facial hair of races whose beards are
// geometry, so it resolves through `hair_mesh_texture`'s bald fallback: a bald orc has a beard.
let hair = sections
.0
.tables
.hair_mesh_texture(look.race, look.sex, look.hair_style, look.hair_color)
.and_then(|path| {
let hair_part = parts
@@ -377,7 +410,7 @@ pub(super) fn build_char_skin_materials(
// skinColor, loaded plain as the reference does, never composited. Its batches are an opaque
// single-sided core and alpha-cut two-sided fringe cards, so one set per sidedness.
let skin_extra = sections
.0
.tables
.skin_extra_texture(look.race, look.sex, look.skin)
.map_or((None, None), |path| {
let tex = asset_server.load::<Image>(format!(
@@ -660,7 +660,10 @@ mod tests {
bake_center: Vec3::ZERO,
idle_aabb: None,
now: 0.0,
fade: JoinedFade::Pending { since: SINCE },
fade: JoinedFade::Pending {
since: SINCE,
arrived: SINCE,
},
};
let mut own = TestOwn::default();
let mut queue = bevy::ecs::world::CommandQueue::default();
+80 -21
View File
@@ -7,7 +7,6 @@ use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use benilla_assets::WorldAssets;
use bevy::animation::transition::AnimationTransitions;
use crate::creature_anim::AnimDriver;
@@ -19,13 +18,17 @@ use benilla_world::model_fade::JoinedFade;
use benilla_world::model_render::ModelKind;
use benilla_world::particles;
use super::skin_composite::BodyAtlas;
use super::{
Characters, Creatures, CubeAssets, DisplayModel, GameObjects, ModelHandle, SkinComposites,
SkinSections, VisualAttached,
};
mod char_skin;
use char_skin::{build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip};
use char_skin::{
body_atlas, build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip,
skin_key,
};
mod dress;
mod merge;
use dress::{spawn_group, PartDress};
@@ -33,6 +36,8 @@ pub(super) use merge::MergedFormsCache;
mod preview;
pub(crate) use preview::equip_slot;
pub(super) use preview::{build_dressup_preview, build_glue_pet, build_glue_preview};
#[cfg(test)]
mod arrival_tests;
mod redress;
/// One body's drawn parts as `WOW_DRESS_CENSUS` prints them: index, merge group, blend, material.
@@ -229,6 +234,8 @@ pub(super) fn attach_entity_visuals(
Option<&super::mount::MountChild>,
Option<&super::mount::MountBody>,
Has<crate::transport::TransportAnchor>,
Option<&super::Arrival>,
Has<super::live_display::ShownRebuild>,
),
// A torn-down unit gets no model: the reference frees it at once (`0x464920`).
(
@@ -254,8 +261,6 @@ pub(super) fn attach_entity_visuals(
// The character-skin build chain, nested for Bevy's 16-param system limit.
skin_build: (
Option<Res<SkinSections>>,
Option<Res<WorldAssets>>,
ResMut<Assets<Image>>,
ResMut<SkinComposites>,
Res<AssetServer>,
benilla_world::model_render::M2BatchMaterials,
@@ -264,6 +269,8 @@ pub(super) fn attach_entity_visuals(
ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
ResMut<benilla_world::mat_anim_table::MatAnimTable>,
// A forced composite's upload.
ResMut<Assets<Image>>,
),
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
mut collider_epoch: ResMut<benilla_world::collision::ColliderEpoch>,
@@ -271,8 +278,6 @@ pub(super) fn attach_entity_visuals(
) {
let (
sections,
world_assets,
mut images,
mut skin_composites,
asset_server,
mut mats,
@@ -281,9 +286,23 @@ pub(super) fn attach_entity_visuals(
mut uv_reg,
mut tint_reg,
mut anim_table,
mut images,
) = skin_build;
let now = time.elapsed_secs();
for (entity, net, equipment, reattached, mount_child, mount_body, anchored) in &pending {
for (
entity,
net,
equipment,
reattached,
mount_child,
mount_body,
anchored,
arrival,
shown_rebuild,
) in &pending
{
// The appear ramp's origin: the unit's arrival, however long its visual waited to build.
let arrived = arrival.map_or(now, |a| a.0);
// A player attaches once its equipment settles, so it never flashes naked.
if net.kind == EntityKind::Player && !equipment.is_some_and(|e| e.settled) {
continue;
@@ -328,6 +347,44 @@ pub(super) fn attach_entity_visuals(
None => None,
};
// A character model carries every hair, facial and body geoset, and is shared by its
// display, so the entity's own look picks which show.
let look = model.and_then(|_| resolve_char_look(net, dm, entity, &stores));
// A body whose composite is still running is not built: nothing of it draws, its mount
// included, until it can draw dressed, as the reference's ShouldRender answers the
// composite driver's 0 (`0x607e7c` → `0x481749`). Retried each frame, with the look of
// that frame. A display swap waits the same way, a fresh component in the reference
// (`0x607da0` → `0x5fb200`); a body benilla rebuilds while it was drawn does not.
let body_tex = match look.as_ref() {
Some(l) => {
let key = skin_key(l, equip, worn.emblem, worn.tabard_preview);
if shown_rebuild {
char_skin::forced_body_atlas(
l,
key,
displays.as_deref(),
sections.as_deref(),
&mut skin_composites,
&asset_server,
&mut images,
)
} else {
match body_atlas(
l,
key,
displays.as_deref(),
sections.as_deref(),
&mut skin_composites,
&asset_server,
) {
BodyAtlas::Ready(tex) => tex,
BodyAtlas::Pending => continue,
}
}
}
None => None,
};
// Invisible GameObjects and trigger creatures hide by transparent or empty M2s, never by a
// flag: the reference draws any loaded model and culls zero-alpha batches (`0x707b3a`),
// so their `parts` are empty and `named_a_model` keeps the cube off them.
@@ -353,6 +410,7 @@ pub(super) fn attach_entity_visuals(
mount_child.map(|&super::mount::MountChild(c)| c),
mount_display,
reattached,
arrival.copied(),
) {
super::mount::Seat::Wait => continue,
super::mount::Seat::Frame(anchor) => rider_root = anchor,
@@ -529,9 +587,6 @@ pub(super) fn attach_entity_visuals(
.insert(crate::creature_anim::BodyTwist::new(spine, head));
}
}
// A character model carries every hair, facial and body geoset, and is shared by its
// display, so the entity's own look picks which show.
let look = resolve_char_look(net, dm, entity, &stores);
// The worn geoset selectors, the helm's hide-mask rows included (`0x4799a0`).
let equip_geosets = equip_geosets(
displays.as_deref(),
@@ -553,16 +608,11 @@ pub(super) fn attach_entity_visuals(
let char_mats = match look.as_ref() {
Some(l) => build_char_skin_materials(
l,
equip,
body_tex,
worn.cloak,
worn.emblem,
worn.tabard_preview,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut mats,
),
@@ -629,7 +679,10 @@ pub(super) fn attach_entity_visuals(
fade: if reattached {
JoinedFade::Steady
} else {
JoinedFade::Pending { since: now }
JoinedFade::Pending {
since: now,
arrived,
}
},
};
// The shown batches spawn as material groups (`merge`).
@@ -657,9 +710,12 @@ pub(super) fn attach_entity_visuals(
}
// A `Reattached` rebuild drops any in-flight fade, as its parts spawned steady.
if unit_will_fade {
commands
.entity(entity)
.insert(benilla_world::model_fade::UnitAppearFade::Pending { since: now });
commands.entity(entity).insert(
benilla_world::model_fade::UnitAppearFade::Pending {
since: now,
arrived,
},
);
} else if reattached {
commands
.entity(entity)
@@ -854,7 +910,10 @@ pub(super) fn attach_entity_visuals(
.insert(VisualAttached)
// The display the visual was built with, for `refresh_live_display` to diff.
.insert(super::live_display::AppliedDisplay(net.display_id))
.remove::<super::equipment::Reattached>();
.remove::<(
super::equipment::Reattached,
super::live_display::ShownRebuild,
)>();
}
}
@@ -17,7 +17,6 @@ use crate::portrait::{
PreviewRider,
};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WorldAssets;
use super::super::equipment::{attach_id, ensure_item_model, placement, ItemModelKind};
use super::super::item_glow::{self, ItemGlows};
@@ -102,7 +101,6 @@ pub(in crate::entities) struct PreviewCtx<'a, 'w> {
/// nobody wears.
pub(in crate::entities) glows: Option<&'a mut ItemGlows>,
pub(in crate::entities) sections: Option<&'a SkinSections>,
pub(in crate::entities) world_assets: Option<&'a WorldAssets>,
pub(in crate::entities) images: &'a mut Assets<Image>,
pub(in crate::entities) skin_composites: &'a mut SkinComposites,
pub(in crate::entities) asset_server: &'a AssetServer,
@@ -120,7 +118,6 @@ pub(in crate::entities) fn build_glue_preview(
mut displays: Option<ResMut<ItemDisplays>>,
mut glows: Option<ResMut<ItemGlows>>,
sections: Option<Res<SkinSections>>,
world_assets: Option<Res<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut skin_composites: ResMut<SkinComposites>,
asset_server: Res<AssetServer>,
@@ -208,7 +205,6 @@ pub(in crate::entities) fn build_glue_preview(
displays: displays.as_deref_mut(),
glows: glows.as_deref_mut(),
sections: sections.as_deref(),
world_assets: world_assets.as_deref(),
images: &mut images,
skin_composites: &mut skin_composites,
asset_server: &asset_server,
@@ -286,7 +282,6 @@ pub(in crate::entities) fn build_dressup_preview(
mut displays: Option<ResMut<ItemDisplays>>,
mut glows: Option<ResMut<ItemGlows>>,
sections: Option<Res<SkinSections>>,
world_assets: Option<Res<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut skin_composites: ResMut<SkinComposites>,
asset_server: Res<AssetServer>,
@@ -342,7 +337,6 @@ pub(in crate::entities) fn build_dressup_preview(
displays: displays.as_deref_mut(),
glows: glows.as_deref_mut(),
sections: sections.as_deref(),
world_assets: world_assets.as_deref(),
images: &mut images,
skin_composites: &mut skin_composites,
asset_server: &asset_server,
@@ -574,18 +568,22 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
let visible = ctx.characters.map(|c| {
c.0.visible_geosets(race, sex, char_look.hair_style, char_look.facial_hair, &eg)
});
let char_mats = super::char_skin::build_char_skin_materials(
let body_tex = super::char_skin::forced_body_atlas(
&char_look,
bodyslots,
cloak,
spec.emblem,
false,
super::char_skin::skin_key(&char_look, bodyslots, spec.emblem, false),
ctx.displays.as_deref(),
ctx.sections,
ctx.world_assets,
parts,
ctx.skin_composites,
ctx.asset_server,
ctx.images,
&mut ctx.skin_composites.0,
);
let char_mats = super::char_skin::build_char_skin_materials(
&char_look,
body_tex,
cloak,
ctx.displays.as_deref(),
ctx.sections,
parts,
ctx.asset_server,
ctx.mats,
);
+119 -22
View File
@@ -11,7 +11,6 @@ use bevy::prelude::*;
use crate::net::{NetEntity, ObjectStore};
use crate::portrait::PortraitPart;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WorldAssets;
use benilla_world::interior::InteriorLit;
use benilla_world::model_fade::{
join_unit_appear_fade, FadeMaterials, PendingAppearFade, RenderFade,
@@ -23,8 +22,8 @@ use super::super::{
VisualAttached,
};
use super::char_skin::{
build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip,
CharSkinMaterials,
build_char_skin_materials, equip_geosets, forced_body_atlas, resolve_char_look,
resolve_worn_equip, skin_key, CharSkinMaterials,
};
use super::dress::{part_materials, spawn_group, DressedPart, PartDress};
use super::merge::{self, DressedGroup, MergedFormsCache};
@@ -81,13 +80,13 @@ pub(in crate::entities) fn redress_player_looks(
// The skin build chain, nested for Bevy's system-param limit.
skin_build: (
Option<Res<SkinSections>>,
Option<Res<WorldAssets>>,
ResMut<Assets<Image>>,
ResMut<SkinComposites>,
Res<AssetServer>,
benilla_world::model_render::M2BatchMaterials,
ResMut<Assets<Mesh>>,
ResMut<MergedFormsCache>,
// The composite's upload.
ResMut<Assets<Image>>,
),
// The own-material lane `spawn_part` takes; no character batch in the shipped data uses it.
mut own_lane: (
@@ -97,16 +96,8 @@ pub(in crate::entities) fn redress_player_looks(
),
time: Res<Time>,
) {
let (
sections,
world_assets,
mut images,
mut skin_composites,
asset_server,
mut mats,
mut meshes,
mut merged,
) = skin_build;
let (sections, mut skin_composites, asset_server, mut mats, mut meshes, mut merged, mut images) =
skin_build;
let now = time.elapsed_secs();
for (entity, net, live, mut applied, children, rig, bones, mut pose, bake_center, unit_fade) in
&mut players
@@ -129,6 +120,21 @@ pub(in crate::entities) fn redress_player_looks(
let worn = resolve_worn_equip(net, Some(live), Some(dm));
let look = resolve_char_look(net, Some(dm), entity, &stores);
// A re-dress is the reference's incremental composite (`0x47789c`–`0x4778c3`): the new
// geosets apply at once and the atlas composites that frame when its sections are
// resident, never gated or withheld. So it composites here, in the frame, taking over a
// running composite of the same look; only an arriving body waits for its atlas.
let body_tex = look.as_ref().and_then(|l| {
forced_body_atlas(
l,
skin_key(l, worn.bodyslots, worn.emblem, worn.tabard_preview),
displays.as_deref(),
sections.as_deref(),
&mut skin_composites,
&asset_server,
&mut images,
)
});
let eg = equip_geosets(
displays.as_deref(),
&worn.bodyslots,
@@ -144,16 +150,11 @@ pub(in crate::entities) fn redress_player_looks(
let char_mats: CharSkinMaterials = match look.as_ref() {
Some(l) => build_char_skin_materials(
l,
worn.bodyslots,
body_tex,
worn.cloak,
worn.emblem,
worn.tabard_preview,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut mats,
),
@@ -490,6 +491,58 @@ mod tests {
self.app.update();
}
/// Composite against the real tables and chain, landing each frame before the re-dress;
/// `false` without the install.
fn with_skin_data(&mut self) -> bool {
let Some(data) = benilla_formats::wow_data() else {
benilla_formats::skipped("no WoW install found", &benilla_formats::candidates());
return false;
};
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let tables = benilla_formats::CharSections::load(&mut chain).expect("CharSections");
let items = benilla_formats::load_item_display_catalog(&mut chain).expect("items");
self.app
.insert_resource(SkinSections::new(
tables,
std::sync::Arc::new(std::sync::Mutex::new(chain)),
))
.insert_resource(ItemDisplays::icons_for_tests(items))
.add_systems(
Update,
super::super::super::skin_composite::land_skin_composites
.before(redress_player_looks),
);
true
}
/// What the player's visual is dressed with.
fn applied(&self) -> Equipment {
self.app
.world()
.get::<AppliedEquipment>(self.player)
.unwrap()
.0
}
/// Whether the atlas of the fixture's look (race 1, sex 0, every dial 0) in `gear` is
/// composited and cached.
fn composited(&mut self, gear: Equipment) -> bool {
let key = super::super::super::SkinKey {
race: 1,
sex: 0,
skin: 0,
face: 0,
facial_hair: 0,
hair_style: 0,
hair_color: 0,
equip: gear.bodyslots,
emblem: None,
tabard_preview: false,
};
let mut lane = self.app.world_mut().resource_mut::<SkinComposites>();
matches!(lane.done.fetch(&key), Some(Some(_))) && lane.running() == 0
}
/// The batch indices currently standing under the player.
fn showing(&mut self) -> Vec<u32> {
let mut out: Vec<u32> = self
@@ -527,7 +580,7 @@ mod tests {
);
}
/// The re-dress fires once per change: the composite behind it reads BLPs synchronously.
/// The re-dress fires once per change.
#[test]
fn a_gear_change_restamps_what_the_visual_is_dressed_with() {
let mut s = stand(&[0], &[0], None);
@@ -569,4 +622,48 @@ mod tests {
"and the held item never moved",
);
}
/// Tier-2 gear, worn by bodyslot − 2.
fn tier2(sets: [u32; 8]) -> Equipment {
Equipment {
bodyslots: sets,
settled: true,
..Default::default()
}
}
/// A gear change on a standing body is the reference's incremental composite, never gated or
/// withheld (`0x47789c`–`0x4778c3`): the new look, atlas included, lands the frame it is seen.
#[test]
fn a_gear_change_composites_in_the_frame_it_is_seen() {
let mut s = stand(&[0, 401], &[0, 1], None);
if !s.with_skin_data() {
return;
}
let gear = tier2([10840, 33983, 33990, 33986, 33989, 33982, 33984, 0]);
s.wear(gear);
assert_eq!(s.applied(), gear, "the change lands this frame");
assert!(s.composited(gear), "over its own composited atlas");
assert_eq!(s.showing(), vec![0, 1], "the body kept drawing");
assert!(
s.app.world().get_entity(s.held).is_ok(),
"the held item never moved"
);
}
/// Gear that changes again: each change lands its own frame, and the latest is the look worn.
#[test]
fn the_latest_gear_wins() {
let mut s = stand(&[0], &[0], None);
if !s.with_skin_data() {
return;
}
let first = tier2([0, 33650, 31110, 31115, 31111, 31127, 33651, 0]);
let last = tier2([0, 33667, 33665, 33672, 34269, 33666, 33668, 0]);
s.wear(first);
assert_eq!(s.applied(), first);
s.wear(last);
assert_eq!(s.applied(), last, "the latest gear is worn");
assert!(s.composited(last));
}
}
@@ -1253,7 +1253,10 @@ mod tests {
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
UnitAppearFade::Pending {
since: SINCE,
arrived: SINCE,
},
))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
@@ -1341,7 +1344,10 @@ mod tests {
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
UnitAppearFade::Pending {
since: SINCE,
arrived: SINCE,
},
))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
@@ -192,6 +192,11 @@ pub(super) fn tick_scale_ease(
}
}
/// A rebuild of a body that was drawn, benilla's own palette heal: its composite is forced, so the
/// body never drops out for frames as an arriving one waits, and rebuilds at once as it always has.
#[derive(Component)]
pub(super) struct ShownRebuild;
/// Palette headroom the healer waits for, under the doodad reaper's low-water (256) so the reaper
/// makes room first; rebuilding into a tight table would starve again.
const HEAL_MIN_HEADROOM: usize = 128;
@@ -246,7 +251,7 @@ pub(super) fn heal_rig_starved(
super::equipment::HeldAttached,
benilla_world::rig_palette::RigStarved,
)>()
.insert(super::equipment::Reattached);
.insert((super::equipment::Reattached, ShownRebuild));
}
}
+17 -2
View File
@@ -44,6 +44,8 @@ pub(super) fn spawn_mount_child(
display: u32,
scale: f32,
fade_skip: bool,
// The rider's arrival, so the mount rides the rider's appear ramp.
arrival: Option<super::Arrival>,
) -> Entity {
let child = commands
.spawn((
@@ -57,6 +59,9 @@ pub(super) fn spawn_mount_child(
Visibility::default(),
))
.id();
if let Some(arrival) = arrival {
commands.entity(child).insert(arrival);
}
if fade_skip {
commands.entity(child).insert(super::equipment::Reattached);
}
@@ -120,13 +125,21 @@ pub(super) fn seat_or_spawn_mount(
child: Option<Entity>,
mount_display: u32,
fade_skip: bool,
arrival: Option<super::Arrival>,
) -> Seat {
// `0x607a75`: `SCALE_X` × `creatureModelScale` alone, without `CreatureModelData.modelScale`.
let mount_scale = creatures
.and_then(|c| c.catalog.display_scale(mount_display))
.unwrap_or(1.0);
let Some(child) = child else {
spawn_mount_child(commands, unit, mount_display, mount_scale, fade_skip);
spawn_mount_child(
commands,
unit,
mount_display,
mount_scale,
fade_skip,
arrival,
);
return Seat::Wait;
};
// Built for a display the field has left, or gone: drop it and start over next pass.
@@ -226,6 +239,7 @@ pub(super) fn reseat_mounts(
&ObjectStore,
Option<&AppliedMount>,
Option<&MountChild>,
Option<&super::Arrival>,
),
// A torn-down unit is freed, not re-seated (`0x464920`).
(
@@ -239,7 +253,7 @@ pub(super) fn reseat_mounts(
conforms: Query<(), With<super::conform::ConformNode>>,
creatures: Option<Res<super::Creatures>>,
) {
for (entity, net, store, applied, child) in &units {
for (entity, net, store, applied, child, arrival) in &units {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
@@ -297,6 +311,7 @@ pub(super) fn reseat_mounts(
child,
live,
true,
arrival.copied(),
) {
Seat::Wait => {}
Seat::Frame(anchor) => {
@@ -286,3 +286,35 @@ fn a_mount_without_a_seat_leaves_the_body_at_the_unit_matrix() {
assert_eq!(s.frame(), s.rider);
assert!(s.attachments_alive());
}
/// A mount child rides its rider's appear ramp: it carries the rider's arrival, which the arrival
/// stamp leaves alone, as the reference writes the unit's one fade onto the mount model
/// (`0x614ae6`–`0x614af4`, `0x614b9e`–`0x614bba`).
#[test]
fn a_mount_child_carries_its_riders_arrival() {
use crate::entities::Arrival;
use bevy::ecs::world::CommandQueue;
let mut app = App::new();
app.init_resource::<Time>()
.add_systems(Update, crate::entities::stamp_arrivals);
app.world_mut()
.resource_mut::<Time>()
.advance_to(std::time::Duration::from_secs(5));
let rider = app
.world_mut()
.spawn((Transform::default(), Visibility::default(), Arrival(1.0)))
.id();
let mut queue = CommandQueue::default();
let child = {
let mut commands = Commands::new(&mut queue, app.world());
spawn_mount_child(&mut commands, rider, 7, 1.0, false, Some(Arrival(1.0)))
};
queue.apply(app.world_mut());
app.update();
assert_eq!(
app.world().get::<Arrival>(child),
Some(&Arrival(1.0)),
"the mount fades on its rider's clock, not from its own spawn"
);
}
@@ -0,0 +1,363 @@
//! Composited body skins, one 256² atlas per look. An arriving body's first composite runs off the
//! main thread: the reference's world composite is unforced, so its section loads poll and never
//! block the frame (`0x44b430`), and it draws nothing of a unit whose first composite has not
//! finished, its ShouldRender answering `0x477860(cc, 0)`'s result (`0x607e7c`). Only the forced
//! callers wait on their loads (`0x44ad50`): the glue model (`0x470c59`, `0x471308`, `0x4731b6`),
//! the dressing room (`0x504485`), `PlayerModel`'s `SetUnit` (`0x5059be`) and world entry
//! (`0x49091e`). Here the forced lane ([`SkinComposites::force`]) composites on the calling thread.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use anyhow::Context;
use benilla_assets::{repeat_texture_authored, LockRecover, SpatialCache};
use benilla_formats::{blp_bytes_to_mip_chain, BlpMipChain, Chain, CharSections, CompositePlan};
use bevy::prelude::*;
use bevy::tasks::{block_on, futures_lite::future, AsyncComputeTaskPool, Task};
/// The `CharSections` skin lookup and the chain its textures read from; without it a player's body
/// skin stays untextured.
#[derive(Resource)]
pub(super) struct SkinSections {
pub(super) tables: CharSections,
chain: Arc<Mutex<Chain>>,
}
impl SkinSections {
pub(super) fn new(tables: CharSections, chain: Arc<Mutex<Chain>>) -> Self {
Self { tables, chain }
}
}
/// What decides a composited body skin: race and sex pick the `CharSections` rows, the dials pick
/// the variations, and `equip` holds the worn armour display ids by body slot − 2.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(super) struct SkinKey {
pub(super) race: u8,
pub(super) sex: u8,
pub(super) skin: u8,
pub(super) face: u8,
pub(super) facial_hair: u8,
pub(super) hair_style: u8,
pub(super) hair_color: u8,
pub(super) equip: [u32; 8],
/// The guild emblem: two guilds' members wear one tabard display but must not share an atlas.
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
/// The tabard designer's preview: the emblem paints over an empty tabard slot.
pub(super) tabard_preview: bool,
}
/// Where a body's atlas stands.
#[derive(Debug, PartialEq)]
pub(super) enum BodyAtlas {
/// Ready to bind; `None` for a look with no atlas (no base skin row, or art that never read).
Ready(Option<Handle<Image>>),
/// Its composite is running.
Pending,
}
/// A composite's whole work, run where it lands: reads, decodes and blits.
type Work = Box<dyn FnOnce() -> Option<BlpMipChain> + Send>;
/// Composited body skins by look, so every body wearing a look shares one atlas, and the
/// composites still running.
#[derive(Resource, Default)]
pub(super) struct SkinComposites {
/// Finished atlases, swept by distance ([`benilla_world::art_scope`]); `None` for a look with
/// no atlas, so it is never retried.
pub(super) done: SpatialCache<SkinKey, Option<Handle<Image>>>,
/// Composites on the async compute pool, one per look however many bodies wait on it.
running: HashMap<SkinKey, Task<Option<BlpMipChain>>>,
}
impl SkinComposites {
/// A world body's atlas: finished, or running, its composite started here on a miss. `plan` is
/// read only then; `None` is a look with no atlas.
pub(super) fn request(
&mut self,
key: SkinKey,
sections: &SkinSections,
plan: impl FnOnce() -> Option<CompositePlan>,
) -> BodyAtlas {
self.request_with(key, || plan().map(|p| work(p, sections.chain.clone())))
}
/// [`Self::request`] over any work, so the lane is testable without the install.
fn request_with(&mut self, key: SkinKey, work: impl FnOnce() -> Option<Work>) -> BodyAtlas {
if let Some(done) = self.done.fetch(&key) {
return BodyAtlas::Ready(done);
}
if self.running.contains_key(&key) {
return BodyAtlas::Pending;
}
let Some(work) = work() else {
self.done.insert(key, None);
return BodyAtlas::Ready(None);
};
self.running.insert(
key,
AsyncComputeTaskPool::get().spawn(async move { work() }),
);
BodyAtlas::Pending
}
/// An atlas composited on this thread on a miss, or waited for if it is already running, as
/// the reference's forced composite waits on its section loads (`0x44ad50`) and passes no
/// admission test: the glue model's (`0x477860(cc, 1)` at `0x470c59`, `0x471308`, `0x4731b6`)
/// and the dressing room's (`0x504485`, in `0x504470`). A re-dress of a standing body and a
/// rig-heal rebuild come here too, so neither drops out for a frame.
pub(super) fn force(
&mut self,
key: SkinKey,
sections: &SkinSections,
plan: impl FnOnce() -> Option<CompositePlan>,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
self.force_with(
key,
|| plan().map(|p| work(p, sections.chain.clone())),
images,
)
}
/// [`Self::force`] over any work, so the lane is testable without the install.
fn force_with(
&mut self,
key: SkinKey,
work: impl FnOnce() -> Option<Work>,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
if let Some(done) = self.done.fetch(&key) {
return done;
}
let atlas = match self.running.remove(&key) {
Some(task) => block_on(task),
None => work().and_then(|w| w()),
};
self.install(key, atlas, images)
}
/// Move every finished composite into [`Self::done`]; how many landed.
pub(super) fn land(&mut self, images: &mut Assets<Image>) -> usize {
let mut finished = Vec::new();
self.running
.retain(|key, task| match block_on(future::poll_once(task)) {
Some(atlas) => {
finished.push((*key, atlas));
false
}
None => true,
});
let landed = finished.len();
for (key, atlas) in finished {
self.install(key, atlas, images);
}
landed
}
/// Upload one atlas and cache it by look.
fn install(
&mut self,
key: SkinKey,
atlas: Option<BlpMipChain>,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
// Through the upload gate like every texture: a no-op on this RGBA8 composite, but it
// keeps the format and the bytes in agreement.
let handle = atlas.map(|a| {
images.add(repeat_texture_authored(
benilla_assets::for_upload(a),
(true, true),
))
});
self.done.insert(key, handle.clone());
handle
}
/// How many composites are running.
#[cfg(test)]
pub(super) fn running(&self) -> usize {
self.running.len()
}
/// Drop every atlas and every running composite: the map-change teardown.
pub(super) fn clear(&mut self) {
self.done.clear();
self.running.clear();
}
}
/// A plan's work over the shared chain, which each read locks for its archive lookup alone.
fn work(plan: CompositePlan, chain: Arc<Mutex<Chain>>) -> Work {
Box::new(move || {
plan.run(|path| read_texture(&chain, path))
.inspect_err(|e| warn!("body skin composite failed: {e:#}"))
.ok()
})
}
/// Read and decode one BLP off `chain`, locked only to find its archive.
fn read_texture(chain: &Mutex<Chain>, path: &str) -> anyhow::Result<BlpMipChain> {
let name = path.replace('/', "\\");
let archive = chain.lock_recover().archive_for(&name)?;
let bytes = archive
.read_file(&name)
.with_context(|| format!("reading texture '{name}'"))?;
blp_bytes_to_mip_chain(&bytes).with_context(|| format!("decoding texture '{name}'"))
}
/// Land every finished composite before this frame's bodies ask for theirs.
pub(super) fn land_skin_composites(
mut composites: ResMut<SkinComposites>,
mut images: ResMut<Assets<Image>>,
) {
composites.land(&mut images);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
fn key(race: u8) -> SkinKey {
SkinKey {
race,
sex: 0,
skin: 0,
face: 0,
facial_hair: 0,
hair_style: 0,
hair_color: 0,
equip: [0; 8],
emblem: None,
tabard_preview: false,
}
}
/// A 1×1 atlas.
fn atlas() -> BlpMipChain {
BlpMipChain {
width: 1,
height: 1,
texels: benilla_formats::BlpTexels::Rgba8Unorm,
mips: vec![vec![255; 4]],
}
}
/// Work that finishes when the test says so, and counts how many times it ran. It gives up
/// after two seconds, so work run inline on the test's thread fails the test, never hangs it.
fn gated(ran: &Arc<std::sync::atomic::AtomicUsize>) -> (mpsc::Sender<()>, Work) {
let (tx, rx) = mpsc::channel::<()>();
let ran = ran.clone();
let work: Work = Box::new(move || {
ran.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
rx.recv_timeout(std::time::Duration::from_secs(2)).ok()?;
Some(atlas())
});
(tx, work)
}
/// Land until `key` is done, or give up after two seconds.
fn land_until(c: &mut SkinComposites, images: &mut Assets<Image>, key: SkinKey) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while c.running.contains_key(&key) && std::time::Instant::now() < deadline {
c.land(images);
std::thread::yield_now();
}
}
/// A look's composite runs once off the main thread however many bodies ask, and every body
/// gets the one atlas once it lands.
#[test]
fn a_look_composites_once_off_the_main_thread() {
AsyncComputeTaskPool::get_or_init(bevy::tasks::TaskPool::new);
let mut images = Assets::<Image>::default();
let mut c = SkinComposites::default();
let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (tx, w) = gated(&ran);
let mut w = Some(w);
assert_eq!(c.request_with(key(1), || w.take()), BodyAtlas::Pending);
// A second body of the same look waits on the same composite; its work is never built.
assert_eq!(
c.request_with(key(1), || panic!(
"a running look starts no second composite"
)),
BodyAtlas::Pending
);
assert_eq!(c.running(), 1);
assert_eq!(
c.land(&mut images),
0,
"nothing lands before the work finishes"
);
tx.send(()).unwrap();
land_until(&mut c, &mut images, key(1));
let BodyAtlas::Ready(Some(first)) = c.request_with(key(1), || None) else {
panic!("the landed atlas is ready");
};
assert_eq!(
c.request_with(key(1), || None),
BodyAtlas::Ready(Some(first))
);
assert_eq!(ran.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(c.running(), 0);
}
/// A forced composite of a look already running waits for that composite rather than starting
/// a second, and the atlas is the one every later request gets.
#[test]
fn a_forced_composite_takes_over_the_running_one() {
AsyncComputeTaskPool::get_or_init(bevy::tasks::TaskPool::new);
let mut images = Assets::<Image>::default();
let mut c = SkinComposites::default();
let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (tx, w) = gated(&ran);
let mut w = Some(w);
assert_eq!(c.request_with(key(3), || w.take()), BodyAtlas::Pending);
tx.send(()).unwrap();
let forced = c.force_with(
key(3),
|| panic!("a running look starts no second composite"),
&mut images,
);
assert!(forced.is_some(), "the running composite's atlas");
assert_eq!(ran.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(c.running(), 0);
assert_eq!(c.request_with(key(3), || None), BodyAtlas::Ready(forced));
}
/// A look with no atlas is ready at once, as `None`, and never retried.
#[test]
fn a_look_without_an_atlas_never_waits() {
let mut c = SkinComposites::default();
assert_eq!(c.request_with(key(9), || None), BodyAtlas::Ready(None));
assert_eq!(
c.request_with(key(9), || panic!("a look with no atlas is never retried")),
BodyAtlas::Ready(None)
);
assert_eq!(c.running(), 0);
}
/// The map-change teardown drops running composites with the cache: a body still waiting
/// starts its composite again.
#[test]
fn a_teardown_drops_the_running_composites() {
AsyncComputeTaskPool::get_or_init(bevy::tasks::TaskPool::new);
let mut c = SkinComposites::default();
let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (_tx, w) = gated(&ran);
let mut w = Some(w);
assert_eq!(c.request_with(key(2), || w.take()), BodyAtlas::Pending);
c.clear();
assert_eq!(c.running(), 0);
let (_tx2, w2) = gated(&ran);
let mut w2 = Some(w2);
assert_eq!(c.request_with(key(2), || w2.take()), BodyAtlas::Pending);
assert!(
w2.is_none(),
"a body still waiting starts its composite again"
);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ pub use customization::{CharCreateCatalog, DialRanges, StartOutfitItem};
pub use geosets::{CharacterGeosets, EquipGeosets, VisibleGeosets};
pub use sections::{
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, forearm_dressed,
BlitSource, CharSections, EmblemLayer, EquipBlit, GuildEmblem,
BlitSource, CharSections, CompositePlan, EmblemLayer, EquipBlit, GuildEmblem,
};
// The loaders' own schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`).
@@ -142,12 +142,8 @@ impl CharSections {
.filter(|s| !s.is_empty())
}
/// The body-skin atlas as one mip pyramid: the 256² base skin, the head overlays, the
/// underwear, the equipment by bodyslot − 2 and the guild emblem ([`equip_blits`]), each at its
/// tile per authored mip level (`0x475c50`, `0x4770f0`); `Ok(None)` without a base skin row.
///
/// Deviation: blends in 8-bit RGBA, not the client's RGB565 with 2-bit coverage, because that
/// format only saves texture memory and loses precision.
/// The body-skin atlas as one mip pyramid, read off `chain` on this thread:
/// [`Self::composite_plan`] run by [`CompositePlan::run`]; `Ok(None)` without a base skin row.
pub fn composite_body(
&self,
chain: &mut Chain,
@@ -162,11 +158,43 @@ impl CharSections {
emblem: Option<GuildEmblem>,
tabard_preview: bool,
) -> Result<Option<BlpMipChain>> {
let Some(base_path) = self.skin_texture(race, sex, skin) else {
let Some(plan) = self.composite_plan(
race,
sex,
skin,
face,
facial_hair,
hair_style,
hair_color,
equipment,
emblem,
tabard_preview,
) else {
return Ok(None);
};
let mut atlas = read_texture_mip_chain(chain, base_path)
.with_context(|| format!("reading base skin '{base_path}'"))?;
plan.run(|path| read_texture_mip_chain(chain, path))
.map(Some)
}
/// What a body composite reads and where each file lands, from the tables alone: the 256²
/// base skin, the head overlays, the underwear, the equipment by bodyslot − 2 and the guild
/// emblem ([`equip_blits`]), in blit order; `None` without a base skin row. It owns its paths,
/// so [`CompositePlan::run`] can read and blit on any thread.
pub fn composite_plan(
&self,
race: u8,
sex: u8,
skin: u8,
face: u8,
facial_hair: u8,
hair_style: u8,
hair_color: u8,
equipment: [Option<&ItemDisplay>; 8],
emblem: Option<GuildEmblem>,
tabard_preview: bool,
) -> Option<CompositePlan> {
let base = self.skin_texture(race, sex, skin)?.to_owned();
let mut layers = Vec::new();
// The head overlays `(sectionType, variation, color, column, tile)` of `0x4782e0`, in order
// face, facial hair, hair; face and facial hair use columns 0/1, hair 1/2.
let overlays: [(u8, u8, u8, usize, Tile); 6] = [
@@ -178,11 +206,8 @@ impl CharSections {
(SECTION_HAIR, hair_style, hair_color, 2, TILE_G8),
];
for (ty, var, color, col, tile) in overlays {
let Some(path) = self.tex(race, sex, ty, var, color, col) else {
continue;
};
if let Ok(overlay) = read_texture_mip_chain(chain, path) {
blit_over(&mut atlas, &overlay, tile);
if let Some(path) = self.tex(race, sex, ty, var, color, col) {
layers.push((vec![path.to_owned()], tile));
}
}
// One plan gates the underwear and drives the equipment blits.
@@ -193,24 +218,15 @@ impl CharSections {
if plan.iter().any(|s| s.layer == layer && s.column < tested) {
continue;
}
let Some(path) = self.tex(race, sex, SECTION_UNDERWEAR, 0, skin, col) else {
continue;
};
if let Ok(overlay) = read_texture_mip_chain(chain, path) {
blit_over(&mut atlas, &overlay, EQUIP_TILES[layer]);
if let Some(path) = self.tex(race, sex, SECTION_UNDERWEAR, 0, skin, col) {
layers.push((vec![path.to_owned()], EQUIP_TILES[layer]));
}
}
// The equipment and emblem layers in plan order, each from its first decodable candidate.
for step in &plan {
if let Some(overlay) = step
.candidates(sex)
.iter()
.find_map(|path| read_texture_mip_chain(chain, path).ok())
{
blit_over(&mut atlas, &overlay, EQUIP_TILES[step.layer]);
}
layers.push((step.candidates(sex), EQUIP_TILES[step.layer]));
}
Ok(Some(atlas))
Some(CompositePlan { base, layers })
}
/// Load CharSections.dbc from the patch chain.
@@ -249,6 +265,32 @@ impl CharSections {
}
}
/// One body composite's reads in blit order ([`CharSections::composite_plan`]): the base skin, then
/// each overlay's candidate paths, the first that decodes winning, with its atlas tile.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompositePlan {
base: String,
layers: Vec<(Vec<String>, Tile)>,
}
impl CompositePlan {
/// Read, decode and blit: each overlay at its tile per authored mip level (`0x475c50`,
/// `0x4770f0`). An overlay that reads nowhere is skipped; a base skin that does not read fails.
///
/// Deviation: blends in 8-bit RGBA, not the client's RGB565 with 2-bit coverage, because that
/// format only saves texture memory and loses precision.
pub fn run(&self, mut read: impl FnMut(&str) -> Result<BlpMipChain>) -> Result<BlpMipChain> {
let mut atlas =
read(&self.base).with_context(|| format!("reading base skin '{}'", self.base))?;
for (candidates, tile) in &self.layers {
if let Some(overlay) = candidates.iter().find_map(|path| read(path).ok()) {
blit_over(&mut atlas, &overlay, *tile);
}
}
Ok(atlas)
}
}
/// One step of the equipment plan [`CharSections::composite_body`] runs, public so
/// `benilla-extract charatlas` reports the same plan.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+2 -2
View File
@@ -25,8 +25,8 @@ pub use install::{
mod characters;
pub use characters::{
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, forearm_dressed,
BlitSource, CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EmblemLayer,
EquipBlit, EquipGeosets, GuildEmblem, StartOutfitItem, VisibleGeosets,
BlitSource, CharCreateCatalog, CharSections, CharacterGeosets, CompositePlan, DialRanges,
EmblemLayer, EquipBlit, EquipGeosets, GuildEmblem, StartOutfitItem, VisibleGeosets,
};
mod camera_shakes;
mod cinematics;
@@ -0,0 +1,129 @@
//! What one body composite costs, on the real install: the naked body and a crowd of distinct
//! tier-2 looks, each read, decoded and blitted in full, as a cache miss does, and what planning
//! the same crowd costs.
use std::time::{Duration, Instant};
use benilla_formats::{load_item_display_catalog, open_chain, CharSections, ItemDisplay};
/// Worn display ids by bodyslot − 2 (shirt, chest, belt, pants, boots, wrist, gloves, tabard):
/// the tier-2 sets at their 1.12 displays, a linen shirt under the first.
const SETS: [[u32; 8]; 9] = [
[10840, 33983, 33990, 33986, 33989, 33982, 33984, 0],
[0, 33650, 31110, 31115, 31111, 31127, 33651, 0],
[0, 33667, 33665, 33672, 34269, 33666, 33668, 0],
[0, 34081, 34078, 34084, 34083, 34079, 34082, 0],
[0, 33635, 33633, 33637, 33639, 33634, 33636, 0],
[0, 34014, 34011, 29857, 34015, 34012, 34013, 0],
[0, 34038, 34046, 34039, 34044, 34045, 34041, 0],
[0, 34047, 34053, 34049, 34055, 34052, 34051, 0],
[0, 30536, 30541, 30540, 30542, 30548, 34016, 0],
];
fn median(mut v: Vec<Duration>) -> Duration {
v.sort_unstable();
v[v.len() / 2]
}
#[test]
#[ignore = "instrument: run by hand — cargo test --release -p benilla-formats --test composite_cost -- --ignored --nocapture"]
fn one_composite_costs() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open chain");
let cs = CharSections::load(&mut chain).expect("CharSections");
let items = load_item_display_catalog(&mut chain).expect("ItemDisplayInfo");
let worn = |set: &[u32; 8]| -> [Option<&ItemDisplay>; 8] {
std::array::from_fn(|i| (set[i] != 0).then(|| items.get(set[i])).flatten())
};
// One look, over and over: the body's whole read-decode-blit, the OS file cache warm.
let naked = (0..20)
.map(|_| {
let t = Instant::now();
let atlas = cs
.composite_body(&mut chain, 1, 0, 0, 0, 0, 0, 0, [None; 8], None, false)
.expect("composite")
.expect("base row");
std::hint::black_box(atlas);
t.elapsed()
})
.collect::<Vec<_>>();
let geared = (0..20)
.map(|_| {
let t = Instant::now();
let atlas = cs
.composite_body(&mut chain, 1, 0, 0, 0, 0, 0, 0, worn(&SETS[0]), None, false)
.expect("composite")
.expect("base row");
std::hint::black_box(atlas);
t.elapsed()
})
.collect::<Vec<_>>();
println!(
"COMPOSITE naked human male: median {:.2} ms",
median(naked).as_secs_f64() * 1e3
);
println!(
"COMPOSITE tier-2 human male: median {:.2} ms",
median(geared).as_secs_f64() * 1e3
);
// A crowd of 40 distinct looks arriving together: eight races, both sexes, the nine sets.
let races = [1u8, 2, 3, 4, 5, 6, 7, 8];
let t = Instant::now();
let mut built = 0;
for i in 0..40usize {
let (race, sex) = (races[i % races.len()], (i / races.len() % 2) as u8);
let set = &SETS[i % SETS.len()];
if let Ok(Some(atlas)) = cs.composite_body(
&mut chain,
race,
sex,
0,
(i % 3) as u8,
0,
(i % 4) as u8,
0,
worn(set),
None,
false,
) {
std::hint::black_box(atlas);
built += 1;
}
}
let crowd = t.elapsed();
println!(
"COMPOSITE crowd: {built} distinct geared looks in {:.1} ms ({:.2} ms each)",
crowd.as_secs_f64() * 1e3,
crowd.as_secs_f64() * 1e3 / built.max(1) as f64
);
// The same crowd's plans alone: the table lookups a body's first frame does before its
// composite leaves the thread.
let t = Instant::now();
let mut planned = 0;
for i in 0..40usize {
let (race, sex) = (races[i % races.len()], (i / races.len() % 2) as u8);
let set = &SETS[i % SETS.len()];
if let Some(plan) = cs.composite_plan(
race,
sex,
0,
(i % 3) as u8,
0,
(i % 4) as u8,
0,
worn(set),
None,
false,
) {
std::hint::black_box(plan);
planned += 1;
}
}
println!(
"COMPOSITE crowd plans: {planned} in {:.3} ms",
t.elapsed().as_secs_f64() * 1e3
);
}
+126 -24
View File
@@ -72,7 +72,7 @@ pub fn fade_band(radius: f32) -> Option<(f32, f32)> {
/// behind ([`DespawnFade`]). The interior classifier keeps the light law through either.
#[derive(Component, Clone)]
pub struct RenderFade {
/// `Time::elapsed_secs` at arming.
/// `Time::elapsed_secs` the ramp runs from: its arming, or an appear ramp's arrival.
pub started: f32,
/// Seconds; both reference ramps are 2000 ms (`FadeTo`'s `0x7d0`, the pump's `age > 0x7d0`).
pub duration: f32,
@@ -278,7 +278,10 @@ mod unit_render_alpha_tests {
fn a_pending_unit_is_zero_and_a_settled_one_is_opaque() {
assert_eq!(
unit_at(10.0, |u| {
u.insert(UnitAppearFade::Pending { since: 9.0 });
u.insert(UnitAppearFade::Pending {
since: 9.0,
arrived: 9.0,
});
}),
0.0,
"pending: not shown yet, so nothing that rides this number may show either"
@@ -494,12 +497,16 @@ pub fn apply_render_fade(
}
}
/// An appear fade waiting to arm, the entity hidden meanwhile. The reference arms it on the object
/// becoming visible (`0x4651a0`), not at stream-in, so it never plays behind a loading screen.
/// An appear fade waiting to arm, the entity hidden meanwhile. The reference arms it as the
/// object's create block is processed (`0x4651a0` → `0x465c50` → the type's appear handler →
/// `0x613af0`), so the ramp runs from the arrival; here it is held while a loading cover is up,
/// so it never plays behind one.
#[derive(Component, Clone)]
pub struct PendingAppearFade {
/// `Time::elapsed_secs` at attach, the backstop timeout's origin.
/// `Time::elapsed_secs` when the part began waiting, the backstop timeout's origin.
pub since: f32,
/// `Time::elapsed_secs` at the object's arrival, the ramp's origin once the world is shown.
pub arrived: f32,
}
/// Arm a pending fade after this long anyway, so a stuck load cannot leave an entity invisible.
@@ -510,8 +517,9 @@ const PENDING_TIMEOUT_SECS: f32 = 8.0;
/// the ramp ends, after which a new part spawns steady.
#[derive(Component, Clone, Copy, Debug, PartialEq)]
pub enum UnitAppearFade {
/// Waiting for the world to be shown.
Pending { since: f32 },
/// Waiting for the world to be shown: since `since`, the backstop's origin, with the ramp
/// running from `arrived`.
Pending { since: f32, arrived: f32 },
/// A joiner copies `started`, which reproduces the same curve.
Live { started: f32 },
}
@@ -522,7 +530,7 @@ pub enum JoinedFade {
/// No unit fade in flight: spawn steady.
Steady,
/// Arm with the unit once the world is shown.
Pending { since: f32 },
Pending { since: f32, arrived: f32 },
/// Join the running ramp at its current position.
Live { started: f32 },
}
@@ -531,7 +539,7 @@ pub enum JoinedFade {
pub fn join_unit_appear_fade(unit: Option<UnitAppearFade>) -> JoinedFade {
match unit {
None => JoinedFade::Steady,
Some(UnitAppearFade::Pending { since }) => JoinedFade::Pending { since },
Some(UnitAppearFade::Pending { since, arrived }) => JoinedFade::Pending { since, arrived },
Some(UnitAppearFade::Live { started }) => JoinedFade::Live { started },
}
}
@@ -548,9 +556,22 @@ pub(crate) fn arm_appear_fade(
Has<crate::billboard::BillboardCard>,
)>,
mut units: Query<&mut UnitAppearFade>,
// When the cover last lifted; `None` while it is up.
mut shown_at: Local<Option<f32>>,
) {
let now = time.elapsed_secs();
let shown = !viewer.world_covered;
if shown {
shown_at.get_or_insert(now);
} else {
*shown_at = None;
}
// The ramp runs from the unit's arrival, whatever its model and composite waited on: the
// create block's appear handler stamps it (`0x465c50` → `0x613af0` → `0x614f80`, `obj+0xec`)
// and the per-frame ramp is `t³` of the wall time since (`0x614a90`, called at `0x60800c`
// whether or not the body draws). Never from behind a cover: an arrival under the loading
// screen starts when it lifts, and a backstop arm, which counts from the attach, starts now.
let origin = |arrived: f32| shown_at.map_or(now, |lifted| arrived.max(lifted));
let mut armed = 0usize;
// Counted apart: a world-root billboard card arms only through its own spawn.
let mut armed_cards = 0usize;
@@ -562,14 +583,16 @@ pub(crate) fn arm_appear_fade(
// can land between this query and its commands.
commands
.entity(entity)
.try_insert(RenderFade::appear(now))
.try_insert(RenderFade::appear(origin(pending.arrived)))
.try_remove::<PendingAppearFade>();
}
}
for mut unit_fade in &mut units {
if let UnitAppearFade::Pending { since } = *unit_fade {
if let UnitAppearFade::Pending { since, arrived } = *unit_fade {
if shown || now - since > PENDING_TIMEOUT_SECS {
*unit_fade = UnitAppearFade::Live { started: now };
*unit_fade = UnitAppearFade::Live {
started: origin(arrived),
};
}
}
}
@@ -647,7 +670,10 @@ pub struct FadeSet<'a> {
pub enum PartFade {
/// No ramp to join, or the batch cannot feather: open opaque.
Steady,
Pending(f32),
Pending {
since: f32,
arrived: f32,
},
Live(f32),
}
@@ -656,7 +682,7 @@ impl PartFade {
pub fn resolve(joined: JoinedFade, set: &FadeSet<'_>) -> Self {
match (joined, set.blend) {
(_, None) | (JoinedFade::Steady, _) => Self::Steady,
(JoinedFade::Pending { since }, _) => Self::Pending(since),
(JoinedFade::Pending { since, arrived }, _) => Self::Pending { since, arrived },
(JoinedFade::Live { started }, _) => Self::Live(started),
}
}
@@ -666,7 +692,7 @@ impl PartFade {
pub fn seed(self, set: &FadeSet<'_>, now: f32) -> (Handle<WowModelMaterial>, f32) {
match self {
Self::Steady => (set.steady.clone(), 1.0),
Self::Pending(_) => (set.blend.cloned().unwrap_or_default(), 0.0),
Self::Pending { .. } => (set.blend.cloned().unwrap_or_default(), 0.0),
Self::Live(started) => (
set.blend.cloned().unwrap_or_default(),
fade_alpha(0.0, 1.0, (now - started) / APPEAR_FADE_SECS),
@@ -687,8 +713,8 @@ impl PartFade {
}
match self {
Self::Steady => false,
Self::Pending(since) => {
child.insert(PendingAppearFade { since });
Self::Pending { since, arrived } => {
child.insert(PendingAppearFade { since, arrived });
true
}
Self::Live(started) => {
@@ -939,6 +965,73 @@ mod tests {
assert_eq!(state.get(&world).get(a), 1.0);
}
/// The ramp runs from the unit's arrival once the world is shown, so a body that waited on its
/// model or composite shows it already advanced, and never from behind the loading cover: an
/// arrival under it starts when the cover lifts, even one that arrived long before, as the
/// backstop counts from when the fade began waiting.
#[test]
fn an_appear_ramp_runs_from_arrival_never_from_behind_the_cover() {
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<crate::view::Viewer>()
.add_systems(Update, arm_appear_fade);
let frame = |app: &mut App, secs: f32, covered: bool| {
app.world_mut()
.resource_mut::<Time>()
.advance_to(std::time::Duration::from_secs_f32(secs));
app.world_mut()
.resource_mut::<crate::view::Viewer>()
.world_covered = covered;
app.update();
};
let unit = |app: &App, e: Entity| *app.world().get::<UnitAppearFade>(e).unwrap();
let pending = |since, arrived| UnitAppearFade::Pending { since, arrived };
// Arrived at 1 s under the loading cover and built at 1.5 s; the cover lifts at 5 s.
let early = app.world_mut().spawn(pending(1.5, 1.0)).id();
frame(&mut app, 2.0, true);
assert_eq!(unit(&app, early), pending(1.5, 1.0));
frame(&mut app, 5.0, false);
assert_eq!(unit(&app, early), UnitAppearFade::Live { started: 5.0 });
// A second cover from 6 s to 16 s: a unit that arrived at 6 s, 10 s before it lifts, and
// was built at 15 s.
frame(&mut app, 6.0, true);
let long_before = app.world_mut().spawn(pending(15.0, 6.0)).id();
frame(&mut app, 15.5, true);
assert_eq!(
unit(&app, long_before),
pending(15.0, 6.0),
"the backstop counts from the attach, so it still waits under the cover"
);
frame(&mut app, 16.0, false);
assert_eq!(
unit(&app, long_before),
UnitAppearFade::Live { started: 16.0 },
"it fades in after the cover lifts"
);
// Arrived at 17 s in view and built at 17.5 s: its ramp is half a second in.
let late = app.world_mut().spawn(pending(17.5, 17.0)).id();
let part = app
.world_mut()
.spawn(PendingAppearFade {
since: 17.5,
arrived: 17.0,
})
.id();
frame(&mut app, 17.5, false);
assert_eq!(unit(&app, late), UnitAppearFade::Live { started: 17.0 });
let fade = app.world().get::<RenderFade>(part).expect("the part armed");
assert_eq!(fade.started, 17.0, "the part rides the same ramp");
// A cover that never lifts: the backstop arms 8 s after the attach, starting then.
let stuck = app.world_mut().spawn(pending(20.0, 19.0)).id();
frame(&mut app, 21.0, true);
frame(&mut app, 28.5, true);
assert_eq!(unit(&app, stuck), UnitAppearFade::Live { started: 28.5 });
}
/// `SystemState::apply` after a manual despawn recreates a wire destroy landing between the
/// system's query and its commands.
#[test]
@@ -950,7 +1043,12 @@ mod tests {
world.init_resource::<Time>();
// The default viewer covers nothing, so every pending fade arms this frame.
world.init_resource::<crate::view::Viewer>();
let doomed = world.spawn(PendingAppearFade { since: 0.0 }).id();
let doomed = world
.spawn(PendingAppearFade {
since: 0.0,
arrived: 0.0,
})
.id();
let mut state: SystemState<(
Res<Time>,
@@ -962,9 +1060,10 @@ mod tests {
Has<crate::billboard::BillboardCard>,
)>,
Query<&mut UnitAppearFade>,
Local<Option<f32>>,
)> = SystemState::new(&mut world);
let (time, viewer, commands, q, units) = state.get_mut(&mut world);
arm_appear_fade(time, viewer, commands, q, units);
let (time, viewer, commands, q, units, shown_at) = state.get_mut(&mut world);
arm_appear_fade(time, viewer, commands, q, units, shown_at);
world.despawn(doomed);
state.apply(&mut world); // would panic without the `try_*` contract
@@ -1127,10 +1226,10 @@ mod tests {
#[test]
fn pending_unit_joins_at_the_same_since() {
let since = 3.25;
let (since, arrived) = (3.25, 2.5);
assert_eq!(
join_unit_appear_fade(Some(UnitAppearFade::Pending { since })),
JoinedFade::Pending { since }
join_unit_appear_fade(Some(UnitAppearFade::Pending { since, arrived })),
JoinedFade::Pending { since, arrived }
);
}
@@ -1166,7 +1265,10 @@ mod tests {
assert_eq!(
model_render_alpha(
10.0,
Some(UnitAppearFade::Pending { since: 9.0 }),
Some(UnitAppearFade::Pending {
since: 9.0,
arrived: 9.0,
}),
None,
1.0,
1.0