mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Reuse installed online models by content hash and pose replacement rigs before display
This commit is contained in:
1 parent
94e5f613f3
commit
bf7b894af6
5 files changed
+201
-7
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user