From 464692f94c7ba1de64f01a6a0f27d3251a5a1c7d Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:19:45 +0100 Subject: [PATCH] Project player shadows onto baked world materials --- crates/skate-game/src/retail_character.rs | 108 ++++++++++++++++++-- crates/skate-game/src/retail_character.wgsl | 8 +- crates/skate-game/src/retail_world.wgsl | 15 ++- docs/character-lighting-investigation.md | 36 +++++++ docs/retail-renderer.md | 5 +- 5 files changed, 157 insertions(+), 15 deletions(-) diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index b6fefdaf..5212d98e 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -2,6 +2,7 @@ use crate::retail_irradiance::Irradiance; use bevy::{ asset::embedded_asset, + camera::visibility::RenderLayers, gltf::GltfMaterialName, prelude::*, render::render_resource::{AsBindGroup, ShaderType}, @@ -17,7 +18,7 @@ impl Plugin for CharacterLightingPlugin { embedded_asset!(app, "retail_character_depth.wgsl"); app.add_plugins(MaterialPlugin::::default()) .add_systems(Startup, load) - .add_systems(Update, (bind, update).chain()); + .add_systems(Update, (bind, shadow_views, update).chain()); } } #[derive(Deserialize)] @@ -36,6 +37,7 @@ struct Lighting { data: LightingData, probes: Irradiance, light: Vec4, + shadow_floor: Option, } #[derive(Clone, ShaderType)] struct CharacterParams { @@ -135,16 +137,44 @@ fn load(mut commands: Commands, config: Res) { 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. + spawn_shadow_sources(&mut commands, light.truncate()); + commands.insert_resource(Lighting { + data, + probes, + light, + shadow_floor: None, + }); +} +fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { + // World + skater casters, sampled only by the character shader. commands.spawn(( Name::new("Character shadow visibility"), DirectionalLight { illuminance: 0., shadows_enabled: true, + affects_lightmapped_mesh_diffuse: false, ..default() }, - Transform::default().looking_to(-light.truncate(), Vec3::Y), + Transform::default().looking_to(-light, Vec3::Y), + bevy::light::CascadeShadowConfigBuilder { + maximum_distance: 100., + first_cascade_far_bound: 10., + ..default() + } + .build(), + )); + // Only skinned player pieces inhabit layer 31. The world receiver samples + // this separate map so baked building/terrain shadows are not re-applied. + commands.spawn(( + Name::new("Player shadow onto baked world"), + DirectionalLight { + illuminance: 0., + shadows_enabled: true, + affects_lightmapped_mesh_diffuse: true, + ..default() + }, + RenderLayers::layer(31), + Transform::default().looking_to(-light, Vec3::Y), bevy::light::CascadeShadowConfigBuilder { maximum_distance: 100., first_cascade_far_bound: 10., @@ -152,12 +182,8 @@ fn load(mut commands: Commands, config: Res) { } .build(), )); - commands.insert_resource(Lighting { - data, - probes, - light, - }); } + fn bind( mut commands: Commands, lighting: Option>, @@ -215,13 +241,32 @@ fn bind( commands .entity(entity) .remove::>() - .insert(MeshMaterial3d(material)); + .insert(( + MeshMaterial3d(material), + RenderLayers::from_layers(&[0, 31]), + )); + } +} +fn shadow_views( + mut commands: Commands, + lighting: Option>, + cameras: Query<(Entity, Option<&RenderLayers>), With>, +) { + if lighting.is_none() { + return; + } + for (entity, layers) in &cameras { + let layers = layers.cloned().unwrap_or_default(); + if !layers.intersects(&RenderLayers::layer(31)) { + commands.entity(entity).insert(layers.with(31)); + } } } fn update( lighting: Option>, root: Query<&Transform, With>, mut materials: ResMut>, + mut world_materials: ResMut>, ) { let (Some(mut lighting), Ok(root)) = (lighting, root.single()) else { return; @@ -234,4 +279,47 @@ fn update( for (_, material) in materials.iter_mut() { material.params.sh = sh; } + // Adapter floor: the local probe's direction-independent ambient term. + // The native per-frame c8 shadow-colour controller remains unrecovered. + let floor = sh[0].truncate().clamp(Vec3::ZERO, Vec3::ONE); + if lighting.shadow_floor != Some(floor) { + for (_, material) in world_materials.iter_mut() { + material.params.shadow_color = floor.extend(1.); + } + lighting.shadow_floor = Some(floor); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn world_receiver_source_excludes_world_casters_and_emits_no_light() { + let mut world = World::new(); + let mut queue = bevy::ecs::world::CommandQueue::default(); + spawn_shadow_sources(&mut Commands::new(&mut queue, &world), Vec3::Y); + queue.apply(&mut world); + let player = RenderLayers::from_layers(&[0, 31]); + let terrain = RenderLayers::default(); + let mut receivers = 0; + let mut character_sources = 0; + for (light, layers) in world + .query::<(&DirectionalLight, Option<&RenderLayers>)>() + .iter(&world) + { + let layers = layers.cloned().unwrap_or_default(); + assert_eq!(light.illuminance, 0.); + assert!(light.shadows_enabled); + assert!(layers.intersects(&player)); + if light.affects_lightmapped_mesh_diffuse { + assert!(!layers.intersects(&terrain)); + receivers += 1; + } else { + assert!(layers.intersects(&terrain)); + character_sources += 1; + } + } + assert_eq!((receivers, character_sources), (1, 1)); + } } diff --git a/crates/skate-game/src/retail_character.wgsl b/crates/skate-game/src/retail_character.wgsl index 5184f19c..f6de4d00 100644 --- a/crates/skate-game/src/retail_character.wgsl +++ b/crates/skate-game/src/retail_character.wgsl @@ -53,8 +53,12 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { 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); + for (var light_id=0u; light_id, foliage_debug: vec4, surface: vec4, family: vec4, @@ -70,6 +71,18 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { var d = a.rgb*a.rgb; var alpha = 1.0; var lin = vec3(0.0); + var baked = lm*lm; + if p.shadow_color.w>0.0 && (fam<=8u || fam==13u) { + let view_z=(frame::view.view_from_world*i.world_position).z; + for (var light_id=0u; light_id(visibility)+p.shadow_color.rgb); + break; + } + } + } if fam == 9u || fam == 10u { lin = d * max(lm*lm,vec3(p.family.y)) * p.family.x; if fam == 9u { lin *= p.family.z; } @@ -102,7 +115,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { wn = normalize(raw.x*tt+raw.y*bb+wn*max(raw.z,0.05)); kd = (vnd.x*0.58*sign(dot(kt,sun))+vnd.y*0.62*sign(dot(kb,sun))+vnd.z*0.39)*2.39562; } - var lml = lm*lm; + var lml = baked; lin = lml*kd*d; if fam == 13u { lin = lml*d*a.a; } if (flags & 16u) != 0u { diff --git a/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index a5572d7c..2af34c13 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -105,3 +105,39 @@ 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. + +## Player shadow receiver, 2026-09-08 + +The baked-world receiver is now connected. Native `scene.hlsl` evaluates +`min(lightmap * lightmap, shadow_visibility + shadow_colour)` before diffuse, +normal/detail lighting and specular. This is a component-wise minimum cap, not +an extra multiplicative darkening pass. The local native reference's caster path +is corroborated by TU3 `0x827EBEF0` (CSM setup), `0x827F82F8` (CSM render) and +the character-family shadow shader names in the executable. The animated mesh +and board are the caster geometry; no ground decal or circular blob is used. + +Two zero-lux shadow sources separate the receiver contracts. The existing source +includes world and player casters, and is sampled by the character. The new +source includes only the ten skinned retail player/board pieces on reserved +render layer 31. Camera views include that layer; ordinary world geometry stays +on layer zero. World families 1–8 and 13 sample the player-only source and apply +the recovered minimum clamp. This prevents terrain/building shadows from being +applied a second time over the baked map. The source's lightmapped-diffuse flag +identifies its role in the GPU light table, so entity ordering does not determine +which shadow map the shaders select. Both lights have zero illuminance. + +This reproduces the projected-mesh and lightmap-combination method, **not full +console parity**. Shadow map projection/filtering/biases still use Bevy cascades. +The original per-frame c8 shadow-colour controller has not been recovered: the +adapter currently uses the local irradiance probe's constant RGB coefficient as +the ambient shadow floor. At University's starting point that is approximately +(0.071711, 0.095925, 0.127945), read from the source sample, not a global preset. +The floor updates when the selected probe changes. Native caster exclusions, +atlas coverage encoding/blur, shadow colour and pixel matching remain separate +parity work. Unsupported world material families still have no custom receiver. + +Offline validation: world, character and character depth shaders pass Naga +composition/validation; a windowless ECS regression test verifies the caster-layer +separation and zero illuminance. Release compilation passes. The prepared user +launcher retains the texture-band repairs and prior character lighting. No game +or recomp was launched, and no physics/animation implementation changed. diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index fb3b4378..d0eab74b 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -89,8 +89,9 @@ selection and regional/streaming state also remain unverified. This is not a claim of pixel parity. Native frame constants are supplied by game hooks in the reference; this engine does not yet recover that controller. Scene exposure 2.5, world material multiplier 1, and tree/proxy values still use -documented day-capture defaults. Imported maps have no dynamic directional sun or realtime -sun shadows: their world lighting comes from the baked lightmaps. Normal-map +documented day-capture defaults. Imported maps have no additive directional sun: +their world lighting comes from baked lightmaps, with a separate player-only +projected shadow receiver described in the character-lighting notes. Normal-map sign terms use the initial world's authored direction when new sky metadata is available; old packages retain the previous direction. Regional changes and the runtime environment controller are not implemented. Fog selection