Stabilize player shadow state without rebuilding world materials

This commit is contained in:
chazmm committed 2026-09-08 17:29:48 +01:00
1 parent 464692f94c
commit b5a00ede43
6 files changed
+153 -16

No files matched your search

+13 -14
View File
@@ -37,7 +37,6 @@ struct Lighting {
data: LightingData,
probes: Irradiance,
light: Vec4,
shadow_floor: Option<Vec3>,
}
#[derive(Clone, ShaderType)]
struct CharacterParams {
@@ -142,7 +141,6 @@ fn load(mut commands: Commands, config: Res<crate::config::Config>) {
data,
probes,
light,
shadow_floor: None,
});
}
fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) {
@@ -171,13 +169,16 @@ fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) {
illuminance: 0.,
shadows_enabled: true,
affects_lightmapped_mesh_diffuse: true,
// Receiver-only map: no self-shadow acne to hide with large bias.
shadow_depth_bias: 0.002,
shadow_normal_bias: 0.0,
..default()
},
RenderLayers::layer(31),
Transform::default().looking_to(-light, Vec3::Y),
bevy::light::CascadeShadowConfigBuilder {
maximum_distance: 100.,
first_cascade_far_bound: 10.,
num_cascades: 1,
maximum_distance: 24.,
..default()
}
.build(),
@@ -266,7 +267,8 @@ 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>>,
mut shadow: ResMut<crate::retail_render::ShadowState>,
time: Res<Time>,
) {
let (Some(mut lighting), Ok(root)) = (lighting, root.single()) else {
return;
@@ -281,13 +283,7 @@ fn update(
}
// 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);
}
shadow.approach(sh[0].truncate(), time.delta_secs());
}
#[cfg(test)]
@@ -304,8 +300,8 @@ mod tests {
let terrain = RenderLayers::default();
let mut receivers = 0;
let mut character_sources = 0;
for (light, layers) in world
.query::<(&DirectionalLight, Option<&RenderLayers>)>()
for (light, layers, cascades) in world
.query::<(&DirectionalLight, Option<&RenderLayers>, &bevy::light::CascadeShadowConfig)>()
.iter(&world)
{
let layers = layers.cloned().unwrap_or_default();
@@ -314,6 +310,9 @@ mod tests {
assert!(layers.intersects(&player));
if light.affects_lightmapped_mesh_diffuse {
assert!(!layers.intersects(&terrain));
assert_eq!(cascades.bounds, vec![24.]);
assert_eq!(light.shadow_normal_bias, 0.);
assert!(light.shadow_depth_bias < 0.005);
receivers += 1;
} else {
assert!(layers.intersects(&terrain));
+8
View File
@@ -18,6 +18,7 @@ use std::collections::BTreeMap;
pub(crate) struct RetailRenderPlugin;
impl Plugin for RetailRenderPlugin {
fn build(&self, app: &mut App) {
shadow::install(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");
@@ -34,6 +35,10 @@ impl Plugin for RetailRenderPlugin {
}
}
#[path = "retail_shadow.rs"]
mod shadow;
pub(crate) use shadow::ShadowState;
#[derive(Resource)]
pub(crate) struct RetailScene(pub bool);
@@ -106,6 +111,8 @@ pub(crate) struct RetailWorldMaterial {
pub specular: Option<Handle<Image>>,
#[texture(15, dimension = "cube")]
pub environment: Option<Handle<Image>>,
#[storage(16, read_only)]
pub shadow_state: Handle<bevy::render::storage::ShaderStorageBuffer>,
pub alpha: AlphaMode,
pub two_sided: bool,
}
@@ -305,6 +312,7 @@ impl Definition {
decal,
specular,
environment,
shadow_state: shadow::BUFFER,
alpha,
two_sided: self.flags & 4 != 0,
}
+89
View File
@@ -0,0 +1,89 @@
//! Shared, in-place shadow state: moving probes must not rebuild world materials.
use bevy::{
prelude::*,
render::{
Render, RenderApp, RenderSystems,
extract_resource::{ExtractResource, ExtractResourcePlugin},
render_asset::RenderAssets,
render_resource::BufferUsages,
renderer::RenderQueue,
storage::{GpuShaderStorageBuffer, ShaderStorageBuffer},
},
};
pub(super) const BUFFER: Handle<ShaderStorageBuffer> =
bevy::asset::uuid_handle!("cd736f89-4882-4a5d-8fcb-32273beeaaf4");
#[derive(Resource, Clone, Default, ExtractResource)]
pub(crate) struct ShadowState(pub Vec4);
impl ShadowState {
pub(crate) fn approach(&mut self, target: Vec3, dt: f32) {
let target = target.clamp(Vec3::ZERO, Vec3::ONE);
let value = if self.0.w == 0. {
target
} else {
// Adapter smoothing, not a recovered native constant. Cap a hitch's
// contribution so one long frame cannot cause a darkness step.
self.0
.truncate()
.lerp(target, 1. - (-dt.clamp(0., 0.05) / 0.35).exp())
};
self.0 = value.extend(1.);
}
}
pub(super) fn install(app: &mut App) {
app.init_resource::<ShadowState>()
.add_plugins(ExtractResourcePlugin::<ShadowState>::default())
.add_systems(Startup, initialize);
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(Render, upload.in_set(RenderSystems::PrepareResources));
}
}
fn initialize(mut buffers: ResMut<Assets<ShaderStorageBuffer>>) {
let mut buffer = ShaderStorageBuffer::from(Vec4::ZERO);
buffer.buffer_description.usage |= BufferUsages::COPY_DST;
buffers
.insert(BUFFER.id(), buffer)
.expect("reserved shadow state asset");
}
fn upload(
state: Res<ShadowState>,
buffers: Res<RenderAssets<GpuShaderStorageBuffer>>,
queue: Res<RenderQueue>,
) {
if let Some(buffer) = buffers.get(BUFFER.id()) {
let bytes: Vec<u8> = state
.0
.to_array()
.into_iter()
.flat_map(f32::to_le_bytes)
.collect();
// Keep the buffer and every material bind group alive; only 16 bytes change.
queue.write_buffer(&buffer.buffer, 0, &bytes);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_transition_is_gradual_and_hitches_are_bounded() {
let mut state = ShadowState::default();
state.approach(Vec3::splat(0.1), 0.016);
assert_eq!(state.0, Vec3::splat(0.1).extend(1.));
state.approach(Vec3::splat(0.3), 0.016);
assert!(state.0.x > 0.1 && state.0.x < 0.12);
let mut hitch = state.clone();
state.approach(Vec3::splat(0.3), 0.05);
hitch.approach(Vec3::splat(0.3), 10.);
assert_eq!(state.0, hitch.0);
for _ in 0..240 {
state.approach(Vec3::splat(0.3), 1. / 60.);
}
assert!((state.0.x - 0.3).abs() < 0.0001);
}
}
+4 -2
View File
@@ -22,6 +22,8 @@ struct WorldParams {
@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(16) var<storage, read> shadow_state: vec4<f32>;
@fragment
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
let fam = u32(p.mode.x);
@@ -72,13 +74,13 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
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) {
if shadow_state.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);
baked=min(baked,vec3<f32>(visibility)+shadow_state.rgb);
break;
}
}
+33
View File
@@ -141,3 +141,36 @@ composition/validation; a windowless ECS regression test verifies the caster-lay
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.
## Shadow stability follow-up, 2026-09-08
User testing reported thin/warped limbs in the shadow, heavy lag, a crash, and
abrupt changes in darkness. The previous implementation changed every world
material when nearest-probe selection changed. This both stepped the ambient
floor and invalidated all those material assets. It is a confirmed code defect
and a plausible source of the reported stalls; no contemporaneous crash dump or
Application error record was found, so the crash itself is not diagnosed.
World materials now bind one persistent 16-byte storage buffer. An extracted
resource supplies the shadow floor, and the render queue writes it in place
after asset preparation. Neither the storage asset nor world materials are
mutated during probe changes; existing bind groups remain valid. The floor uses
an exponential 0.35-second transition, with each frame's contribution capped at
50 ms to avoid a jump after a hitch. These are adapter settings, not recovered
native constants. The initial sample is applied directly.
The player-only source now uses one 24-metre cascade instead of four extending
to 100 metres. This removes its internal cascade transitions and three shadow
views. Its receiver normal offset is zero and depth bias is 0.002 metres; the
previous defaults were 1.8 texels and 0.02 metres. World-plus-character self
shadowing retains its original source and bias. The new settings target the
reported silhouette distortion, but visible improvement is not confirmed offline.
A single cascade trades distant coverage for stability; shadows outside its
24-metre camera range are intentionally unavailable. Exact console parity is
still pending the native colour controller and filtering work above.
Offline world/character/depth shader validation and six filtered unit tests pass,
including smooth probe transitions, hitch limiting, caster isolation and cascade
configuration. Release compilation passes. The updated private University
launcher records stdout/stderr, backtraces and the exit code in
`logs/renderer-work/player-shadow-runtime.log`. It has not been executed.
+6
View File
@@ -227,3 +227,9 @@ using both scalar and NumPy paths. All 16 asset-pipeline tests pass. The game wa
not launched; visual confirmation remains user-run. Existing exported maps need
their textures regenerated from RX2, since changing the decoder cannot repair
already-baked PNG/RGBA cache files. Fresh conversions use the fix across maps.
Player-shadow stability follow-up: the ambient floor is now a smoothed shared
16-byte GPU buffer, updated without rebuilding world materials. The player-only
source uses one 24 m cascade and reduced receiver bias. Native colour/filter
parity and runtime visual/performance verification remain outstanding; see the
character lighting investigation. The private launcher now captures crash output.