Project player shadows onto baked world materials

This commit is contained in:
chazmm committed 2026-09-08 17:19:45 +01:00
1 parent ad992f2001
commit 464692f94c
5 files changed
+157 -15

No files matched your search

+98 -10
View File
@@ -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::<CharacterMaterial>::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<Vec3>,
}
#[derive(Clone, ShaderType)]
struct CharacterParams {
@@ -135,16 +137,44 @@ fn load(mut commands: Commands, config: Res<crate::config::Config>) {
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<crate::config::Config>) {
}
.build(),
));
commands.insert_resource(Lighting {
data,
probes,
light,
});
}
fn bind(
mut commands: Commands,
lighting: Option<Res<Lighting>>,
@@ -215,13 +241,32 @@ fn bind(
commands
.entity(entity)
.remove::<MeshMaterial3d<StandardMaterial>>()
.insert(MeshMaterial3d(material));
.insert((
MeshMaterial3d(material),
RenderLayers::from_layers(&[0, 31]),
));
}
}
fn shadow_views(
mut commands: Commands,
lighting: Option<Res<Lighting>>,
cameras: Query<(Entity, Option<&RenderLayers>), With<Camera3d>>,
) {
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<ResMut<Lighting>>,
root: Query<&Transform, With<crate::world::PlayerRoot>>,
mut materials: ResMut<Assets<CharacterMaterial>>,
mut world_materials: ResMut<Assets<crate::retail_render::RetailWorldMaterial>>,
) {
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));
}
}
+6 -2
View File
@@ -53,8 +53,12 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
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<frame::lights.n_directional_lights; light_id+=1u) {
// All-caster visibility light does not affect lightmapped world diffuse.
if (frame::lights.directional_lights[light_id].flags & 5u)==1u {
shadow=fetch_directional_shadow(light_id,i.world_position,vn,view_z);
break;
}
}
var lit=p.rows[1].rgb*saturate(ndl)*shadow+irr*p.rows[5].w;
let fb=1.0-saturate(dot(vn,vd));
+14 -1
View File
@@ -1,4 +1,5 @@
#import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame}
#import bevy_pbr::shadows::fetch_directional_shadow
struct WorldParams {
mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>,
@@ -70,6 +71,18 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
var d = a.rgb*a.rgb;
var alpha = 1.0;
var lin = vec3<f32>(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<frame::lights.n_directional_lights; light_id+=1u) {
// The lightmapped receiver source contains only player/board casters.
if (frame::lights.directional_lights[light_id].flags & 5u)==5u {
let visibility=fetch_directional_shadow(light_id,i.world_position,wn,view_z);
baked=min(baked,vec3<f32>(visibility)+p.shadow_color.rgb);
break;
}
}
}
if fam == 9u || fam == 10u {
lin = d * max(lm*lm,vec3<f32>(p.family.y)) * p.family.x;
if fam == 9u { lin *= p.family.z; }
@@ -102,7 +115,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
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 {
+36
View File
@@ -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.
+3 -2
View File
@@ -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