From bf7b894af6838c0981d6181ef6880e4615e026f8 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 16:56:19 +0100 Subject: [PATCH] Reuse installed online models by content hash and pose replacement rigs before display --- crates/skate-game/src/animation.rs | 50 +++++++- crates/skate-game/src/custom_models.rs | 6 + .../skate-game/src/multiplayer/appearance.rs | 114 +++++++++++++++++- crates/skate-game/src/multiplayer/render.rs | 26 +++- docs/multiplayer-test.md | 12 +- 5 files changed, 201 insertions(+), 7 deletions(-) diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 01128cba..24b4adbc 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -16,6 +16,14 @@ struct BoneBinding { parent_bone: Option, } impl AnimationStatus { + pub(crate) fn pose_transforms(&self, pose:&[Mat4])->Vec<(Entity,Transform)> { + self.bindings.iter().filter_map(|b|{ + let global=*pose.get(b.bone)?*render_basis(); + let local=if let Some(parent)=b.parent_bone {(*pose.get(parent)?*render_basis()).inverse()*global} else {global}; + Some((b.entity,Transform::from_matrix(local))) + }).collect() + } + pub(crate) fn online_bindings(&self) -> Vec<(Entity,usize,Option)> { self.bindings.iter().map(|b|(b.entity,b.bone,b.parent_bone)).collect() } @@ -197,6 +205,7 @@ fn bind( } fn present( history: Res, + skater: Res, replay: Res, time: Res>, animation: Res, @@ -209,7 +218,11 @@ fn present( // rotated -90 degrees about X relative to the native frames. Both files' // world positions are Y-up. This is a skin basis change, not a physics turn. let basis = render_basis(); - let Some((previous, current, alpha)) = history.view(&replay, time.overstep_fraction()) else { return; }; + let Some((previous, current, alpha)) = history.view(&replay, time.overstep_fraction()) else { + let pose:Vec<_>=skater.render_pose.iter().copied().map(native_matrix).collect(); + for (entity,transform) in animation.pose_transforms(&pose) {if let Ok(mut node)=nodes.get_mut(entity){*node=transform;}} + return; + }; for binding in &animation.bindings { // Blend bone-local rotations, not matrix entries or independent world // positions: joints retain their hierarchy while limbs turn. @@ -280,3 +293,38 @@ pub(crate) fn network_visual(world: &mut World) -> skate_net::packed::PoseState let basis=render_basis().inverse(); skate_net::packed::PoseState{root:crate::physics::network::pose(root.to_matrix()),bones:anchors.into_iter().map(|i|skate_net::Bone{index:i as u16,pose:crate::physics::network::pose(if locals.contains_key(&i) {global(i,&locals,0)*basis} else {native_matrix(world.resource::().render_pose[i])})}).collect()} } +#[cfg(test)] +mod online_swap_tests { + use super::*; + use bevy::{ecs::system::SystemState,mesh::skinning::SkinnedMesh}; + #[test] + fn online_appearance_swap_seeds_new_rig_and_keeps_animating() { + let mut world=World::new(); + let mut roots=vec![]; + for _ in 0..2 { + let root=world.spawn_empty().id(); + let hip=world.spawn((Name::new("HIPS"),Transform::default(),ChildOf(root))).id(); + let head=world.spawn((Name::new("HEAD"),Transform::default(),ChildOf(hip))).id(); + world.spawn((ChildOf(root),SkinnedMesh{inverse_bindposes:default(),joints:vec![hip,head]})); + roots.push((root,hip,head)); + } + let names=vec!["HIPS".to_owned(),"HEAD".to_owned()]; + let bind=|world:&mut World,root| { + let mut query:SystemState<(Query<(Entity,&SkinnedMesh)>,Query<(&Name,&Transform)>,Query<&ChildOf>)>=SystemState::new(world); + let (skins,nodes,parents)=query.get(world); + AnimationStatus::for_scene(root,&names,&skins,&nodes,&parents).unwrap() + }; + let old=bind(&mut world,roots[0].0); + let pose=[Mat4::from_translation(Vec3::new(1.,2.,3.)),Mat4::from_rotation_z(0.7)]; + for (entity,t) in old.pose_transforms(&pose){*world.get_mut::(entity).unwrap()=t;} + let replacement=bind(&mut world,roots[1].0); + assert_eq!(*world.get::(roots[1].1).unwrap(),Transform::default()); + for (entity,t) in replacement.pose_transforms(&pose){*world.get_mut::(entity).unwrap()=t;} + assert_eq!(world.get::(roots[0].1),world.get::(roots[1].1)); + assert_eq!(world.get::(roots[0].2),world.get::(roots[1].2)); + let next=[Mat4::from_translation(Vec3::new(2.,3.,4.)),Mat4::from_rotation_z(1.2)]; + for (entity,t) in replacement.pose_transforms(&next){*world.get_mut::(entity).unwrap()=t;} + assert_ne!(world.get::(roots[0].1),world.get::(roots[1].1)); + assert_ne!(world.get::(roots[0].2),world.get::(roots[1].2)); + } +} diff --git a/crates/skate-game/src/custom_models.rs b/crates/skate-game/src/custom_models.rs index 6c112f9b..b005d7ad 100644 --- a/crates/skate-game/src/custom_models.rs +++ b/crates/skate-game/src/custom_models.rs @@ -101,6 +101,9 @@ pub(crate) struct CustomModels { dirty: bool, } impl CustomModels { + pub(crate) fn online_imports(&self) -> Vec<(String,PathBuf)> { + self.entries.iter().filter(|e|e.native.is_none()).map(|e|(e.asset_path("character.glb"),self.directory.join("entries").join(&e.id).join("character.glb"))).collect() + } pub(crate) fn online_selection(&self) -> Option<(Option, PathBuf)> { let e = self.entries.iter().find(|e| Some(&e.id)==self.active.as_ref())?; let directory = if e.asset_prefix.is_empty() { &self.directory } else { &self.native_directory }; @@ -600,6 +603,9 @@ fn publish( if let Ok((_, _, mut visibility)) = scenes.get_mut(pending.root) { *visibility = Visibility::Inherited; } + // Seed the new rig before publishing visibility, even when paused. + let pose:Vec<_>=skater.render_pose.iter().copied().map(crate::animation::native_matrix).collect(); + for (joint,transform) in bindings.pose_transforms(&pose) {commands.entity(joint).insert(transform);} *animation = bindings; state.active = Some(pending.id.clone()); state.status = match save_selection(&state.directory, Some(pending.id)) { diff --git a/crates/skate-game/src/multiplayer/appearance.rs b/crates/skate-game/src/multiplayer/appearance.rs index 9d60cb4c..158327b5 100644 --- a/crates/skate-game/src/multiplayer/appearance.rs +++ b/crates/skate-game/src/multiplayer/appearance.rs @@ -3,7 +3,10 @@ use super::{ Multiplayer, appearance_transfer::{Exchange, MAX_BLOB}, }; -use bevy::prelude::*; +use bevy::{ + prelude::*, + tasks::{AsyncComputeTaskPool, Task, block_on, poll_once}, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ @@ -30,6 +33,32 @@ pub(super) struct Appearances { cached: BTreeMap<[u8; 32], Look>, pub status: String, pub progress: String, + indexed: Vec, + indexing: Option>>, +} +fn index_imports( + imports: Vec<(String, PathBuf)>, +) -> Vec<(super::appearance_transfer::Identity, Look)> { + imports + .into_iter() + .filter_map(|(asset, path)| { + if std::fs::metadata(&path).ok()?.len() as usize >= MAX_BLOB { + return None; + } + let bytes = std::fs::read(path).ok()?; + validate_glb(&bytes).ok()?; + let mut hash = blake3::Hasher::new(); + hash.update(&[0]); + hash.update(&bytes); + Some(( + super::appearance_transfer::Identity { + hash: *hash.finalize().as_bytes(), + size: bytes.len() + 1, + }, + Look::Imported(asset), + )) + }) + .collect() } pub(super) fn cleanup(mut exit: MessageReader) { // Generated process-private directory, never the personal import library. @@ -61,6 +90,24 @@ pub(super) fn sync( ..default() }; } + let imports = models.online_imports(); + let keys: Vec<_> = imports.iter().map(|(key, _)| key.clone()).collect(); + if state.indexed != keys { + state.indexed = keys; + state.indexing = + Some(AsyncComputeTaskPool::get().spawn(async move { index_imports(imports) })); + } + if let Some(found) = state + .indexing + .as_mut() + .and_then(|task| block_on(poll_once(task))) + { + for (id, look) in found { + state.cached.insert(id.hash, look); + state.exchange.remember(id); + } + state.indexing = None; + } let selection = models.online_selection(); let signature = models .active @@ -113,7 +160,9 @@ pub(super) fn sync( } let now = net.started.elapsed().as_millis() as u64; let lobby = net.lobby.as_mut().unwrap(); - state.exchange.tick(lobby, now); + if state.indexing.is_none() { + state.exchange.tick(lobby, now); + } state.progress = state.exchange.progress(); state.looks.retain(|id, _| lobby.actors.contains_key(id)); state.seen.retain(|id, _| lobby.actors.contains_key(id)); @@ -364,3 +413,64 @@ mod tests { validate_glb(&std::fs::read(path).unwrap()).unwrap(); } } +#[cfg(test)] +mod local_reuse_tests { + use super::*; + #[test] + #[ignore = "requires SKATE_ONLINE_TEST_GLB"] + fn online_appearance_existing_import_needs_no_model_upload() { + let path = PathBuf::from(std::env::var("SKATE_ONLINE_TEST_GLB").unwrap()); + let found = index_imports(vec![( + "characters://owned/character.glb".into(), + path.clone(), + )]); + assert_eq!(found.len(), 1); + let (identity, look) = found[0].clone(); + assert!(matches!(look, Look::Imported(_))); + let mut bytes = vec![0]; + bytes.extend(std::fs::read(path).unwrap()); + assert_eq!(identity.hash, *blake3::hash(&bytes).as_bytes()); + let mut sessions: Vec<_> = (0..2) + .map(|i| { + skate_net::lobby::Session::new( + 3, + skate_net::lobby::Info { + id: 10 + i, + map: 1, + rig: 1, + physics: 1, + appearance: 1, + }, + if i == 0 { None } else { Some(1) }, + ) + }) + .collect(); + let mut transfer = [Exchange::default(), Exchange::default()]; + transfer[0].publish(bytes); + transfer[1].publish(vec![1]); + transfer[1].remember(identity.clone()); + for step in 0..200 { + let now = step * 10; + let mut wire = vec![]; + for i in 0..2 { + transfer[i].tick(&mut sessions[i], now); + for packet in sessions[i].service(now) { + assert!( + packet.data.len() < 200, + "local content must not send model chunks" + ); + wire.push((i + 1, packet)); + } + } + for (from, p) in wire { + sessions[p.peer as usize - 1].receive(from as u64, &p.data, now); + } + } + let (received, payload) = transfer[1] + .ready + .get(&10) + .expect("local appearance must be ready after handshake"); + assert_eq!(received, &identity); + assert!(payload.is_empty()); + } +} diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs index d4413c3b..a98db69b 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -236,6 +236,7 @@ pub(super) fn spawn( fn bind( mut commands: Commands, + net: Res, mut skins: ResMut, skater: Res, meshes: Query<(Entity, &SkinnedMesh)>, @@ -249,7 +250,21 @@ fn bind( pieces: Query<&OutfitPiece>, mut morphs: Query<(Entity, &mut MorphWeights)>, ) { - for skin in skins.actors.values_mut() { + let initial_poses: BTreeMap<_, _> = skins + .actors + .iter() + .filter(|(_,skin)|skin.pending.is_some()) + .map(|(&id,_)| { + let bones = net + .remotes + .get(&id) + .and_then(|r| r.poses.back()) + .map(|p| p.bones.as_slice()) + .unwrap_or(&[]); + (id, globals(bones, &skins)) + }) + .collect(); + for (id, skin) in skins.actors.iter_mut() { let Some(p) = skin.pending.as_ref() else { continue; }; @@ -345,6 +360,15 @@ fn bind( )); } } + let pose = &initial_poses[id]; + let basis = Mat4::from_cols(Vec4::X, -Vec4::Z, Vec4::Y, Vec4::W); + for &(entity, bone, parent) in &bindings { + let global = pose[bone] * basis; + let local = parent.map_or(global, |parent| (pose[parent] * basis).inverse() * global); + commands + .entity(entity) + .insert(Transform::from_matrix(local)); + } if let Some(old) = skin.visible.replace(p.root) { commands.entity(old).despawn(); } diff --git a/docs/multiplayer-test.md b/docs/multiplayer-test.md index e86e6d77..ecb02d02 100644 --- a/docs/multiplayer-test.md +++ b/docs/multiplayer-test.md @@ -10,15 +10,21 @@ prepared character library. The same setup update that supplies the local customiser supplies these online assets. Imported characters transfer their converted, self-contained GLB automatically; -other players do not need to import the model. Completed appearances are cached for +other players do not need to import the model. Installed imports are indexed on a +background worker by content hash. When a matching file is already available, the +receiver loads that local asset and requests no model data, including when a +different character is currently equipped. Completed appearances are cached for the connection: switching back sends only the identity, and acknowledged model data stops sending. Departed receivers and completed requests are explicitly cleared. Updates and late joins use bounded, reliable chunks through the existing host relay. Movement/collision packets keep priority and existing internet bandwidth budgets. Loopback test connections use -a larger local budget so large files do not take minutes on one machine. The HUD shows character transfer progress. +a larger local bandwidth budget. The HUD shows character transfer progress. Each remote keeps its visible character until the new scenes, textures and every -clothing rig are ready. Imported textures retain their own materials; retail +clothing rig are ready. A replacement is posed before visibility is published; +paused local swaps use the current skeleton and remote swaps use the latest +received pose (or the idle pose while waiting for a sample). +Imported textures retain their own materials; retail characters use their authored clothing lighting. Remote colours/tattoos never modify the local player or another remote's materials.