From d9f497322ea4f49ba7ce1f4a08945ed41b018686 Mon Sep 17 00:00:00 2001 From: Gh4zi Date: Sun, 11 Oct 2026 14:08:54 +0300 Subject: [PATCH] Sun shadows: script models out of view still cast into it Favela's hanging laundry is animated script models. They were posed only when the camera's scene-entity walk reached them, so once the clothes left the screen their shadows on the wall below vanished (only the static rope's stayed). IW4 culls scene entities for each sun partition apart from the camera; here a script model the walk hid is kept for the sun shadow only (colour refused, no lighting request) while its bound is inside one of the sun partitions. The partitions are published before the models are posed, from one shared builder the command context now uses as well, so both see the same planes. Co-Authored-By: Claude Opus 5.5 --- .../render_anim/src/occupancy/script_model.rs | 31 +++++++--- .../src/assemble/drawsurf/command_context.rs | 25 ++------ .../src/assemble/drawsurf/sun_shadow.rs | 57 +++++++++++++++++++ .../src/assemble/drawsurf/tess/xmodel.rs | 19 +++++-- crates/render_frontend/src/prepare/mod.rs | 7 +++ crates/render_scene/src/lib.rs | 4 +- crates/render_scene/src/plugin.rs | 3 +- crates/render_scene/src/xmodel_draw.rs | 16 ++++++ 8 files changed, 127 insertions(+), 35 deletions(-) diff --git a/crates/render_anim/src/occupancy/script_model.rs b/crates/render_anim/src/occupancy/script_model.rs index 3c64f8b..ae7cf6b 100644 --- a/crates/render_anim/src/occupancy/script_model.rs +++ b/crates/render_anim/src/occupancy/script_model.rs @@ -76,6 +76,7 @@ struct ScriptModelPoseLocals<'w, 's> { remap: Local<'s, Vec>>, lod_skinned: Res<'w, render_scene::LodRampSkinnedDvar>, gfx_scene: Res<'w, HostGfxScene>, + sun_casters: Res<'w, render_scene::SunShadowCasterVolume>, } #[derive(SystemParam)] @@ -588,6 +589,7 @@ fn pose_script_models( mut remap, lod_skinned, gfx_scene, + sun_casters, } = locals; live_assets.clear(); live_ids.clear(); @@ -634,7 +636,13 @@ fn pose_script_models( let entnum = owner.gentity_number.map(u32::from); let slot = entnum.map(|entnum| script_scene_slot(&gfx_scene.scene, entnum)); - if let Some(why) = slot.as_ref().and_then(|slot| slot.skip) { + // Out of the camera's walk, a model can still shade what the camera sees (laundry + // above the view on the wall below it): it stays, for the sun shadow only, while it is + // inside a sun partition. + let walk_hidden = entnum.is_some_and(|entnum| gfx_scene.scene.scene_ent_hidden(entnum)); + if let Some(why) = slot.as_ref().and_then(|slot| slot.skip) + && !walk_hidden + { if let Some(id) = focused_owner_id { focus.refuse(id, &owner.current_model.0, why); } @@ -647,6 +655,18 @@ fn pose_script_models( continue; }; let origin = transform.translation.to_array(); + let radius = skels + .first() + .and_then(|skel| skel.radius) + .unwrap_or(1.0) + .max(1.0); + let caster_bound = render_scene::XModelCasterBound { origin, radius }; + if walk_hidden && !sun_casters.admits(caster_bound) { + if let Some(id) = focused_owner_id { + focus.refuse(id, &owner.current_model.0, "scene_ent_hidden"); + } + continue; + } if lod_culled(&skels, origin, eye, skinned_ramp) { if let Some(id) = focused_owner_id { @@ -732,11 +752,6 @@ fn pose_script_models( }; let box_half = crate::script_model_lighting_box_half(&assets, &owner.dobj_state.composition.models); - let radius = skels - .first() - .and_then(|skel| skel.radius) - .unwrap_or(1.0) - .max(1.0); product.rows.push(ScriptOwnerRow { entity, asset_index, @@ -744,10 +759,10 @@ fn pose_script_models( model: owner.current_model.clone(), world_from_local: transform.to_matrix(), entnum, - camera_hidden: sphere_behind_frustum(origin, radius, &planes), + camera_hidden: walk_hidden || sphere_behind_frustum(origin, radius, &planes), lighting_origin: owner.lighting_origin, lookup_fallback: atpoint.fallback(owner.lighting_origin, box_half), - caster_bound: render_scene::XModelCasterBound { origin, radius }, + caster_bound, }); } persist.by_id.retain(|id, _| live_ids.contains(id)); diff --git a/crates/render_frontend/src/assemble/drawsurf/command_context.rs b/crates/render_frontend/src/assemble/drawsurf/command_context.rs index 7b956c0..3b3d631 100644 --- a/crates/render_frontend/src/assemble/drawsurf/command_context.rs +++ b/crates/render_frontend/src/assemble/drawsurf/command_context.rs @@ -618,27 +618,10 @@ pub(crate) fn update_command_context_code_sources( if let Some(scene) = scene.as_deref() && let Some(bounds) = scene.world_bounds { - let world_mid = [bounds[0], bounds[1], bounds[2]]; - let world_half = [bounds[3], bounds[4], bounds[5]]; - let shadow_forward = super::sun_shadow_forward_from_light_dir(light.direction); - let world = prepared.view_from_world.inverse(); - let forward = world.transform_vector3(Vec3::NEG_Z).normalize_or_zero(); - let right = world.transform_vector3(Vec3::X).normalize_or_zero(); - let up = world.transform_vector3(Vec3::Y).normalize_or_zero(); - let (tx, ty) = super::tan_half_fov_from_clip(clip_from_view); - let frame = super::forced_fallback_frame( - shadow_forward, - super::SunShadowCamera { - origin: eye.to_array(), - forward: forward.to_array(), - right: right.to_array(), - up: up.to_array(), - tan_half_fov_x: tx, - tan_half_fov_y: ty, - z_near: prepared.near, - }, - world_mid, - world_half, + let frame = super::sun_shadow_frame_for_view( + light.direction, + &prepared, + bounds, scene.sun_sample_size_near, ); produce_sun_shadow_receiver_constants(&mut mat_frame.code_sources, frame); diff --git a/crates/render_frontend/src/assemble/drawsurf/sun_shadow.rs b/crates/render_frontend/src/assemble/drawsurf/sun_shadow.rs index f75f1e5..a5f0f98 100644 --- a/crates/render_frontend/src/assemble/drawsurf/sun_shadow.rs +++ b/crates/render_frontend/src/assemble/drawsurf/sun_shadow.rs @@ -553,3 +553,60 @@ pub fn forced_fallback_frame( projection_offset_clip, } } + +/// The sun frame for the prepared view. The command context builds the frame with it, and so +/// does the caster volume published before the models are posed, so both see the same +/// partitions. +pub fn sun_shadow_frame_for_view( + light_direction: [f32; 3], + prepared: &crate::prepare::scene::view_parms::PreparedSceneView, + world_bounds: [f32; 6], + sample_near: f32, +) -> SunShadowForcedFrame { + let world = prepared.view_from_world.inverse(); + let axis = |local: bevy::math::Vec3| world.transform_vector3(local).normalize_or_zero(); + let (tan_half_fov_x, tan_half_fov_y) = tan_half_fov_from_clip(prepared.clip_from_view); + forced_fallback_frame( + sun_shadow_forward_from_light_dir(light_direction), + SunShadowCamera { + origin: prepared.eye.to_array(), + forward: axis(bevy::math::Vec3::NEG_Z).to_array(), + right: axis(bevy::math::Vec3::X).to_array(), + up: axis(bevy::math::Vec3::Y).to_array(), + tan_half_fov_x, + tan_half_fov_y, + z_near: prepared.near, + }, + [world_bounds[0], world_bounds[1], world_bounds[2]], + [world_bounds[3], world_bounds[4], world_bounds[5]], + sample_near, + ) +} + +/// Publishes this frame's sun caster volumes (see [`render_scene::SunShadowCasterVolume`]) +/// from the inputs the command context builds the sun frame from. +pub(crate) fn publish_sun_shadow_caster_volume( + dir_light: Option>, + prepared: bevy::prelude::Res, + scene: Option>, + mut volume: bevy::prelude::ResMut, +) { + let scene = scene.as_deref(); + let partitions = match ( + dir_light.as_deref(), + scene.and_then(|scene| scene.world_bounds), + ) { + (Some(light), Some(bounds)) if prepared.ready => { + let sample_near = scene.map_or(0.25, |scene| scene.sun_sample_size_near); + sun_shadow_frame_for_view(light.direction, &prepared, bounds, sample_near) + .partitions + .iter() + .map(|partition| partition.clip_planes.as_slice().to_vec()) + .collect() + } + _ => Vec::new(), + }; + if volume.partitions != partitions { + volume.partitions = partitions; + } +} diff --git a/crates/render_frontend/src/assemble/drawsurf/tess/xmodel.rs b/crates/render_frontend/src/assemble/drawsurf/tess/xmodel.rs index b968308..bb4f97e 100644 --- a/crates/render_frontend/src/assemble/drawsurf/tess/xmodel.rs +++ b/crates/render_frontend/src/assemble/drawsurf/tess/xmodel.rs @@ -176,6 +176,17 @@ fn scene_ent_admitted(scene: Option<&crate::GfxScene>, entnum: Option) -> b } } +/// A script model out of the camera's walk is drawn only into the sun shadow: its producer +/// keeps it, colour refused, while it is inside a sun partition. +#[must_use] +fn script_draw_admitted(scene: Option<&crate::GfxScene>, draw: &XModelSurfaceDraw) -> bool { + scene_ent_admitted(scene, draw.scene_entnum) + || (draw.colour_refusal.is_some() + && draw + .scene_entnum + .is_some_and(|n| scene.is_some_and(|s| s.scene_ent_hidden(n)))) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct XModelTopologyKey { generation: [u64; 8], @@ -352,7 +363,7 @@ fn xmodel_merge_stamp( scene_ent_admitted(scene, draw.scene_entnum).hash(&mut occupancy); } for draw in scripts.draws() { - scene_ent_admitted(scene, draw.scene_entnum).hash(&mut occupancy); + script_draw_admitted(scene, draw).hash(&mut occupancy); } for draw in missiles.draws() { scene_ent_admitted(scene, draw.scene_entnum).hash(&mut occupancy); @@ -588,7 +599,7 @@ pub fn merge_xmodel_draw_plan( .draws() .iter() .copied() - .filter(|d| scene_ent_admitted(scene, d.scene_entnum)), + .filter(|d| script_draw_admitted(scene, d)), ); append_admitted_source( @@ -1014,7 +1025,7 @@ fn refresh_concat_draws( .draws() .iter() .copied() - .filter(|d| scene_ent_admitted(scene, d.scene_entnum)), + .filter(|d| script_draw_admitted(scene, d)), ); append_concat_owner_draws( merged, @@ -1091,7 +1102,7 @@ fn admitted_mask( if scripts .draws() .iter() - .any(|d| scene_ent_admitted(scene, d.scene_entnum)) + .any(|d| script_draw_admitted(scene, d)) { admitted |= ADMIT_SCRIPT; } diff --git a/crates/render_frontend/src/prepare/mod.rs b/crates/render_frontend/src/prepare/mod.rs index fb711db..30af02c 100644 --- a/crates/render_frontend/src/prepare/mod.rs +++ b/crates/render_frontend/src/prepare/mod.rs @@ -196,6 +196,13 @@ impl Plugin for RenderPreparePlugin { .after(crate::prepare::scene::sun_stage::update_active_sun_stage) .in_set(WorkerCmdSet::CellStatic), ) + .add_systems( + Update, + crate::assemble::drawsurf::publish_sun_shadow_caster_volume.in_set(frame::InMatch) + .after(stamp_prepared_scene_view) + .after(crate::prepare::scene::sun_stage::update_active_sun_stage) + .before(crate::adapters::anim::script_model::ScriptModelSkinSet), + ) .add_systems( Update, crate::prepare::scene::sun_stage::update_active_sun_stage.in_set(frame::InMatch) diff --git a/crates/render_scene/src/lib.rs b/crates/render_scene/src/lib.rs index 52d6db4..4e00195 100644 --- a/crates/render_scene/src/lib.rs +++ b/crates/render_scene/src/lib.rs @@ -52,4 +52,6 @@ pub use view_parms::{ pack_live_view_parms, prepare_scene_view, }; pub use world_instances::{WorldDynEntInstance, WorldScriptModelInstance}; -pub use xmodel_draw::{XModelCasterBound, XModelColourRefusal, XModelSurfaceDraw}; +pub use xmodel_draw::{ + SunShadowCasterVolume, XModelCasterBound, XModelColourRefusal, XModelSurfaceDraw, +}; diff --git a/crates/render_scene/src/plugin.rs b/crates/render_scene/src/plugin.rs index 00ed145..cbf3443 100644 --- a/crates/render_scene/src/plugin.rs +++ b/crates/render_scene/src/plugin.rs @@ -33,6 +33,7 @@ impl Plugin for RenderScenePlugin { .init_resource::() .init_resource::() .init_resource::() - .init_resource::(); + .init_resource::() + .init_resource::(); } } diff --git a/crates/render_scene/src/xmodel_draw.rs b/crates/render_scene/src/xmodel_draw.rs index 152109f..e1e891d 100644 --- a/crates/render_scene/src/xmodel_draw.rs +++ b/crates/render_scene/src/xmodel_draw.rs @@ -56,6 +56,22 @@ impl XModelCasterBound { } } +/// The volumes each sun partition collects its casters from this frame, published before the +/// models are posed (empty while sun shadows are off). A model the camera does not see still +/// shades what it does see while its bound is inside one of them, as IW4 culls scene entities +/// for each sun partition apart from the camera. +#[derive(Resource, Clone, Debug, Default, PartialEq)] +pub struct SunShadowCasterVolume { + pub partitions: Vec>, +} + +impl SunShadowCasterVolume { + #[must_use] + pub fn admits(&self, bound: XModelCasterBound) -> bool { + self.partitions.iter().any(|planes| !bound.outside(planes)) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum XModelColourRefusal { CameraFrustum,