From 7d44d84f19081f8de7ae9cbaf574a3207f5865e3 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:41:23 +0100 Subject: [PATCH] Add spatial irradiance and character lighting adapter --- crates/skate-game/src/graphics_menu.rs | 1 + crates/skate-game/src/main.rs | 2 + crates/skate-game/src/retail_character.rs | 237 ++++++++++++++++++ crates/skate-game/src/retail_character.wgsl | 72 ++++++ .../src/retail_character_depth.wgsl | 32 +++ crates/skate-game/src/retail_irradiance.rs | 163 ++++++++++++ crates/skate-game/src/retail_render.rs | 1 + crates/skate-game/src/skate_world.rs | 2 + docs/character-lighting-investigation.md | 68 ++++- docs/retail-renderer.md | 9 +- tools/asset_pipeline/character_lighting.py | 55 ++++ tools/asset_pipeline/install.py | 2 + tools/asset_pipeline/irradiance.py | 51 ++++ tools/asset_pipeline/map_writer.py | 3 + tools/asset_pipeline/test_irradiance.py | 37 +++ 15 files changed, 730 insertions(+), 5 deletions(-) create mode 100644 crates/skate-game/src/retail_character.rs create mode 100644 crates/skate-game/src/retail_character.wgsl create mode 100644 crates/skate-game/src/retail_character_depth.wgsl create mode 100644 crates/skate-game/src/retail_irradiance.rs create mode 100644 tools/asset_pipeline/character_lighting.py create mode 100644 tools/asset_pipeline/irradiance.py create mode 100644 tools/asset_pipeline/test_irradiance.py diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index a3d77f99..158b54d9 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -462,6 +462,7 @@ mod tests { .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), + maps: Vec::new(), selected_map: 0, }) .add_systems(Update, apply); app.world_mut().spawn((Window::default(), PrimaryWindow)); diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 8d93deda..8345f00f 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -17,6 +17,8 @@ mod performance; mod graphics_menu; mod render_capacity; mod retail_render; +mod retail_character; +mod retail_irradiance; mod presentation; mod replay; mod world; diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs new file mode 100644 index 00000000..b6fefdaf --- /dev/null +++ b/crates/skate-game/src/retail_character.rs @@ -0,0 +1,237 @@ +//! Character SH/key/rim shader, independent of the world's additive lights. +use crate::retail_irradiance::Irradiance; +use bevy::{ + asset::embedded_asset, + gltf::GltfMaterialName, + prelude::*, + render::render_resource::{AsBindGroup, ShaderType}, + shader::ShaderRef, +}; +use serde::Deserialize; +use std::collections::HashMap; + +pub(crate) struct CharacterLightingPlugin; +impl Plugin for CharacterLightingPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "retail_character.wgsl"); + embedded_asset!(app, "retail_character_depth.wgsl"); + app.add_plugins(MaterialPlugin::::default()) + .add_systems(Startup, load) + .add_systems(Update, (bind, update).chain()); + } +} +#[derive(Deserialize)] +struct MaterialData { + shader: String, + params: Vec<[f32; 4]>, + specular: Option, +} +#[derive(Deserialize)] +struct LightingData { + materials: HashMap, + default_sh: [[f32; 3]; 9], +} +#[derive(Resource)] +struct Lighting { + data: LightingData, + probes: Irradiance, + light: Vec4, +} +#[derive(Clone, ShaderType)] +struct CharacterParams { + light: Vec4, + tint: Vec4, + // normal map present, dedicated specular mask, alpha cutoff, hair family + options: Vec4, + rows: [Vec4; 9], + sh: [Vec4; 9], +} +#[derive(Asset, TypePath, AsBindGroup, Clone)] +struct CharacterMaterial { + #[uniform(0)] + params: CharacterParams, + #[texture(1)] + #[sampler(2)] + diffuse: Option>, + #[texture(3)] + #[sampler(4)] + normal: Option>, + #[texture(5)] + #[sampler(6)] + mask: Option>, + alpha: AlphaMode, +} +impl Material for CharacterMaterial { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/retail_character.wgsl".into() + } + fn prepass_fragment_shader() -> ShaderRef { + "embedded://skate3rust/retail_character_depth.wgsl".into() + } + fn alpha_mode(&self) -> AlphaMode { + self.alpha + } +} +fn load(mut commands: Commands, config: Res) { + let Some(map_path) = &config.map_path else { + return; + }; + let path = config.asset_root.join("private/character-lighting.json"); + let data = match std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) + { + Ok(data) => data, + Err(e) => { + warn!("SKATE_CHARACTER_LIGHTING: {e}"); + return; + } + }; + let name = map_path.file_stem().unwrap_or_default().to_string_lossy(); + let overlay = config + .asset_root + .join("private/native-lighting") + .join(format!("{name}.irradiance")); + let probe_path = if overlay.is_file() { + overlay + } else { + map_path.with_extension("irradiance") + }; + let probes = match std::fs::read(probe_path) + .map_err(|e| e.to_string()) + .and_then(|b| Irradiance::parse(&b)) + { + Ok(probes) => probes, + Err(e) => { + warn!("SKATE_CHARACTER_LIGHTING: spatial data unavailable: {e}"); + return; + } + }; + let sky_path = config + .asset_root + .join("private/native-skies") + .join(format!("{name}.json")); + let sky: serde_json::Value = match std::fs::read(sky_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + { + Some(sky) => sky, + None => { + warn!("SKATE_CHARACTER_LIGHTING: missing authored light direction"); + return; + } + }; + let Some(sun) = sky["environment"]["sun_direction"].as_array() else { + return; + }; + if sun.len() != 3 { + return; + } + let mut light = Vec4::new(0., 0., 0., 2.5); // Existing retail scene exposure. + for i in 0..3 { + let Some(v) = sun[i].as_f64() else { + return; + }; + light[i] = v as f32; + } + info!("SKATE_CHARACTER_LIGHTING: loaded authored {name} irradiance and character parameters"); + // Shadow visibility only. World shaders do not sample this map; zero lux + // also prevents additive energy on any remaining StandardMaterials. + commands.spawn(( + Name::new("Character shadow visibility"), + DirectionalLight { + illuminance: 0., + shadows_enabled: true, + ..default() + }, + Transform::default().looking_to(-light.truncate(), Vec3::Y), + bevy::light::CascadeShadowConfigBuilder { + maximum_distance: 100., + first_cascade_far_bound: 10., + ..default() + } + .build(), + )); + commands.insert_resource(Lighting { + data, + probes, + light, + }); +} +fn bind( + mut commands: Commands, + lighting: Option>, + source: Res>, + server: Res, + mut materials: ResMut>, + entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d)>, +) { + let Some(lighting) = lighting else { + return; + }; + for (entity, name, handle) in &entities { + let Some(data) = lighting.data.materials.get(&name.0) else { + continue; + }; + if data.params.len() != 9 { + continue; + } + let Some(m) = source.get(&handle.0) else { + continue; + }; + let alpha_cutoff = if let AlphaMode::Mask(cutoff) = m.alpha_mode { + cutoff + } else { + -1. + }; + let material = materials.add(CharacterMaterial { + params: CharacterParams { + light: lighting.light, + tint: Vec4::from_array(m.base_color.to_linear().to_f32_array()), + 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"), + ), + rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])), + sh: lighting + .data + .default_sh + .map(|v| Vec3::from_array(v).extend(0.)), + }, + diffuse: m.base_color_texture.clone(), + normal: m.normal_map_texture.clone(), + mask: data.specular.as_ref().map(|path| { + server.load_with_settings( + path.clone(), + |settings: &mut bevy::image::ImageLoaderSettings| { + settings.is_srgb = false; + }, + ) + }), + alpha: m.alpha_mode, + }); + commands + .entity(entity) + .remove::>() + .insert(MeshMaterial3d(material)); + } +} +fn update( + lighting: Option>, + root: Query<&Transform, With>, + mut materials: ResMut>, +) { + let (Some(mut lighting), Ok(root)) = (lighting, root.single()) else { + return; + }; + let fallback = lighting + .data + .default_sh + .map(|v| Vec3::from_array(v).extend(0.)); + let sh = lighting.probes.sample(root.translation, fallback); + for (_, material) in materials.iter_mut() { + material.params.sh = sh; + } +} diff --git a/crates/skate-game/src/retail_character.wgsl b/crates/skate-game/src/retail_character.wgsl new file mode 100644 index 00000000..5184f19c --- /dev/null +++ b/crates/skate-game/src/retail_character.wgsl @@ -0,0 +1,72 @@ +#import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame} +#import bevy_pbr::shadows::fetch_directional_shadow +struct CharacterParams { + light: vec4, tint: vec4, options: vec4, + rows: array,9>, sh: array,9>, +} +@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(3) var normal_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(4) var normal_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler; + +@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); + if p.options.w>0.0 { + // Native hair response. The installed GLB supplies the selected dye + // and composited strand coverage; live CAC colour rows are not present. + let ndl=saturate(dot(n,p.light.xyz)); + let fres=pow(1.0-saturate(dot(n,vd)),max(p.rows[2].w,1.0)); + 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); + } + 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)))); + } + var smask=albedo.a; + if p.options.y>0.0 { smask=textureSample(mask_map,mask_sampler,i.uv).r; } + let s=p.rows[7].y; + let v=vn*s; + let irr=saturate(p.sh[0].rgb+v.x*p.sh[1].rgb+v.y*p.sh[2].rgb+v.z*p.sh[3].rgb + +v.x*v.z*p.sh[4].rgb+v.z*v.y*p.sh[5].rgb+v.y*v.x*p.sh[6].rgb + +(3.0*v.z*v.z-1.0)*p.sh[7].rgb+(v.x*v.x-v.y*v.y)*p.sh[8].rgb); + let ndl=dot(vn,p.light.xyz); + let view_z=(frame::view.view_from_world*i.world_position).z; + var shadow=1.0; + if frame::lights.n_directional_lights>0u { + shadow=fetch_directional_shadow(0u,i.world_position,vn,view_z); + } + var lit=p.rows[1].rgb*saturate(ndl)*shadow+irr*p.rows[5].w; + let fb=1.0-saturate(dot(vn,vd)); + let kfres=pow(max(fb,1e-6),p.rows[1].w); + let rfres=pow(max(fb,1e-6),p.rows[0].z); + let rd=normalize(p.light.xyz*vec3(-1.,0.2,-1.)-vd); + lit+=saturate(rfres*saturate(dot(vn,rd)))*p.rows[6].rgb; + let kr=p.light.xyz-2.0*ndl*vn; + let ks=pow(saturate(dot(vd,-kr)),p.rows[2].w)*select(0.0,1.0,ndl>=0.0)*shadow; + let rr=rd-2.0*dot(vn,rd)*vn; + let rs=pow(saturate(dot(vd,-rr)),p.rows[3].w)*rfres; + let spec=saturate((ks*p.rows[2].rgb*kfres+rs*p.rows[3].rgb)*smask*smask); + if albedo.a((d*lit+spec)*p.rows[0].y*p.light.w,albedo.a*p.tint.a); +} diff --git a/crates/skate-game/src/retail_character_depth.wgsl b/crates/skate-game/src/retail_character_depth.wgsl new file mode 100644 index 00000000..b1ea6e8e --- /dev/null +++ b/crates/skate-game/src/retail_character_depth.wgsl @@ -0,0 +1,32 @@ +#import bevy_pbr::prepass_io::VertexOutput +#ifdef PREPASS_FRAGMENT +#import bevy_pbr::prepass_io::FragmentOutput +#endif +#ifdef MOTION_VECTOR_PREPASS +#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector +#endif +struct CharacterParams { light: vec4, tint: vec4, options: vec4, rows: array,9>, sh: array,9> } +@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; +@fragment +fn fragment(i: VertexOutput) +#ifdef PREPASS_FRAGMENT + -> FragmentOutput +#endif +{ + if textureSample(diffuse,diffuse_sampler,i.uv).a < p.options.z { discard; } +#ifdef PREPASS_FRAGMENT + var out: FragmentOutput; +#ifdef NORMAL_PREPASS + out.normal = vec4(normalize(i.world_normal)*0.5+0.5,1.0); +#endif +#ifdef MOTION_VECTOR_PREPASS + out.motion_vector = calculate_motion_vector(i.world_position,i.previous_world_position); +#endif +#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION + out.frag_depth = i.unclipped_depth; +#endif + return out; +#endif +} diff --git a/crates/skate-game/src/retail_irradiance.rs b/crates/skate-game/src/retail_irradiance.rs new file mode 100644 index 00000000..5c49c693 --- /dev/null +++ b/crates/skate-game/src/retail_irradiance.rs @@ -0,0 +1,163 @@ +//! Spatial SH samples from RX2 0xEB0024. TU3 827E6AC8 selects a nearest sample. +use bevy::prelude::*; + +#[derive(Clone)] +pub(crate) struct Probe { + pub position: Vec3, + pub sh: [Vec4; 9], +} +pub(crate) struct ProbeGroup { + min: Vec3, + max: Vec3, + probes: Vec, +} +#[derive(Default)] +pub(crate) struct Irradiance { + groups: Vec, + recent: Vec, +} +impl Irradiance { + pub fn parse(bytes: &[u8]) -> Result { + if bytes.get(..4) != Some(b"SHP1") { + return Err("Invalid irradiance header".into()); + } + let mut at = 4; + fn word(bytes: &[u8], at: &mut usize) -> Result { + let raw = bytes.get(*at..*at + 4).ok_or("Truncated irradiance")?; + *at += 4; + Ok(u32::from_le_bytes(raw.try_into().unwrap())) + } + let count = word(bytes, &mut at)?; + let mut groups = Vec::new(); + for _ in 0..count { + let n = word(bytes, &mut at)?; + let mut probes = Vec::new(); + let mut min = Vec3::splat(f32::INFINITY); + let mut max = Vec3::splat(f32::NEG_INFINITY); + for _ in 0..n { + let mut v = [0.; 39]; + for x in &mut v { + *x = f32::from_bits(word(bytes, &mut at)?); + } + word(bytes, &mut at)?; // Runtime linked-list word, not lighting. + if !v.iter().all(|v| v.is_finite()) { + return Err("Non-finite irradiance".into()); + } + let position = Vec3::from_slice(&v[36..39]); + min = min.min(position); + max = max.max(position); + probes.push(Probe { + position, + sh: std::array::from_fn(|i| Vec4::from_slice(&v[i * 4..i * 4 + 4])), + }); + } + if !probes.is_empty() { + groups.push(ProbeGroup { min, max, probes }); + } + } + if at != bytes.len() { + return Err("Trailing irradiance bytes".into()); + } + Ok(Self { + groups, + recent: Vec::new(), + }) + } + pub fn sample(&mut self, position: Vec3, fallback: [Vec4; 9]) -> [Vec4; 9] { + // Native six-entry cache is accepted inside 2.5 metres of a sample. + if let Some((i, _)) = self + .recent + .iter() + .enumerate() + .map(|(i, p)| (i, p.position.distance_squared(position))) + .filter(|(_, d)| *d < 6.25) + .min_by(|a, b| a.1.total_cmp(&b.1)) + { + let probe = self.recent.remove(i); + let sh = probe.sh; + self.recent.insert(0, probe); + return sh; + } + let mut best = None; + let mut distance = f32::MAX; + for group in &self.groups { + if position.cmplt(group.min - Vec3::splat(15.)).any() + || position.cmpgt(group.max + Vec3::splat(15.)).any() + { + continue; + } + for p in &group.probes { + let delta = (p.position - position).abs(); + if delta.x > 15. || delta.z > 15. { + continue; + } + let d = p.position.distance_squared(position); + if d < distance { + distance = d; + best = Some(p.clone()); + } + } + } + match best { + Some(probe) => { + let sh = probe.sh; + self.recent.insert(0, probe); + self.recent.truncate(6); + sh + } + None => fallback, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn spatial_selection_and_outside_fallback() { + let mut b = b"SHP1".to_vec(); + b.extend(1u32.to_le_bytes()); + b.extend(2u32.to_le_bytes()); + for (x, colour) in [(0f32, 0.2f32), (10., 0.8)] { + let mut v = [0f32; 40]; + v[0] = colour; + v[36] = x; + for f in v { + b.extend(f.to_le_bytes()); + } + } + let mut data = Irradiance::parse(&b).unwrap(); + assert_eq!(data.sample(Vec3::ZERO, [Vec4::ZERO; 9])[0].x, 0.2); + assert_eq!(data.sample(Vec3::X * 9., [Vec4::ZERO; 9])[0].x, 0.8); + assert_eq!(data.sample(Vec3::X * 100., [Vec4::ONE; 9]), [Vec4::ONE; 9]); + assert!(Irradiance::parse(&b[..b.len() - 1]).is_err()); + } + #[test] + fn cache_retains_native_two_point_five_metre_hysteresis() { + let a = Probe { + position: Vec3::ZERO, + sh: [Vec4::ZERO; 9], + }; + let b = Probe { + position: Vec3::X * 3., + sh: [Vec4::ONE; 9], + }; + let mut data = Irradiance { + groups: vec![ProbeGroup { + min: Vec3::ZERO, + max: b.position, + probes: vec![a.clone(), b], + }], + recent: vec![a], + }; + // B is closer, but A remains selected within the native cache radius. + assert_eq!( + data.sample(Vec3::X * 2., [Vec4::splat(2.); 9]), + [Vec4::ZERO; 9] + ); + assert_eq!( + data.sample(Vec3::X * 2.5, [Vec4::splat(2.); 9]), + [Vec4::ONE; 9] + ); + } +} diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index f6e9b922..38f583a0 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -18,6 +18,7 @@ use std::collections::BTreeMap; pub(crate) struct RetailRenderPlugin; impl Plugin for RetailRenderPlugin { fn build(&self, app: &mut App) { + app.add_plugins(crate::retail_character::CharacterLightingPlugin); if std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1") { eprintln!("SKATE_FOLIAGE_DEBUG: solid cyan tree-wall cards, magenta other foliage; alpha rejection disabled for foliage only"); } diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index ee1903d2..b4d98cb7 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -853,12 +853,14 @@ mod tests { let mut queue = bevy::ecs::world::CommandQueue::default(); let mut meshes = Assets::::default(); let mut materials = Assets::::default(); + let mut retail_materials = Assets::::default(); let mut images = Assets::::default(); spawn( &map, &mut Commands::new(&mut queue, &world), &mut meshes, &mut materials, + &mut retail_materials, &mut images, ); queue.apply(&mut world); diff --git a/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index fcffbd14..a5572d7c 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -1,6 +1,6 @@ # Character lighting investigation -Status: native character-lighting reconstruction started; no runtime lighting change yet. +Status: position-dependent character lighting implemented; user-run visual validation pending. Observed in the local reference renderer (`skate3_native_scene.cpp`, CaptureCharLighting, and `scene_char.hlsli`): defaultcharacter/CAC shading consumes a directional key, @@ -24,7 +24,7 @@ Observed in TU3 generated PPC source: ambient, and arrays of cSimpleLightData. Do not assume these are the same as the spatial baked SH samples just because of the class name. -Implementation work still required: +Initial investigation checklist (superseded by the implementation status below): 1. Recover the spatial irradiance resource/registration and sampling path and resolve default probe selection from world/render state. 2. Extract character frame inputs and per-material parameters through native @@ -41,3 +41,67 @@ Implementation work still required: Reference caveat: community symbol labels are hypotheses. In particular the 82E3B548 label must not be treated as a verified prototype. No copied renderer capture's lighting constants should become a universal outdoor preset. + +## Implementation, 2026-09-08 + +`tools.asset_pipeline.irradiance` extracts RX2 type `0xEB0024` from the district's +other presentation assets. Its section starts with count and relative array +offset; each 160-byte record contains nine float4 SH rows, XYZ at byte 144 and a +runtime link word at 156. The `SHP1` sidecar preserves groups, values and original +world coordinates, converting only endianness. University contains 17,493 samples +in 64 resources. Future map conversions write `.irradiance` beside the map. + +TU3 `0x827E6E28` registers these resources into a 41-by-41 spatial grid; +`0x827E6AC8` selects the nearest sample in three dimensions from the XZ +neighborhood and falls back to the default coefficients. `0x827E7348` checks a +six-entry MRU cache with a strict squared-distance threshold of 6.25. Raw binary +constant `0x820BD16C` is 15.0 (query margin); `0x822F8F38` is 6.25. The evidence +is the generated PPC instructions corroborated with resource contents and the +TU3 dump with SHA-256 +`F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4`. +Addresses use the dump's `0x82000000` base. + +`retail_irradiance.rs` retains the native nearest-sample/cache behavior. Its +static-world adaptation searches all loaded groups using expanded bounds and an +XZ 15-metre neighborhood. It does not reproduce the original 12-resource +streaming limit or grid-cell boundary quantization. The freeskate collection +supplies the nine fallback rows; live changes of the manager's default mode are +not implemented. This is not trilinear probe interpolation. + +`tools.asset_pipeline.character_lighting` resolves each selected model's +AttribulatorMaterialName through VLT inheritance and exports its nine complete +material parameter rows. Skin/face specular textures preserve the original red +channel separately; the GLB's grayscale inverse-roughness conversion is not used +as the native specular mask. Source assets remain private. + +`retail_character.rs` replaces the ten named retail GLB materials while retaining +Bevy's skinned vertex path. It updates the SH rows from PlayerRoot's position. +The fragment shader evaluates the native SH basis, authored directional key, +ambient multiplier, rim response, key/rim highlights, normal maps and material +multiplier. Diffuse sampling accounts for the GLB's sRGB texture format before +the retail gamma-two decode. Output remains linear HDR for the existing shared +tone pass. The separate depth shader retains alpha rejection. + +A zero-lux directional shadow source provides world/self occlusion to the +character's key and key specular terms. The baked world shader does not sample +this shadow map; remaining StandardMaterials receive zero additive sunlight. +This uses Bevy cascades/filtering/biases, not the console's exact cascade layout +and 9-tap shadow filter. It does not add a skater-shadow receiver to baked world +surfaces. No gameplay or physics code changes are involved. + +Hair now uses the native wrapped diffuse/ambient/fresnel response, with the +authored material colours and the existing GLB dye/coverage composition. The +live CAC colour-register derivation and separate second-UV blended strand pass +are not reconstructed; hair keeps the installed cutout mode. This is an explicit +adapter limitation, not a claim of exact hair parity. + +Scene exposure remains the existing 2.5 day-capture value shared with the world; +the regional exposure controller, water and fog are still unfinished. Missing +lighting sidecars leave the existing GLB material path active and log the reason. + +Validation: 13 synthetic Python extraction/inheritance/GUID tests, two spatial +selection/cache tests, and the windowless world-render adapter test pass. Character +colour, shadow and normal-depth shader variants pass offline Naga composition +against the vendored Bevy 0.18 shader modules. Release compilation passes. The +game/recomp was not launched. User visual checks should cover exposed terrain, +sheltered spots, skin/clothing highlights, hair coverage and body/board shadows. diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index 3a1c8255..cdeaa94e 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -99,8 +99,11 @@ the CPU transformation to native frame rows is not yet proven, so shader fog remains disabled. The selected `material_fog/default` must not be confused with the different `fog_default` collection. -Native water, character shaders, SSAO, SSR, bloom and volumetric lighting are -not yet ported. The water arrays are recoverable now, but flowing-water +Native water, SSAO, SSR, bloom and volumetric lighting are +not yet ported. Character lighting now has a dedicated adapter; see +[character-lighting-investigation.md](character-lighting-investigation.md) for +implemented terms, spatial data, validation and remaining parity differences. +The water arrays are recoverable now, but flowing-water normal/tangent unpacking, ocean PCA input bindings and the separate horizon water resource still need their own adapters. Character parity needs the native CAC-composed texture/mask inputs plus verified key/rim/specular and @@ -108,7 +111,7 @@ nine SH rows; portable GLB materials do not retain that contract. Reference post effects need their depth/normal/reflection inputs and native pass ordering; volumetric sun visibility also conflicts with the currently disabled world sun-shadow source. These gaps are not enabled using guessed settings. -The character and +Characters without the new lighting sidecars and unsupported world families still use Bevy materials beneath the shared tone curve. Reflection normals use the reference's analytic world-up construction; there has been no matched-camera pixel comparison against the recomp. diff --git a/tools/asset_pipeline/character_lighting.py b/tools/asset_pipeline/character_lighting.py new file mode 100644 index 00000000..9dd863ee --- /dev/null +++ b/tools/asset_pipeline/character_lighting.py @@ -0,0 +1,55 @@ +"""Export authored character shader parameters and default SH coefficients.""" +import json +import struct +import sys +from PIL import Image +from pathlib import Path +from .environment import Collections, key_hash + + +def convert(models, private, converted): + sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt')) + import mdl_parser + collections = Collections(converted) + result = {'materials': {}} + recipe = json.loads((Path(__file__).resolve().parents[1] / 'default_skater_retail_manifest.json').read_text()) + components = {c['slot']: c for c in recipe['components']} + for slot in sorted(models.iterdir()): + if not slot.is_dir(): + continue + files = list(slot.glob('*.rx2')) + if len(files) != 1: + raise ValueError('Ambiguous character lighting model: '+slot.name) + model = mdl_parser.parse_rx2(files[0].read_bytes()) + shaders = {p.value for p in model.materials if p.kind == 'AttribulatorMaterialName'} + if len(shaders) != 1: + raise ValueError('Ambiguous character shader: '+slot.name) + shader = shaders.pop() + family, key = shader.split('.', 1) + fields, _ = collections.resolve('material_'+family, key) + rows = fields[key_hash('m_params')]['array']['items'] + result['materials']['Retail_'+slot.name] = dict(shader=shader, + params=[struct.unpack('>4f', bytes.fromhex(row)) for row in rows]) + specular = components[slot.name]['textures'].get('specular') + if specular: + # The native skin shader consumes R, not luminance or roughness. + source = private / 'default_skater/textures/decoded' / (specular+'.png') + destination = private / 'native-character' / (slot.name+'_specular.png') + destination.parent.mkdir(parents=True, exist_ok=True) + with Image.open(source) as image: + image.convert('RGB').getchannel('R').save(destination) + result['materials']['Retail_'+slot.name]['specular'] = 'private/native-character/'+destination.name + fields, _ = collections.resolve('Hash_6A2B5946514B8490', 'freeskate') + result['default_sh'] = [struct.unpack('>3f', bytes.fromhex(row)) + for row in fields[key_hash('Hash_F45DFCAB6EC195CD')]['array']['items']] + (private/'character-lighting.json').write_text(json.dumps(result)) + + +if __name__ == '__main__': + import argparse + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--models', type=Path, required=True) + p.add_argument('--private', type=Path, required=True) + p.add_argument('--collections', type=Path, required=True) + a = p.parse_args() + convert(a.models, a.private, json.loads(a.collections.read_text())) diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index 070bbfe0..23a2f1bf 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -188,6 +188,8 @@ def install(iso,base,game_exe,report,game_root=None): report('Building the skater model and rig') from .character_glb import convert as write_character write_character(character/'selected/models',private,manifest) + from .character_lighting import convert as write_character_lighting + write_character_lighting(character/'selected/models',private,converted) game_manifest={'version':1,'character_scene':'private/skater.glb','initial_animation':'R_IDLE_HCOM_000', 'action_graph':'private/stock/data/state/ActionGraph_OnBoard.stategraph', 'motion_graph':'private/stock/data/state/MotionGraph_OnBoard.stategraph'} diff --git a/tools/asset_pipeline/irradiance.py b/tools/asset_pipeline/irradiance.py new file mode 100644 index 00000000..8a425536 --- /dev/null +++ b/tools/asset_pipeline/irradiance.py @@ -0,0 +1,51 @@ +"""Export retail RWOBJECTTYPE_IRRADIANCEDATA (0xEB0024) without resampling.""" +import json +import math +import struct +from pathlib import Path + + +def decode(raw): + if raw[:7] != b'\x89RW4xb2': + raise ValueError('Not an Xbox RX2 resource') + count, table = struct.unpack_from('>I', raw, 32)[0], struct.unpack_from('>I', raw, 48)[0] + groups = [] + for i in range(count): + offset, _, size, _, _, kind = struct.unpack_from('>6I', raw, table + i*24) + if kind != 0xEB0024: + continue + n, start = struct.unpack_from('>2I', raw, offset) + if start < 8 or start+n*160 > size or offset+size > len(raw): + raise ValueError('Invalid irradiance record extent') + records = [] + for j in range(n): + values = struct.unpack_from('>39fI', raw, offset+start+j*160) + if not all(math.isfinite(x) for x in values[:39]): + raise ValueError('Non-finite irradiance sample') + records.append(struct.pack('<39fI', *values)) + if records: + groups.append(records) + return groups + + +def write(manifest_path, output): + manifest = json.loads(manifest_path.read_text()) + groups = [] + for asset in manifest['other_presentation_assets']: + groups.extend(decode((manifest_path.parent / asset['rx2']).read_bytes())) + output.parent.mkdir(parents=True, exist_ok=True) + with output.open('wb') as f: + f.write(b'SHP1' + struct.pack('I', data, 32, 1) + struct.pack_into('>I', data, 48, 64) + struct.pack_into('>6I', data, 64, 96, 0, 176, 0, 0, 0xEB0024) + struct.pack_into('>2I', data, 96, 1, 16) + values = [i / 4 for i in range(39)] + struct.pack_into('>39fI', data, 112, *values, 0xDEDEDEDE) + return data, values + + +class IrradianceTests(unittest.TestCase): + def test_preserves_coefficients_position_and_link_word(self): + data, values = resource() + groups = decode(data) + self.assertEqual(len(groups), 1) + self.assertEqual(struct.unpack('<39fI', groups[0][0]), (*values, 0xDEDEDEDE)) + + def test_rejects_records_outside_section(self): + data, _ = resource() + struct.pack_into('>I', data, 96, 2) + with self.assertRaises(ValueError): + decode(data) + + def test_rejects_nonfinite_sh(self): + data, _ = resource() + struct.pack_into('>f', data, 112, float('nan')) + with self.assertRaises(ValueError): + decode(data)