mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Add spatial irradiance and character lighting adapter
This commit is contained in:
1 parent
8c31384a12
commit
7d44d84f19
15 files changed
+730
-5
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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()))
|
||||
@@ -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'}
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user