Add spatial irradiance and character lighting adapter

This commit is contained in:
chazmm committed 2026-09-08 16:41:42 +01:00
1 parent 8c31384a12
commit 7d44d84f19
15 files changed
+730 -5

No files matched your search

+1
View File
@@ -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));
+2
View File
@@ -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;
+237
View File
@@ -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::<CharacterMaterial>::default())
.add_systems(Startup, load)
.add_systems(Update, (bind, update).chain());
}
}
#[derive(Deserialize)]
struct MaterialData {
shader: String,
params: Vec<[f32; 4]>,
specular: Option<String>,
}
#[derive(Deserialize)]
struct LightingData {
materials: HashMap<String, MaterialData>,
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<Handle<Image>>,
#[texture(3)]
#[sampler(4)]
normal: Option<Handle<Image>>,
#[texture(5)]
#[sampler(6)]
mask: Option<Handle<Image>>,
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<crate::config::Config>) {
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::<LightingData>(&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<Res<Lighting>>,
source: Res<Assets<StandardMaterial>>,
server: Res<AssetServer>,
mut materials: ResMut<Assets<CharacterMaterial>>,
entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d<StandardMaterial>)>,
) {
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::<MeshMaterial3d<StandardMaterial>>()
.insert(MeshMaterial3d(material));
}
}
fn update(
lighting: Option<ResMut<Lighting>>,
root: Query<&Transform, With<crate::world::PlayerRoot>>,
mut materials: ResMut<Assets<CharacterMaterial>>,
) {
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;
}
}
@@ -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<f32>, tint: vec4<f32>, options: vec4<f32>,
rows: array<vec4<f32>,9>, sh: array<vec4<f32>,9>,
}
@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;
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var normal_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var normal_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler;
@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);
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<p.options.z { discard; }
return vec4<f32>(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<f32>(-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<p.options.z { discard; }
return vec4<f32>((d*lit+spec)*p.rows[0].y*p.light.w,albedo.a*p.tint.a);
}
@@ -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<f32>, tint: vec4<f32>, options: vec4<f32>, rows: array<vec4<f32>,9>, sh: array<vec4<f32>,9> }
@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;
@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<f32>(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
}
+163
View File
@@ -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<Probe>,
}
#[derive(Default)]
pub(crate) struct Irradiance {
groups: Vec<ProbeGroup>,
recent: Vec<Probe>,
}
impl Irradiance {
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
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<u32, String> {
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]
);
}
}
+1
View File
@@ -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");
}
+2
View File
@@ -853,12 +853,14 @@ mod tests {
let mut queue = bevy::ecs::world::CommandQueue::default();
let mut meshes = Assets::<Mesh>::default();
let mut materials = Assets::<StandardMaterial>::default();
let mut retail_materials = Assets::<crate::retail_render::RetailWorldMaterial>::default();
let mut images = Assets::<Image>::default();
spawn(
&map,
&mut Commands::new(&mut queue, &world),
&mut meshes,
&mut materials,
&mut retail_materials,
&mut images,
);
queue.apply(&mut world);
+66 -2
View File
@@ -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 `<map>.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.
+6 -3
View File
@@ -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.
@@ -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()))
+2
View File
@@ -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'}
+51
View File
@@ -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', len(groups)))
for records in groups:
f.write(struct.pack('<I', len(records)))
f.write(b''.join(records))
return sum(map(len, groups))
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
print('Exported', write(args.manifest, args.output), 'irradiance samples')
+3
View File
@@ -151,6 +151,9 @@ def write(manifest_path,output,collision,report=lambda _:None, *, render_only=Fa
for tag,data in extensions:
f.write(tag);u(f,1,len(data));stored(f,data)
report('Map written: '+m['map_name'])
if not render_only:
from tools.asset_pipeline.irradiance import write as write_irradiance
write_irradiance(manifest_path, output.with_suffix('.irradiance'))
if __name__=='__main__':
import argparse
+37
View File
@@ -0,0 +1,37 @@
"""RX2 irradiance layout checks without owned-game fixtures."""
import struct
import unittest
from .irradiance import decode
def resource():
data = bytearray(272)
data[:7] = b'\x89RW4xb2'
struct.pack_into('>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)