From d9f497322ea4f49ba7ce1f4a08945ed41b018686 Mon Sep 17 00:00:00 2001 From: Gh4zi Date: Sun, 11 Oct 2026 14:08:54 +0300 Subject: [PATCH 1/3] 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, From a690ea55c07851347a6432f80e8e2aa54c72343d Mon Sep 17 00:00:00 2001 From: Gh4zi Date: Sun, 11 Oct 2026 15:19:58 +0300 Subject: [PATCH 2/3] Sun shadows: trees cast through their shadow-caster mesh again MW2 trees (Favela's oaks: mc/mtl_tree_oak_*) cast their sun shadow only through a separate, never-drawn mesh (mc/mtl_tree_shadow_caster). Its shadow-only material was kept out of the static model colour material keys, and the sun shadow pass looked its material up in those same keys, so the surface was dropped and the trees cast nothing. What stayed was the lightmap's sun mask: MW2's shader forces shadow where the mask is 0, which showed as blocky dark rectangles on otherwise sunlit ground under the trees. Shadow-only materials are now listed in their own key, read by the sun shadow pass only; the colour pass still never sees them. Co-Authored-By: Claude Opus 5.5 --- .../assemble/drawsurf/retained_list/smodel.rs | 6 +- .../src/assemble/drawsurf/tess/smodel.rs | 65 ++++++++++++++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/crates/render_frontend/src/assemble/drawsurf/retained_list/smodel.rs b/crates/render_frontend/src/assemble/drawsurf/retained_list/smodel.rs index bf94367..a7e31a6 100644 --- a/crates/render_frontend/src/assemble/drawsurf/retained_list/smodel.rs +++ b/crates/render_frontend/src/assemble/drawsurf/retained_list/smodel.rs @@ -366,7 +366,8 @@ pub(super) fn expand_smodel_destination( .lit_material_key .get(&(authored, placement.reflection_probe_index)) .copied() - .or_else(|| smodel_plan.unlit_material_key.get(&authored).copied()), + .or_else(|| smodel_plan.unlit_material_key.get(&authored).copied()) + .or_else(|| smodel_plan.shadow_material_key.get(&authored).copied()), }; let Some(material) = material else { continue; @@ -472,7 +473,8 @@ fn push_smodel_destination( .lit_material_key .get(&(authored, placement.reflection_probe_index)) .copied() - .or_else(|| smodel_plan.unlit_material_key.get(&authored).copied()), + .or_else(|| smodel_plan.unlit_material_key.get(&authored).copied()) + .or_else(|| smodel_plan.shadow_material_key.get(&authored).copied()), }; let Some(material) = material else { return stats; diff --git a/crates/render_frontend/src/assemble/drawsurf/tess/smodel.rs b/crates/render_frontend/src/assemble/drawsurf/tess/smodel.rs index f5b5661..0e7c3a1 100644 --- a/crates/render_frontend/src/assemble/drawsurf/tess/smodel.rs +++ b/crates/render_frontend/src/assemble/drawsurf/tess/smodel.rs @@ -95,6 +95,10 @@ pub struct SmodelGpuPlan { pub lit_material_key: std::collections::HashMap<(Option, u8), u32>, pub unlit_material_key: std::collections::HashMap, u32>, + + /// Shadow-caster surfaces (a tree's shadow mesh): drawn into the shadow maps only, so kept + /// out of the colour keys above. + pub shadow_material_key: std::collections::HashMap, u32>, pub upload_pending: bool, pub packed_share: Option>>, pub index_share: Option>>, @@ -490,6 +494,45 @@ pub fn expand_smodel_cached_vertices( plan.cached_skip_n = 0; } +/// Lists a shadow-caster surface's material for the shadow passes, and says whether it was one. +/// MW2 trees cast their sun shadow through such a surface alone, so dropping it with the colour +/// pass left them shadowless (only the lightmap's blocky shadow under them stayed). +fn register_shadow_only_material( + plan: &mut SmodelGpuPlan, + authored: Option, + catalog: &render_material::RuntimeMaterialCatalog, + exact: &[Option>], + material_sorted_index: Option, +) -> bool { + let Some(derived) = authored.and_then(|id| catalog.derived(id)) else { + return false; + }; + if !derived.shadow_only { + return false; + } + if !plan.shadow_material_key.contains_key(&authored) { + let maps = runtime_maps(authored, catalog, exact); + let mat_idx = plan.materials.len() as u32; + plan.materials.push(SmodelPassMaterial { + model_lighting_required: false, + color: maps.color, + specular: None, + probe: None, + atlas: None, + alpha_mode: maps.alpha_mode, + draw_mode: maps.draw_mode, + cull_mode: maps.cull_mode, + env_map_parms: maps.env_map_parms, + lighting_lookup_scale: [0.0; 4], + atlas_lookup: [0.0; 4], + sort_key: derived.sort_key, + material_sorted_index, + }); + plan.shadow_material_key.insert(authored, mat_idx); + } + true +} + pub(crate) fn build_smodel_gpu_plan( mut scene: Option>, job: Res, @@ -635,10 +678,13 @@ pub(crate) fn build_smodel_gpu_plan( if plan.lit_material_key.contains_key(&key) { continue; } - if authored - .and_then(|id| scene.runtime_material_catalog.derived(id)) - .is_some_and(|m| m.shadow_only) - { + if register_shadow_only_material( + &mut plan, + authored, + &scene.runtime_material_catalog, + exact, + sorted_ordinal(authored), + ) { continue; } let maps = runtime_maps(authored, &scene.runtime_material_catalog, exact); @@ -694,10 +740,13 @@ pub(crate) fn build_smodel_gpu_plan( if plan.unlit_material_key.contains_key(&authored) { continue; } - if authored - .and_then(|id| scene.runtime_material_catalog.derived(id)) - .is_some_and(|m| m.shadow_only) - { + if register_shadow_only_material( + &mut plan, + authored, + &scene.runtime_material_catalog, + exact, + sorted_ordinal(authored), + ) { continue; } let maps = runtime_maps(authored, &scene.runtime_material_catalog, exact); From 025ea1259dbcefa38ae43699fba02411d9d2cbbd Mon Sep 17 00:00:00 2001 From: Gh4zi Date: Sun, 11 Oct 2026 16:30:50 +0300 Subject: [PATCH 3/3] Boot: zone discovery skips cargo output and the artifacts folder When the games root is the build's own `target/play`, discovery walked every file of cargo's output (incremental, deps, build, .fingerprint, examples) and the artifacts folder, several times at boot: the window sat white and "Not Responding" for seconds before the map loaded. Those folders never hold zones, so the walk skips them (retail MW2, MW3 and Black Ops installs have no folders by these names). Co-Authored-By: Claude Opus 5.5 --- crates/asset_transport/src/discover.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/asset_transport/src/discover.rs b/crates/asset_transport/src/discover.rs index c715197..cf35515 100644 --- a/crates/asset_transport/src/discover.rs +++ b/crates/asset_transport/src/discover.rs @@ -250,6 +250,28 @@ fn game_files(root: &Path) -> impl Iterator> { files_under(search_roots(root)) } +/// Folders the game never keeps zones in: cargo's output when the games root is the build's own +/// `target/play` (tens of thousands of files, walked on every discovery), and our own +/// artifacts. +const NOT_GAME_DIRS: [&str; 6] = [ + "incremental", + "deps", + "build", + ".fingerprint", + "examples", + "iw4l-artifacts", +]; + +fn is_not_game_dir(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + NOT_GAME_DIRS + .iter() + .any(|skip| name.eq_ignore_ascii_case(skip)) + }) +} + fn files_under(roots: Vec) -> impl Iterator> { let mut pending: VecDeque<_> = roots.into_iter().map(Ok).collect(); let mut visited = HashSet::new(); @@ -259,6 +281,9 @@ fn files_under(roots: Vec) -> impl Iterator path, Err(error) => return Some(Err(error)), }; + if is_not_game_dir(&path) { + continue; + } let canonical = match std::fs::canonicalize(&path) { Ok(path) => path, Err(error) => {