From 96adff5d9635051983808a276ea90df2fef34302 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 18:30:31 +0100 Subject: [PATCH] Extend retail environment shading and refresh texture workflow --- crates/skate-game/src/retail_backdrop.rs | 2 +- crates/skate-game/src/retail_character.rs | 21 +- crates/skate-game/src/retail_character.wgsl | 9 +- .../src/retail_character_depth.wgsl | 6 + crates/skate-game/src/retail_depth.wgsl | 2 +- crates/skate-game/src/retail_exposure.rs | 268 ++++++++++++++++++ crates/skate-game/src/retail_exposure.wgsl | 30 ++ crates/skate-game/src/retail_render.rs | 68 +++-- crates/skate-game/src/retail_shadow.rs | 49 +++- crates/skate-game/src/retail_sky.rs | 19 ++ crates/skate-game/src/retail_tone.wgsl | 4 +- crates/skate-game/src/retail_world.wgsl | 87 +++++- crates/skate-game/src/skate_world.rs | 9 +- crates/skate-game/src/world.rs | 2 +- docs/character-lighting-investigation.md | 7 + docs/retail-renderer.md | 44 +-- docs/visual-parity-status.md | 124 ++++++++ tools/asset_pipeline/backdrop.py | 2 +- tools/asset_pipeline/character_glb.py | 5 + tools/asset_pipeline/character_lighting.py | 4 + tools/asset_pipeline/environment.py | 20 +- tools/asset_pipeline/install.py | 2 + tools/asset_pipeline/ocean_pca.py | 41 +++ tools/asset_pipeline/refresh_textures.py | 105 +++++++ tools/asset_pipeline/render_parameters.py | 29 ++ tools/asset_pipeline/sky.py | 3 +- tools/asset_pipeline/test_character_uv.py | 31 ++ tools/asset_pipeline/test_environment.py | 18 +- tools/asset_pipeline/test_refresh_textures.py | 35 +++ tools/vendor/skate3_anim/PROVENANCE.md | 6 + tools/vendor/skate3_anim/rx2_skeleton.py | 7 +- 31 files changed, 969 insertions(+), 90 deletions(-) create mode 100644 crates/skate-game/src/retail_exposure.rs create mode 100644 crates/skate-game/src/retail_exposure.wgsl create mode 100644 docs/visual-parity-status.md create mode 100644 tools/asset_pipeline/ocean_pca.py create mode 100644 tools/asset_pipeline/refresh_textures.py create mode 100644 tools/asset_pipeline/render_parameters.py create mode 100644 tools/asset_pipeline/test_character_uv.py create mode 100644 tools/asset_pipeline/test_refresh_textures.py diff --git a/crates/skate-game/src/retail_backdrop.rs b/crates/skate-game/src/retail_backdrop.rs index 598930a3..9f597770 100644 --- a/crates/skate-game/src/retail_backdrop.rs +++ b/crates/skate-game/src/retail_backdrop.rs @@ -32,5 +32,5 @@ pub(crate) fn spawn_backdrop( return; } info!("SKATE_BACKDROP: {name} authored foliage triangles={}", map.geometry.indices.len() / 3); - crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images); + crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images, &super::MaterialTuning::load(asset_root)); } diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 2b1e3b34..49be936c 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -26,6 +26,7 @@ struct MaterialData { shader: String, params: Vec<[f32; 4]>, specular: Option, + coverage: Option, } #[derive(Deserialize)] struct LightingData { @@ -37,6 +38,7 @@ struct Lighting { data: LightingData, probes: Irradiance, light: Vec4, + display_sh: Option<[Vec4; 9]>, } #[derive(Clone, ShaderType)] struct CharacterParams { @@ -60,6 +62,9 @@ struct CharacterMaterial { #[texture(5)] #[sampler(6)] mask: Option>, + #[texture(7)] + #[sampler(8)] + coverage: Option>, alpha: AlphaMode, } impl Material for CharacterMaterial { @@ -141,6 +146,7 @@ fn load(mut commands: Commands, config: Res) { data, probes, light, + display_sh: None, }); } fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { @@ -218,8 +224,8 @@ fn bind( options: Vec4::new( f32::from(m.normal_map_texture.is_some()), f32::from(data.specular.is_some()), - alpha_cutoff, - f32::from(data.shader == "character.hair"), + if data.coverage.is_some() { -1. } else { alpha_cutoff }, + if data.coverage.is_some() { 2. } else { f32::from(data.shader == "character.hair") }, ), rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])), sh: lighting @@ -237,7 +243,9 @@ fn bind( }, ) }), - alpha: m.alpha_mode, + coverage: data.coverage.as_ref().map(|path| server.load_with_settings( + path.clone(), |settings: &mut bevy::image::ImageLoaderSettings| { settings.is_srgb = false; })), + alpha: if data.coverage.is_some() { AlphaMode::Blend } else { m.alpha_mode }, }); commands .entity(entity) @@ -278,9 +286,14 @@ fn update( .default_sh .map(|v| Vec3::from_array(v).extend(0.)); let sh = lighting.probes.sample(root.translation, fallback); + let displayed = lighting.display_sh.map_or(sh, |old| { + let weight = 1. - (-time.delta_secs().clamp(0., 0.05) / 0.35).exp(); + std::array::from_fn(|i| old[i].lerp(sh[i], weight)) + }); for (_, material) in materials.iter_mut() { - material.params.sh = sh; + material.params.sh = displayed; } + lighting.display_sh = Some(displayed); // Adapter floor: the local probe's direction-independent ambient term. // The native per-frame c8 shadow-colour controller remains unrecovered. shadow.approach(sh[0].truncate(), time.delta_secs()); diff --git a/crates/skate-game/src/retail_character.wgsl b/crates/skate-game/src/retail_character.wgsl index f6de4d00..a4a3fdd7 100644 --- a/crates/skate-game/src/retail_character.wgsl +++ b/crates/skate-game/src/retail_character.wgsl @@ -12,6 +12,8 @@ struct CharacterParams { @group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(7) var coverage: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(8) var coverage_sampler: sampler; @fragment fn fragment(i: VertexOutput) -> @location(0) vec4 { let albedo = textureSample(diffuse,diffuse_sampler,i.uv); @@ -29,7 +31,12 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { let hl=p.rows[1].rgb*(saturate(ndl*0.75+0.25)+p.rows[5].w*0.25) +p.rows[6].rgb*fres*saturate(ndl*1.75+0.25); if albedo.a(d*hl*p.light.w,albedo.a*p.tint.a); + var strand_alpha=albedo.a; +#ifdef VERTEX_UVS_B + // cac_hair: raw second UV, red coverage, m_params[6].x scale. + if p.options.w>1.5 { strand_alpha=saturate(textureSample(coverage,coverage_sampler,i.uv_b).r*p.rows[6].x); } +#endif + return vec4(d*hl*p.light.w,strand_alpha*p.tint.a); } let dp1=dpdx(rpos); let dp2=dpdy(rpos); let du1=dpdx(i.uv); let du2=dpdy(i.uv); diff --git a/crates/skate-game/src/retail_character_depth.wgsl b/crates/skate-game/src/retail_character_depth.wgsl index b1ea6e8e..634abb97 100644 --- a/crates/skate-game/src/retail_character_depth.wgsl +++ b/crates/skate-game/src/retail_character_depth.wgsl @@ -9,6 +9,8 @@ struct CharacterParams { light: vec4, tint: vec4, options: vec4, @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; +@group(#{MATERIAL_BIND_GROUP}) @binding(7) var coverage: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(8) var coverage_sampler: sampler; @fragment fn fragment(i: VertexOutput) #ifdef PREPASS_FRAGMENT @@ -16,6 +18,10 @@ fn fragment(i: VertexOutput) #endif { if textureSample(diffuse,diffuse_sampler,i.uv).a < p.options.z { discard; } +#ifdef VERTEX_UVS_B + // Retain strand holes in depth-only passes. Blended colour uses continuous coverage. + if p.options.w>1.5 && textureSample(coverage,coverage_sampler,i.uv_b).r*p.rows[6].x < 30.0/255.0 { discard; } +#endif #ifdef PREPASS_FRAGMENT var out: FragmentOutput; #ifdef NORMAL_PREPASS diff --git a/crates/skate-game/src/retail_depth.wgsl b/crates/skate-game/src/retail_depth.wgsl index 41aedc41..28417943 100644 --- a/crates/skate-game/src/retail_depth.wgsl +++ b/crates/skate-game/src/retail_depth.wgsl @@ -5,7 +5,7 @@ #ifdef MOTION_VECTOR_PREPASS #import bevy_pbr::pbr_prepass_functions::calculate_motion_vector #endif -struct WorldParams { mode: vec4, foliage_debug: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4 } +struct WorldParams { mode: vec4, foliage_debug: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4, water: array, 4> } @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; diff --git a/crates/skate-game/src/retail_exposure.rs b/crates/skate-game/src/retail_exposure.rs new file mode 100644 index 00000000..fac3fcf0 --- /dev/null +++ b/crates/skate-game/src/retail_exposure.rs @@ -0,0 +1,268 @@ +//! GPU-only exposure adaptation. Native evaluator, portable luminance sampling. +use bevy::{ + asset::embedded_asset, + core_pipeline::{ + FullscreenShader, + core_3d::graph::{Core3d, Node3d}, + }, + ecs::query::QueryItem, + prelude::*, + render::{ + Render, RenderApp, RenderStartup, RenderSystems, + extract_component::ExtractComponentPlugin, + extract_resource::{ExtractResource, ExtractResourcePlugin}, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::{RenderContext, RenderDevice, RenderQueue}, + view::ViewTarget, + }, +}; + +#[derive(Resource, Clone, ExtractResource)] +struct Settings { + tuning: Vec4, + timing: Vec4, +} +impl Default for Settings { + fn default() -> Self { + // Missing metadata retains the existing fixed exposure. + Self { + tuning: Vec4::new(0., 2.5, 2.5, 0.), + timing: Vec4::ZERO, + } + } +} +#[derive(serde::Deserialize)] +struct Authored { + target_luminance: f32, + min: f32, + max: f32, + damping: f32, +} + +pub(super) fn install(app: &mut App) { + embedded_asset!(app, "retail_exposure.wgsl"); + app.init_resource::() + .add_plugins(( + ExtractResourcePlugin::::default(), + ExtractComponentPlugin::::default(), + )) + .add_systems(Startup, load) + .add_systems(Update, advance); + if let Some(render) = app.get_sub_app_mut(RenderApp) { + render + .add_systems(RenderStartup, initialize) + .add_systems(Render, upload.in_set(RenderSystems::PrepareResources)) + .add_render_graph_node::>(Core3d, ExposureLabel) + .add_render_graph_edges( + Core3d, + ( + Node3d::Tonemapping, + ExposureLabel, + Node3d::EndMainPassPostProcessing, + ), + ); + } +} + +fn load(config: Res, mut settings: ResMut) { + if std::env::var_os("SKATE_FIXED_EXPOSURE").is_some_and(|v| v == "1") { + info!("RETAIL_EXPOSURE: fixed 2.5 comparison mode"); + return; + } + let data = std::fs::read(config.asset_root.join("private/exposure.json")); + if let Some(a) = data + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + { + if [a.target_luminance, a.min, a.max, a.damping] + .iter() + .all(|x| x.is_finite()) + && a.min > 0. + && a.max >= a.min + && a.target_luminance > 0. + && a.damping >= 0. + { + settings.tuning = Vec4::new(a.target_luminance, a.min, a.max, a.damping); + info!( + "RETAIL_EXPOSURE: authored target={} range={}..{} damping={}; portable GPU meter", + a.target_luminance, a.min, a.max, a.damping + ); + } + } +} +fn advance(time: Res