From 61dbdfce8e9318247a0ccda8eb4b81bddbccbd77 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:31:30 +0100 Subject: [PATCH] Fix native hair coverage and verify customiser gameplay effects --- crates/skate-game/src/customiser.rs | 24 ++- crates/skate-game/src/customiser_material.rs | 79 +++++--- crates/skate-game/src/customiser_parts.rs | 45 ++++- crates/skate-game/src/customiser_prepass.wgsl | 172 ++++++++++++++++++ crates/skate-game/src/customiser_stamp.wgsl | 6 + .../src/physics/ground_runtime/settings.rs | 18 ++ .../skate-game/src/tests/physics_startup.rs | 54 +++++- docs/character-customisation.md | 54 +++++- tools/asset_pipeline/customisation_library.py | 5 +- .../test_customisation_library.py | 21 ++- tools/launch_customiser_test.bat | 14 +- 11 files changed, 438 insertions(+), 54 deletions(-) create mode 100644 crates/skate-game/src/customiser_prepass.wgsl diff --git a/crates/skate-game/src/customiser.rs b/crates/skate-game/src/customiser.rs index 5d7f467b..1cef1272 100644 --- a/crates/skate-game/src/customiser.rs +++ b/crates/skate-game/src/customiser.rs @@ -972,12 +972,19 @@ fn preferences( state: Res, mut physics: ResMut, mut skater: ResMut, +) { + apply_preferences(&state.draft, &mut physics, &mut skater.animation); +} +pub(crate) fn apply_preferences( + profile: &Value, + physics: &mut crate::physics::GamePhysics, + animation: &mut crate::skater_animation::SkaterAnimation, ) { physics.set_equipment_preferences( - scalar(&state.draft, "truck", 0.7) as f32, - scalar(&state.draft, "wheel", 0.7) as f32, + scalar(profile, "truck", 0.7) as f32, + scalar(profile, "wheel", 0.7) as f32, ); - physics.set_gesture_preferences(state.draft["gestures"].as_object().map(|g| { + physics.set_gesture_preferences(profile["gestures"].as_object().map(|g| { // ResetGestureSet824FA730 marks all 37 entries available and selects // the first four in table order for Up,Down,Left,Right. std::array::from_fn(|i| { @@ -986,16 +993,15 @@ fn preferences( .unwrap_or(i as u64) as u32 }) })); - skater.animation.set_customisation( - scalar(&state.draft, "stance", 1.) as u32, - scalar(&state.draft, "style", 0.) as u32, + animation.set_customisation( + scalar(profile, "stance", 1.) as u32, + scalar(profile, "style", 0.) as u32, ); - skater - .animation + animation .motion .animation .posture - .set_profile(scalar(&state.draft, "posture", 0.) as u32); + .set_profile(scalar(profile, "posture", 0.) as u32); } fn draw( mut commands: Commands, diff --git a/crates/skate-game/src/customiser_material.rs b/crates/skate-game/src/customiser_material.rs index 0a337107..0c3b008a 100644 --- a/crates/skate-game/src/customiser_material.rs +++ b/crates/skate-game/src/customiser_material.rs @@ -14,6 +14,10 @@ pub(crate) struct SkinStamp { #[texture(100)] #[sampler(101)] pub texture: Option>, + /// cac_hair_defaultPS samples opacity.r on TEXCOORD1, independently of diffuse alpha. + #[texture(103)] + #[sampler(104)] + pub hair_opacity: Option>, /// Scale.xy, translation.zw in the authored secondary UV stream. #[uniform(102)] pub transform: Vec4, @@ -27,12 +31,16 @@ impl MaterialExtension for SkinStamp { fn fragment_shader() -> ShaderRef { "embedded://skate3rust/customiser_stamp.wgsl".into() } + fn prepass_fragment_shader() -> ShaderRef { + "embedded://skate3rust/customiser_prepass.wgsl".into() + } fn deferred_fragment_shader() -> ShaderRef { Self::fragment_shader() } } pub(crate) fn register(app: &mut App) { embedded_asset!(app, "customiser_stamp.wgsl"); + embedded_asset!(app, "customiser_prepass.wgsl"); app.add_plugins(MaterialPlugin::::default()); } @@ -118,28 +126,57 @@ mod tests { let h = assets.add(shader.clone()); cache.set_shader(h.id(), shader); } - let shader = Shader::from_wgsl( - include_str!("customiser_stamp.wgsl"), - "customiser_stamp.wgsl", - ); - let h = assets.add(shader.clone()); - cache.set_shader(h.id(), shader); - for secondary_uv in [false, true] { - let mut d = defs.clone(); - for name in [ - "VERTEX_UVS_A", - "VERTEX_OUTPUT_INSTANCE_INDEX", - "VERTEX_NORMALS", - "MESH_PIPELINE", - "STANDARD_MATERIAL", - "STANDARD_MATERIAL_BASE_COLOR_TEXTURE", - ] { - d.push(ShaderDefVal::Bool(name.into(), true)); + for (source, path, prepass) in [ + ( + include_str!("customiser_stamp.wgsl"), + "customiser_stamp.wgsl", + false, + ), + ( + include_str!("customiser_prepass.wgsl"), + "customiser_prepass.wgsl", + true, + ), + ] { + let shader = Shader::from_wgsl(source, path); + let h = assets.add(shader.clone()); + cache.set_shader(h.id(), shader); + for secondary_uv in [false, true] { + let mut d = defs.clone(); + for name in [ + "VERTEX_UVS", + "MAY_DISCARD", + "VERTEX_UVS_A", + "VERTEX_OUTPUT_INSTANCE_INDEX", + "VERTEX_NORMALS", + "MESH_PIPELINE", + "STANDARD_MATERIAL", + "STANDARD_MATERIAL_BASE_COLOR_TEXTURE", + ] { + d.push(ShaderDefVal::Bool(name.into(), true)); + } + if secondary_uv { + d.push(ShaderDefVal::Bool("VERTEX_UVS_B".into(), true)); + } + if prepass { + d.push(ShaderDefVal::Bool("PREPASS_PIPELINE".into(), true)); + } + cache + .get(&(), 0, h.id(), &d) + .unwrap_or_else(|e| panic!("{path} secondary UV {secondary_uv}: {e:?}")); + if prepass { + for name in [ + "PREPASS_FRAGMENT", + "NORMAL_PREPASS", + "NORMAL_PREPASS_OR_DEFERRED_PREPASS", + ] { + d.push(ShaderDefVal::Bool(name.into(), true)); + } + cache + .get(&(), 0, h.id(), &d) + .unwrap_or_else(|e| panic!("Normal {path} UV {secondary_uv}: {e:?}")); + } } - if secondary_uv { - d.push(ShaderDefVal::Bool("VERTEX_UVS_B".into(), true)); - } - cache.get(&(), 0, h.id(), &d).unwrap(); } } } diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index 2038595f..75b24086 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -26,6 +26,8 @@ pub(crate) struct Material { normal: Option, rough: Option, alpha: bool, + #[serde(default)] + opacity: Option, tint: [f32; 3], metallic: f32, roughness: f32, @@ -413,12 +415,19 @@ impl Parts { let diffuse = load(&m.diffuse, false); let normal = m.normal.as_ref().map(|p| load(p, true)); let rough = m.rough.as_ref().map(|p| load(p, true)); + let opacity = m.opacity.as_ref().map(|p| load(p, true)); let material = materials.add(SkaterMaterial { base: StandardMaterial { base_color: Color::srgb(m.tint[0], m.tint[1], m.tint[2]), base_color_texture: Some(diffuse), normal_map_texture: normal, metallic_roughness_texture: rough, + double_sided: m.opacity.is_some(), + cull_mode: if m.opacity.is_some() { + None + } else { + Some(bevy::render::render_resource::Face::Back) + }, metallic: m.metallic, perceptual_roughness: if m.rough.is_some() { 1. } else { m.roughness }, alpha_mode: if m.alpha { @@ -428,7 +437,15 @@ impl Parts { }, ..default() }, - extension: SkinStamp::default(), + extension: SkinStamp { + hair_opacity: opacity, + enabled: if m.opacity.is_some() { + Vec4::Y + } else { + Vec4::ZERO + }, + ..default() + }, }); self.materials.insert(id.into(), (material, images)); } @@ -438,10 +455,15 @@ pub(crate) fn setup( config: Res, server: Res, ) { - let library = std::fs::read(config.asset_root.join("private/customisation/library.json")) - .ok() - .and_then(|b| serde_json::from_slice::(&b).ok()) - .unwrap_or_default(); + let library = std::fs::read( + config + .asset_root + .join("private/customisation/library-v3.json"), + ) + .or_else(|_| std::fs::read(config.asset_root.join("private/customisation/library.json"))) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .unwrap_or_default(); let geometry = library .models .iter() @@ -638,7 +660,17 @@ pub(crate) fn update( let selected = stamp["id"] .as_str() .and_then(|id| parts.library.tattoos.get(id).map(|t| (id, t))); - let mut extension = SkinStamp::default(); + let mut extension = SkinStamp { + hair_opacity: materials + .get(&handle) + .and_then(|m| m.extension.hair_opacity.clone()), + enabled: if source.opacity.is_some() { + Vec4::Y + } else { + Vec4::ZERO + }, + ..default() + }; if let Some((tid, t)) = selected { let key = match stamp["side"].as_u64().unwrap_or(0) { 1 => "StampUVConstraintQ4", @@ -659,6 +691,7 @@ pub(crate) fn update( transform, rectangle, enabled: Vec4::X, + ..default() }; } } diff --git a/crates/skate-game/src/customiser_prepass.wgsl b/crates/skate-game/src/customiser_prepass.wgsl new file mode 100644 index 00000000..e6227c7f --- /dev/null +++ b/crates/skate-game/src/customiser_prepass.wgsl @@ -0,0 +1,172 @@ +// Bevy 0.18.1 pbr_prepass.wgsl, MIT/Apache-2.0, copyright Bevy contributors. +// Match native hair coverage in colour, depth and shadow passes. +struct SkinStamp { + transform: vec4, + rectangle: vec4, + enabled: vec4, +} +@group(#{MATERIAL_BIND_GROUP}) @binding(103) var hair_opacity: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(104) var hair_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(102) var stamp: SkinStamp; +#import bevy_pbr::{ + pbr_prepass_functions, + pbr_bindings, + pbr_bindings::material, + pbr_types, + pbr_functions, + pbr_functions::SampleBias, + prepass_io, + mesh_bindings::mesh, + mesh_view_bindings::view, +} + +#import bevy_render::bindless::{bindless_samplers_filtering, bindless_textures_2d} + +#ifdef MESHLET_MESH_MATERIAL_PASS +#import bevy_pbr::meshlet_visibility_buffer_resolve::resolve_vertex_output +#endif + +#ifdef BINDLESS +#import bevy_pbr::pbr_bindings::material_indices +#endif // BINDLESS + +fn customiser_alpha_discard(in: prepass_io::VertexOutput) { +#ifdef VERTEX_UVS_B + let alpha = textureSample(hair_opacity, hair_sampler, in.uv_b).r; + if stamp.enabled.y > 0.5 { + let coverage = pbr_functions::alpha_discard(pbr_bindings::material, vec4(1.0, 1.0, 1.0, alpha)); + return; + } +#endif + pbr_prepass_functions::prepass_alpha_discard(in); +} + +#ifdef PREPASS_FRAGMENT +@fragment +fn fragment( +#ifdef MESHLET_MESH_MATERIAL_PASS + @builtin(position) frag_coord: vec4, +#else + in: prepass_io::VertexOutput, + @builtin(front_facing) is_front: bool, +#endif +) -> prepass_io::FragmentOutput { +#ifdef MESHLET_MESH_MATERIAL_PASS + let in = resolve_vertex_output(frag_coord); + let is_front = true; +#else // MESHLET_MESH_MATERIAL_PASS + +#ifdef BINDLESS + let slot = mesh[in.instance_index].material_and_lightmap_bind_group_slot & 0xffffu; + let flags = pbr_bindings::material_array[material_indices[slot].material].flags; + let uv_transform = pbr_bindings::material_array[material_indices[slot].material].uv_transform; +#else // BINDLESS + let flags = pbr_bindings::material.flags; + let uv_transform = pbr_bindings::material.uv_transform; +#endif // BINDLESS + + // If we're in the crossfade section of a visibility range, conditionally + // discard the fragment according to the visibility pattern. +#ifdef VISIBILITY_RANGE_DITHER + pbr_functions::visibility_range_dither(in.position, in.visibility_range_dither); +#endif // VISIBILITY_RANGE_DITHER + + customiser_alpha_discard(in); +#endif // MESHLET_MESH_MATERIAL_PASS + + var out: prepass_io::FragmentOutput; + +#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION + out.frag_depth = in.unclipped_depth; +#endif // UNCLIPPED_DEPTH_ORTHO_EMULATION + +#ifdef NORMAL_PREPASS + // NOTE: Unlit bit not set means == 0 is true, so the true case is if lit + if (flags & pbr_types::STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { + let double_sided = (flags & pbr_types::STANDARD_MATERIAL_FLAGS_DOUBLE_SIDED_BIT) != 0u; + + let world_normal = pbr_functions::prepare_world_normal( + in.world_normal, + double_sided, + is_front, + ); + + var normal = world_normal; + +#ifdef VERTEX_UVS +#ifdef VERTEX_TANGENTS +#ifdef STANDARD_MATERIAL_NORMAL_MAP + +// TODO: Transforming UVs mean we need to apply derivative chain rule for meshlet mesh material pass +#ifdef STANDARD_MATERIAL_NORMAL_MAP_UV_B + let uv = (uv_transform * vec3(in.uv_b, 1.0)).xy; +#else + let uv = (uv_transform * vec3(in.uv, 1.0)).xy; +#endif + + // Fill in the sample bias so we can sample from textures. + var bias: SampleBias; +#ifdef MESHLET_MESH_MATERIAL_PASS + bias.ddx_uv = in.ddx_uv; + bias.ddy_uv = in.ddy_uv; +#else // MESHLET_MESH_MATERIAL_PASS + bias.mip_bias = view.mip_bias; +#endif // MESHLET_MESH_MATERIAL_PASS + + let Nt = +#ifdef MESHLET_MESH_MATERIAL_PASS + textureSampleGrad( +#else // MESHLET_MESH_MATERIAL_PASS + textureSampleBias( +#endif // MESHLET_MESH_MATERIAL_PASS +#ifdef BINDLESS + bindless_textures_2d[material_indices[slot].normal_map_texture], + bindless_samplers_filtering[material_indices[slot].normal_map_sampler], +#else // BINDLESS + pbr_bindings::normal_map_texture, + pbr_bindings::normal_map_sampler, +#endif // BINDLESS + uv, +#ifdef MESHLET_MESH_MATERIAL_PASS + bias.ddx_uv, + bias.ddy_uv, +#else // MESHLET_MESH_MATERIAL_PASS + bias.mip_bias, +#endif // MESHLET_MESH_MATERIAL_PASS + ).rgb; + let TBN = pbr_functions::calculate_tbn_mikktspace(normal, in.world_tangent); + + normal = pbr_functions::apply_normal_mapping( + flags, + TBN, + double_sided, + is_front, + Nt, + ); + +#endif // STANDARD_MATERIAL_NORMAL_MAP +#endif // VERTEX_TANGENTS +#endif // VERTEX_UVS + + out.normal = vec4(normal * 0.5 + vec3(0.5), 1.0); + } else { + out.normal = vec4(in.world_normal * 0.5 + vec3(0.5), 1.0); + } +#endif // NORMAL_PREPASS + +#ifdef MOTION_VECTOR_PREPASS +#ifdef MESHLET_MESH_MATERIAL_PASS + out.motion_vector = in.motion_vector; +#else + out.motion_vector = pbr_prepass_functions::calculate_motion_vector(in.world_position, in.previous_world_position); +#endif +#endif + + return out; +} +#else +@fragment +fn fragment(in: prepass_io::VertexOutput) { + customiser_alpha_discard(in); +} +#endif // PREPASS_FRAGMENT diff --git a/crates/skate-game/src/customiser_stamp.wgsl b/crates/skate-game/src/customiser_stamp.wgsl index 793bc01b..8801336f 100644 --- a/crates/skate-game/src/customiser_stamp.wgsl +++ b/crates/skate-game/src/customiser_stamp.wgsl @@ -7,6 +7,8 @@ struct SkinStamp { } @group(#{MATERIAL_BIND_GROUP}) @binding(100) var stamp_texture: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(101) var stamp_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(103) var hair_opacity: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(104) var hair_sampler: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(102) var stamp: SkinStamp; #import bevy_pbr::{ pbr_types, @@ -77,6 +79,10 @@ fn fragment( #ifdef VERTEX_UVS_B + // The diffuse alpha is NOT coverage. Hair has a separate red-channel + // opacity map on secondary UVs (cac_hair_defaultVS/PS). + let hair_alpha = textureSample(hair_opacity, hair_sampler, in.uv_b).r; + pbr_input.material.base_color.a = mix(pbr_input.material.base_color.a, hair_alpha, stamp.enabled.y); // cacstamp_skin_defaultPS: skin * (1 + (decal.rgb - 1) * decal.a). // Its VS transforms TEXCOORD1 by the two i_customGraphic rows. let stamp_uv = in.uv_b * stamp.transform.xy + stamp.transform.zw; diff --git a/crates/skate-game/src/physics/ground_runtime/settings.rs b/crates/skate-game/src/physics/ground_runtime/settings.rs index f17bd963..a3965f07 100644 --- a/crates/skate-game/src/physics/ground_runtime/settings.rs +++ b/crates/skate-game/src/physics/ground_runtime/settings.rs @@ -293,3 +293,21 @@ mod tests { super::super::GroundState::load(&data, "default", true).unwrap(); } } + +#[cfg(test)] +#[test] +#[ignore = "requires extracted private stock collections"] +fn customiser_truck_tightness_changes_authored_steering() { + use skate_core::riding::steering::{SteeringInput, calculate_tilt}; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + let data = Collections::load(&root).unwrap(); + let settings = GroundSettings::load(&data, "default", "default").unwrap(); + let samples: Vec<_> = [0.0, 0.7, 1.0].into_iter().map(|tightness| calculate_tilt( + &settings.steering, + SteeringInput { turn: 0.6, absolute_body_speed: 5.0, flipped_controls_scalar: 1.0, + truck_tightness: tightness, ..Default::default() }, None, None, + )).collect(); + assert!(samples[0].abs() > samples[2].abs()); + assert!((samples[2] / samples[0] - settings.steering.tight_trucks_scalar).abs() < 0.00001); + eprintln!("Authored truck tightness tilt samples: {samples:?}; tight scalar {}", settings.steering.tight_trucks_scalar); +} diff --git a/crates/skate-game/src/tests/physics_startup.rs b/crates/skate-game/src/tests/physics_startup.rs index 83097702..91e4954d 100644 --- a/crates/skate-game/src/tests/physics_startup.rs +++ b/crates/skate-game/src/tests/physics_startup.rs @@ -356,7 +356,7 @@ fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig() #[test] #[ignore = "requires private stock skater, animation banks and collections"] -fn stock_wheel_hardness_reaches_ground_force_and_torque() { +fn customiser_equipment_reaches_ground_force_and_torque() { let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); let root = std::path::Path::new(&root); let manifest = skate_data::GameAssets::load(root).unwrap(); @@ -365,6 +365,10 @@ fn stock_wheel_hardness_reaches_ground_force_and_torque() { for hardness in [0.0_f32, 0.7, 1.0] { let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + crate::customiser::apply_preferences( + &serde_json::json!({"truck": hardness, "wheel": hardness}), + &mut physics, &mut skater.animation, + ); let mut controls = PlayerControls::default(); let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); let input = crate::input::ControllerInput::default(); @@ -382,7 +386,8 @@ fn stock_wheel_hardness_reaches_ground_force_and_torque() { // Replay identical observed sideways travel at the Ground input boundary. // Only the published profile input changes; run the actual Ground adapter. let p = &mut skater.player_input.processed; - p.scalar_2764 = hardness; + assert_eq!(p.scalar_2764, hardness); + assert_eq!(p.truck_tightness_2760, hardness); p.vectors_400_416 = [[1.0_f32, 0.0, 3.0, 0.0].map(f32::to_bits); 2]; p.scalar_2656 = 10.0_f32.sqrt(); skater.ground.state.elapsed_2648 = 0.25; @@ -454,3 +459,48 @@ fn stock_geometry_loads_and_reaches_the_live_board_solver() { let deck = physics.board.part_transforms()[BodyId::Deck.index()]; assert!(deck.translation.y > ground::HEIGHT); } + +#[test] +#[ignore = "requires private stock animation banks and collections; no window or renderer"] +fn customiser_styles_and_postures_change_stock_pose() { + use skate_core::animation::playback::{PlayAnimation, PlayAnimationInstance, TransitionSettings}; + use skate_core::animation::playback_tree::{Evaluation, PoseCommand}; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + let manifest = skate_data::GameAssets::load(&root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(&root, &manifest).unwrap(); + let mut physics = GamePhysics::load(&root).unwrap(); + let mut skater = SkaterRuntime::load(&root, &graphs, &physics, "normal").unwrap(); + let operation = PlayAnimation { + animation: "S_R_PUSHLSP_HSTR_N_0_CYC1".into(), switch_animation: None, mirror_animation: None, + no_board_animation: None, playback_speed: 1.0, apply_posture: true, + transition: TransitionSettings { kind: 1, seconds: 0.0, under: 0, matching: 0, use_channels_from_weights: false }, + parameters: vec![], + }; + let mut samples = Vec::new(); + for (style, posture) in [(0,0), (1,0), (2,0), (3,0), (0,1), (0,2), (0,3)] { + crate::customiser::apply_preferences( + &serde_json::json!({"style":style, "posture":posture}), + &mut physics, &mut skater.animation, + ); + let motion = &mut skater.animation.motion; + PlayAnimationInstance::default().begin(&operation, &mut motion.playback_context, &mut motion.animation).unwrap(); + motion.animation.apply_parameters().unwrap(); + motion.animation.seek_current_fraction(0.35); + let commands = motion.animation.evaluate_pose(Evaluation { cull_threshold: 0.0001, update_history: false }).unwrap(); + let pose = skater.animation.evaluator.evaluate(&commands).unwrap(); + let names: Vec<_> = commands.iter().filter_map(|c| match c { + PoseCommand::Clip { name, .. } | PoseCommand::Pose { name } => Some(name.clone()), _ => None, + }).collect(); + eprintln!("Style {style}, posture {posture}: {names:?}"); + if let Some(baseline) = samples.first() { + assert!(&pose != baseline, "Style {style}, posture {posture} did not change the evaluated stock pose"); + } + samples.push(pose); + } + skater.animation.set_customisation(1,0); + let stance = skater.animation.stance(); + skater.animation.set_customisation(0,0); + assert_ne!(skater.animation.stance(), stance); + skater.animation.set_customisation(1,0); + assert_eq!(skater.animation.stance(), stance); +} diff --git a/docs/character-customisation.md b/docs/character-customisation.md index 9fa3622f..9cb63ef6 100644 --- a/docs/character-customisation.md +++ b/docs/character-customisation.md @@ -1,10 +1,54 @@ -# Character customiser V2 +# Character customiser V3 -The dedicated local test build uses `Play Character Customiser V2.bat` and -`skate3rust-customiser-v2.exe`. Open Escape/Start, then Character customiser. +The dedicated local test build uses `Play Character Customiser V3.bat` and +`skate3rust-customiser-v3.exe`. Open Escape/Start, then Character customiser. The game and launcher have not been executed for validation; the build is ready for user playtesting. No existing game executable is replaced. +## Hair coverage and gameplay verification (V3) + +Hair now uses its independent opacity texture's **red channel on TEXCOORD1**. +The owned `cac_hair_defaultVS` copies primary/secondary UVs into XY/ZW, and +`cac_hair_defaultPS` samples diffuse RGB from XY and opacity R from ZW. Output +alpha is opacity R times `i_params.x`. The old combined diffuse-alpha texture +was incorrect: on the moussed-up example only 0.29% of diffuse pixels exceed the +0.5 cutoff, versus 42.18% of the opacity red channel. That discarded most raised +hair geometry. All 31 opacity-bearing hair materials now retain separate maps. +Thin hair cards render from both sides. The colour and depth/shadow passes use +the same corrected coverage; existing PBR lighting formulas remain unchanged. + +V3 uses `library-v3.json` when present, with a legacy `library.json` fallback. +For side-by-side preparation set `"library_index":"library-v3.json"` in the +private preparation config. V2's index, executable and launcher are retained. + +Offline checks use the same `apply_preferences` function called by the menu, +then the real stock actor publication, processed physics input and consumers: + +- Truck tightness reaches processed field 2760. At identical steering input, + loose/default/tight produce tilt 0.08955247 / 0.07074646 / 0.062686734. The + tight/loose ratio is the loaded native `tight_trucks_scalar`, 0.7. +- Wheel hardness reaches processed field 2764. At identical sideways travel, + soft/default/hard produce side force -23.31247 / -15.153105 / -11.656235. + Ratios match `physicswheels/default/SoftestWheelSideFrictionScalar` from the + extracted collection data. Straightening torque also changes, from -1.113991 + to 1.1697853 at the endpoints. These are controlled test samples, not universal + steering angles, grip percentages or speed ratings. +- Standard/Loose/Gonzo/Aggressive select distinct stock push clips through the + `PROSKATER` selector: default, Hsu, Gonzales and PJ respectively. Evaluated bone + poses differ. Stiff/Slouch/Buff add their respective authored posture poses + and change the evaluated result. Switching natural stance changes its basis + and switching back restores it. + +The stock riding idle is shared between styles. Style differences appear in +specific authored movements (including pushes and selected tricks), and posture +is applied on eligible motion-tree construction. Wheel/truck merchandise models +are appearance choices; **Wheel hardness** and **Truck tightness** are the native +handling controls. No invented brand-dependent statistics are added. + +Shader validation covers colour, depth/shadow and normal prepass paths, each +with and without secondary UVs. Tests run without a game window or GPU. The hair +fix still needs the user's visual playtest; no game process was launched here. + ## Menu and controls Four main sections: **Body, Clothes, Board, Style**. Item lists are alphabetical, @@ -75,9 +119,9 @@ python -m tools.asset_pipeline.customisation_library python -m tools.asset_pipeline.customisation_profiles ``` -The old worker remains an offline compatibility tool. It is not the V2 runtime. +The old worker remains an offline compatibility tool. It is not the runtime. -Validation: 21 Python tests and five Rust customiser tests pass. Owned-data checks +Validation: 21 Python tests and eight Rust customiser tests pass. Owned-data checks cover all 480 GLBs, indices, skin weights, 22 GPU target slots, texture references, both default outfits, all selectable model/material combinations against those outfits, presets, search, colour selection, JSON round trips, and temporary tattoo diff --git a/tools/asset_pipeline/customisation_library.py b/tools/asset_pipeline/customisation_library.py index b390a33f..922ae7bc 100644 --- a/tools/asset_pipeline/customisation_library.py +++ b/tools/asset_pipeline/customisation_library.py @@ -107,7 +107,7 @@ def prepare(config): mat=catalog['materials'][mid];tex={t['channel']:t['id'] for t in mat['textures']} if 'diffuse' not in tex:continue diffuse=texture(tex['diffuse']) - if 'alpha' in tex: + if 'alpha' in tex and slot!='Hair': alpha_path=assets/texture(tex['alpha']);combined=out/'textures'/(tex['diffuse']+'_'+tex['alpha']+'.png') if not combined.exists(): im=Image.open(assets/diffuse).convert('RGBA');alpha=Image.open(alpha_path).convert('RGBA') @@ -118,6 +118,7 @@ def prepare(config): mat_data[mid]=dict(name=variant_label(slot,model['name'],v['name']),flags={k[4:]:val for k,val in mat['flags'].items() if val}, diffuse=diffuse,normal=texture(tex['normal'],'normal') if 'normal' in tex else None, rough=texture(tex['specular'],'rough') if 'specular' in tex else None, + opacity=texture(tex['alpha']) if slot=='Hair' and 'alpha' in tex else None, alpha='alpha' in tex,tint=defaults.get(mid,{}).get('tint', [0.72,0.57,0.49] if mat['flags'].get('cas.SkinTone')=='light' else [0.33,0.26,0.23] if mat['flags'].get('cas.SkinTone')=='dark' else @@ -148,7 +149,7 @@ def prepare(config): colours.sort(key=lambda c:c['name']) library=dict(version=3,colours=colours,tattoos=tattoos,models=model_data,materials=mat_data,defaults={'male':male,'female':female}, morphs=recipe_base['morph_assembly']['live_targets'],errors=errors) - (cache/'library.json').write_text(json.dumps(library,separators=(',',':'))) + (cache/config.get('library_index','library.json')).write_text(json.dumps(library,separators=(',',':'))) print('LIBRARY_READY',len(model_data),len(mat_data),'errors',errors,flush=True) return library diff --git a/tools/asset_pipeline/test_customisation_library.py b/tools/asset_pipeline/test_customisation_library.py index 215f806d..6d206665 100644 --- a/tools/asset_pipeline/test_customisation_library.py +++ b/tools/asset_pipeline/test_customisation_library.py @@ -61,6 +61,9 @@ class LibraryTests(unittest.TestCase): for target in primitive['targets']: self.assertEqual(len(array(target['POSITION'])),count) self.assertEqual(len(array(target['NORMAL'])),count) + if part['slot'] == 'Hair': + self.assertIn('TEXCOORD_1', a) + self.assertEqual(len(array(a['TEXCOORD_1'])), count) if (part['slot']=='Arm' and part['flags'].get('ArmModelType')=='shoulder') or (part['slot']=='Leg' and part['flags'].get('LegModelType') in ['thigh','thighdown'] and part['flags'].get('RequiresSockStyle','none') in ['','none']): uv = array(a['TEXCOORD_1']) for side in ['Q3','Q4']: @@ -68,8 +71,22 @@ class LibraryTests(unittest.TestCase): inside=(uv[:,0]>=left)&(uv[:,0]<=right)&(uv[:,1]>=1-top)&(uv[:,1]<=1-bottom) self.assertTrue(inside.any(),part['name']+' '+side) for material in library['materials'].values(): - for channel in ['diffuse','normal','rough']: - if material[channel]: self.assertTrue((assets/material[channel]).is_file()) + for channel in ['diffuse','normal','rough','opacity']: + if material.get(channel): self.assertTrue((assets/material[channel]).is_file()) + # V3 preserves the native hair opacity texture independently of diffuse. + hair = [m for m in library['materials'].values() if m.get('opacity')] + self.assertEqual(len(hair), 31) + for material in hair: + self.assertNotEqual(material['diffuse'], material['opacity']) + self.assertIn('/decoded/', material['diffuse']) + self.assertTrue(material['alpha']) + # Native moussed-up hair demonstrates why diffuse A cannot be coverage. + from PIL import Image + sample = library['materials']['00000c5603e38817'] + diffuse = np.asarray(Image.open(assets/sample['diffuse']).convert('RGBA')) + opacity = np.asarray(Image.open(assets/sample['opacity']).convert('RGBA')) + self.assertLess(np.mean(diffuse[:,:,3] >= 128), .01) + self.assertGreater(np.mean(opacity[:,:,0] >= 128), .4) for tattoo in library['tattoos'].values(): self.assertTrue((assets/tattoo['texture']).is_file()) diff --git a/tools/launch_customiser_test.bat b/tools/launch_customiser_test.bat index 9812edef..bb876996 100644 --- a/tools/launch_customiser_test.bat +++ b/tools/launch_customiser_test.bat @@ -1,26 +1,26 @@ @echo off setlocal cd /d "%~dp0" -if not exist "%~dp0skate3rust-customiser-v2.exe" ( - echo The V2 customiser executable is missing. +if not exist "%~dp0skate3rust-customiser-v3.exe" ( + echo The V3 customiser executable is missing. pause exit /b 1 ) -if not exist "%~dp0assets\private\customisation\library.json" ( +if not exist "%~dp0assets\private\customisation\library-v3.json" ( echo The prepared character library is missing. pause exit /b 1 ) -echo Skate 3 Character Customiser V2 +echo Skate 3 Character Customiser V3 echo Open Escape / Start, then Character customiser. echo Browse with arrows / D-pad. Change with left-right or the minus-plus buttons. echo Type to search an item list. Escape / B goes back. Done saves and resumes. -echo Diagnostic output: customiser-v2-session.log -"%~dp0skate3rust-customiser-v2.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-v2-session.log" 2>&1 +echo Diagnostic output: customiser-v3-session.log +"%~dp0skate3rust-customiser-v3.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-v3-session.log" 2>&1 set "skate_customiser_exit=%errorlevel%" if not "%skate_customiser_exit%"=="0" ( echo The test exited with code %skate_customiser_exit%. - echo See %~dp0customiser-v2-session.log + echo See %~dp0customiser-v3-session.log pause ) exit /b %skate_customiser_exit%