Restore authored stock surface detail and specular masks after model swaps

This commit is contained in:
chazmm committed 2026-09-09 17:56:21 +01:00
1 parent e6a1dfe79c
commit 983771f99b
5 files changed
+76 -27

No files matched your search

+18 -5
View File
@@ -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<f32>=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::<Image>::default();let image=images.add(Image::default());parts.tattoos.insert("ink".into(),image.clone());
let mut materials=Assets::<SkaterMaterial>::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());
+15 -3
View File
@@ -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<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var retail_mask_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(107) var<uniform> 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<f32>(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);
+4 -19
View File
@@ -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<uniform> p: CharacterParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
@@ -11,24 +11,9 @@
@fragment
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
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<f32>(0.0)),vec3<f32>(1.0/2.4))-0.055, albedo.rgb>vec3<f32>(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);
@@ -6,6 +6,26 @@ struct CharacterParams {
rows: array<vec4<f32>,9>, sh: array<vec4<f32>,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<f32>, normal: vec3<f32>, uv: vec2<f32>, sample: vec2<f32>) -> vec3<f32> {
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<f32>) -> vec3<f32> {
let gamma=select(linear*12.92,1.055*pow(max(linear,vec3<f32>(0.0)),vec3<f32>(1.0/2.4))-0.055,linear>vec3<f32>(0.0031308));
return gamma*gamma;
}
fn shade_character(p: CharacterParams, vn: vec3<f32>, position: vec4<f32>, d: vec3<f32>, smask: f32, alpha: f32) -> vec4<f32> {
let vd = -normalize(position.xyz-frame::view.world_position);
if p.options.w>0.0 {
+19
View File
@@ -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.