Add scrolling custom character library and native skater roster

This commit is contained in:
chazmm committed 2026-09-08 19:55:19 +01:00
1 parent 657eec2d93
commit ab538ea732
26 files changed
+2420 -18

No files matched your search

Generated
+17
View File
@@ -2856,6 +2856,8 @@ dependencies = [
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
@@ -6001,3 +6003,18 @@ dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+6 -1
View File
@@ -13,12 +13,17 @@ name = "skate3-multiplayer"
path = "src/multiplayer_main.rs"
test = false
[[bin]]
name = "skate3-custom-models"
path = "src/custom_models_main.rs"
test = false
[features]
default = ["dev-dynamic"]
dev-dynamic = ["bevy/dynamic_linking"]
[dependencies]
bevy.workspace = true
bevy = { workspace = true, features = ["jpeg"] }
skate-core.workspace = true
skate-data.workspace = true
half = "=2.7.1"
+69
View File
@@ -15,6 +15,75 @@ struct BoneBinding {
bone: usize,
parent_bone: Option<usize>,
}
impl AnimationStatus {
/// Prepare a hidden imported scene without disturbing the live bindings.
pub(crate) fn for_scene(
root: Entity,
names: &[String],
skins: &Query<(Entity, &SkinnedMesh)>,
nodes: &Query<(&Name, &Transform)>,
parents: &Query<&ChildOf>,
) -> Result<Self, String> {
let mut bindings = Vec::new();
let mut seen = std::collections::HashSet::new();
for (entity, skin) in skins.iter() {
if !parents.iter_ancestors(entity).any(|p| p == root) { continue; }
for &joint in &skin.joints {
if !seen.insert(joint) { continue; }
if !parents.iter_ancestors(joint).any(|p| p == root) {
return Err("Imported skin refers to a joint outside its scene".into());
}
let (name, _) = nodes.get(joint).map_err(|_| "Imported joint has no name/transform")?;
let bone = names.iter().position(|n| n.eq_ignore_ascii_case(name.as_str()))
.ok_or_else(|| format!("Unsupported imported bone: {name}"))?;
let mut parent_bone = None;
for p in parents.iter_ancestors(joint) {
if p == root { break; }
if let Ok((name, transform)) = nodes.get(p) {
if let Some(i) = names.iter().position(|n| n.eq_ignore_ascii_case(name.as_str())) {
parent_bone = Some(i);
break;
}
if !transform.to_matrix().abs_diff_eq(Mat4::IDENTITY, 0.00001) {
return Err("Imported armature has an unsupported ancestor transform".into());
}
}
}
bindings.push(BoneBinding { entity: joint, bone, parent_bone });
}
}
if bindings.is_empty() { return Err("Imported scene has no skinned character".into()); }
Ok(Self { ready: true, bindings })
}
}
#[cfg(test)]
mod custom_model_binding_tests {
use super::*;
use bevy::ecs::system::SystemState;
#[test]
fn custom_models_bind_only_the_candidate_and_reject_foreign_joints() {
let mut world = World::new();
let root = world.spawn_empty().id();
let hips = world.spawn((Name::new("HIPS"), Transform::default(), ChildOf(root))).id();
let head = world.spawn((Name::new("HEAD"), Transform::default(), ChildOf(hips))).id();
for _ in 0..2 {
world.spawn((ChildOf(root), SkinnedMesh { inverse_bindposes: default(), joints: vec![hips, head] }));
}
let foreign = world.spawn((Name::new("unrelated"), Transform::default())).id();
world.spawn(SkinnedMesh { inverse_bindposes: default(), joints: vec![foreign] });
let names = vec!["HIPS".into(), "HEAD".into()];
let mut queries: SystemState<(Query<(Entity, &SkinnedMesh)>, Query<(&Name, &Transform)>, Query<&ChildOf>)> = SystemState::new(&mut world);
let (skins,nodes,parents) = queries.get(&world);
let prepared = AnimationStatus::for_scene(root,&names,&skins,&nodes,&parents).unwrap();
assert!(prepared.ready);
assert_eq!(prepared.bindings.len(),2);
assert_eq!(prepared.bindings.iter().find(|b| b.entity == head).unwrap().parent_bone,Some(0));
world.spawn((ChildOf(root), SkinnedMesh { inverse_bindposes: default(), joints: vec![foreign] }));
let (skins,nodes,parents) = queries.get(&world);
assert!(AnimationStatus::for_scene(root,&names,&skins,&nodes,&parents).is_err());
assert_eq!(prepared.bindings.len(),2);
}
}
pub(crate) struct AnimationPlugin;
impl Plugin for AnimationPlugin {
fn build(&self, app: &mut App) {
+2
View File
@@ -39,6 +39,7 @@ pub(crate) fn build(
) -> App {
let retail_scene = config.map.as_ref().is_some_and(|map| map.materials.iter().any(|m| m.retail_definition.is_some()));
let mut app = App::new();
crate::custom_models::register_source(&mut app);
app.add_plugins(
DefaultPlugins
.set(AssetPlugin {
@@ -108,6 +109,7 @@ pub(crate) fn build(
crate::performance::PerformancePlugin,
));
app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin));
app.add_plugins(crate::custom_models::CustomModelsPlugin);
app.add_plugins(crate::multiplayer::MultiplayerPlugin);
app
}
+909
View File
@@ -0,0 +1,909 @@
//! Persistent local visual characters. Import and ECS publication are separate transactions.
use bevy::{
asset::{
LoadState,
io::{AssetSourceBuilder, file::FileAssetReader},
},
gltf::Gltf,
input::mouse::{MouseScrollUnit, MouseWheel},
mesh::skinning::SkinnedMesh,
prelude::*,
scene::SceneInstance,
};
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::Instant,
};
const COLUMNS: usize = 3;
const CARD_HEIGHT: f32 = 246.;
const ROW_HEIGHT: f32 = CARD_HEIGHT + 10.;
#[derive(Clone, Deserialize)]
pub(crate) struct Entry {
version: u32,
pub id: String,
pub name: String,
#[serde(default)]
native: Option<NativeCharacter>,
}
#[derive(Clone, Deserialize)]
struct NativeCharacter {
key: String,
category: String,
}
pub(crate) fn native_animation_style(key: &str) -> &'static str {
// TU3 GetCACSettings 82590BE0..82590DBC; other Marquees use Aggressive.
match key {
"danny_way" => "DannyWay",
"mike_carroll" => "MikeCarroll",
"pj_ladd" => "PJLadd",
"jason_dill" => "JasonDill",
"jerry_hsu" => "JerryHsu",
"rob_dyrdek" => "RobDyrdek",
_ => "Aggressive",
}
}
#[derive(Default, Serialize, Deserialize)]
struct Selection {
version: u32,
selected: Option<String>,
}
struct Pending {
id: String,
root: Entity,
asset: Handle<Gltf>,
started: Instant,
}
struct Import {
child: Child,
result: PathBuf,
started: Instant,
}
#[derive(Component)]
pub(crate) struct CustomModelRoot;
#[derive(Component)]
struct Panel;
#[derive(Component)]
struct Action(usize);
#[derive(Component)]
struct ModelScroll;
#[derive(Resource)]
pub(crate) struct CustomModels {
pub open: bool,
just_opened: bool,
pub active: Option<String>,
entries: Vec<Entry>,
directory: PathBuf,
request: Option<Option<String>>,
pending: Option<Pending>,
active_root: Option<Entity>,
stock_visibility: Vec<(Entity, Visibility)>,
import: Option<Import>,
status: String,
selected: usize,
scroll: f32,
filter: usize,
dirty: bool,
}
impl CustomModels {
pub(crate) fn native_style(&self) -> Option<&'static str> {
self.active
.as_ref()
.and_then(|id| self.entries.iter().find(|e| &e.id == id))
.and_then(|e| e.native.as_ref())
.map(|n| native_animation_style(&n.key))
}
fn visible_entries(&self) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| match self.filter {
1 => e.native.as_ref().is_some_and(|n| n.category == "Pro"),
2 => e.native.as_ref().is_some_and(|n| n.category == "Special"),
3 => e.native.is_none(),
_ => true,
})
.collect()
}
pub fn begin(&mut self) {
self.open = true;
self.just_opened = true;
self.dirty = true;
}
pub fn request_stock(&mut self) {
self.request = Some(None);
}
}
pub(crate) fn library_path() -> PathBuf {
std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("Skate3RustEngine/custom-characters")
}
pub(crate) fn register_source(app: &mut App) {
let directory = library_path();
app.register_asset_source(
"characters",
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(directory.clone()))),
);
}
fn valid_id(id: &str) -> bool {
id.len() == 64
&& id
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
fn discover(directory: &Path) -> Vec<Entry> {
let mut entries: Vec<Entry> = std::fs::read_dir(directory.join("entries"))
.into_iter()
.flatten()
.flatten()
.filter_map(|folder| {
let id = folder.file_name().to_string_lossy().into_owned();
if !valid_id(&id) || !folder.file_type().ok()?.is_dir() {
return None;
}
let path = folder.path();
let data = std::fs::read(path.join("manifest.json")).ok()?;
if data.len() > 65536 {
return None;
}
let mut entry: Entry = serde_json::from_slice(&data).ok()?;
if entry.version != 1
|| entry.id != id
|| !path.join("character.glb").is_file()
|| !path.join("preview.png").is_file()
{
return None;
}
entry.name = entry
.name
.chars()
.filter(|c| !c.is_control())
.take(64)
.collect();
Some(entry)
})
.collect();
entries.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then(a.id.cmp(&b.id))
});
entries
}
fn save_selection(directory: &Path, selected: Option<String>) -> Result<(), String> {
std::fs::create_dir_all(directory).map_err(|e| e.to_string())?;
let temporary = directory.join("selection.tmp");
std::fs::write(
&temporary,
serde_json::to_vec(&Selection {
version: 1,
selected,
})
.unwrap(),
)
.map_err(|e| e.to_string())?;
std::fs::rename(temporary, directory.join("selection.json")).map_err(|e| e.to_string())
}
pub(crate) struct CustomModelsPlugin;
impl Plugin for CustomModelsPlugin {
fn build(&self, app: &mut App) {
let directory = library_path();
let entries = discover(&directory);
let saved = std::fs::read(directory.join("selection.json"))
.ok()
.and_then(|b| serde_json::from_slice::<Selection>(&b).ok())
.filter(|s| s.version == 1)
.and_then(|s| s.selected)
.filter(|id| entries.iter().any(|e| &e.id == id));
app.insert_resource(CustomModels {
open: false,
just_opened: false,
active: None,
entries,
directory,
request: saved.map(Some),
pending: None,
active_root: None,
stock_visibility: vec![],
import: None,
status: String::new(),
selected: 0,
scroll: 0.,
filter: 0,
dirty: true,
})
.add_systems(PreUpdate, interact.after(crate::graphics_menu::MenuInput))
.add_systems(
Update,
(poll_import, publish, draw)
.chain()
.before(crate::customiser_parts::update)
.before(crate::app::FrameSet::Animation),
)
.add_systems(Last, stop_import);
}
}
fn interact(
mut state: ResMut<CustomModels>,
keys: Res<ButtonInput<KeyCode>>,
nav: Res<crate::customiser::Navigation>,
buttons: Query<(&Interaction, &Action), Changed<Interaction>>,
mut wheel: MessageReader<MouseWheel>,
mut scroll_nodes: Query<(&mut ScrollPosition, &ComputedNode), With<ModelScroll>>,
config: Res<crate::config::Config>,
manifest: Res<crate::assets::AssetManifest>,
) {
if !state.open {
wheel.clear();
return;
}
for event in wheel.read() {
let amount = if event.unit == MouseScrollUnit::Line {
event.y * 48.
} else {
event.y
};
for (mut position, computed) in &mut scroll_nodes {
let limit = ((computed.content_size().y - computed.size().y)
* computed.inverse_scale_factor())
.max(0.);
state.scroll = (state.scroll - amount).clamp(0., limit);
position.y = state.scroll;
}
}
if state.just_opened {
state.just_opened = false;
return;
}
if keys.just_pressed(KeyCode::Escape) || nav.pressed & (0x2000 | 0x10) != 0 {
state.open = false;
state.dirty = true;
return;
}
let count = state.visible_entries().len() + 4;
let previous = state.selected;
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
state.selected = if state.selected >= 4 + COLUMNS {
state.selected - COLUMNS
} else {
state.selected.saturating_sub(1)
};
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
state.selected =
(state.selected + if state.selected >= 4 { COLUMNS } else { 1 }).min(count - 1);
}
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
state.selected = state.selected.saturating_sub(1);
}
if keys.just_pressed(KeyCode::ArrowRight) || nav.pressed & 8 != 0 {
state.selected = (state.selected + 1).min(count - 1);
}
if previous != state.selected {
if state.selected >= 4 {
let top = ((state.selected - 4) / COLUMNS) as f32 * ROW_HEIGHT;
let height = scroll_nodes
.iter()
.next()
.map(|(_, n)| n.size().y * n.inverse_scale_factor())
.unwrap_or(400.);
state.scroll = state
.scroll
.min(top)
.max(top + CARD_HEIGHT - height)
.max(0.);
}
state.dirty = true;
}
let mut action = if keys.just_pressed(KeyCode::Enter) || nav.pressed & 0x1000 != 0 {
Some(state.selected)
} else {
None
};
for (interaction, button) in &buttons {
if *interaction == Interaction::Pressed {
action = Some(button.0);
}
}
if let Some(action) = action {
state.selected = action;
match action {
0 if state.import.is_none() => {
state.status = match start_import(
&state.directory,
&config
.asset_root
.join(manifest.0.character_scene.split('#').next().unwrap()),
) {
Ok(job) => {
state.import = Some(job);
"Choose a Mixamo FBX in the file picker. Import may take a moment.".into()
}
Err(e) => e,
};
}
1 => state.request_stock(),
2 => {
state.filter = (state.filter + 1) % 4;
state.scroll = 0.;
}
3 => state.open = false,
action if action >= 4 => {
if let Some(entry) = state.visible_entries().get(action - 4) {
state.request = Some(Some(entry.id.clone()));
}
}
_ => (),
}
state.dirty = true;
}
}
fn start_import(directory: &Path, reference: &Path) -> Result<Import, String> {
let executable = std::env::current_exe()
.map_err(|e| e.to_string())?
.parent()
.ok_or("Missing game directory")?
.join("support/custom-models/Mixamo to Skate.exe");
if !executable.is_file() {
return Err("Character importer is missing. Use the complete Custom Models build.".into());
}
let jobs = directory.join("jobs");
std::fs::create_dir_all(&jobs).map_err(|e| e.to_string())?;
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let result = jobs.join(format!("{}-{nonce}.json", std::process::id()));
let mut command = Command::new(executable);
command
.arg("--library-import")
.arg(directory)
.arg("--reference")
.arg(reference)
.arg("--result")
.arg(&result)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let child = command
.spawn()
.map_err(|e| format!("Could not open importer: {e}"))?;
Ok(Import {
child,
result,
started: Instant::now(),
})
}
fn poll_import(mut state: ResMut<CustomModels>) {
let Some(job) = state.import.as_mut() else {
return;
};
if job.started.elapsed().as_secs() > 900 {
let _ = job.child.kill();
}
let finished = match job.child.try_wait() {
Ok(Some(_)) => true,
Ok(None) => false,
Err(_) => true,
};
if !finished {
return;
}
let job = state.import.take().unwrap();
let reply = std::fs::read(&job.result)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok());
let _ = std::fs::remove_file(&job.result);
state.entries = discover(&state.directory);
state.status = match reply.as_ref().and_then(|r| r["status"].as_str()) {
Some("cancelled") => "Import cancelled. Your character is unchanged.".into(),
Some("ready") => {
let id = reply.as_ref().unwrap()["id"].as_str().unwrap_or("");
if let Some(index) = state.entries.iter().position(|e| e.id == id) {
let id = state.entries[index].id.clone();
state.filter = 0;
state.selected = index + 4;
state.scroll = (index / COLUMNS) as f32 * ROW_HEIGHT;
state.request = Some(Some(id));
"Imported. Loading character...".into()
} else {
"Import did not produce a valid library entry.".into()
}
}
Some("error") => reply.as_ref().unwrap()["message"]
.as_str()
.unwrap_or("Import failed")
.chars()
.take(350)
.collect(),
_ => "The importer stopped before finishing. Your character is unchanged.".into(),
};
state.dirty = true;
}
fn publish(
mut commands: Commands,
mut state: ResMut<CustomModels>,
server: Res<AssetServer>,
skater: Res<crate::physics::SkaterRuntime>,
mut animation: ResMut<crate::animation::AnimationStatus>,
roots: Query<Entity, With<crate::world::PlayerRoot>>,
spawner: Res<SceneSpawner>,
instances: Query<&SceneInstance>,
skins: Query<(Entity, &SkinnedMesh)>,
nodes: Query<(&Name, &Transform)>,
parents: Query<&ChildOf>,
mut scenes: Query<(Entity, &ChildOf, &mut Visibility), With<SceneRoot>>,
) {
let Ok(player) = roots.single() else {
return;
};
if let Some(request) = state.request.take() {
if let Some(p) = state.pending.take() {
commands.entity(p.root).despawn();
}
match request {
None => {
if let Some(root) = state.active_root.take() {
commands.entity(root).despawn();
}
for (e, visibility) in state.stock_visibility.drain(..) {
if let Ok((_, _, mut v)) = scenes.get_mut(e) {
*v = visibility;
}
}
state.active = None;
*animation = default();
state.status = match save_selection(&state.directory, None) {
Ok(()) => "Stock skater restored.".into(),
Err(e) => format!("Restored; selection could not be saved: {e}"),
};
}
Some(id)
if state.active.as_ref() != Some(&id)
&& state.entries.iter().any(|e| e.id == id) =>
{
let path = format!("characters://entries/{id}/character.glb");
let asset = server.load(path.clone());
let root = commands
.spawn((
CustomModelRoot,
Transform::default(),
Visibility::Hidden,
SceneRoot(server.load(GltfAssetLabel::Scene(0).from_asset(path))),
))
.id();
commands.entity(player).add_child(root);
state.pending = Some(Pending {
id,
root,
asset,
started: Instant::now(),
});
state.status = "Loading model; your current skater stays active.".into();
}
_ => (),
}
state.dirty = true;
}
let Some(p) = state.pending.as_ref() else {
return;
};
let error = match server.get_load_state(p.asset.id()) {
Some(LoadState::Failed(e)) => Some(format!("Could not load model: {e}")),
_ if p.started.elapsed().as_secs() > 120 => Some("Character loading timed out.".into()),
_ => None,
};
let prepared = if let Some(error) = error {
Some(Err(error))
} else if server.is_loaded_with_dependencies(p.asset.id())
&& instances
.get(p.root)
.is_ok_and(|i| spawner.instance_is_ready(**i))
{
Some(crate::animation::AnimationStatus::for_scene(
p.root,
&skater.animation.evaluator.frames.bone_names,
&skins,
&nodes,
&parents,
))
} else {
None
};
let Some(prepared) = prepared else {
return;
};
let pending = state.pending.take().unwrap();
match prepared {
Err(error) => {
commands.entity(pending.root).despawn();
state.status = format!("{error} Previous character retained.");
}
Ok(bindings) => {
if state.active_root.is_none() {
for (entity, parent, mut visibility) in &mut scenes {
if parent.parent() == player && entity != pending.root {
state.stock_visibility.push((entity, *visibility));
*visibility = Visibility::Hidden;
}
}
}
if let Some(old) = state.active_root.replace(pending.root) {
commands.entity(old).despawn();
}
if let Ok((_, _, mut visibility)) = scenes.get_mut(pending.root) {
*visibility = Visibility::Inherited;
}
*animation = bindings;
state.active = Some(pending.id.clone());
state.status = match save_selection(&state.directory, Some(pending.id)) {
Ok(()) => "Character equipped. Resume whenever you're ready.".into(),
Err(e) => format!("Equipped; could not save selection: {e}"),
};
}
}
state.dirty = true;
}
fn stop_import(mut state: ResMut<CustomModels>, mut exit: MessageReader<AppExit>) {
if exit.read().next().is_some() {
if let Some(mut job) = state.import.take() {
let _ = job.child.kill();
let _ = job.child.wait();
}
}
}
fn draw(
mut commands: Commands,
mut state: ResMut<CustomModels>,
server: Res<AssetServer>,
existing: Query<Entity, With<Panel>>,
) {
if !state.dirty {
return;
}
state.dirty = false;
for e in &existing {
commands.entity(e).despawn();
}
if !state.open {
return;
}
let selected = state.selected;
commands.spawn((Panel, GlobalZIndex(30), Node { position_type:PositionType::Absolute, width:percent(100), height:percent(100),
justify_content:JustifyContent::Center, align_items:AlignItems::Center, ..default() }, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.94))))
.with_children(|root| { root.spawn((Node { width:px(800), height:percent(94), max_width:percent(96), padding:UiRect::all(px(20)),
flex_direction:FlexDirection::Column, row_gap:px(14), ..default() }, BackgroundColor(Color::srgb(0.035,0.055,0.08))))
.with_children(|panel| {
label(panel, "CUSTOM MODELS", 30.);
label(panel, "Choose a pro, special character, or your own imported model.", 17.);
panel.spawn(Node { column_gap:px(10), ..default() }).with_children(|bar| {
button(bar,0, if state.import.is_some() { "Importing..." } else { "Import model..." },selected);
button(bar,2,["Show: All", "Show: Pros", "Show: Specials", "Show: Imported"][state.filter],selected);
button(bar,1,if state.active.is_none() { "Stock skater (active)" } else { "Use stock skater" },selected);
});
panel.spawn((ModelScroll, ScrollPosition(Vec2::new(0., state.scroll)), Node {
display:Display::Grid, grid_template_columns:RepeatedGridTrack::flex(COLUMNS as u16, 1.),
grid_auto_rows:vec![GridTrack::px(CARD_HEIGHT)], column_gap:px(10), row_gap:px(10),
flex_grow:1., min_height:px(0), overflow:Overflow::scroll_y(), align_content:AlignContent::Start,
..default() })).with_children(|cards| {
for (i,entry) in state.visible_entries().into_iter().enumerate() {
let action=i+4;
cards.spawn((Button,Action(action),Node { min_width:px(0),height:px(CARD_HEIGHT),align_items:AlignItems::Center,padding:UiRect::all(px(6)),flex_direction:FlexDirection::Column,row_gap:px(6), ..default() },
BackgroundColor(if selected==action { Color::srgb(0.12,0.3,0.34) } else { Color::srgb(0.075,0.105,0.14) })))
.with_children(|card| {
card.spawn((ImageNode::new(server.load(format!("characters://entries/{}/preview.png",entry.id))),Node {width:px(128),height:px(160),..default()}));
label(card,&entry.name,15.);
if let Some(native) = &entry.native { label(card,&format!("{} · Native",native.category),12.); }
if state.active.as_ref()==Some(&entry.id) { label(card,"Equipped",14.); }
});
}
if state.visible_entries().is_empty() { label(cards,"Your imported characters will appear here.",18.); }
});
panel.spawn(Node { column_gap:px(10), ..default() }).with_children(|bar| {
label(bar,&format!("{} models / Scroll to browse",state.visible_entries().len()),16.);
button(bar,3,"Back",selected);
});
label(panel,&state.status,16.);
label(panel,"Up/Down select · Enter/A equip · Escape/B back\nNative characters retain retail animation styles. Imports use your customiser style.",14.);
}); });
}
fn label(parent: &mut ChildSpawnerCommands, text: &str, size: f32) {
parent.spawn((
Text::new(text),
TextFont {
font_size: size,
..default()
},
TextColor(Color::srgb(0.85, 0.91, 0.95)),
));
}
fn button(parent: &mut ChildSpawnerCommands, action: usize, text: &str, selected: usize) {
parent
.spawn((
Button,
Action(action),
Node {
padding: UiRect::all(px(10)),
..default()
},
BackgroundColor(if action == selected {
Color::srgb(0.12, 0.3, 0.34)
} else {
Color::srgb(0.08, 0.12, 0.17)
}),
))
.with_children(|p| label(p, text, 17.));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "Requires private retail roster and animation banks; asset loading only"]
fn native_roster_assets_bind_to_the_retail_animation_skeleton() {
use bevy::{
asset::AssetPlugin, ecs::system::SystemState, gltf::GltfPlugin, image::ImagePlugin,
mesh::MeshPlugin, scene::ScenePlugin,
};
let directory = PathBuf::from(std::env::var("SKATE_TEST_LIBRARY").unwrap());
let banks = skate_data::animation_banks::AnimationBanks::load(Path::new(
&std::env::var("SKATE_TEST_ASSETS").unwrap(),
))
.unwrap();
let names = skate_data::animation_frames::AnimationFrames::from_banks(&banks)
.unwrap()
.bone_names;
let metadata = banks.metadata().unwrap();
let mut supported = std::collections::HashSet::new();
for bank in &banks.banks {
for record in bank.records().iter().filter(|r| r.header.type_id == 8) {
if let Ok(skate_data::animation_metadata::TreeMetadata::Selector(selector)) =
metadata.tree(&record.header.name)
{
if selector.parameter.eq_ignore_ascii_case("ProSkater") {
for (value, child) in selector.values.iter().zip(&selector.children) {
assert!(
metadata.tree(child).is_ok(),
"Missing native style child {child}"
);
supported.insert(value.to_ascii_uppercase());
}
}
}
}
}
let entries: Vec<_> = discover(&directory)
.into_iter()
.filter(|e| e.native.is_some())
.collect();
assert_eq!(
entries.len(),
41,
"All registered retail pros and specials must be installed"
);
for entry in &entries {
let style = native_animation_style(&entry.native.as_ref().unwrap().key);
assert!(
supported.contains(&style.to_ascii_uppercase()),
"Native animation bank has no {style} selector"
);
}
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
AssetPlugin {
file_path: directory.to_string_lossy().into_owned(),
..default()
},
ImagePlugin::default(),
MeshPlugin,
ScenePlugin,
GltfPlugin::default(),
));
app.init_asset::<StandardMaterial>()
.init_asset::<AnimationClip>();
app.finish();
app.cleanup();
for entry in entries {
let handle: Handle<Gltf> = app
.world()
.resource::<AssetServer>()
.load(format!("entries/{}/character.glb", entry.id));
let started = Instant::now();
loop {
app.update();
let server = app.world().resource::<AssetServer>();
if server.is_loaded_with_dependencies(handle.id()) {
break;
}
assert!(
!matches!(
server.get_load_state(handle.id()),
Some(LoadState::Failed(_))
),
"{} failed to load",
entry.name
);
assert!(started.elapsed().as_secs() < 45, "{} timed out", entry.name);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let scene = app
.world()
.resource::<Assets<Gltf>>()
.get(&handle)
.unwrap()
.scenes[0]
.clone();
let mut scenes = app.world_mut().resource_mut::<Assets<Scene>>();
let world = &mut scenes.get_mut(&scene).unwrap().world;
let roots: Vec<Entity> = world
.query_filtered::<Entity, Without<ChildOf>>()
.iter(world)
.collect();
let root = world.spawn_empty().id();
for e in roots {
world.entity_mut(e).insert(ChildOf(root));
}
let mut queries: SystemState<(
Query<(Entity, &SkinnedMesh)>,
Query<(&Name, &Transform)>,
Query<&ChildOf>,
)> = SystemState::new(world);
let (skins, nodes, parents) = queries.get(world);
let binding = crate::animation::AnimationStatus::for_scene(
root, &names, &skins, &nodes, &parents,
)
.unwrap_or_else(|e| panic!("{}: {e}", entry.name));
assert!(binding.ready);
drop(scenes);
drop(scene);
drop(handle);
app.update();
app.update();
}
}
#[test]
fn imported_png_and_jpeg_textures_decode_in_the_game() {
use bevy::{
asset::RenderAssetUsages,
image::{CompressedImageFormats, ImageSampler, ImageType},
};
for (data, mime) in [
(
include_bytes!("tests/fixtures/texture-codec.jpg").as_slice(),
"image/jpeg",
),
(
include_bytes!("tests/fixtures/texture-codec.png").as_slice(),
"image/png",
),
] {
let image = Image::from_buffer(
data,
ImageType::MimeType(mime),
CompressedImageFormats::NONE,
true,
ImageSampler::default(),
RenderAssetUsages::default(),
)
.unwrap();
assert_eq!(image.size(), UVec2::new(2, 2));
let pixels = image.data.unwrap();
assert!(
pixels[0] > 200 && pixels[1] < 60 && pixels[2] < 40,
"Texture must keep its authored red colour"
);
}
}
#[test]
#[ignore = "Requires a private model and its expected diffuse image; loads assets only, no window or renderer"]
fn private_model_keeps_its_diffuse_texture() {
use bevy::{
asset::AssetPlugin, gltf::GltfPlugin, image::ImagePlugin, mesh::MeshPlugin,
scene::ScenePlugin,
};
let source = PathBuf::from(std::env::var("SKATE_TEST_CHARACTER").unwrap());
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
AssetPlugin {
file_path: source.parent().unwrap().to_string_lossy().into_owned(),
..default()
},
ImagePlugin::default(),
MeshPlugin,
ScenePlugin,
GltfPlugin::default(),
));
app.init_asset::<StandardMaterial>()
.init_asset::<AnimationClip>();
app.finish();
app.cleanup();
let asset: Handle<Gltf> = app
.world()
.resource::<AssetServer>()
.load(source.file_name().unwrap().to_string_lossy().into_owned());
let started = Instant::now();
loop {
app.update();
let server = app.world().resource::<AssetServer>();
if server.is_loaded_with_dependencies(asset.id()) {
break;
}
assert!(
!matches!(
server.get_load_state(asset.id()),
Some(LoadState::Failed(_))
),
"Model load failed"
);
assert!(started.elapsed().as_secs() < 30, "Model load timed out");
std::thread::sleep(std::time::Duration::from_millis(10));
}
let gltf = app.world().resource::<Assets<Gltf>>().get(&asset).unwrap();
let material = app
.world()
.resource::<Assets<StandardMaterial>>()
.get(&gltf.materials[0])
.unwrap();
let diffuse = app
.world()
.resource::<Assets<Image>>()
.get(material.base_color_texture.as_ref().unwrap())
.unwrap();
let bytes = std::fs::read(std::env::var("SKATE_TEST_DIFFUSE").unwrap()).unwrap();
let expected = Image::from_buffer(
&bytes,
bevy::image::ImageType::MimeType("image/png"),
bevy::image::CompressedImageFormats::NONE,
true,
default(),
default(),
)
.unwrap();
assert_eq!(diffuse.size(), expected.size());
assert_eq!(
diffuse.data, expected.data,
"Game material must use the authored diffuse, not a shifted texture handle"
);
}
#[test]
fn library_ids_cannot_escape_the_asset_source() {
assert!(valid_id(&"ab".repeat(32)));
for bad in ["../character", "C:/model", "", &"F".repeat(64)] {
assert!(!valid_id(bad));
}
}
#[test]
fn corrupt_or_incomplete_entries_are_not_published() {
let root = std::env::temp_dir().join(format!("skate-model-library-{}", std::process::id()));
let id = "a".repeat(64);
let entry = root.join("entries").join(&id);
std::fs::create_dir_all(&entry).unwrap();
std::fs::write(
entry.join("manifest.json"),
format!(r#"{{"version":1,"id":"{id}","name":"Model"}}"#),
)
.unwrap();
assert!(discover(&root).is_empty());
std::fs::write(entry.join("character.glb"), b"fixture").unwrap();
std::fs::write(entry.join("preview.png"), b"fixture").unwrap();
assert_eq!(discover(&root).len(), 1);
save_selection(&root, Some(id.clone())).unwrap();
save_selection(&root, None).unwrap();
let saved: Selection =
serde_json::from_slice(&std::fs::read(root.join("selection.json")).unwrap()).unwrap();
assert!(saved.selected.is_none());
std::fs::remove_dir_all(root).unwrap();
}
}
@@ -0,0 +1 @@
include!("main.rs");
+6
View File
@@ -990,10 +990,16 @@ fn interact(
}
fn preferences(
state: Res<Customiser>,
models: Res<crate::custom_models::CustomModels>,
mut physics: ResMut<crate::physics::GamePhysics>,
mut skater: ResMut<crate::physics::SkaterRuntime>,
) {
apply_preferences(&state.draft, &mut physics, &mut skater.animation);
if let Some(style) = models.native_style() {
skater.animation.motion.playback_context.pro_skater = skate_core::animation::skeleton_input::name::encode(style.as_bytes());
skater.animation.motion.animation.posture.set_profile(0);
physics.set_gesture_preferences(None);
}
}
pub(crate) fn apply_preferences(
profile: &Value,
+14 -4
View File
@@ -1,6 +1,6 @@
//! Skin stamps modify albedo before the unchanged StandardMaterial lighting.
use bevy::{
asset::embedded_asset,
asset::{AssetPath, embedded_asset, embedded_path},
pbr::{ExtendedMaterial, MaterialExtension},
prelude::*,
render::render_resource::AsBindGroup,
@@ -29,10 +29,10 @@ pub(crate) struct SkinStamp {
}
impl MaterialExtension for SkinStamp {
fn fragment_shader() -> ShaderRef {
"embedded://skate3rust/customiser_stamp.wgsl".into()
AssetPath::from(embedded_path!("customiser_stamp.wgsl")).with_source("embedded").into()
}
fn prepass_fragment_shader() -> ShaderRef {
"embedded://skate3rust/customiser_prepass.wgsl".into()
AssetPath::from(embedded_path!("customiser_prepass.wgsl")).with_source("embedded").into()
}
fn deferred_fragment_shader() -> ShaderRef {
Self::fragment_shader()
@@ -64,13 +64,23 @@ pub(crate) fn placement(target: [f32; 4], art: [f32; 4]) -> (Vec4, Vec4) {
mod tests {
use super::*;
#[test]
fn embedded_shader_paths_match_this_binary() {
for (shader, registered) in [
(SkinStamp::fragment_shader(), embedded_path!("customiser_stamp.wgsl")),
(SkinStamp::prepass_fragment_shader(), embedded_path!("customiser_prepass.wgsl")),
] {
let ShaderRef::Path(path) = shader else { panic!("Expected embedded shader path") };
assert_eq!(path, AssetPath::from(registered).with_source("embedded"));
}
}
#[test]
fn customiser_stamp_maps_authored_borders() {
let (t, r) = placement([0.5, 0.0, 0.1, 0.4], [0.9, 0.1, 0.2, 0.8]);
assert!((Vec2::new(r.x, r.y) * t.xy() + t.zw()).abs_diff_eq(Vec2::new(0.2, 0.1), 1e-6));
assert!((Vec2::new(r.z, r.w) * t.xy() + t.zw()).abs_diff_eq(Vec2::new(0.8, 0.9), 1e-6));
assert_eq!(
bevy::asset::embedded_path!("customiser_stamp.wgsl"),
std::path::PathBuf::from("skate3rust/customiser_stamp.wgsl")
std::path::PathBuf::from(format!("{}/customiser_stamp.wgsl", module_path!().split("::").next().unwrap()))
);
}
#[test]
+3 -2
View File
@@ -499,12 +499,13 @@ pub(crate) fn update(
mut materials: ResMut<Assets<SkaterMaterial>>,
root: Query<Entity, With<crate::world::PlayerRoot>>,
parents: Query<&ChildOf>,
mut scenes: Query<(Entity, Option<&PartRoot>, &mut Visibility), With<SceneRoot>>,
mut scenes: Query<(Entity, Option<&PartRoot>, &mut Visibility), (With<SceneRoot>, Without<crate::custom_models::CustomModelRoot>)>,
mut mesh_materials: Query<(Entity, Option<&mut MeshMaterial3d<SkaterMaterial>>), With<Mesh3d>>,
mut morphs: Query<(Entity, &mut MorphWeights)>,
mut animation: ResMut<crate::animation::AnimationStatus>,
custom_models: Res<crate::custom_models::CustomModels>,
) {
if !state.enabled {
if !state.enabled || custom_models.active.is_some() {
return;
}
let Ok(root) = root.single() else {
+12 -9
View File
@@ -207,12 +207,12 @@ fn setup(
display: Display::None, width:percent(100), height:percent(100), align_items:AlignItems::Center,
justify_content:JustifyContent::Center, position_type:PositionType::Absolute, ..default()
}, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.88)))).with_children(|root| {
root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(6),border_radius:BorderRadius::all(px(12)),..default() },
root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(4),border_radius:BorderRadius::all(px(12)),..default() },
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
for i in 0..12 {
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
for i in 0..13 {
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
});
@@ -255,6 +255,7 @@ pub(crate) fn interact(
mut config: ResMut<crate::config::Config>,
mut transition: ResMut<crate::map_transition::MapTransition>,
mut customiser: ResMut<crate::customiser::Customiser>,
mut custom_models: ResMut<crate::custom_models::CustomModels>,
nav: Res<crate::customiser::Navigation>,
mut physics: ResMut<crate::physics::GamePhysics>,
keys: Res<ButtonInput<KeyCode>>,
@@ -270,7 +271,7 @@ pub(crate) fn interact(
time.pause();
return;
}
if customiser.open { return; }
if customiser.open || custom_models.open { return; }
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
menu.open = !menu.open;
}
@@ -296,7 +297,7 @@ pub(crate) fn interact(
}
}
if menu.open {
let rows = if menu.multiplayer { 11 } else { 12 };
let rows = if menu.multiplayer { 11 } else { 13 };
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + rows - 1) % rows;
}
@@ -409,11 +410,12 @@ pub(crate) fn interact(
9 => {
exit.write(AppExit::Success);
}
10 => customiser.begin(),
10 => { custom_models.request_stock(); customiser.begin(); },
11 => {
menu.multiplayer = true;
menu.selected = 0;
}
12 => custom_models.begin(),
_ => {}
}
}
@@ -497,6 +499,7 @@ fn labels(
transition: Res<crate::map_transition::MapTransition>,
time: Res<Time<Real>>,
customiser: Res<crate::customiser::Customiser>,
custom_models: Res<crate::custom_models::CustomModels>,
net: Res<crate::multiplayer::Multiplayer>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
@@ -504,7 +507,7 @@ fn labels(
mut status: Single<&mut Text, With<StatusLabel>>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without<MenuRoot>>,
) {
root.display = if menu.open && !customiser.open {
root.display = if menu.open && !customiser.open && !custom_models.open {
Display::Flex
} else {
Display::None
@@ -596,6 +599,7 @@ fn labels(
8 => "Resume".into(),
9 => "Quit game".into(),
10 => "Character customiser".into(),
12 => "Custom models".into(),
_ => "Multiplayer".into(),
}
};
@@ -618,7 +622,7 @@ fn labels(
menu.status.clone()
};
for (row, interaction, mut color, mut node) in &mut buttons {
node.display = if menu.multiplayer && row.0 == 11 { Display::None } else { Display::Flex };
node.display = if menu.multiplayer && row.0 >= 11 { Display::None } else { Display::Flex };
color.0 = if row.0 == menu.selected || *interaction == Interaction::Hovered {
Color::srgb(0.10, 0.30, 0.34)
} else {
@@ -656,7 +660,6 @@ mod tests {
.insert_resource(images)
.insert_resource(Menu {
open: false, selected: 0, settings: GraphicsSettings::default(),
maps: Vec::new(), selected_map: 0,
difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(),
multiplayer: false, browser: false,
maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0,
+1
View File
@@ -22,6 +22,7 @@ mod graphics_menu;
mod customiser;
mod customiser_parts;
mod customiser_material;
mod custom_models;
mod render_capacity;
mod retail_render;
mod presentation;
+11 -2
View File
@@ -1,7 +1,7 @@
//! Native noise pass, composited before the original HUD.
use super::noise::Noise;
use bevy::{
asset::{RenderAssetUsages, embedded_asset},
asset::{AssetPath, RenderAssetUsages, embedded_asset, embedded_path},
camera::visibility::RenderLayers,
image::{ImageAddressMode, ImageSampler, ImageSamplerDescriptor},
prelude::*,
@@ -27,7 +27,7 @@ struct Effect {
}
impl Material2d for Effect {
fn fragment_shader() -> ShaderRef {
"embedded://skate3rust/session_marker/effect.wgsl".into()
AssetPath::from(embedded_path!("effect.wgsl")).with_source("embedded").into()
}
fn alpha_mode(&self) -> AlphaMode2d {
AlphaMode2d::Blend
@@ -39,6 +39,15 @@ struct Runtime {
material: Handle<Effect>,
time: f64,
}
#[cfg(test)]
mod shader_path_tests {
use super::*;
#[test]
fn embedded_shader_paths_match_this_binary() {
let ShaderRef::Path(path) = Effect::fragment_shader() else { panic!("Expected embedded shader path") };
assert_eq!(path, AssetPath::from(embedded_path!("effect.wgsl")).with_source("embedded"));
}
}
#[derive(Component)]
struct Screen;
Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 B

+15
View File
@@ -0,0 +1,15 @@
# Custom model library
Open the pause menu, choose **Custom models**, then **Import model...**. The native file picker accepts Mixamo FBX and self-contained GLB characters. Successful imports generate textured thumbnail cards and equip automatically. Click any saved card to swap, or **Use stock skater** to restore the regular customiser. Selection persists between sessions. The three-column grid scrolls vertically with the mouse wheel. Keyboard: arrow keys and Enter; controller: D-pad and A; Escape/B returns to the pause menu. Keyboard/controller selection scrolls into view. The Show filter switches between all models, native pros, native specials, and imports.
The game runs `support/custom-models/Mixamo to Skate.exe` as a hidden child process; its file picker remains visible. Bundle the complete PyInstaller onedir output, FBX2glTF under `tools`, licenses, and optionally a locally generated `calibration.json`. Python and Blender are not required on the player's machine. Conversion instructions and restrictions are in `mixamo-to-skate.md`.
The library lives in `%LOCALAPPDATA%/Skate3RustEngine/custom-characters`. Each content-addressed entry contains `manifest.json`, `character.glb`, `source.glb`, `preview.png`, and a conversion report when converted. Imports publish through an atomic directory rename, so failed imports never appear as completed entries. Saved assets do not depend on the original FBX location. Import failures/cancellation preserve the existing character.
The game loads each candidate as a hidden scene, waits for its dependencies and scene instance, validates its stock animation bone bindings, and then commits the visibility/binding switch. The same player entity, physics, animation evaluator, camera and replay state remain in use. Stock modular scene visibility is restored when leaving the custom character. Converted characters include the reference board; the stock board customisation returns with the stock character. Custom selections are local visuals and are not transferred to multiplayer peers.
The paired calibration reproduces the supplied stock character's bind geometry; arbitrary humanoids use the converter's rig fitting and may need adjustment for unusual proportions. This is not a promise of original weight recovery or identical deformation on every model.
Build the separate executable with `cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --bin skate3-custom-models --no-default-features`, using `RUSTFLAGS=-C target-feature=+crt-static`. Stage beside `support/custom-models` and launch with `--assets` pointing at the prepared owned asset directory.
Verification: procedural Python conversion/library tests, Rust library discovery and candidate binding tests, release compilation, packaged importer conversion of the supplied FBX, thumbnail inspection, and independent Khronos glTF validation. No game launch or gameplay testing was performed; menu rendering and live visual swapping still need user playtesting.
+116
View File
@@ -0,0 +1,116 @@
# Mixamo to Skate converter
This offline converter produces a GLB for the existing stock-pose renderer. It
does not install assets, implement a menu library, change physics, or launch the
game. Export a standard Mixamo humanoid **with skin**, preferably in T-pose.
Use owned/local assets as the stock reference. Blender is not required.
## Player workflow
The Windows package contains `Mixamo to Skate.exe` and `tools/FBX2glTF.exe`.
Drag one or more FBX files onto the executable, or double-click it and choose
files. Results appear in a new `<input>-skate` directory next to the input;
existing results get a numeric suffix. The directory contains `character.glb`,
`report.json`, and a short readme. The stock reference is found in the local
Skate3RustEngine installation; multiple/missing installations prompt for it.
No settings are modified. Output publication is transactional: failure leaves
existing characters and source files unchanged. No assets are uploaded.
## Source use
Install the Python requirements into your own tool environment. Obtain
FBX2glTF 0.9.7 from its official release and put the Windows executable in
`tools/mixamo_to_skate/tools/FBX2glTF.exe`, or pass `--fbx-tool` explicitly.
```text
python tools/mixamo_to_skate/main.py model.fbx --reference path/to/private/skater.glb --noninteractive
python tools/mixamo_to_skate/main.py model.glb --reference path/to/private/skater.glb --no-board --noninteractive
```
The input GLB option is for an already normalized Mixamo conversion, not an
arbitrary rig. Arguments are passed directly to FBX2glTF without a shell.
Embedded FBX media extraction goes to a disposable temporary directory.
To package with an existing Python/NumPy/PyInstaller environment:
```text
python -m PyInstaller --onedir --console --name "Mixamo to Skate" --paths tools/mixamo_to_skate --distpath <output>/app --workpath <output>/build --specpath <output>/build tools/mixamo_to_skate/main.py
```
Keep the resulting executable and `_internal` directory together. Add
`tools/FBX2glTF.exe` next to the executable. The official 0.9.7 Windows binary
used here has SHA-256
`8d90fb5e0a8d186a3d9a7ff8c75eaee541c3975ce4df0d80351f20092ae0877f`.
Copy dependency license files into the package. A local private calibration
may be placed beside the executable; it is excluded from redistributable builds.
## What conversion means
This is **stock-proportion fitting**, not runtime motion retargeting. The
converter reads bind matrices, estimates the character's body axes and scale,
fits its bind geometry, and remaps/merges weights. The output uses the owned
stock hierarchy, stock bone axes and inverse binds (with negligible floating
point affine-row residue cleared). Local transforms are exported as TRS after
removing tiny stock matrix roundoff shear, retaining the authored inverse binds. All output
primitives share one skin, including the optional stock board. The engine
therefore retains its existing physics, contacts, motion and camera behavior.
Mixamo Spine/Spine1/Spine2 map to stock SPINE/SPINE1/SPINE3; the unused stock
SPINE2 and NECK1 remain in the hierarchy. Missing target finger/end bones fold
into their nearest mapped ancestor. Missing toe joints are permitted. No
individual finger animation is created. Foot/hand contact quality and fitted
proportions require visual review; success is a structural validation result,
not a guarantee that every skating motion looks correct.
Generic fitting aligns limb directions and positions to the stock rig. An
optional private `calibration.json` next to the executable improves fitting
using the user's round-trip pair: the original stock GLB and a Mixamo-rigged
copy of the same neutral geometry, normalized to GLB by FBX2glTF. Generate it
with `--calibrate --profile calibration.json` and the normalized source GLB.
It is locked to the reference SHA-256. It contains derived bind frames, not
meshes. Do not bundle private calibration or stock assets in public releases.
The converter works without it, with an explicit generic-fit warning.
## Limits
- Standard Mixamo body chain required; duplicate names and unrelated rigs fail.
- Stock-sized output: original unusual proportions are not preserved reliably.
- No automatic rigging, animation export, facial rigging, cloth or hair physics.
- Morph targets are rejected rather than silently lost. Source clips are omitted.
- Supports triangles, normals, UV0/UV1, vertex colours, embedded PNG/JPEG and
basic glTF materials. Texture/material extensions and external GLB resources
are rejected. FBX conversion diagnostics are retained in the report.
- Up to eight source influences; merge then retain four. More than 10% lost
weight on any vertex is an error. Any smaller loss is reported.
- Up to 512 MiB input and one million output character vertices. These are
import limits, not recommended game budgets.
- The board is copied from the local stock reference by default so the existing
combined-character presentation does not lose it. `--no-board` emits only the
character for a future modular importer. Stock outfit/profile integration and
gameplay installation are separate work.
## Dependencies and verification
FBX parsing uses Facebook's FBX2glTF 0.9.7 (BSD-3-Clause project, Autodesk FBX
SDK dependency). The Python conversion uses NumPy. Packaged Python builds use
PyInstaller and Tcl/Tk for the file chooser. Retain dependency notices when
packaging; no game content belongs in the redistributable tool folder.
Primary sources:
- https://github.com/facebookincubator/FBX2glTF/releases/tag/v0.9.7
- https://github.com/facebookincubator/FBX2glTF
- https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html
Run `python -m unittest discover -s tools/mixamo_to_skate -p "test_*.py"`.
Tests use procedural fixtures, not retail models. Engine compilation and game
launches are not necessary for this standalone converter. Rendered gameplay
validation remains a later user-run step.
Validation during development: nine procedural checks cover paired geometry,
unit/axis changes, hand-weight collapse and physical-pose skinning, generic
fitting, invalid joints, failed-output cleanup, existing-output protection,
reference mismatch, missing bones, morph rejection and truncated input. The
owned round-trip FBX was also converted and checked with Khronos glTF Validator
2.0.0-dev.3.10: zero errors; stock board normal maps retain the original
engine-generated tangent-space warnings. No gameplay tests were run.
+31
View File
@@ -0,0 +1,31 @@
# Native pro and special characters
The Custom models menu includes the 41 character definitions in the owned retail `characters_marquee` table that inherit from `pro_skaters` or `ip` and name a real recipe. It includes Isaac Clarke (`isaak` / `z_legend`), Meat Man (`steak` / `security_03`), Dem Bones, Coach Frank, Dr Pepper Mascot and the retail pros. Props, ambient pedestrians, unused recipe-only assets and absent DLC definitions are not presented as additional playable pros.
`tools/asset_pipeline/native_roster.py` extracts the original LOD0 modular RX2 meshes and recipe materials from `marquee.big`. It uses the existing retail RX2/ABIN scene exporter, retaining native vertices, weights, bone hierarchy and inverse binds. It performs no Mixamo processing, humanoid fitting, automatic weighting or animation baking. Matrix perspective residue below 1e-6 is made exactly affine for glTF compliance. Original diffuse, normal, alpha and specular textures feed the existing renderer's material representation; this does not reproduce every proprietary retail shader effect.
Entries share the persistent model library and hot-swap mechanism. The menu's **Show** filter cycles All / Pros / Specials / Imported. Native metadata is committed with the scene, so failed loads retain both the previous model and its style. Saved selection works for native and imported characters alike. Switching to the stock customiser or an imported model restores the customiser's style preferences.
## Native animation evidence
Observed in Xbox 360 TU3 / retail 1.05, input dump SHA-256 `f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`, image base `0x82000000`:
`GetCACSettings`, VA `0x82590BE0..0x82590DBC` (module offsets `0x590BE0..0x590DBC`), compares these character hashes and returns the strings at VA `0x82224820..0x82224864`:
| Character | Hash | Animation selector |
|---|---|---|
| Danny Way | 42545B0938E341C9 | DannyWay |
| Mike Carroll | 146D3B7D88385267 | MikeCarroll |
| PJ Ladd | 1733A97B7064D4C2 | PJLadd |
| Jason Dill | 0DED833242F2F857 | JasonDill |
| Jerry Hsu | 1D939610FEA090EE | JerryHsu |
| Rob Dyrdek | BE3309CE9BA41097 | RobDyrdek |
| Other Marquees | fallback at 82590D88 | Aggressive |
Character hashes independently resolve against the retail VLT names. These exact values are passed through the existing `ProSkater` construction attribute and graph condition, using the retained OnBoard/OffBoard banks. The skin choice does not replace those banks. Native gestures use the retail reset defaults; CAC posture overrides are disabled for these characters. Current stance, equipment tuning and in-progress skating state remain with the player. The authored selector takes effect when the native graph constructs its next animation tree, as with existing style edits; this avoids restarting a trick during a model swap. Not every pro or special character has a unique animation set.
Private provenance and extraction reports live under `Documents/Mixamo-to-Skate/native-roster`. The source archive SHA-256 is `0e12660e22c33239d9f28a708d09b2e3f291276012c0c6296c42ae20b6b41acc`.
## Verification
All 41 exported models pass Khronos glTF validation with no errors. The native-roster test loads every GLB through Bevy, waits for image dependencies and binds every skinned scene against the retained retail animation skeleton. It also checks that the installed animation banks contain the selected ProSkater values and their child trees. These are offline asset tests; no game was launched and live gameplay/visual parity still requires user playtesting.
+125
View File
@@ -0,0 +1,125 @@
"""Publish owned retail Marquee models without autorigging or retargeting."""
import argparse, hashlib, json, os, shutil, struct, sys, tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
import numpy as np
from PIL import Image, ImageChops
from tools.owned_game.big import BigArchive
from tools.extract_default_skater import import_rx2_parser, decode_texture
from tools.asset_pipeline.character_glb import Glb, convert
from tools.asset_pipeline.retail_character import RX2, decode_dense_morphs
# GetCACSettings 82590BE0..82590DBC, TU3: six named styles, Aggressive otherwise.
STYLES={'danny_way':'DannyWay','mike_carroll':'MikeCarroll','pj_ladd':'PJLadd',
'jason_dill':'JasonDill','jerry_hsu':'JerryHsu','rob_dyrdek':'RobDyrdek'}
LABELS={'isaak':'Isaac Clarke (Dead Space)','steak':'Meat Man','dem_bones':'Dem Bones',
'dr_pepper':'Dr Pepper Mascot','deerman':'Deerman of Dark Woods','pj_ladd':'PJ Ladd',
'reda':'Giovanni Reda','attiba_jefferson':'Atiba Jefferson','cuz':'Cuz Parry'}
def finalize_glb(path):
"""Remove numerical perspective residue; retain native affine bind transforms."""
data=path.read_bytes();length=struct.unpack_from('<I',data,12)[0]
writer=Glb();writer.doc=json.loads(data[20:20+length]);writer.data=bytearray(data[28+length:])
for node in writer.doc['nodes']:
if 'matrix' in node:
m=node['matrix']
if max(abs(m[i]) for i in [3,7,11])+abs(m[15]-1)>1e-6:raise ValueError('Non-affine retail joint')
for i in [3,7,11]:m[i]=0.
m[15]=1.
for skin in writer.doc['skins']:
a=writer.doc['accessors'][skin['inverseBindMatrices']];v=writer.doc['bufferViews'][a['bufferView']]
for j in range(a['count']):
offset=v.get('byteOffset',0)+a.get('byteOffset',0)+j*64
for i in [3,7,11]:struct.pack_into('<f',writer.data,offset+i*4,0.)
struct.pack_into('<f',writer.data,offset+60,1.)
for mesh in writer.doc['meshes']:
for p in mesh['primitives']:
for a in p['attributes'].values():writer.doc['bufferViews'][writer.doc['accessors'][a]['bufferView']]['target']=34962
writer.doc['bufferViews'][writer.doc['accessors'][p['indices']]['bufferView']]['target']=34963
temporary=path.with_suffix('.tmp');writer.save(temporary);os.replace(temporary,path)
def roster(rows):
rows={r['key']:r for r in rows if r['class']=='characters_marquee'}
result=[]
for key,row in rows.items():
chain=[]; node=row
while node:
if node['key'] in chain:raise ValueError('Cyclic character inheritance')
chain.append(node['key']);node=rows.get(node['parent'])
if not {'pro_skaters','ip'}.intersection(chain):continue
recipe=row['fields'].get('Hash_6C9F05D8DBE7A492',{}).get('data','')
if not recipe:continue
result.append({'key':key,'recipe':recipe,'name':LABELS.get(key,key.replace('_',' ').title()),
'category':'Special' if 'ip' in chain else 'Pro',
'animation_style':STYLES.get(key,'Aggressive')})
return sorted(result,key=lambda r:r['name'])
def prepare(game,assets,library,collections,work,only=None):
archive=BigArchive(game/'data/content/marquee.big');entries={e.path.lower():e for e in archive.entries}
work.mkdir(parents=True,exist_ok=True)
parser=import_rx2_parser(Path(__file__).resolve().parents[1]/'vendor/utt')
def extract(path):
dest=work/'source'/path
if not dest.exists():dest.parent.mkdir(parents=True,exist_ok=True);dest.write_bytes(archive.read(entries[path.lower()]))
return dest
def texture(tid):
dest=work/'decoded'/(tid+'.png');dest.parent.mkdir(exist_ok=True)
if not dest.exists():decode_texture(parser,extract('data/content/marquee/texture/0x'+tid+'.rx2'),dest)
return dest
records=roster(json.loads(collections.read_text())['collections']);report=[]
for item in records:
if only and item['key'] not in only:continue
key=item['key'];recipe_name=item['recipe'];folder=work/key;models=folder/'models';mats=folder/'materials'
models.mkdir(parents=True,exist_ok=True);mats.mkdir(exist_ok=True)
identity=hashlib.sha256(('native-marquee-v1:'+key).encode()).hexdigest();target=library/'entries'/identity
try:
xml=extract('data/content/recipe/marquee/'+recipe_name+'.xml');root=ET.fromstring(xml.read_bytes())
material_defs={m.attrib['id']:m for m in root.findall('mat')}
recipe={'components':[],'morph_assembly':{'expected_targets':{},'face_targets':[]},'preset':{'body_mods':{}}}
for component in root.findall('comp'):
slot=component.attrib['n'];mods=component.findall('mod')
if len(mods)!=1:raise ValueError('Ambiguous native component '+slot)
lod=next(l for l in mods[0].findall('lod') if l.get('idx')=='0')
raw=extract(f"data/content/marquee/model/{recipe_name}/{slot}/{lod.attrib['arenaid']}.rx2")
dest=models/slot/raw.name;dest.parent.mkdir(exist_ok=True);shutil.copyfile(raw,dest)
mid=lod.find('matinst/matvar').attrib['id'];mat=material_defs[mid]
tex={s.attrib['chn']:s.attrib['id'].removeprefix('0x') for s in mat.findall('sp')}
diffuse=Image.open(texture(tex['diffuse'])).convert('RGBA')
if 'alpha' in tex:
alpha=Image.open(texture(tex['alpha'])).convert('RGB').getchannel('R');diffuse.putalpha(alpha)
diffuse.save(mats/(slot+'_base_color.png'))
if 'normal' in tex:
a=np.asarray(Image.open(texture(tex['normal'])).convert('RGBA'),dtype=float)
x=a[:,:,3]/127.5-1.;y=a[:,:,1]/127.5-1.;s=np.maximum(np.sqrt(x*x+y*y),1.);x/=s;y/=s
z=np.sqrt(np.maximum(0.,1.-x*x-y*y));Image.fromarray(np.rint((np.stack((x,y,z),2)*.5+.5)*255).astype('uint8')).save(mats/(slot+'_normal.png'))
if 'specular' in tex:ImageChops.invert(Image.open(texture(tex['specular'])).convert('L')).save(mats/(slot+'_roughness.png'))
parsed=RX2.parse_rx2(str(dest));mesh=next(m for m in parsed['meshes'] if m.get('positions') and m.get('indices'))
morphs=decode_dense_morphs(dest,parsed,len(mesh['positions']),RX2)
recipe['morph_assembly']['expected_targets'][slot]=[m['name'] for m in morphs]
recipe['components'].append({'slot':slot,'tint':[1,1,1],'textures':tex,'alpha_mode':'MASK' if 'alpha' in tex else 'OPAQUE'})
target.parent.mkdir(parents=True,exist_ok=True)
if not target.exists():
with tempfile.TemporaryDirectory(prefix='.native-',dir=library) as tmp:
stage=Path(tmp)/'entry';stage.mkdir()
convert(models,assets/'private',recipe,output=stage/'character.glb',materials=mats)
finalize_glb(stage/'character.glb')
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'mixamo_to_skate'))
from library_import import thumbnail
thumbnail(stage/'character.glb',stage/'preview.png')
metadata={'version':1,'id':identity,'name':item['name'],'native':item,
'source_recipe_sha256':hashlib.sha256(xml.read_bytes()).hexdigest()}
(stage/'manifest.json').write_text(json.dumps(metadata,indent=2))
(stage/'recipe.json').write_text(json.dumps(recipe,indent=2))
stage.rename(target)
report.append({**item,'id':identity,'status':'ready'})
print('READY',key,flush=True)
except Exception as e:
report.append({**item,'status':'error','error':str(e)});print('ERROR',key,str(e),flush=True)
(work/'report.json').write_text(json.dumps(report,indent=2))
return report
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('--game',type=Path,required=True);p.add_argument('--assets',type=Path,required=True)
p.add_argument('--library',type=Path,required=True);p.add_argument('--collections',type=Path,required=True);p.add_argument('--work',type=Path,required=True);p.add_argument('--only',nargs='*')
a=p.parse_args();r=prepare(a.game,a.assets,a.library,a.collections,a.work,a.only)
raise SystemExit(1 if any(x['status']=='error' for x in r) else 0)
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+424
View File
@@ -0,0 +1,424 @@
"""Fit a Mixamo bind mesh to the installed Skate render rig. No runtime edits."""
import copy
import hashlib
import json
import os
import subprocess
import tempfile
from pathlib import Path
import numpy as np
from glb import ConversionError, Document, Writer, require, clean_node_transform
VERSION = 1
MAP = {'hips':'HIPS', 'spine':'SPINE', 'spine1':'SPINE1', 'spine2':'SPINE3',
'neck':'NECK', 'head':'HEAD'}
for side in ('left', 'right'):
for part in ('shoulder', 'arm', 'forearm', 'hand', 'upleg', 'leg', 'foot', 'toebase'):
MAP[side+part] = (side+part).upper()
REQUIRED = set(MAP) - {'lefttoebase', 'righttoebase'}
BOARD = {'SKATEBOARD_ROOT', 'TRUCK_FRONT', 'TRUCK_BACK', 'LEFT_WHEELFRONT',
'RIGHT_WHEELFRONT', 'LEFT_WHEELBACK', 'RIGHT_WHEELBACK'}
def sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def key(name):
# Mixamo prefixes may include a namespace number. Do not guess arbitrary rigs.
name = name.rsplit(':', 1)[-1].lower()
return name
def named_nodes(doc):
result = {}
for i, n in enumerate(doc.doc['nodes']):
k = key(n.get('name', ''))
if k in MAP or k.startswith(('lefthand', 'righthand')) or k.endswith('_end'):
require(k not in result, 'Ambiguous duplicated bone: '+k)
result[k] = i
require(REQUIRED <= result.keys(), 'Not a supported Mixamo humanoid; missing: '+
', '.join(sorted(REQUIRED-result.keys())))
for k in REQUIRED - {'hips'}:
parent = doc.parents.get(result[k])
ancestors = set()
while parent is not None:
ancestors.add(parent)
parent = doc.parents.get(parent)
require(result['hips'] in ancestors, k+' is outside the hip hierarchy')
return result
def bind_worlds(doc):
"""Derive bind globals from skin matrices, not frame-zero animation."""
result = {}
for ni, node in enumerate(doc.doc['nodes']):
if 'mesh' not in node:
continue
require('skin' in node, 'Unskinned mesh: '+node.get('name', str(ni)))
skin = doc.doc['skins'][node['skin']]
require('inverseBindMatrices' in skin, 'Missing inverse bind matrices')
matrices = doc.accessor(skin['inverseBindMatrices'])
require(len(matrices) == len(skin['joints']), 'Inverse bind count mismatch')
for ji, joint in enumerate(skin['joints']):
inverse = matrices[ji].reshape(4, 4).T
require(abs(np.linalg.det(inverse)) > 1e-12, 'Singular skin bind')
world = doc.worlds[ni] @ np.linalg.inv(inverse)
if joint in result:
require(np.allclose(result[joint], world, atol=1e-5, rtol=1e-4),
'Meshes disagree about a bone bind pose')
result[joint] = world
# Unweighted endpoints inherit the nearest known bind transform.
def solve(i):
if i in result:
return result[i]
parent = doc.parents.get(i)
result[i] = (solve(parent) @ np.linalg.inv(doc.worlds[parent]) @ doc.worlds[i]
if parent is not None else doc.worlds[i])
return result[i]
for i in range(len(doc.doc['nodes'])):
solve(i)
return result
def mesh_bounds(doc, exclude_board=False):
points = []
for ni, node in enumerate(doc.doc['nodes']):
if 'mesh' not in node:
continue
for p in doc.doc['meshes'][node['mesh']]['primitives']:
name = doc.doc.get('materials', [])[p['material']].get('name', '') if 'material' in p else ''
if exclude_board and name in ('Retail_SkateBoard', 'Retail_SkateTruck', 'Retail_SkateWheel'):
continue
pos = doc.accessor(p['attributes']['POSITION'])
points.append(pos @ doc.worlds[ni][:3, :3].T + doc.worlds[ni][:3, 3])
require(points, 'No character geometry')
return np.concatenate(points)
def normalization(doc, ref, binds, names):
"""Resolve body axes from landmarks, then height/ground, avoiding unit guesses."""
p = lambda k: binds[names[k]][:3, 3]
up = p('head')-p('hips')
require(np.linalg.norm(up)>1e-8, 'Degenerate head/hip landmarks')
up /= np.linalg.norm(up)
right = p('leftarm')-p('rightarm') # stock anatomical left is positive X
right -= up*np.dot(right, up)
require(np.linalg.norm(right) > 1e-8, 'Degenerate shoulder landmarks')
right /= np.linalg.norm(right)
forward = np.cross(right, up)
rotation = np.stack([right, up, forward])
points = mesh_bounds(doc) @ rotation.T
target = mesh_bounds(ref, True)
height = np.ptp(points[:, 1])
require(height > 1e-6, 'Degenerate character height')
scale = np.ptp(target[:, 1])/height
matrix = np.eye(4)
matrix[:3, :3] = rotation*scale
hip = rotation @ p('hips')*scale
matrix[:3, 3] = [-hip[0], target[:, 1].min()-points[:, 1].min()*scale, -hip[2]]
return matrix, scale
def source_skin(doc, primitive, skin):
attrs = primitive['attributes']
require('JOINTS_0' in attrs and 'WEIGHTS_0' in attrs, 'Mesh has no skin weights')
require(not any(k.startswith(('JOINTS_', 'WEIGHTS_')) and k[-1] not in '01' for k in attrs),
'More than eight source influences are unsupported')
js, ws = [], []
for channel in (0, 1):
j, w = 'JOINTS_'+str(channel), 'WEIGHTS_'+str(channel)
require((j in attrs) == (w in attrs), 'Unpaired skin attributes')
if j in attrs:
js.append(doc.accessor(attrs[j]).astype(np.int64))
ws.append(doc.accessor(attrs[w]).astype(float))
joints, weights = np.concatenate(js, axis=1), np.concatenate(ws, axis=1)
require((joints >= 0).all() and (joints < len(skin['joints'])).all(), 'Invalid joint index')
require((weights >= 0).all() and (weights.sum(axis=1) > 1e-8).all(), 'Invalid skin weights')
return joints, weights/weights.sum(axis=1, keepdims=True)
def reference_rig(ref):
require(len(ref.doc.get('skins', [])) == 1, 'Stock reference must have one shared skin')
skin = ref.doc['skins'][0]
names = [ref.doc['nodes'][i].get('name', '').upper() for i in skin['joints']]
require(len(names) == len(set(names)) and set(MAP.values()) <= set(names),
'Reference is not a complete supported stock render rig')
require(len(names) <= 256, 'Stock rig exceeds renderer joint limit')
inverse = ref.accessor(skin['inverseBindMatrices']).reshape(-1, 4, 4).transpose(0, 2, 1)
require(len(inverse) == len(names), 'Stock inverse bind count mismatch')
for j, inv in zip(skin['joints'], inverse):
require(np.allclose(ref.worlds[j] @ inv, np.eye(4), atol=2e-4),
'Stock reference is not in its mesh bind pose')
return skin, names, inverse
def create_profile(source, reference):
doc, ref = Document(source), Document(reference)
reference_rig(ref)
names, binds = named_nodes(doc), bind_worlds(doc)
norm, scale = normalization(doc, ref, binds, names)
# Round-trip pairing is meaningful only when the same exported mesh returned.
# Bounding-box calibration avoids hip fitting shifts in the paired source.
src_points, ref_points = mesh_bounds(doc), mesh_bounds(ref, True)
src_box = np.stack([src_points.min(axis=0), src_points.max(axis=0)])
ref_box = np.stack([ref_points.min(axis=0), ref_points.max(axis=0)])
scale = float(np.ptp(ref_points[:,1])/np.ptp(src_points[:,1]))
fit = np.eye(4)
fit[:3, :3] *= scale
fit[:3, 3] = ref_box.mean(axis=0)-src_box.mean(axis=0)*scale
require(np.max(np.abs((src_box*scale+fit[:3, 3])-ref_box)) < 0.015,
'Calibration is not the returned stock mesh (bounds differ by over 1.5 cm)')
return {'version':VERSION, 'reference_sha256':sha(reference), 'source_sha256':sha(source),
'normalization_scale':scale,
'bones':{k:(fit@binds[i]).tolist() for k, i in names.items()},
'note':'Private paired-stock calibration; contains no mesh or textures.'}
def fallback_targets(ref, names, binds, norm):
"""Anatomical aim frames for non-calibrated use. Never apply stock bone roll to Mixamo axes."""
_, target_names, inverse = reference_rig(ref)
stock = {n:np.linalg.inv(m) for n,m in zip(target_names, inverse)}
child = {'hips':'spine', 'spine':'spine1', 'spine1':'spine2', 'spine2':'neck',
'neck':'head'}
for s in ('left', 'right'):
child.update({s+'shoulder':s+'arm', s+'arm':s+'forearm', s+'forearm':s+'hand',
s+'upleg':s+'leg', s+'leg':s+'foot', s+'foot':s+'toebase'})
targets = {}
def rotation_between(a, b):
a, b = a/np.linalg.norm(a), b/np.linalg.norm(b)
v, c = np.cross(a, b), np.dot(a, b)
require(c > -0.99, 'Reversed limb in reference pose; export Mixamo T-pose')
k = np.array([[0,-v[2],v[1]], [v[2],0,-v[0]], [-v[1],v[0],0]])
return np.eye(3)+k+k@k/(1+c)
for k, target in MAP.items():
if k not in names:
continue
src = norm@binds[names[k]]
src[:3,:3] /= np.linalg.norm(src[:3,:3],axis=0)
result = src.copy()
result[:3, 3] = stock[target][:3, 3]
if k in child and child[k] in names:
src_direction = (norm@binds[names[child[k]]])[:3,3]-src[:3,3]
dst_direction = stock[MAP[child[k]]][:3,3]-result[:3,3]
if min(np.linalg.norm(src_direction), np.linalg.norm(dst_direction)) > 1e-6:
result[:3,:3] = rotation_between(src_direction, dst_direction)@src[:3,:3]
targets[k] = result
return targets
def convert_glb(source, reference, output, profile=None, include_board=True):
doc, ref = Document(source), Document(reference)
ref_skin, target_names, inverse = reference_rig(ref)
# The stock export contains ~1e-16 numerical residue in the affine row.
# glTF requires this row to be exactly (0,0,0,1).
require(np.allclose(inverse[:,3,:],[0,0,0,1],atol=1e-7), 'Non-affine stock inverse binds')
inverse[:,3,:] = [0,0,0,1]
names, binds = named_nodes(doc), bind_worlds(doc)
norm, scale = normalization(doc, ref, binds, names)
report = {'version':VERSION, 'source_sha256':sha(source), 'reference_sha256':sha(reference),
'mode':'fit_to_stock_proportions', 'scale':float(scale), 'warnings':[],
'vertices':0, 'triangles':0, 'collapsed_bones':{}, 'max_discarded_weight':0.0}
if profile:
require(profile.get('version') == VERSION and profile['reference_sha256'] == sha(reference),
'Calibration belongs to another stock reference or converter version')
targets = {k:np.asarray(v,dtype=float) for k,v in profile['bones'].items()}
require(REQUIRED <= targets.keys(), 'Incomplete calibration')
# Calibration is world-space in metres; strip source export scale from its bone axes.
for m in targets.values():
require(m.shape == (4,4) and np.isfinite(m).all(), 'Invalid calibration matrix')
m[:3,:3] /= np.linalg.norm(m[:3,:3], axis=0)
target_length = np.linalg.norm(targets['head'][:3,3]-targets['hips'][:3,3])
source_length = np.linalg.norm(binds[names['head']][:3,3]-binds[names['hips']][:3,3])
fitted_scale = target_length/source_length
norm[:3,:] *= fitted_scale/scale
scale = fitted_scale
report['scale'] = float(scale)
report['calibrated'] = True
else:
targets = fallback_targets(ref, names, binds, norm)
report['calibrated'] = False
report['warnings'].append('No paired calibration: generic anatomical fitting needs visual review.')
normalized = {}
for i, m in binds.items():
m = norm@m
lengths = np.linalg.norm(m[:3,:3],axis=0)
require((lengths>1e-10).all(), 'Degenerate bind axes')
m[:3,:3] /= lengths
require(np.allclose(m[:3,:3].T@m[:3,:3],np.eye(3),atol=1e-3) and
np.linalg.det(m[:3,:3])>0, 'Sheared or mirrored bind rig is unsupported')
normalized[i] = m
target_index = {name:i for i,name in enumerate(target_names)}
def mapped(i):
original = i
while key(doc.doc['nodes'][i].get('name','')) not in MAP:
require(i in doc.parents, 'Weighted bone cannot map to stock: '+doc.doc['nodes'][original].get('name',''))
i = doc.parents[i]
k = key(doc.doc['nodes'][i]['name'])
if original != i:
report['collapsed_bones'][doc.doc['nodes'][original]['name']] = MAP[k]
return i, k, target_index[MAP[k]]
writer = Writer()
writer.materials_from(doc)
primitives = []
for ni,node in enumerate(doc.doc['nodes']):
if 'mesh' not in node:
continue
skin = doc.doc['skins'][node['skin']]
matrices, mapped_joints = [], []
for joint in skin['joints']:
ancestor,k,ti = mapped(joint)
# Unavailable destination fingers/endpoints follow the mapped ancestor.
use = joint if key(doc.doc['nodes'][joint].get('name','')) in targets else ancestor
tk = key(doc.doc['nodes'][use].get('name',''))
matrices.append(targets[tk] @ np.linalg.inv(normalized[use]) @ norm @ doc.worlds[ni])
mapped_joints.append(ti)
matrices = np.asarray(matrices)
require(np.isfinite(matrices).all(), 'Invalid fit transforms')
for p in doc.doc['meshes'][node['mesh']]['primitives']:
require(p.get('mode',4) == 4, 'Only triangle meshes are supported')
require(not p.get('targets'), 'Blendshapes are not supported by version 1; export the neutral mesh')
attrs = p['attributes']
require('NORMAL' in attrs, 'Normals are missing')
positions, normals = doc.accessor(attrs['POSITION']), doc.accessor(attrs['NORMAL'])
joints, weights = source_skin(doc,p,skin)
require(len(positions)==len(normals)==len(joints), 'Attribute lengths differ')
require(report['vertices']+len(positions) <= 1_000_000, 'Character exceeds 1 million vertices')
blended = np.einsum('vi,vijk->vjk', weights, matrices[joints])
require((np.linalg.det(blended[:,:3,:3]) > 1e-10).all(),
'Fit folds or collapses the mesh; export a neutral T-pose')
fitted = np.einsum('vij,vj->vi',blended[:,:3,:3],positions)+blended[:,:3,3]
normal_fit = np.einsum('vij,vj->vi',np.linalg.inv(blended[:,:3,:3]).transpose(0,2,1),normals)
normal_fit /= np.maximum(np.linalg.norm(normal_fit,axis=1,keepdims=True),1e-12)
merged = np.zeros((len(positions),len(target_names)))
for column in range(joints.shape[1]):
np.add.at(merged,(np.arange(len(positions)),np.asarray(mapped_joints)[joints[:,column]]),weights[:,column])
output_joints = np.argsort(-merged,axis=1,kind='stable')[:,:4]
output_weights = np.take_along_axis(merged,output_joints,axis=1)
discarded = 1-output_weights.sum(axis=1)
report['max_discarded_weight'] = max(report['max_discarded_weight'],float(discarded.max()))
require(discarded.max() <= 0.1, 'Reducing to four weights loses over 10%; simplify skin weights first')
output_weights /= output_weights.sum(axis=1,keepdims=True)
output_joints[output_weights==0] = 0
out_attrs = {'POSITION':writer.accessor(fitted,'VEC3'), 'NORMAL':writer.accessor(normal_fit,'VEC3'),
'JOINTS_0':writer.accessor(output_joints,'VEC4',5123),
'WEIGHTS_0':writer.accessor(output_weights,'VEC4')}
for name in ('TEXCOORD_0','TEXCOORD_1','COLOR_0'):
if name in attrs:
values = doc.accessor(attrs[name])
require(len(values)==len(positions), 'Attribute length differs: '+name)
out_attrs[name] = writer.accessor(values,'VEC'+str(values.shape[1]))
indices = doc.accessor(p['indices']).ravel() if 'indices' in p else np.arange(len(positions))
require(len(indices)%3==0 and indices.min()>=0 and indices.max()<len(positions),'Invalid triangle indices')
out = {'attributes':out_attrs,'indices':writer.accessor(indices,'SCALAR',5125)}
if 'material' in p:
out['material'] = p['material']
primitives.append(out)
report['vertices'] += len(positions)
report['triangles'] += len(indices)//3
if include_board:
offset = writer.materials_from(ref)
for mesh in ref.doc['meshes']:
for p in mesh['primitives']:
attrs = p['attributes']
js = ref.accessor(attrs['JOINTS_0']).astype(int)
ws = ref.accessor(attrs['WEIGHTS_0'])
used = set(js[ws>1e-6].ravel())
if used and all(target_names[j] in BOARD for j in used):
out_attrs = {}
for name in ('POSITION','NORMAL','TEXCOORD_0','TEXCOORD_1','JOINTS_0','WEIGHTS_0'):
if name in attrs:
a = ref.doc['accessors'][attrs[name]]
values = ref.accessor(attrs[name])
if name=='JOINTS_0':
values[ws==0] = 0
out_attrs[name] = writer.accessor(values,a['type'],5123 if name=='JOINTS_0' else 5126)
primitives.append({'attributes':out_attrs,'indices':writer.accessor(ref.accessor(p['indices']).ravel(),'SCALAR',5125),
'material':p['material']+offset})
# A single shared rig/skin also works with the older binder which binds one skin.
nodes = []
node_map = {}
for i,n in enumerate(ref.doc['nodes']):
if 'mesh' not in n:
node_map[i] = len(nodes)
nodes.append({'name':n.get('name',''),**clean_node_transform(n)})
for i,oi in node_map.items():
children = [node_map[c] for c in ref.doc['nodes'][i].get('children',[]) if c in node_map]
if children:
nodes[oi]['children'] = children
roots = [node_map[i] for i in node_map if i not in ref.parents]
mesh_node = len(nodes)
nodes.append({'name':'Imported_Skater','mesh':0,'skin':0})
roots.append(mesh_node)
writer.doc.update(nodes=nodes,meshes=[{'name':'Converted_Character','primitives':primitives}],
skins=[{'joints':[node_map[j] for j in ref_skin['joints']],
'inverseBindMatrices':writer.accessor(inverse.transpose(0,2,1).reshape(-1,16),'MAT4')}],
scenes=[{'nodes':roots}],scene=0)
report['board_included'] = include_board
report['warnings'].append('Fits stock proportions; fingers collapse to hands. Contact/deformation quality needs in-game review.')
if include_board:
report['warnings'].append('Stock board normal maps use engine-generated tangent space, as in the original asset.')
if doc.doc.get('animations'):
report['warnings'].append('Source animations omitted; the engine supplies stock skating poses.')
writer.save(output)
validate_output(output,reference)
return report
def validate_output(path, reference):
doc, ref = Document(path), Document(reference)
_, names, inverse = reference_rig(doc)
_, expected, ref_inverse = reference_rig(ref)
require(names==expected and np.allclose(inverse,ref_inverse,atol=1e-7), 'Output rig differs from stock')
require('animations' not in doc.doc, 'Unexpected imported animation')
for mesh in doc.doc['meshes']:
for p in mesh['primitives']:
positions = doc.accessor(p['attributes']['POSITION'])
joints, weights = source_skin(doc,p,doc.doc['skins'][0])
require(len(joints)==len(positions) and joints.shape[1]==4,'Invalid output skin')
require(np.allclose(doc.accessor(p['attributes']['WEIGHTS_0']).sum(axis=1),1,atol=1e-5),'Weights not normalized')
indices = doc.accessor(p['indices'])
require(indices.max()<len(positions),'Invalid output index')
for i in range(len(doc.doc.get('images',[]))):
doc.image(i)
def convert_file(source, reference, destination, fbx_tool, profile=None, include_board=True, keep_source=False):
source,reference,destination = Path(source).resolve(),Path(reference).resolve(),Path(destination).resolve()
require(source.suffix.lower() in ('.fbx','.glb'), 'Choose a Mixamo FBX or GLB')
require(source.stat().st_size <= 512*1024**2,'Source exceeds 512 MiB')
require(not destination.exists(), 'Output folder already exists; choose another name')
require(source != reference, 'Source and stock reference must be different files')
destination.parent.mkdir(parents=True,exist_ok=True)
# Publish the complete directory only after every conversion/validation succeeds.
with tempfile.TemporaryDirectory(prefix='.mixamo-',dir=destination.parent) as temporary:
temp = Path(temporary)
normalized = source
log = ''
if source.suffix.lower()=='.fbx':
require(Path(fbx_tool).is_file(),'FBX2glTF.exe is missing; run the converter setup')
normalized = temp/'source.glb'
proc = subprocess.run([str(fbx_tool),'--binary','--input',str(source),'--output',str(temp/'source'),
'--fbx-temp-dir',str(temp),'--compute-normals','missing','--pbr-metallic-roughness'],
capture_output=True,text=True,timeout=180,
creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
log = proc.stdout+'\n'+proc.stderr
require(proc.returncode==0 and normalized.is_file(),'FBX conversion failed: '+log[-2000:])
publish = temp/'publish'
publish.mkdir()
report = convert_glb(normalized,reference,publish/'character.glb',profile,include_board)
if keep_source:
import shutil
shutil.copy2(normalized,publish/'source.glb')
report['original_source_sha256'] = sha(source)
report['fbx_conversion_log'] = log
report['output_sha256'] = sha(publish/'character.glb')
(publish/'report.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
(publish/'README.txt').write_text('character.glb is a stock-rig appearance asset.\n'
'Original source and installed assets were not modified.\n'
'This converter does not install the character or add a menu importer.\n'
'Inspect report.json. Gameplay/visual validation is still required.\n',encoding='utf-8')
publish.rename(destination)
return destination/'character.glb'
+215
View File
@@ -0,0 +1,215 @@
"""Small, strict GLB reader/writer for the offline character converter."""
import base64
import copy
import json
import struct
from pathlib import Path
import numpy as np
class ConversionError(ValueError):
pass
def require(condition, message):
if not condition:
raise ConversionError(message)
def transform(node):
if 'matrix' in node:
result = np.asarray(node['matrix'], dtype=float).reshape(4, 4).T
else:
x, y, z, w = node.get('rotation', [0, 0, 0, 1])
q = np.asarray([x, y, z, w], dtype=float)
require(abs(np.linalg.norm(q) - 1) < 1e-3, 'Invalid node quaternion')
result = np.eye(4)
result[:3, :3] = [
[1-2*y*y-2*z*z, 2*x*y-2*z*w, 2*x*z+2*y*w],
[2*x*y+2*z*w, 1-2*x*x-2*z*z, 2*y*z-2*x*w],
[2*x*z-2*y*w, 2*y*z+2*x*w, 1-2*x*x-2*y*y]]
result[:3, :3] *= node.get('scale', [1, 1, 1])
result[:3, 3] = node.get('translation', [0, 0, 0])
require(np.isfinite(result).all() and abs(np.linalg.det(result)) > 1e-12,
'Non-finite or singular node transform')
return result
def clean_node_transform(node):
"""Round tiny authored matrix shear to a valid glTF TRS representation."""
matrix = transform(node)
u, _, vh = np.linalg.svd(matrix[:3,:3])
rotation = u@vh
require(np.linalg.det(rotation)>0, 'Mirrored stock node unsupported')
scale = np.diag(rotation.T@matrix[:3,:3])
require(np.allclose(rotation*scale,matrix[:3,:3],atol=2e-5), 'Stock node has significant shear')
# Stable matrix-to-quaternion, including 180-degree rotations.
trace = np.trace(rotation)
if trace > 0:
s = np.sqrt(trace+1)*2
q = [(rotation[2,1]-rotation[1,2])/s,(rotation[0,2]-rotation[2,0])/s,
(rotation[1,0]-rotation[0,1])/s,s/4]
else:
i = int(np.argmax(np.diag(rotation)));j=(i+1)%3;k=(i+2)%3
s = np.sqrt(1+rotation[i,i]-rotation[j,j]-rotation[k,k])*2
q = [0.,0.,0.,0.]
q[i]=s/4;q[j]=(rotation[j,i]+rotation[i,j])/s;q[k]=(rotation[k,i]+rotation[i,k])/s
q[3]=(rotation[k,j]-rotation[j,k])/s
q = np.asarray(q)/np.linalg.norm(q)
return {'translation':matrix[:3,3].tolist(),'rotation':q.tolist(),'scale':scale.tolist()}
class Document:
def __init__(self, path):
self.path = Path(path)
require(self.path.stat().st_size <= 512 * 1024**2, 'GLB exceeds 512 MiB limit')
self.raw = self.path.read_bytes()
require(len(self.raw) >= 28, 'Truncated GLB')
magic, version, total = struct.unpack_from('<III', self.raw)
require(magic == 0x46546c67 and version == 2 and total == len(self.raw),
'Invalid GLB header')
chunks = {}
offset = 12
while offset < total:
require(offset + 8 <= total, 'Truncated chunk header')
length, kind = struct.unpack_from('<I4s', self.raw, offset)
require(offset + 8 + length <= total and kind not in chunks, 'Invalid GLB chunk')
chunks[kind] = self.raw[offset+8:offset+8+length]
offset += 8 + length
self.doc = json.loads(chunks[b'JSON'])
self.data = chunks.get(b'BIN\0', b'')
require(len(self.doc.get('buffers', [])) == 1 and
'uri' not in self.doc['buffers'][0], 'Only embedded GLB buffers are supported')
require(not self.doc.get('extensionsRequired'), 'Required glTF extensions are unsupported')
self.parents = {}
nodes = self.doc['nodes']
require(len(nodes) <= 4096, 'Too many scene nodes')
for i, node in enumerate(nodes):
for child in node.get('children', []):
require(0 <= child < len(nodes) and child not in self.parents,
'Invalid or multiply parented node')
self.parents[child] = i
self.worlds = {}
def visit(i, active):
require(i not in active, 'Skeleton/scene contains a cycle')
if i not in self.worlds:
parent = self.parents.get(i)
self.worlds[i] = ((visit(parent, active | {i}) if parent is not None else np.eye(4))
@ transform(nodes[i]))
return self.worlds[i]
for i in range(len(nodes)):
visit(i, set())
def view(self, i):
v = self.doc['bufferViews'][i]
require(v.get('buffer', 0) == 0, 'External buffer unsupported')
start, length = v.get('byteOffset', 0), v['byteLength']
require(start >= 0 and length >= 0 and start+length <= len(self.data), 'Buffer view out of bounds')
return self.data[start:start+length]
def accessor(self, i):
a = self.doc['accessors'][i]
require('sparse' not in a, 'Sparse accessors need an explicit conversion first')
types = {5120:'i1', 5121:'u1', 5122:'<i2', 5123:'<u2', 5125:'<u4', 5126:'<f4'}
sizes = {'SCALAR':1, 'VEC2':2, 'VEC3':3, 'VEC4':4, 'MAT4':16}
dtype, width = np.dtype(types[a['componentType']]), sizes[a['type']]
require(0 < a['count'] <= 6_000_000, 'Accessor count out of supported range')
v = self.doc['bufferViews'][a['bufferView']]
data = self.view(a['bufferView'])
start, stride = a.get('byteOffset', 0), v.get('byteStride', dtype.itemsize*width)
require(start >= 0 and stride >= dtype.itemsize*width and
start+(a['count']-1)*stride+dtype.itemsize*width <= len(data), 'Accessor out of bounds')
values = np.ndarray((a['count'], width), dtype=dtype, buffer=data,
offset=start, strides=(stride, dtype.itemsize)).copy()
if a.get('normalized'):
require(dtype.kind in 'iu', 'Invalid normalized accessor')
values = np.maximum(values.astype(float)/np.iinfo(dtype).max, -1)
require(np.isfinite(values).all(), 'Non-finite accessor')
return values
def image(self, i):
image = self.doc['images'][i]
if 'bufferView' in image:
return self.view(image['bufferView']), image['mimeType']
uri = image.get('uri', '')
require(uri.startswith('data:') and ';base64,' in uri, 'Missing embedded texture; export FBX with media')
header, payload = uri.split(',', 1)
return base64.b64decode(payload, validate=True), header[5:].split(';')[0]
class Writer:
def __init__(self):
self.data = bytearray()
self.doc = {'asset': {'version':'2.0', 'generator':'Mixamo to Skate 1'},
'bufferViews':[], 'accessors':[], 'materials':[], 'textures':[], 'images':[]}
def view(self, data):
self.data.extend(b'\0' * (-len(self.data) % 4))
offset = len(self.data)
self.data.extend(data)
self.doc['bufferViews'].append({'buffer':0, 'byteOffset':offset, 'byteLength':len(data)})
return len(self.doc['bufferViews'])-1
def accessor(self, values, kind, component=5126):
values = np.asarray(values, dtype={5126:'<f4', 5123:'<u2', 5125:'<u4'}[component])
require(np.isfinite(values).all(), 'Non-finite output')
a = {'bufferView':self.view(values.tobytes()), 'componentType':component,
'count':len(values), 'type':kind}
if kind == 'VEC3':
a.update(min=values.min(axis=0).tolist(), max=values.max(axis=0).tolist())
self.doc['accessors'].append(a)
return len(self.doc['accessors'])-1
def materials_from(self, source):
offset = len(self.doc['materials'])
texture_offset = len(self.doc['textures'])
for image_index in range(len(source.doc.get('images', []))):
data, mime = source.image(image_index)
require(mime in ('image/png', 'image/jpeg'), 'Only PNG/JPEG textures supported')
self.doc['images'].append({'bufferView':self.view(data), 'mimeType':mime})
image_offset = len(self.doc['images']) - len(source.doc.get('images', []))
sampler_offset = len(self.doc.get('samplers', []))
self.doc.setdefault('samplers', []).extend(copy.deepcopy(source.doc.get('samplers', [])))
for t in source.doc.get('textures', []):
require('source' in t and not t.get('extensions'), 'Unsupported texture extension')
t = copy.deepcopy(t)
t['source'] += image_offset
if 'sampler' in t:
t['sampler'] += sampler_offset
self.doc['textures'].append(t)
def fix(obj):
if isinstance(obj, dict):
for key, value in obj.items():
if key.endswith('Texture') and isinstance(value, dict):
value['index'] += texture_offset
else:
fix(value)
elif isinstance(obj, list):
for value in obj:
fix(value)
for m in source.doc.get('materials', []):
m = copy.deepcopy(m)
require(not m.get('extensions'), 'Material extensions require explicit conversion')
m.pop('extras', None)
fix(m)
self.doc['materials'].append(m)
return offset
def save(self, path):
for mesh in self.doc.get('meshes',[]):
for p in mesh['primitives']:
for index in p['attributes'].values():
self.doc['bufferViews'][self.doc['accessors'][index]['bufferView']]['target']=34962
if 'indices' in p:
self.doc['bufferViews'][self.doc['accessors'][p['indices']]['bufferView']]['target']=34963
for key in ('materials', 'textures', 'images', 'samplers'):
if not self.doc.get(key):
self.doc.pop(key, None)
self.doc['buffers'] = [{'byteLength':len(self.data)}]
data = bytes(self.data) + b'\0'*(-len(self.data)%4)
encoded = json.dumps(self.doc, separators=(',', ':'), allow_nan=False).encode()
encoded += b' '*(-len(encoded)%4)
Path(path).write_bytes(struct.pack('<III', 0x46546c67, 2, 28+len(encoded)+len(data)) +
struct.pack('<I4s', len(encoded), b'JSON') + encoded +
struct.pack('<I4s', len(data), b'BIN\0') + data)
+137
View File
@@ -0,0 +1,137 @@
"""Game-facing import job. File chooser and CPU thumbnail run outside the game."""
import hashlib
import json
import os
import shutil
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
from PIL import Image
from converter import convert_file, sha, validate_output
from glb import Document, require
def thumbnail(path, output, width=256, height=320):
doc=Document(path)
items=[]
for node in doc.doc['nodes']:
if 'mesh' not in node:
continue
for p in doc.doc['meshes'][node['mesh']]['primitives']:
pos=doc.accessor(p['attributes']['POSITION'])
idx=doc.accessor(p['indices']).ravel().reshape(-1,3).astype(int)
material=doc.doc.get('materials',[])[p['material']] if 'material' in p else {}
pbr=material.get('pbrMetallicRoughness',{})
color=np.asarray(pbr.get('baseColorFactor',[.65,.72,.8,1]))
texture=None
if 'baseColorTexture' in pbr:
import io
data,_=doc.image(doc.doc['textures'][pbr['baseColorTexture']['index']]['source'])
with Image.open(io.BytesIO(data)) as image:
image.thumbnail((1024,1024))
texture=np.asarray(image.convert('RGBA'))/255.
uv=doc.accessor(p['attributes']['TEXCOORD_0']) if 'TEXCOORD_0' in p['attributes'] else None
items.append((pos,idx,color,texture,uv,material))
require(items,'Cannot preview an empty model')
angle=.22
basis=np.array([[np.cos(angle),0,-np.sin(angle)], [0,1,0], [np.sin(angle),0,np.cos(angle)]])
all_points=np.concatenate([p for p,*_ in items])@basis.T
low,high=all_points.min(0),all_points.max(0)
scale=min((width-24)/max(high[0]-low[0],.01),(height-24)/max(high[1]-low[1],.01))
centre=(high+low)/2
rgb=np.zeros((height,width,3),dtype=float)+np.array([.055,.075,.11])
depth=np.full((height,width),-np.inf)
light=np.array([.3,.6,1]);light/=np.linalg.norm(light)
for pos,idx,color,texture,uv,material in items:
world=pos@basis.T
points=(world-centre)*scale
points[:,0]+=width/2;points[:,1]=height/2-points[:,1]
for face in idx:
tri=points[face];a,b,c=tri[:,:2]
xmin=max(0,int(np.floor(tri[:,0].min())));xmax=min(width-1,int(np.ceil(tri[:,0].max())))
ymin=max(0,int(np.floor(tri[:,1].min())));ymax=min(height-1,int(np.ceil(tri[:,1].max())))
if xmin>xmax or ymin>ymax:continue
denom=(b[1]-c[1])*(a[0]-c[0])+(c[0]-b[0])*(a[1]-c[1])
if abs(denom)<1e-8:continue
yy,xx=np.mgrid[ymin:ymax+1,xmin:xmax+1];xx=xx+.5;yy=yy+.5
w0=((b[1]-c[1])*(xx-c[0])+(c[0]-b[0])*(yy-c[1]))/denom
w1=((c[1]-a[1])*(xx-c[0])+(a[0]-c[0])*(yy-c[1]))/denom
w2=1-w0-w1
z=w0*tri[0,2]+w1*tri[1,2]+w2*tri[2,2]
visible=(w0>=0)&(w1>=0)&(w2>=0)&(z>depth[ymin:ymax+1,xmin:xmax+1])
if not visible.any():continue
pixels=np.broadcast_to(color,(*xx.shape,4)).copy()
if texture is not None and uv is not None:
tex=w0[...,None]*uv[face[0]]+w1[...,None]*uv[face[1]]+w2[...,None]*uv[face[2]]
tx=np.clip((tex[...,0]%1*texture.shape[1]).astype(int),0,texture.shape[1]-1)
ty=np.clip((tex[...,1]%1*texture.shape[0]).astype(int),0,texture.shape[0]-1)
pixels*=texture[ty,tx]
if material.get('alphaMode')=='MASK':visible&=pixels[...,3]>=material.get('alphaCutoff',.5)
normal=np.cross(world[face[1]]-world[face[0]],world[face[2]]-world[face[0]])
normal/=max(np.linalg.norm(normal),1e-10)
shade=.45+.55*abs(np.dot(normal,light))
rgb[ymin:ymax+1,xmin:xmax+1][visible]=(pixels[...,:3]*shade)[visible]
depth[ymin:ymax+1,xmin:xmax+1][visible]=z[visible]
Image.fromarray(np.uint8(np.clip(rgb,0,1)*255)).save(output)
def import_model(source,reference,library,tool,profile=None):
source,reference,library=Path(source),Path(reference),Path(library)
digest=hashlib.sha256((sha(source)+sha(reference)+json.dumps(profile,sort_keys=True)+'library-v1').encode()).hexdigest()
entries=library/'entries';entries.mkdir(parents=True,exist_ok=True)
target=entries/digest
if target.exists():
require((target/'manifest.json').is_file() and (target/'preview.png').is_file(), 'Existing library entry is incomplete')
validate_output(target/'character.glb',reference)
return digest
with tempfile.TemporaryDirectory(prefix='.import-',dir=library) as temp:
staging=Path(temp)/'entry'
# Already converted stock-rig GLBs are accepted as well as Mixamo exports.
ready=False
if source.suffix.lower()=='.glb':
try:validate_output(source,reference);ready=True
except (ValueError,KeyError):pass
if ready:
staging.mkdir();shutil.copy2(source,staging/'character.glb');shutil.copy2(source,staging/'source.glb')
else:
convert_file(source,reference,staging,tool,profile,keep_source=True)
thumbnail(staging/'character.glb',staging/'preview.png')
metadata={'version':1,'id':digest,'name':source.stem[:64],
'source_sha256':sha(source),'reference_sha256':sha(reference),
'created_utc':datetime.now(timezone.utc).isoformat()}
(staging/'manifest.json').write_text(json.dumps(metadata,indent=2),encoding='utf-8')
staging.rename(target)
return digest
def run(args,app):
result={'status':'cancelled'}
try:
require(args.result is not None and args.reference is not None,'Import needs result and reference paths')
source=args.files[0] if args.files else None
if source is None:
import tkinter as tk
from tkinter import filedialog
root=tk.Tk();root.withdraw();root.attributes('-topmost',True)
try:
name=filedialog.askopenfilename(title='Import a Mixamo character',
filetypes=[('Character models','*.fbx *.glb')],parent=root)
source=Path(name) if name else None
finally:root.destroy()
if source:
profile_path=args.profile or app/'calibration.json'
profile=json.loads(profile_path.read_text(encoding='utf-8')) if profile_path.is_file() else None
if profile and profile['reference_sha256']!=sha(args.reference):profile=None
identity=import_model(source,args.reference,args.library_import,
args.fbx_tool or app/'tools/FBX2glTF.exe',profile)
result={'status':'ready','id':identity}
except Exception as error:
result={'status':'error','message':str(error)}
if args.result:
args.result.parent.mkdir(parents=True,exist_ok=True)
temporary=args.result.with_suffix('.tmp')
temporary.write_text(json.dumps(result),encoding='utf-8');os.replace(temporary,args.result)
return 1 if result['status']=='error' else 0
+104
View File
@@ -0,0 +1,104 @@
"""Windows drag-and-drop / file-dialog entry point, also usable from the CLI."""
import argparse
import json
import os
import sys
from pathlib import Path
from converter import convert_file, create_profile, sha
from glb import ConversionError, require
def app_root():
return Path(sys.executable).parent if getattr(sys,'frozen',False) else Path(__file__).resolve().parent
def find_reference(expected_hash=None):
base = Path(os.environ.get('LOCALAPPDATA',Path.home()/'AppData/Local'))/'Skate3RustEngine/installations'
candidates = sorted(base.glob('*/assets/private/skater.glb'))
if expected_hash:
candidates = [p for p in candidates if sha(p)==expected_hash]
if candidates:
return candidates[0]
if len(candidates)==1:
return candidates[0]
if candidates and len({sha(p) for p in candidates})==1:
return candidates[0]
return None
def main(argv=None):
parser = argparse.ArgumentParser(description='Convert a Mixamo humanoid to the installed Skate render rig.')
parser.add_argument('files',nargs='*',type=Path)
parser.add_argument('--reference',type=Path,help='Owned stock assets/private/skater.glb')
parser.add_argument('--profile',type=Path,help='Optional local paired-stock calibration JSON')
parser.add_argument('--output',type=Path,help='New output folder (single input only)')
parser.add_argument('--fbx-tool',type=Path)
parser.add_argument('--no-board',action='store_true',help='Output character only, for future modular import')
parser.add_argument('--calibrate',action='store_true',help='Create profile from a paired stock Mixamo GLB')
parser.add_argument('--noninteractive',action='store_true')
parser.add_argument('--library-import',type=Path,help='Import into the game character library')
parser.add_argument('--result',type=Path,help='Game import result file')
args = parser.parse_args(argv)
if args.library_import:
from library_import import run
return run(args,app_root())
gui = not args.noninteractive and (not args.files or getattr(sys,'frozen',False))
root = None
try:
if gui:
import tkinter as tk
from tkinter import filedialog, messagebox
root = tk.Tk()
root.withdraw()
if not args.files:
args.files = [Path(f) for f in filedialog.askopenfilenames(title='Choose Mixamo FBX characters',
filetypes=[('Mixamo characters','*.fbx *.glb')])]
if not args.files:
return 0
profile_path = args.profile or app_root()/'calibration.json'
profile = json.loads(profile_path.read_text(encoding='utf-8')) if profile_path.is_file() else None
reference = args.reference or find_reference(profile.get('reference_sha256') if profile else None)
if reference is None and gui:
selected = filedialog.askopenfilename(title='Choose your installed stock skater.glb',
filetypes=[('Stock GLB','*.glb')])
reference = Path(selected) if selected else None
require(reference is not None,'Stock reference not found. Use --reference path/to/skater.glb')
require(args.files,'No input selected')
require(not args.output or len(args.files)==1,'--output supports one input only')
if args.calibrate:
require(len(args.files)==1 and args.files[0].suffix.lower()=='.glb','Calibration requires one normalized Mixamo GLB')
require(not profile_path.exists(),'Calibration exists; choose another --profile path')
profile_path.write_text(json.dumps(create_profile(args.files[0],reference),indent=2),encoding='utf-8')
print('Calibration saved:',profile_path)
return 0
tool = args.fbx_tool or app_root()/'tools/FBX2glTF.exe'
outputs = []
for source in args.files:
destination = args.output
if destination is None:
destination = source.with_name(source.stem+'-skate')
counter = 2
while destination.exists():
destination = source.with_name(source.stem+f'-skate-{counter}')
counter += 1
print('Converting:',source,flush=True)
result = convert_file(source,reference,destination,tool,profile,not args.no_board)
outputs.append(str(result))
print('Converted and validated:',result,flush=True)
if gui:
messagebox.showinfo('Conversion complete','Saved:\n\n'+'\n'.join(outputs)+
'\n\nThe game was not modified. These files still need visual/gameplay review.')
return 0
except Exception as error:
print('Conversion failed:',str(error),file=sys.stderr)
if gui and root is not None:
messagebox.showerror('Conversion failed',str(error)+'\n\nExisting outputs and game files were not replaced.')
return 1
finally:
if root is not None:
root.destroy()
if __name__=='__main__':
raise SystemExit(main())
+2
View File
@@ -0,0 +1,2 @@
numpy>=1.26,<3
Pillow>=10,<13
+167
View File
@@ -0,0 +1,167 @@
"""Procedural fixtures only: no stock or other copyrighted meshes."""
import json
import tempfile
import unittest
from pathlib import Path
import numpy as np
from converter import MAP, convert_file, convert_glb, create_profile, reference_rig
from glb import ConversionError, Document, Writer
def fixture(path, mixamo, scale=1, bad_weights=False, morph=False):
positions = {'hips':(0,1,0),'spine':(0,1.15,0),'spine1':(0,1.3,0),
'spine2':(0,1.45,0),'neck':(0,1.6,0),'head':(0,1.8,0)}
for s,sign in [('left',1),('right',-1)]:
for part,p in {'shoulder':(.1,1.5,0),'arm':(.2,1.5,0),'forearm':(.45,1.5,0),
'hand':(.7,1.5,0),'upleg':(.1,.95,0),'leg':(.1,.5,0),
'foot':(.1,.08,0),'toebase':(.1,.03,.15)}.items():
positions[s+part]=(p[0]*sign,p[1],p[2])
keys = list(MAP)
w=Writer()
nodes=[]
inverses=[]
verts=[]
joint_weights=[]
joint_indices=[]
for i,k in enumerate(keys):
p=np.asarray(positions[k])*scale
nodes.append({'name':('mixamorig:'+k if mixamo else MAP[k]),'translation':p.tolist()})
if i:
nodes[-1]['translation']=(p-np.asarray(positions['hips'])*scale).tolist()
m=np.eye(4);m[:3,3]=-p
inverses.append(m.T.ravel())
for v in [(0,0,0),(.015,0,0),(0,.015,0)]:
verts.append(p+np.asarray(v)*scale)
joint_indices.append([i,0,0,0])
joint_weights.append([1,0,0,0])
nodes[0]['children']=list(range(1,len(nodes)))
if mixamo:
# Extra weighted finger is not present in the destination rig.
hand=keys.index('lefthand');finger=len(nodes)
nodes.append({'name':'mixamorig:LeftHandIndex1','translation':[.02*scale,0,0]})
nodes[hand]['children']=[finger]
m=np.eye(4);m[:3,3]=-(np.asarray(positions['lefthand'])+np.array([.02,0,0]))*scale
inverses.append(m.T.ravel())
for i in range(hand*3,hand*3+3):
joint_indices[i]=[finger,hand,0,0];joint_weights[i]=[.7,.3,0,0]
if bad_weights:
joint_indices[0][0]=999
attrs={'POSITION':w.accessor(verts,'VEC3'),
'NORMAL':w.accessor([[0,0,1]]*len(verts),'VEC3'),
'JOINTS_0':w.accessor(joint_indices,'VEC4',5123),
'WEIGHTS_0':w.accessor(joint_weights,'VEC4')}
primitive={'attributes':attrs,'indices':w.accessor(np.arange(len(verts)),'SCALAR',5125)}
if morph:
primitive['targets']=[{'POSITION':attrs['POSITION']}]
skin={'joints':list(range(len(nodes))),'inverseBindMatrices':w.accessor(inverses,'MAT4')}
nodes.append({'name':'body','mesh':0,'skin':0})
w.doc.update(nodes=nodes,skins=[skin],meshes=[{'primitives':[primitive]}],
scenes=[{'nodes':[0,len(nodes)-1]}],scene=0)
w.save(path)
class ConversionTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root=Path(self.tmp.name)
self.ref=self.root/'stock.glb';self.src=self.root/'mixamo.glb';self.out=self.root/'out.glb'
fixture(self.ref,False)
fixture(self.src,True,.01)
def test_calibrated_roundtrip_scale_weights_and_runtime_pose(self):
profile=create_profile(self.src,self.ref)
report=convert_glb(self.src,self.ref,self.out,profile,False)
src,ref=Document(self.out),Document(self.ref)
p=src.doc['meshes'][0]['primitives'][0]
r=ref.doc['meshes'][0]['primitives'][0]
got=src.accessor(p['attributes']['POSITION'])
expected=ref.accessor(r['attributes']['POSITION'])
np.testing.assert_allclose(got,expected,atol=2e-6)
_,names,inverse=reference_rig(src)
hand=names.index('LEFTHAND')
js=src.accessor(p['attributes']['JOINTS_0']).astype(int)
ws=src.accessor(p['attributes']['WEIGHTS_0'])
np.testing.assert_allclose(ws.sum(1),1,atol=1e-6)
for i in range(hand*3,hand*3+3):
self.assertEqual(js[i,0],hand)
self.assertAlmostEqual(ws[i,0],1)
# Simulate a translated stock hand: its vertices move exactly 20 cm,
# while an unrelated head vertex stays put. No engine/game is run.
globals=np.linalg.inv(inverse)
globals[hand,0,3]+=.2
skin_mats=globals@inverse
hv=np.column_stack([got,np.ones(len(got))])
deformed=np.einsum('vi,vijk,vk->vj',ws,skin_mats[js],hv)[:,:3]
expected_pose=got.copy();expected_pose[hand*3:hand*3+3,0]+=.2
np.testing.assert_allclose(deformed,expected_pose,atol=2e-6)
self.assertIn('mixamorig:LeftHandIndex1',report['collapsed_bones'])
def test_generic_fit_and_no_imported_bones(self):
report=convert_glb(self.src,self.ref,self.out,None,False)
self.assertFalse(report['calibrated'])
names=[n.get('name','') for n in Document(self.out).doc['nodes']]
self.assertFalse(any('mixamorig' in n for n in names))
got,ref=Document(self.out),Document(self.ref)
p=got.doc['meshes'][0]['primitives'][0]['attributes']['POSITION']
r=ref.doc['meshes'][0]['primitives'][0]['attributes']['POSITION']
np.testing.assert_allclose(got.accessor(p),ref.accessor(r),atol=2e-6)
def test_rotated_translated_rescaled_scene_uses_same_calibration(self):
profile=create_profile(self.src,self.ref)
d=Document(self.src);w=Writer();w.doc=d.doc;w.data=bytearray(d.data)
root=len(w.doc['nodes'])
w.doc['nodes'].append({'name':'export_axes','children':[0,root-1],
'rotation':[0,2**-.5,0,2**-.5],
'scale':[3,3,3],'translation':[8,2,-3]})
w.doc['scenes'][0]['nodes']=[root];w.save(self.src)
convert_glb(self.src,self.ref,self.out,profile,False)
got,ref=Document(self.out),Document(self.ref)
p=got.doc['meshes'][0]['primitives'][0]['attributes']['POSITION']
r=ref.doc['meshes'][0]['primitives'][0]['attributes']['POSITION']
np.testing.assert_allclose(got.accessor(p),ref.accessor(r),atol=2e-6)
def test_bad_joint_fails_transaction_without_mutating_source(self):
fixture(self.src,True,.01,bad_weights=True)
before=self.src.read_bytes();ref_before=self.ref.read_bytes()
with self.assertRaisesRegex(ConversionError,'joint index'):
convert_file(self.src,self.ref,self.root/'result','unused')
self.assertFalse((self.root/'result').exists())
self.assertEqual(self.src.read_bytes(),before)
self.assertEqual(self.ref.read_bytes(),ref_before)
self.assertFalse(list(self.root.glob('.mixamo-*')))
def test_existing_output_never_overwritten(self):
destination=self.root/'result';destination.mkdir()
(destination/'keep').write_text('keep')
with self.assertRaisesRegex(ConversionError,'already exists'):
convert_file(self.src,self.ref,destination,'unused')
self.assertEqual((destination/'keep').read_text(),'keep')
def test_profile_identity_mismatch(self):
profile=create_profile(self.src,self.ref)
profile['reference_sha256']='wrong'
with self.assertRaisesRegex(ConversionError,'another stock reference'):
convert_glb(self.src,self.ref,self.out,profile,False)
def test_morph_rejected_explicitly(self):
fixture(self.src,True,.01,morph=True)
with self.assertRaisesRegex(ConversionError,'Blendshapes'):
convert_glb(self.src,self.ref,self.out,None,False)
def test_truncated_glb_rejected(self):
self.src.write_bytes(self.src.read_bytes()[:-20])
with self.assertRaisesRegex(ConversionError,'header'):
Document(self.src)
def test_missing_bone_rejected(self):
d=Document(self.src);w=Writer();w.doc=d.doc;w.data=bytearray(d.data)
w.doc['nodes'][1]['name']='wrong';w.save(self.src)
with self.assertRaisesRegex(ConversionError,'missing'):
convert_glb(self.src,self.ref,self.out,None,False)
if __name__=='__main__':
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
import json
import tempfile
import unittest
from pathlib import Path
import numpy as np
from PIL import Image
from test_converter import fixture
from converter import create_profile
from library_import import import_model
class LibraryTests(unittest.TestCase):
def test_publish_preview_deduplicate_and_failed_import(self):
with tempfile.TemporaryDirectory() as temp:
root=Path(temp); ref=root/'ref.glb'; src=root/'model.glb'; library=root/'library'
fixture(ref,False); fixture(src,True,.01)
profile=create_profile(src,ref)
identity=import_model(src,ref,library,None,profile)
entry=library/'entries'/identity
self.assertTrue((entry/'source.glb').is_file())
self.assertEqual(json.loads((entry/'manifest.json').read_text())['name'],'model')
with Image.open(entry/'preview.png') as preview:
self.assertEqual(preview.size,(256,320))
self.assertGreater(len(np.unique(np.asarray(preview).reshape(-1,3),axis=0)),1)
self.assertEqual(import_model(src,ref,library,None,profile),identity)
bad=root/'bad.glb';bad.write_bytes(b'broken')
with self.assertRaises(Exception):import_model(bad,ref,library,None,profile)
self.assertEqual(len(list((library/'entries').iterdir())),1)
converted=import_model(entry/'character.glb',ref,library,None,profile)
self.assertTrue((library/'entries'/converted/'preview.png').is_file())
if __name__=='__main__':unittest.main()