Reuse installed online models by content hash and pose replacement rigs before display

This commit is contained in:
chazmm committed 2026-09-09 16:56:19 +01:00
1 parent 94e5f613f3
commit bf7b894af6
5 files changed
+201 -7

No files matched your search

+49 -1
View File
@@ -16,6 +16,14 @@ struct BoneBinding {
parent_bone: Option<usize>,
}
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<usize>)> {
self.bindings.iter().map(|b|(b.entity,b.bone,b.parent_bone)).collect()
}
@@ -197,6 +205,7 @@ fn bind(
}
fn present(
history: Res<crate::presentation::Presentation>,
skater: Res<SkaterRuntime>,
replay: Res<crate::replay::Replay>,
time: Res<Time<Fixed>>,
animation: Res<AnimationStatus>,
@@ -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::<SkaterRuntime>().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::<Transform>(entity).unwrap()=t;}
let replacement=bind(&mut world,roots[1].0);
assert_eq!(*world.get::<Transform>(roots[1].1).unwrap(),Transform::default());
for (entity,t) in replacement.pose_transforms(&pose){*world.get_mut::<Transform>(entity).unwrap()=t;}
assert_eq!(world.get::<Transform>(roots[0].1),world.get::<Transform>(roots[1].1));
assert_eq!(world.get::<Transform>(roots[0].2),world.get::<Transform>(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::<Transform>(entity).unwrap()=t;}
assert_ne!(world.get::<Transform>(roots[0].1),world.get::<Transform>(roots[1].1));
assert_ne!(world.get::<Transform>(roots[0].2),world.get::<Transform>(roots[1].2));
}
}
+6
View File
@@ -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<String>, 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)) {
+112 -2
View File
@@ -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<String>,
indexing: Option<Task<Vec<(super::appearance_transfer::Identity, Look)>>>,
}
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<AppExit>) {
// 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());
}
}
+25 -1
View File
@@ -236,6 +236,7 @@ pub(super) fn spawn(
fn bind(
mut commands: Commands,
net: Res<Multiplayer>,
mut skins: ResMut<RemoteSkins>,
skater: Res<SkaterRuntime>,
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();
}
+9 -3
View File
@@ -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.