diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index 29e950f8..dbff60db 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -430,6 +430,9 @@ impl Parts { retail: material.extension.retail.clone(), retail_mask: material.extension.retail_mask.clone(), enabled: if source.opacity.is_some() {Vec4::Y} else {Vec4::ZERO}, ..default() }; + if extension.retail.tint.w > 0. { + extension.retail.tint = Vec4::from_array(material.base.base_color.to_linear().to_f32_array()); + } if let Some((tid,t)) = stamp["id"].as_str().and_then(|id| self.library.tattoos.get(id).map(|t|(id,t))) { let key=match stamp["side"].as_u64().unwrap_or(0) {1=>"StampUVConstraintQ4",2=>"StampUVConstraintQ1",3=>"StampUVConstraintQ2",_=>"StampUVConstraintQ3"}; let bounds:Vec=part.flag(key).split(',').filter_map(|n|n.parse().ok()).collect(); @@ -472,16 +475,16 @@ impl Parts { let opacity = m.opacity.as_ref().map(|p| load(p, true)); let retail_mask = m.lighting.as_ref().and_then(|l| l.specular.as_ref()).map(|p| load(p, true)); let retail = m.lighting.as_ref().filter(|l| l.params.len() == 9).map(|l| crate::retail_character::CharacterParams { - tint: Vec4::ONE, - options: Vec4::new(0., f32::from(retail_mask.is_some()), -1., f32::from(l.shader.starts_with("character.hair"))), + tint: Vec4::from_array(Color::srgb(m.tint[0],m.tint[1],m.tint[2]).to_linear().to_f32_array()), + options: Vec4::new(f32::from(normal.is_some()), f32::from(retail_mask.is_some()), -1., f32::from(l.shader.starts_with("character.hair"))), rows: std::array::from_fn(|i| Vec4::from_array(l.params[i])), ..default() }).unwrap_or_default(); 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, + base_color_texture: Some(diffuse.clone()), + normal_map_texture: normal.clone(), metallic_roughness_texture: rough, double_sided: m.opacity.is_some(), cull_mode: if m.opacity.is_some() { @@ -814,11 +817,21 @@ mod online_tests { parts.library.tattoos.insert("ink".into(),Tattoo{name:"ink".into(),texture:String::new(),bounds:[0.,0.,1.,1.]}); let mut images=Assets::::default();let image=images.add(Image::default());parts.tattoos.insert("ink".into(),image.clone()); let mut materials=Assets::::default(); - let handle=materials.add(SkaterMaterial{base:StandardMaterial::default(),extension:SkinStamp::default()}); + let handle=materials.add(SkaterMaterial{base:StandardMaterial{normal_map_texture:Some(image.clone()),base_color_texture:Some(image.clone()),..default()},extension:SkinStamp { + retail_mask:Some(image.clone()), + retail:crate::retail_character::CharacterParams{tint:Vec4::ONE,options:Vec4::new(1.,1.,-1.,0.),..default()},..default() + }}); parts.materials.insert("skin".into(),(handle.clone(),vec![])); let red=parts.profile_material("body","skin",&serde_json::json!({"skin_tint":[1,0,0],"tattoos":{"Arm":{"id":"ink","side":0}}}),&materials).unwrap(); let blue=parts.profile_material("body","skin",&serde_json::json!({"skin_tint":[0,0,1]}),&materials).unwrap(); assert_eq!(red.base.base_color,Color::srgb(1.,0.,0.));assert_eq!(blue.base.base_color,Color::srgb(0.,0.,1.)); + for material in [&red,&blue] { + assert_eq!(material.base.normal_map_texture,Some(image.clone())); + assert_eq!(material.base.base_color_texture,Some(image.clone())); + assert_eq!(material.extension.retail_mask,Some(image.clone())); + assert_eq!(material.extension.retail.options,Vec4::new(1.,1.,-1.,0.)); + assert_eq!(material.extension.retail.tint,Vec4::from_array(material.base.base_color.to_linear().to_f32_array())); + } assert_eq!(red.extension.texture,Some(image));assert!(blue.extension.texture.is_none()); assert_eq!(materials.get(&handle).unwrap().base.base_color,Color::WHITE); assert!(materials.get(&handle).unwrap().extension.texture.is_none()); diff --git a/crates/skate-game/src/customiser_stamp.wgsl b/crates/skate-game/src/customiser_stamp.wgsl index fa9b6038..45add9b9 100644 --- a/crates/skate-game/src/customiser_stamp.wgsl +++ b/crates/skate-game/src/customiser_stamp.wgsl @@ -23,7 +23,8 @@ struct SkinStamp { pbr_deferred_functions::deferred_output, } #else -#import skate_character_lighting::{CharacterParams, shade_character} +#import skate_character_lighting::{CharacterParams, shade_character, character_normal, character_albedo} +#import bevy_pbr::pbr_bindings @group(#{MATERIAL_BIND_GROUP}) @binding(105) var retail_mask: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(106) var retail_mask_sampler: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(107) var retail: CharacterParams; @@ -80,6 +81,17 @@ fn fragment( // generate a PbrInput struct from the StandardMaterial bindings var pbr_input = pbr_input_from_standard_material(in, is_front); +#ifndef PREPASS_PIPELINE + // Preserve authored alpha as specular data before opacity/tattoos/discard. + let retail_albedo=textureSample(pbr_bindings::base_color_texture,pbr_bindings::base_color_sampler,in.uv); + let retail_mapped=character_normal(in.world_position,in.world_normal,in.uv, + textureSample(pbr_bindings::normal_map_texture,pbr_bindings::normal_map_sampler,in.uv).xy); + let retail_n=select(normalize(in.world_normal),retail_mapped,retail.options.x>0.0); + if retail.light.w>0.0 && retail.tint.w>0.0 { + pbr_input.material.base_color=vec4(character_albedo(retail_albedo.rgb)*retail.tint.rgb,retail_albedo.a); + } +#endif + #ifdef VERTEX_UVS_B @@ -113,9 +125,9 @@ fn fragment( var out: FragmentOutput; if retail.light.w > 0.0 && retail.tint.w > 0.0 { let mask = textureSample(retail_mask, retail_mask_sampler, in.uv).r; - out.color = shade_character(retail, pbr_input.N, pbr_input.world_position, + out.color = shade_character(retail, retail_n, pbr_input.world_position, pbr_input.material.base_color.rgb, - select(pbr_input.material.base_color.a, mask, retail.options.y > 0.0), + select(retail_albedo.a, mask, retail.options.y > 0.0), pbr_input.material.base_color.a); } else if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { out.color = apply_pbr_lighting(pbr_input); diff --git a/crates/skate-game/src/retail_character.wgsl b/crates/skate-game/src/retail_character.wgsl index d9c99bfd..0e0abb92 100644 --- a/crates/skate-game/src/retail_character.wgsl +++ b/crates/skate-game/src/retail_character.wgsl @@ -1,5 +1,5 @@ #import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame} -#import skate_character_lighting::{CharacterParams, shade_character} +#import skate_character_lighting::{CharacterParams, shade_character, character_normal, character_albedo} @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: CharacterParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; @@ -11,24 +11,9 @@ @fragment fn fragment(i: VertexOutput) -> @location(0) vec4 { let albedo = textureSample(diffuse,diffuse_sampler,i.uv); - // GLTF diffuse is sampled as sRGB; recover retail's gamma-two decode. - let gamma = select(albedo.rgb*12.92, 1.055*pow(max(albedo.rgb,vec3(0.0)),vec3(1.0/2.4))-0.055, albedo.rgb>vec3(0.0031308)); - let d = gamma*gamma*p.tint.rgb; - let n = normalize(i.world_normal); - let rpos = i.world_position.xyz-frame::view.world_position; - let vd = -normalize(rpos); - let dp1=dpdx(rpos); let dp2=dpdy(rpos); - let du1=dpdx(i.uv); let du2=dpdy(i.uv); - var tt=cross(dp2,n)*du1.x+cross(n,dp1)*du2.x; - var bb=cross(dp2,n)*du1.y+cross(n,dp1)*du2.y; - tt *= -inverseSqrt(max(dot(tt,tt),1e-12)); - bb *= inverseSqrt(max(dot(bb,bb),1e-12)); - var vn=n; - if p.options.x>0.0 { - // Installed normal PNGs already unpack DXT5nm into RGB. - let nm=textureSample(normal_map,normal_sampler,i.uv).xy*2.0-1.0; - vn=normalize(nm.x*tt+nm.y*bb+n*sqrt(saturate(1.0-dot(nm,nm)))); - } + let d=character_albedo(albedo.rgb)*p.tint.rgb; + let mapped=character_normal(i.world_position,i.world_normal,i.uv,textureSample(normal_map,normal_sampler,i.uv).xy); + let vn=select(normalize(i.world_normal),mapped,p.options.x>0.0); var smask=albedo.a; if p.options.y>0.0 { smask=textureSample(mask_map,mask_sampler,i.uv).r; } return shade_character(p, vn, i.world_position, d, smask, albedo.a); diff --git a/crates/skate-game/src/retail_character_common.wgsl b/crates/skate-game/src/retail_character_common.wgsl index af2f493c..31158dcc 100644 --- a/crates/skate-game/src/retail_character_common.wgsl +++ b/crates/skate-game/src/retail_character_common.wgsl @@ -6,6 +6,26 @@ struct CharacterParams { rows: array,9>, sh: array,9>, } +// Both original and modular stock meshes use the authored derivative basis. +// Modular GLBs intentionally omit tangents, so StandardMaterial's tangent-only +// normal mapping cannot supply this detail after an outfit/model swap. +fn character_normal(position: vec4, normal: vec3, uv: vec2, sample: vec2) -> vec3 { + let n=normalize(normal); + let rpos=position.xyz-frame::view.world_position; + let dp1=dpdx(rpos); let dp2=dpdy(rpos); + let du1=dpdx(uv); let du2=dpdy(uv); + var tt=cross(dp2,n)*du1.x+cross(n,dp1)*du2.x; + var bb=cross(dp2,n)*du1.y+cross(n,dp1)*du2.y; + tt *= -inverseSqrt(max(dot(tt,tt),1e-12)); + bb *= inverseSqrt(max(dot(bb,bb),1e-12)); + let nm=sample*2.0-1.0; + return normalize(nm.x*tt+nm.y*bb+n*sqrt(saturate(1.0-dot(nm,nm)))); +} +fn character_albedo(linear: vec3) -> vec3 { + let gamma=select(linear*12.92,1.055*pow(max(linear,vec3(0.0)),vec3(1.0/2.4))-0.055,linear>vec3(0.0031308)); + return gamma*gamma; +} + fn shade_character(p: CharacterParams, vn: vec3, position: vec4, d: vec3, smask: f32, alpha: f32) -> vec4 { let vd = -normalize(position.xyz-frame::view.world_position); if p.options.w>0.0 { diff --git a/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index 0cd22c69..cafc253e 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -181,3 +181,22 @@ launcher records stdout/stderr, backtraces and the exit code in See [visual-parity-status.md](visual-parity-status.md) for the subsequent SH transition smoothing, second-UV blended hair coverage, GPU exposure meter and remaining shadow/CAC fidelity gaps. Earlier implementation sections are historical. + +## Stock surface restoration, September 9 + +Returning from an imported/native model reapplies the modular stock outfit. Its +geometry-only GLBs have normal maps in the material library but no tangent +attributes, so the StandardMaterial input path silently omitted their detail. +The customiser shader also used alpha after `alpha_discard` as the clothing +specular mask; opaque coverage had already replaced that authored mask with 1. + +Both original and modular retail shaders now share the derivative normal basis +and gamma-two albedo decode. Modular materials retain the source diffuse alpha +for highlights, independently of opacity and tattoos, and use the dedicated skin +specular mask where authored. Profile updates retain texture handles and apply +colour tint separately. This shared material path covers local and remote stock +outfits without changing imported PBR materials or globally lowering gloss. + +Fourteen focused tests pass with the prepared library, including offline shader +compilation, stock restoration and per-profile material preservation. No game was +launched; visual verification of stock -> imported/pro -> stock remains manual.