Merge PR #95: restore Favela sun shadows and speed up game discovery

This commit is contained in:
vtroc7
2026-10-11 20:25:11 +03:00
11 changed files with 213 additions and 45 deletions
+25
View File
@@ -250,6 +250,28 @@ fn game_files(root: &Path) -> impl Iterator<Item = Result<PathBuf, String>> {
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<PathBuf>) -> impl Iterator<Item = Result<PathBuf, String>> {
let mut pending: VecDeque<_> = roots.into_iter().map(Ok).collect();
let mut visited = HashSet::new();
@@ -259,6 +281,9 @@ fn files_under(roots: Vec<PathBuf>) -> impl Iterator<Item = Result<PathBuf, Stri
Ok(path) => 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) => {
@@ -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);
@@ -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;
@@ -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;
}
}
@@ -95,6 +95,10 @@ pub struct SmodelGpuPlan {
pub lit_material_key: std::collections::HashMap<(Option<assets::MaterialIndex>, u8), u32>,
pub unlit_material_key: std::collections::HashMap<Option<assets::MaterialIndex>, 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<Option<assets::MaterialIndex>, u32>,
pub upload_pending: bool,
pub packed_share: Option<Arc<Vec<[u8; asset_iw4::size::GFX_PACKED_VERTEX]>>>,
pub index_share: Option<Arc<Vec<u32>>>,
@@ -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<assets::MaterialIndex>,
catalog: &render_material::RuntimeMaterialCatalog,
exact: &[Option<Handle<Image>>],
material_sorted_index: Option<u32>,
) -> 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<ResMut<crate::prepare::scene::world::WorldScene>>,
job: Res<crate::prepare::scene::spawn::WorldSpawnJob>,
@@ -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);
@@ -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,