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 <noreply@anthropic.com>
This commit is contained in:
Gh4zi
2026-10-11 14:08:54 +03:00
parent 891edead2e
commit d9f497322e
8 changed files with 127 additions and 35 deletions
@@ -76,6 +76,7 @@ struct ScriptModelPoseLocals<'w, 's> {
remap: Local<'s, Vec<Option<usize>>>,
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));
@@ -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);
@@ -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<bevy::prelude::Res<crate::prepare::scene::world::MapDirPrimaryLight>>,
prepared: bevy::prelude::Res<crate::prepare::scene::view_parms::PreparedSceneView>,
scene: Option<bevy::prelude::Res<crate::prepare::scene::world::WorldScene>>,
mut volume: bevy::prelude::ResMut<render_scene::SunShadowCasterVolume>,
) {
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;
}
}
@@ -176,6 +176,17 @@ fn scene_ent_admitted(scene: Option<&crate::GfxScene>, entnum: Option<u32>) -> 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;
}
@@ -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)
+3 -1
View File
@@ -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,
};
+2 -1
View File
@@ -33,6 +33,7 @@ impl Plugin for RenderScenePlugin {
.init_resource::<RSubwindowDvar>()
.init_resource::<RLockPvs>()
.init_resource::<SmEnableDvar>()
.init_resource::<SmSunEnableDvar>();
.init_resource::<SmSunEnableDvar>()
.init_resource::<crate::SunShadowCasterVolume>();
}
}
+16
View File
@@ -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<Vec<[f32; 4]>>,
}
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,