Fix native hair coverage and verify customiser gameplay effects

This commit is contained in:
chazmm committed 2026-09-08 17:31:30 +01:00
1 parent 6e686edb77
commit 61dbdfce8e
11 files changed
+438 -54

No files matched your search

+15 -9
View File
@@ -972,12 +972,19 @@ fn preferences(
state: Res<Customiser>,
mut physics: ResMut<crate::physics::GamePhysics>,
mut skater: ResMut<crate::physics::SkaterRuntime>,
) {
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,
+58 -21
View File
@@ -14,6 +14,10 @@ pub(crate) struct SkinStamp {
#[texture(100)]
#[sampler(101)]
pub texture: Option<Handle<Image>>,
/// cac_hair_defaultPS samples opacity.r on TEXCOORD1, independently of diffuse alpha.
#[texture(103)]
#[sampler(104)]
pub hair_opacity: Option<Handle<Image>>,
/// 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::<SkaterMaterial>::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();
}
}
}
+39 -6
View File
@@ -26,6 +26,8 @@ pub(crate) struct Material {
normal: Option<String>,
rough: Option<String>,
alpha: bool,
#[serde(default)]
opacity: Option<String>,
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<crate::config::Config>,
server: Res<AssetServer>,
) {
let library = std::fs::read(config.asset_root.join("private/customisation/library.json"))
.ok()
.and_then(|b| serde_json::from_slice::<Library>(&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::<Library>(&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()
};
}
}
@@ -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<f32>,
rectangle: vec4<f32>,
enabled: vec4<f32>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var hair_opacity: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var hair_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> 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<f32>(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<f32>,
#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
@@ -7,6 +7,8 @@ struct SkinStamp {
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var stamp_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var stamp_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var hair_opacity: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var hair_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> 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;
@@ -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);
}
+52 -2
View File
@@ -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);
}
+49 -5
View File
@@ -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 <private-worker.json>
python -m tools.asset_pipeline.customisation_profiles <private-customisation-directory>
```
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
@@ -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
@@ -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())
+7 -7
View File
@@ -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%