From 69cf9ff9fa48f9f8275d985f337dc4249551d60d Mon Sep 17 00:00:00 2001 From: vtroc7 Date: Thu, 24 Sep 2026 05:30:17 +0300 Subject: [PATCH] Prepare weapon presentation and add alternate modes, device feedback and collision fixes --- Cargo.lock | 105 ++ Cargo.toml | 1 + crates/anim_iw4/src/lib.rs | 2 +- crates/anim_iw4/src/quat.rs | 30 +- crates/anim_iw4/src/sample.rs | 4 +- crates/approved_tests/README.md | 13 +- crates/approved_tests/src/report.rs | 52 +- crates/asset_audio/src/sound_catalog.rs | 35 + crates/asset_game/src/fpv_assembly.rs | 8 + crates/asset_game/src/menu_catalog.rs | 1 + crates/asset_game/src/weapon_animations.rs | 8 + crates/asset_game/src/weapon_catalog.rs | 301 +++++- crates/asset_material/src/material_catalog.rs | 30 +- crates/asset_transport/src/progress.rs | 5 +- crates/asset_world/src/clip_collision.rs | 30 + crates/assets/src/lane/sink.rs | 12 +- .../assets/src/session_load/common_cache.rs | 3 + crates/audio/src/clip_store.rs | 16 +- crates/audio/src/entity_events.rs | 234 ++++- crates/audio/src/lib.rs | 7 +- crates/audio/src/match_set.rs | 3 + crates/audio/src/messages.rs | 15 + crates/audio/src/playback.rs | 165 ++- crates/audio/src/plugin.rs | 2 + crates/audio/src/rumble.rs | 224 ++++ crates/audio/src/weapon_lock.rs | 77 ++ crates/bots/src/nav.rs | 2 +- crates/clipmap_iw4/src/mesh.rs | 152 ++- crates/clipmap_iw4/src/world_trace.rs | 1 + crates/console/src/plugin.rs | 27 +- crates/fastfile_iw4/src/load/clipmap.rs | 4 +- crates/fastfile_iw4/src/load/weapon.rs | 8 +- crates/fastfile_iw4/src/zone.rs | 5 + crates/fastfile_iw5/src/attachment.rs | 34 + crates/fastfile_iw5/src/lib.rs | 4 +- crates/fastfile_iw5/src/load/clipmap.rs | 4 +- crates/fastfile_iw5/src/load/weapon.rs | 8 +- crates/fastfile_iw5/src/weapon_override.rs | 2 +- crates/fastfile_iw5/src/zone.rs | 5 + crates/fastfile_t5/src/load/clipmap.rs | 3 +- crates/fastfile_t5/src/load/weapon.rs | 8 +- crates/fastfile_t5/src/zone.rs | 5 + crates/fx/src/draw.rs | 2 +- crates/fx/src/system.rs | 18 + crates/hud/Cargo.toml | 1 + crates/hud/src/flash.rs | 43 +- crates/hud/src/gpu_list.rs | 12 + crates/hud/src/plugin.rs | 2 + crates/hud/src/weaponbar.rs | 97 +- crates/hud_iw4/src/expr.rs | 17 +- crates/input_iw4/src/lib.rs | 8 +- crates/input_iw4/src/weapon_select.rs | 9 +- crates/net/src/client/runtime.rs | 61 +- crates/net/src/lib.rs | 2 +- crates/net/src/transport/meta_wire.rs | 25 + crates/net/src/transport/netfields.rs | 1 + crates/playerstate_iw4/src/playerstate.rs | 2 + crates/render_anim/src/anim/fpv.rs | 41 +- crates/render_anim/src/anim/fpv_host.rs | 89 +- crates/render_anim/src/anim/fpv_pose.rs | 2 + crates/render_anim/src/anim/fpv_prepared.rs | 971 ++++++++++++++++++ crates/render_anim/src/anim/fpv_rig.rs | 556 ++++++---- crates/render_anim/src/anim/mod.rs | 2 + .../render_anim/src/anim/model_materials.rs | 305 ++++++ crates/render_anim/src/anim/remote_body.rs | 183 +++- .../render_anim/src/anim/scene_submission.rs | 45 + .../src/anim/viewmodel_controller.rs | 20 +- crates/render_anim/src/draw_build.rs | 3 +- crates/render_anim/src/lib.rs | 5 + crates/render_anim/src/occupancy/dyn_ent.rs | 54 +- .../render_anim/src/occupancy/fpv_present.rs | 708 +++---------- crates/render_anim/src/occupancy/held_sync.rs | 72 +- crates/render_anim/src/occupancy/item.rs | 275 +++-- .../render_anim/src/occupancy/match_reset.rs | 5 + crates/render_anim/src/occupancy/missile.rs | 46 +- .../render_anim/src/occupancy/remote_body.rs | 148 ++- .../render_anim/src/occupancy/script_model.rs | 63 +- crates/render_backend/src/material_exec.rs | 46 +- crates/render_frame/src/retained.rs | 1 + .../render_frontend/src/adapters/fx/system.rs | 172 ++-- crates/render_frontend/src/adapters/mod.rs | 2 + .../src/adapters/motion_tracker.rs | 337 ++++++ .../src/assemble/drawsurf/depth_range.rs | 3 + .../src/assemble/drawsurf/frame_products.rs | 7 +- .../src/assemble/drawsurf/material_compile.rs | 5 + .../src/assemble/drawsurf/retained_list.rs | 14 +- .../src/assemble/drawsurf/state.rs | 4 +- crates/render_frontend/src/prepare/mod.rs | 2 + .../src/prepare/scene/spawn.rs | 52 +- .../src/prepare/scene/world_gpu.rs | 9 + .../src/prepare/scene/world_plan.rs | 120 +++ crates/render_fx/src/drawsurf/tess/fx.rs | 2 + crates/render_fx/src/host.rs | 1 + crates/render_fx/src/lib.rs | 2 + crates/render_fx/src/model_append.rs | 33 +- crates/render_fx/src/model_draw.rs | 74 +- crates/render_fx/src/system.rs | 3 + .../src/drawsurf/colour_submit/mod.rs | 26 +- .../src/drawsurf/colour_submit/pipeline.rs | 2 +- .../drawsurf/colour_submit/prepare_camera.rs | 8 +- .../src/drawsurf/colour_submit/record.rs | 2 +- crates/render_gpu/src/drawsurf/depth_range.rs | 3 + crates/render_gpu/src/drawsurf/draw.rs | 4 + .../src/drawsurf/geometry_diagnostic.rs | 9 +- crates/render_gpu/src/drawsurf/hud_blood.rs | 6 +- crates/render_gpu/src/drawsurf/iw_tess.rs | 267 +++-- crates/render_gpu/src/drawsurf/mod.rs | 1 + crates/render_gpu/src/drawsurf/scene_depth.rs | 142 +++ .../src/drawsurf/shadowmap_spot_gpu.rs | 2 +- .../src/drawsurf/shadowmap_sun_gpu.rs | 2 +- crates/render_gpu/src/drawsurf/state.rs | 4 +- crates/render_gpu/src/drawsurf/sun_effects.rs | 5 +- crates/render_material/src/execute.rs | 2 +- crates/session/src/combat_table.rs | 9 +- crates/session/src/content_manifest.rs | 22 +- crates/session/src/match_apply.rs | 2 + crates/sim/src/combat.rs | 61 +- crates/sim/src/content.rs | 3 + crates/sim/src/item.rs | 18 +- crates/sim/src/lib.rs | 6 +- crates/sim/src/match_state.rs | 26 + crates/sim/src/step.rs | 108 +- crates/sim/src/weapon_lock.rs | 132 +++ crates/sim/src/world.rs | 3 + crates/ui/src/class_store.rs | 188 ++++ crates/ui/src/load_table.rs | 2 + crates/ui/src/menu.rs | 7 +- crates/weapon_iw4/src/pm_weapon.rs | 29 +- crates/weapon_iw4/src/weapon_change.rs | 29 +- crates/xmodel_runtime/src/dobj.rs | 8 +- docs/ANIM.md | 5 + docs/MAP-LOAD.md | 10 + docs/RUN.md | 2 + 133 files changed, 5933 insertions(+), 1659 deletions(-) create mode 100644 crates/audio/src/rumble.rs create mode 100644 crates/audio/src/weapon_lock.rs create mode 100644 crates/render_anim/src/anim/fpv_prepared.rs create mode 100644 crates/render_anim/src/anim/model_materials.rs create mode 100644 crates/render_frontend/src/adapters/motion_tracker.rs create mode 100644 crates/render_gpu/src/drawsurf/scene_depth.rs create mode 100644 crates/sim/src/weapon_lock.rs diff --git a/Cargo.lock b/Cargo.lock index 350cc71..6f16899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,6 +920,22 @@ dependencies = [ "encase_derive_impl", ] +[[package]] +name = "bevy_gilrs" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7998fe83c89d527efa83c85c574a17f7bb6e34881d638ecb7f706d9d79e82f3c" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_platform", + "bevy_time", + "gilrs", + "thiserror 2.0.19", + "tracing", +] + [[package]] name = "bevy_gizmos" version = "0.19.0" @@ -1062,6 +1078,7 @@ dependencies = [ "bevy_derive", "bevy_diagnostic", "bevy_ecs", + "bevy_gilrs", "bevy_gizmos", "bevy_gizmos_render", "bevy_image", @@ -2754,6 +2771,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -2996,6 +3019,40 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "gilrs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows", +] + [[package]] name = "glam" version = "0.32.1" @@ -3171,6 +3228,7 @@ dependencies = [ "gamemode_iw4", "hud_iw4", "killcam_iw4", + "math_iw4", "movement_iw4", "net", "playerstate_iw4", @@ -3360,6 +3418,26 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "input_iw4" version = "0.1.0" @@ -3553,6 +3631,16 @@ dependencies = [ "redox_syscall 0.9.1", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "lighting_iw4" version = "0.1.0" @@ -4162,6 +4250,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -6201,6 +6300,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index c2fefad..b04a788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ bevy = { version = "0.19", default-features = false, features = [ "bevy_sprite_render", "bevy_asset", "bevy_audio", + "bevy_gilrs", "multi_threaded", "x11", "wayland", diff --git a/crates/anim_iw4/src/lib.rs b/crates/anim_iw4/src/lib.rs index ebca8bf..ee991df 100644 --- a/crates/anim_iw4/src/lib.rs +++ b/crates/anim_iw4/src/lib.rs @@ -53,7 +53,7 @@ pub use player_anim::{ }; pub use quat::{ QUAT_IDENTITY, Quat, VEC3_ZERO, Vec3, normalize, quat_add_weighted, quat_dot, quat_mul, - quat_neg, slerp, vec3_add_scaled, xanim_apply_additive, + quat_neg, vec3_add_scaled, xanim_apply_additive, }; pub use sample::{FrameKind, sample_quat, sample_vec3, span, time_to_frame}; pub use skel::{DuplicatePart, Skel}; diff --git a/crates/anim_iw4/src/quat.rs b/crates/anim_iw4/src/quat.rs index 8021510..eae7bd0 100644 --- a/crates/anim_iw4/src/quat.rs +++ b/crates/anim_iw4/src/quat.rs @@ -32,32 +32,12 @@ pub fn quat_add_weighted(acc: Quat, q: Quat, weight: f32) -> Quat { ] } -pub fn slerp(a: Quat, b: Quat, t: f32) -> Quat { - let mut b = b; - let mut dot = quat_dot(a, b); - if dot < 0.0 { - b = quat_neg(b); - dot = -dot; - } - if dot > 0.9995 { - return normalize([ - a[0] + t * (b[0] - a[0]), - a[1] + t * (b[1] - a[1]), - a[2] + t * (b[2] - a[2]), - a[3] + t * (b[3] - a[3]), - ]); - } - let theta_0 = libm::acosf(dot.clamp(-1.0, 1.0)); - let theta = theta_0 * t; - let sin_theta = libm::sinf(theta); - let sin_theta_0 = libm::sinf(theta_0); - let s0 = libm::cosf(theta) - dot * sin_theta / sin_theta_0; - let s1 = sin_theta / sin_theta_0; +pub fn lerp_quat(a: Quat, b: Quat, t: f32) -> Quat { [ - s0 * a[0] + s1 * b[0], - s0 * a[1] + s1 * b[1], - s0 * a[2] + s1 * b[2], - s0 * a[3] + s1 * b[3], + a[0] + t * (b[0] - a[0]), + a[1] + t * (b[1] - a[1]), + a[2] + t * (b[2] - a[2]), + a[3] + t * (b[3] - a[3]), ] } diff --git a/crates/anim_iw4/src/sample.rs b/crates/anim_iw4/src/sample.rs index 8493c9a..dd25dc7 100644 --- a/crates/anim_iw4/src/sample.rs +++ b/crates/anim_iw4/src/sample.rs @@ -1,4 +1,4 @@ -use crate::quat::{Quat, Vec3, lerp_vec3, slerp}; +use crate::quat::{Quat, Vec3, lerp_quat, lerp_vec3}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FrameKind { @@ -52,7 +52,7 @@ pub fn sample_quat(kind: FrameKind, frames: &[u16], values: &[Quat], frame: f32) return crate::quat::QUAT_IDENTITY; } let (a, b, t) = span(kind, frames, values.len(), frame); - slerp(values[a], values[b], t) + lerp_quat(values[a], values[b], t) } pub fn sample_vec3(kind: FrameKind, frames: &[u16], values: &[Vec3], frame: f32) -> Vec3 { diff --git a/crates/approved_tests/README.md b/crates/approved_tests/README.md index f9441eb..862741b 100644 --- a/crates/approved_tests/README.md +++ b/crates/approved_tests/README.md @@ -63,7 +63,16 @@ selection cannot move a replayed scene. `iw4l-artifacts/logs/latest.log` and `iw4l-artifacts/runs//` perf run. Assertions are liveness only: the map loaded, the players exist, simulation -advanced, input moved the player or the player died, the lifecycle -boundaries happened in order, and `quit` ended the process on its own. There +advanced, the authority applied the scene's movement commands to the local +player, the player covered a path of at least 32 units or died, the lifecycle +boundaries happened in order, and `quit` ended the process on its own. + +Movement is three facts, not one. `mark` reports the local player's input +receipt — commands the authority applied, how many of them carried a move, and +the summed length of every applied move — so a scene that turns while it walks +is judged by the path it walked, not by where it ended up. Each scene records an +`outcome`: `moved`, `died`, `died_before_input`, `held_in_place` (commands +applied, alive, no path: collision or a frozen player) or `input_not_applied` +(the break the scenario exists to catch). There is no frame-time assertion. A run the controller had to kill is a failure, never a quit. diff --git a/crates/approved_tests/src/report.rs b/crates/approved_tests/src/report.rs index 69dd032..35be4af 100644 --- a/crates/approved_tests/src/report.rs +++ b/crates/approved_tests/src/report.rs @@ -375,7 +375,13 @@ pub fn finish(run_dir: &Path, manifest: &mut Value, phases: &[Phase], outcome: & .next() .and_then(|n| n.parse::().ok()) .map_or(0, |n| n.saturating_sub(1)); - let ticks = SCENARIO.scenes.get(index).map_or(0, |s| s.ticks); + let declared = SCENARIO.scenes.get(index); + let ticks = declared.map_or(0, |s| s.ticks); + let commands_move = declared.is_some_and(|s| { + s.hold + .iter() + .any(|h| matches!(*h, "+forward" | "+back" | "+moveleft" | "+moveright")) + }); let phase_log = log.get(&phase); let spawn = phase_log .and_then(|l| l.forced_spawn.as_deref()) @@ -405,6 +411,25 @@ pub fn finish(run_dir: &Path, manifest: &mut Value, phases: &[Phase], outcome: & .and_then(|s| s.num("local_deaths")) .zip(done.and_then(|d| d.num("local_deaths"))) .is_some_and(|(a, b)| b > a); + let receipt = |key: &str| { + spawned + .and_then(|s| s.num(key)) + .zip(done.and_then(|d| d.num(key))) + .map(|(a, b)| b - a) + }; + let applied_cmds = receipt("local_cmds"); + let moving_cmds = receipt("local_moving_cmds"); + let path = receipt("local_path"); + let input_applied = applied_cmds.is_some_and(|n| n >= 1.0) + && (!commands_move || moving_cmds.is_some_and(|n| n >= 1.0)); + let moved = path.is_some_and(|p| p >= 32.0); + let outcome = match (input_applied, moved, died) { + (false, _, true) => "died_before_input", + (false, _, false) => "input_not_applied", + (true, true, _) => "moved", + (true, false, true) => "died", + (true, false, false) => "held_in_place", + }; let lives = spawned .and_then(|s| s.num("local_life")) .zip(done.and_then(|d| d.num("local_life"))) @@ -433,9 +458,24 @@ pub fn finish(run_dir: &Path, manifest: &mut Value, phases: &[Phase], outcome: & json!({ "alive_at_end": done.and_then(|d| d.num("alive")) }), ); asserts.check( - &format!("{phase}.input_moved_or_died"), - travel.is_some_and(|t| t >= 32.0) || died, - json!({ "travel_units": travel, "died": died }), + &format!("{phase}.input_applied"), + input_applied, + json!({ + "applied_cmds": applied_cmds, + "moving_cmds": moving_cmds, + "scene_commands_move": commands_move, + "died": died, + }), + ); + asserts.check( + &format!("{phase}.movement_observed"), + moved || died, + json!({ + "outcome": outcome, + "path_units": path, + "net_displacement_units": travel, + "died": died, + }), ); scene["observed"] = json!({ @@ -445,6 +485,10 @@ pub fn finish(run_dir: &Path, manifest: &mut Value, phases: &[Phase], outcome: & "at_inputs_done": done.map(facts), "ticks": tick_delta, "travel_units": travel, + "path_units": path, + "applied_cmds": applied_cmds, + "moving_cmds": moving_cmds, + "outcome": outcome, "died": died, "lives": lives, "kills_deaths_in_match": kills_deaths, diff --git a/crates/asset_audio/src/sound_catalog.rs b/crates/asset_audio/src/sound_catalog.rs index 18b701e..50e1ba0 100644 --- a/crates/asset_audio/src/sound_catalog.rs +++ b/crates/asset_audio/src/sound_catalog.rs @@ -640,6 +640,10 @@ impl SoundCatalog { found } + pub fn sound_at(&self, index: usize) -> Option<&CapturedSound> { + self.sounds.get(index) + } + pub fn sound_in(&self, ns: AssetNamespace, alias: &str) -> Option<&CapturedSound> { self.index_in(ns, alias).map(|i| &self.sounds[i]) } @@ -920,6 +924,16 @@ impl SoundCatalog { self.streamed_from_row(ns, row) } + pub fn streamed_for_variant_at( + &self, + index: usize, + variant: usize, + ) -> Option<(AssetNamespace, String, String)> { + let sound = self.sounds.get(index)?; + let row = sound.aliases.get(variant)?; + self.streamed_from_row(ns_of(sound.game), row) + } + fn streamed_from_row( &self, ns: AssetNamespace, @@ -1075,6 +1089,16 @@ impl SoundCatalog { self.pick_loaded_outcome_depth(ns, alias, rng, avoid, 0) } + pub fn pick_loaded_outcome_at<'a>( + &'a self, + index: usize, + rng: &mut u32, + avoid: Option, + ) -> Option> { + let sound = self.sounds.get(index)?; + self.pick_from_sound(ns_of(sound.game), sound, rng, avoid, 0) + } + fn pick_loaded_outcome_depth<'a>( &'a self, ns: AssetNamespace, @@ -1084,6 +1108,17 @@ impl SoundCatalog { depth: u8, ) -> Option> { let sound = self.sound_in(ns, alias)?; + self.pick_from_sound(ns, sound, rng, avoid, depth) + } + + fn pick_from_sound<'a>( + &'a self, + ns: AssetNamespace, + sound: &'a CapturedSound, + rng: &mut u32, + avoid: Option, + depth: u8, + ) -> Option> { if sound.aliases.is_empty() { return Some(PickLoadedOutcome { variant_index: 0, diff --git a/crates/asset_game/src/fpv_assembly.rs b/crates/asset_game/src/fpv_assembly.rs index 80886d5..7731ba2 100644 --- a/crates/asset_game/src/fpv_assembly.rs +++ b/crates/asset_game/src/fpv_assembly.rs @@ -30,6 +30,8 @@ pub struct FpvAssemblyTags { pub brass: Option, pub knife: Option, pub laser: Option, + pub tracker_screen: [Option; 3], + pub tracker_light: Option, } #[derive(Debug)] @@ -166,6 +168,12 @@ impl FpvAssembly { brass: tag("tag_brass"), knife: tag("tag_knife_fx"), laser: tag(fx_iw4::FX_LASER_TAG), + tracker_screen: [ + tag("tag_screen_tl"), + tag("tag_screen_bl"), + tag("tag_screen_br"), + ], + tracker_light: tag("tag_motion_tracker_fx"), }; let paired_bones = specs[0].0.num_bones + specs[1].0.num_bones; let parts = parts diff --git a/crates/asset_game/src/menu_catalog.rs b/crates/asset_game/src/menu_catalog.rs index 06b430f..0c2932f 100644 --- a/crates/asset_game/src/menu_catalog.rs +++ b/crates/asset_game/src/menu_catalog.rs @@ -416,6 +416,7 @@ pub const HUD_CHROME_MENUS: &[&str] = &[ "weaponbar_sd", "hud_fullscreen", "dpad_hd", + "javelin_overlay_hd", "dpad_sd", "splash", "challenge", diff --git a/crates/asset_game/src/weapon_animations.rs b/crates/asset_game/src/weapon_animations.rs index 2bc8336..342b593 100644 --- a/crates/asset_game/src/weapon_animations.rs +++ b/crates/asset_game/src/weapon_animations.rs @@ -122,6 +122,8 @@ pub struct WeaponAnimations { pub melee_charge_time_ms: i32, pub raise_time_ms: i32, + pub alternate_raise_time_ms: i32, + pub alternate_drop_time_ms: i32, pub drop_time_ms: i32, @@ -175,6 +177,8 @@ impl WeaponAnimations { melee_time_ms: 0, melee_charge_time_ms: 0, raise_time_ms: 0, + alternate_raise_time_ms: 0, + alternate_drop_time_ms: 0, drop_time_ms: 0, quick_drop_time_ms: 0, quick_raise_time_ms: 0, @@ -269,6 +273,8 @@ impl WeaponAnimations { melee_time_ms: 0, melee_charge_time_ms: 0, raise_time_ms: 0, + alternate_raise_time_ms: 0, + alternate_drop_time_ms: 0, drop_time_ms: 0, quick_drop_time_ms: 0, quick_raise_time_ms: 0, @@ -291,6 +297,8 @@ impl WeaponAnimations { fn with_registry_facts(mut self, registry: &WeaponRegistry, index: u32) -> Self { if let Some(facts) = registry.facts_of(index) { + self.alternate_raise_time_ms = facts.alternate_raise_time_ms; + self.alternate_drop_time_ms = facts.alternate_drop_time_ms; self.melee_time_ms = facts.melee_time_ms; self.melee_charge_time_ms = facts.melee_charge_time_ms; } diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index 1fabd89..3cad88b 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -30,6 +30,8 @@ pub struct WeaponBodyFacts { pub raise_time_ms: i32, pub drop_time_ms: i32, + pub alternate_raise_time_ms: i32, + pub alternate_drop_time_ms: i32, pub fire_delay_ms: i32, @@ -426,6 +428,7 @@ pub fn cac_offhand_bucket(offhand_class: i32) -> Option { #[derive(Clone, Debug)] pub struct CatalogWeapon { pub name: String, + pub alternate_weapon: Option, pub weap_def: Option<(u8, u32)>, @@ -540,6 +543,14 @@ pub struct Iw5ScopeRow { pub aim_assist: Option, pub ammunition: Option, pub damage: Option, + pub projectile: Option, + pub projectile_model: Option, + pub reticle: Option, + pub projectile_explosion_fx: Option, + pub projectile_trail_fx: Option, + pub projectile_ignition_fx: Option, + pub projectile_explosion_sound: Option, + pub projectile_ignition_sound: Option, pub location_damage: Option<[f32; 19]>, pub idle_settings: Option, pub ads_settings: Option, @@ -1122,6 +1133,7 @@ impl CatalogWeapon { ) -> Self { Self { name: name.into(), + alternate_weapon: None, weap_def, display_name_key: None, reticle: WeaponReticleAssets::default(), @@ -1196,11 +1208,21 @@ impl WeaponCatalog { &mut self, stream: &fastfile_iw5::ZoneStream<'_>, geometry: &fastfile_iw5::AttachmentGeometry, + fx_name_at_slot: &dyn Fn(fastfile_iw5::Ptr) -> Option, ) { let Some(name) = geometry.name.and_then(|ptr| leftover_cstr_iw5(stream, ptr)) else { return; }; let facts = &geometry.facts; + let projectile_fx = |x86, x64| { + let body = facts.projectile?.body; + match stream.ptr_at(body, stream.layout(x86, x64)).ok()? { + fastfile_iw5::ZonePtr::Offset(slot) => fx_name_at_slot(stream.resolve_alias(slot)), + _ => None, + } + }; + let projectile_sound = + |x86, x64| leftover_iw5_snd_alias(stream, facts.projectile?.body, x86, x64); let ads_settings = facts.ads_settings; self.iw5_attachments.insert( name.clone(), @@ -1246,9 +1268,36 @@ impl WeaponCatalog { reload: facts.reload, add_ons: facts.add_ons, general: facts.general, + reticle: facts.general.and_then(|general| general.body).map(|body| { + let center = leftover_iw5_material_name(stream, Some(body), 8, 8); + let side = leftover_iw5_material_name(stream, Some(body), 12, 16); + WeaponReticleAssets { + center_authored: center.is_some(), + side_authored: side.is_some(), + center_material: center, + side_material: side, + center_size: stream.i32_at(body, stream.layout(16, 24)).unwrap_or(0), + side_size: stream.i32_at(body, stream.layout(20, 28)).unwrap_or(0), + ..Default::default() + } + }), aim_assist: facts.aim_assist, ammunition: facts.ammunition, damage: facts.damage, + projectile: facts.projectile, + projectile_explosion_fx: projectile_fx(40, 48), + projectile_trail_fx: projectile_fx(76, 104), + projectile_ignition_fx: projectile_fx(84, 120), + projectile_explosion_sound: projectile_sound(48, 64), + projectile_ignition_sound: projectile_sound(88, 128), + projectile_model: facts.projectile.and_then(|p| p.model).and_then(|model| { + match stream.ptr_at(model, 0).ok()? { + fastfile_iw5::ZonePtr::Offset(name) => { + leftover_cstr_iw5(stream, stream.resolve_alias(name)) + } + _ => None, + } + }), location_damage: facts.location_damage, idle_settings: facts.idle_settings, ads_settings, @@ -1320,6 +1369,9 @@ impl WeaponCatalog { .unwrap_or([const { None }; WEAPON_ANIM_SLOTS]); let hide_tags = read_hide_tags(stream, &self.strings, geometry.hide_tags); self.entries.push(CatalogWeapon { + alternate_weapon: geometry + .alternate_weapon_name + .and_then(|p| read_name(stream, p)), name: name.to_owned(), weap_def: geometry.weap_def.map(ptr_key), display_name_key: geometry @@ -1542,6 +1594,8 @@ impl WeaponCatalog { impact_type: geometry.impact_type, raise_time_ms: geometry.raise_time_ms, drop_time_ms: geometry.drop_time_ms, + alternate_raise_time_ms: geometry.alternate_raise_time_ms, + alternate_drop_time_ms: geometry.alternate_drop_time_ms, fire_delay_ms: geometry.fire_delay_ms, hold_fire_time_ms: geometry.hold_fire_time_ms, weap_type: geometry.weap_type, @@ -2014,6 +2068,9 @@ impl WeaponCatalog { let leftover_anim_overrides = leftover_iw5_anim_overrides(stream, &geometry); apply_leftover_default_anim_overrides(&mut sz_xanims, &leftover_anim_overrides); self.entries.push(CatalogWeapon { + alternate_weapon: geometry + .alternate_weapon_name + .and_then(|p| leftover_cstr_iw5(stream, p)), reticle_center_slot: leftover_iw5_asset_slot( stream, geometry.weap_def, @@ -2135,6 +2192,11 @@ impl WeaponCatalog { .map(|arr| read_sz_xanims_t5(stream, arr)) .unwrap_or([const { None }; WEAPON_ANIM_SLOTS]); self.entries.push(CatalogWeapon { + alternate_weapon: geometry + .alternate_weapon_name + .and_then(|p| stream.cstr(p).ok()) + .filter(|name| !name.is_empty()) + .map(str::to_owned), reticle_center_slot: leftover_t5_asset_slot( stream, geometry.weap_def, @@ -2900,6 +2962,8 @@ fn capture_t5_body_facts( ads_move_speed_scale: geometry.ads_move_speed_scale, rechamber_time_ms: geometry.rechamber_time_ms, drop_time_ms: geometry.drop_time_ms, + alternate_raise_time_ms: geometry.alternate_raise_time_ms, + alternate_drop_time_ms: geometry.alternate_drop_time_ms, raise_time_ms: geometry.raise_time_ms, bolt_action: geometry.bolt_action, select_requires_ammo_at_0x667: Some(leftover_t5_select_requires_ammo()), @@ -3354,6 +3418,8 @@ fn capture_iw5_body_facts( facts.reload_start_add_time_ms = i32_at_iw5(stream, body, sz::WEAPON_DEF_RELOAD_START_ADD_TIME_OFF, 984); facts.reload_end_time_ms = i32_at_iw5(stream, body, sz::WEAPON_DEF_RELOAD_END_TIME_OFF, 988); + facts.alternate_raise_time_ms = geometry.alternate_raise_time_ms; + facts.alternate_drop_time_ms = geometry.alternate_drop_time_ms; facts.drop_time_ms = i32_at_iw5(stream, body, sz::WEAPON_DEF_DROP_TIME_OFF, 992); facts.raise_time_ms = i32_at_iw5(stream, body, sz::WEAPON_DEF_RAISE_TIME_OFF, 996); facts.quick_drop_time_ms = i32_at_iw5(stream, body, sz::WEAPON_DEF_QUICK_DROP_TIME_OFF, 1004); @@ -4718,6 +4784,8 @@ fn merge_body_facts(dst: &mut WeaponBodyFacts, src: WeaponBodyFacts) { #[derive(Clone, Debug)] struct WeaponRow { name: String, + alternate_weapon: Option, + alternate_index: u32, namespace: crate::AssetNamespace, @@ -4829,6 +4897,8 @@ impl Default for WeaponRow { fn default() -> Self { Self { name: String::new(), + alternate_weapon: None, + alternate_index: 0, namespace: crate::AssetNamespace::Iw4, facts: WeaponBodyFacts::default(), weap_def: None, @@ -4917,6 +4987,7 @@ pub struct WeaponRegistry { families: crate::WeaponFamilies, + alternate_fpv: HashMap<(u32, u32), [Option>; 2]>, fpv_clip_tracks: Arc, revision: u64, @@ -4991,7 +5062,10 @@ impl WeaponBuild { .registry .resolve_iw5_attachment_slots(base_id, &selection.attachments) .ok() - .and_then(|native| self.registry.compose_iw5_configuration(base_id, native)); + .and_then(|native| { + self.registry + .compose_iw5_configuration(base_id, native, false) + }); match row { Some(row) => prepared.push((base_id, selection, row)), None => census.refused.push(selection), @@ -5013,6 +5087,52 @@ impl WeaponBuild { self.combat_slots.push(slots); self.registry.rows.push(row); } + let primary_count = self.registry.rows.len(); + for parent in 1..primary_count { + let Some((base, native)) = self.registry.rows[parent].iw5_configuration else { + continue; + }; + let Some(mut alternate) = self.registry.compose_iw5_configuration(base, native, true) + else { + continue; + }; + let index = self.registry.rows.len() as u32; + let primary = &self.registry.rows[parent]; + let underbarrel = native.underbarrel as usize + 5; + let shared = self.registry.rows[base as usize].iw5_attachment_slots[underbarrel] + .as_deref() + .and_then(|name| self.registry.iw5_attachments.get(name)) + .is_some_and(|a| a.share_ammo_with_alt); + alternate.facts.ammo_index = if shared { + if primary.facts.ammo_index != 0 { + primary.facts.ammo_index + } else { + parent as i32 + } + } else { + (parent as i32) | 0x1000000 + }; + alternate.facts.clip_index = if shared { + if primary.facts.clip_index != 0 { + primary.facts.clip_index + } else { + parent as i32 + } + } else { + (parent as i32) | 0x1000000 + }; + alternate.prepared_attachments = primary.prepared_attachments.clone(); + alternate.alternate_weapon = None; + alternate.alternate_index = 0; + self.registry.rows[parent].alternate_index = index; + self.combat_slots.push( + self.combat_slots + .get(base as usize) + .copied() + .unwrap_or_default(), + ); + self.registry.rows.push(alternate); + } self.registry.rebuild_name_maps(); self.registry.revision = mint_weapon_revision(); census @@ -5275,11 +5395,38 @@ impl WeaponBuild { HashMap::new(); let mut tracks = crate::FpvClipTracks::default(); let mut census = FpvAssemblyCensus::default(); - let hide_tags: Vec> = (0..self.registry.rows.len() as u32) - .map(|id| crate::effective_hide_tags(&self.registry, id)) + self.registry.alternate_fpv.clear(); + let mut variants = Vec::new(); + for parent in 1..self.registry.rows.len() as u32 { + let alternate = self.registry.alternate_of(parent); + if alternate != 0 + && self + .registry + .facts_of(alternate) + .is_some_and(|f| f.inventory_type == 3) + { + variants.push((alternate, parent)); + } + } + let preparations: Vec<_> = (0..self.registry.rows.len() as u32) + .map(|id| (id, 0)) + .chain(variants) + .map(|(id, parent)| { + ( + id, + parent, + crate::effective_hide_tags( + &self.registry, + if parent == 0 { id } else { parent }, + ), + ) + }) .collect(); - for (row, hide_tags) in self.registry.rows.iter_mut().zip(hide_tags) { - row.fpv_assemblies = [None, None]; + for (id, parent, hide_tags) in preparations { + let row = &mut self.registry.rows[id as usize]; + if parent == 0 { + row.fpv_assemblies = [None, None]; + } let Some(Ok(mounts)) = &row.fpv_mount_plan else { continue; }; @@ -5342,7 +5489,11 @@ impl WeaponBuild { Err(_) => census.refused += 1, } } - row.fpv_assemblies = sides; + if parent == 0 { + row.fpv_assemblies = sides; + } else { + self.registry.alternate_fpv.insert((id, parent), sides); + } } census.clip_tables = tracks.len(); self.registry.fpv_clip_tracks = Arc::new(tracks); @@ -5534,6 +5685,9 @@ impl WeaponBuild { } std::collections::hash_map::Entry::Occupied(mut slot) => { let existing = slot.get_mut(); + if existing.alternate_weapon.is_none() { + existing.alternate_weapon = entry.alternate_weapon; + } if existing.gun_xmodel.is_none() { existing.gun_xmodel = entry.gun_xmodel; } @@ -5638,6 +5792,8 @@ impl WeaponBuild { combat_slots.push(entry.combat_slots); rows.push(WeaponRow { name, + alternate_weapon: entry.alternate_weapon, + alternate_index: 0, namespace: crate::AssetNamespace::Iw4, facts: entry.facts, weap_def: entry.weap_def, @@ -5714,6 +5870,7 @@ impl WeaponBuild { by_namespaced: HashMap::new(), item_groups: HashMap::new(), families: crate::WeaponFamilies::default(), + alternate_fpv: HashMap::new(), fpv_clip_tracks: Arc::default(), revision: mint_weapon_revision(), }; @@ -6025,6 +6182,7 @@ impl WeaponRegistry { &self, base_id: u32, native: Iw5AttachmentSelection, + alternate: bool, ) -> Option { use fastfile_iw5::size as sz; let base = self.rows.get(base_id as usize)?; @@ -6073,10 +6231,49 @@ impl WeaponRegistry { row.attachment_view_models = view; row.attachment_world_models = world; - let assets = self.iw5_primary_attachment_assets(base_id, native)?; - apply_iw5_parameter_blocks(&mut row.facts, &assets); + let primary_assets = self.iw5_primary_attachment_assets(base_id, native)?; + let alternate_assets; + let assets = if alternate { + let underbarrel = underbarrel.filter(|asset| asset.weapon_class != 0)?; + row.facts.weap_type = remap_iw5_weap_type(underbarrel.weapon_type); + row.facts.weap_class = underbarrel.weapon_class; + row.facts.inventory_type = 3; + alternate_assets = if underbarrel.share_ammo_with_alt { + primary_assets + } else { + vec![underbarrel] + }; + &alternate_assets + } else { + &primary_assets + }; + apply_iw5_parameter_blocks(&mut row.facts, assets); + if let Some(reticle) = assets.iter().find_map(|asset| asset.reticle.as_ref()) { + row.reticle = reticle.clone(); + } + if alternate { + if let Some(projectile) = iw5_first_block(assets, |asset| asset.projectile) { + row.facts.explosion_radius = projectile.explosion_radius; + row.facts.explosion_inner_damage = projectile.explosion_inner_damage; + row.facts.explosion_outer_damage = projectile.explosion_outer_damage; + row.facts.projectile_speed = projectile.speed; + row.facts.projectile_speed_up = projectile.speed_up; + row.facts.projectile_activate_dist = projectile.activate_distance; + row.facts.projectile_explosion_type = projectile.explosion_type; + row.facts.proj_impact_explode = projectile.impact_explode; + let asset = assets.iter().find(|asset| asset.projectile.is_some())?; + row.projectile_model = asset.projectile_model.clone(); + row.combat_fx.explosion_hint = asset.projectile_explosion_fx.clone(); + row.proj_trail = asset.projectile_trail_fx.clone(); + row.proj_trail_from_slot = row.proj_trail.is_some(); + row.proj_ignition = asset.projectile_ignition_fx.clone(); + row.proj_ignition_from_slot = row.proj_ignition.is_some(); + row.sounds.proj_explosion = asset.projectile_explosion_sound.clone(); + row.sounds.proj_ignition_sound = asset.projectile_ignition_sound.clone(); + } + } - if let Some(scope) = scope { + if let Some(scope) = assets.iter().find(|asset| asset.overlay.is_some()) { if let Some(overlay) = scope .overlay .as_deref() @@ -6104,12 +6301,21 @@ impl WeaponRegistry { let Some(slot) = iw5_anim_tree_type_to_iw4_slot(anim_type) else { continue; }; - if let Some(anim) = chosen.override_anim.clone() { + if let Some(anim) = if alternate { + chosen.altmode_anim.clone() + } else { + chosen.override_anim.clone() + } { row.sz_xanims[slot] = Some(anim); } - if chosen.anim_time_ms > 0 { + let time = if alternate { + chosen.alt_time_ms + } else { + chosen.anim_time_ms + }; + if time > 0 { if let Some(timer) = iw5_anim_timer(&mut row.facts, slot) { - *timer = chosen.anim_time_ms; + *timer = time; } } } @@ -6131,7 +6337,13 @@ impl WeaponRegistry { ] { if let Some(sound) = self .select_iw5_sound_override(base_id, native, sound_type) - .and_then(|chosen| chosen.override_sound.clone()) + .and_then(|chosen| { + if alternate { + chosen.altmode_sound.clone() + } else { + chosen.override_sound.clone() + } + }) { *target = Some(sound); } @@ -6145,7 +6357,13 @@ impl WeaponRegistry { ] { if let Some(fx) = self .select_iw5_fx_override(base_id, native, fx_type) - .and_then(|chosen| chosen.override_fx.clone()) + .and_then(|chosen| { + if alternate { + chosen.altmode_fx.clone() + } else { + chosen.override_fx.clone() + } + }) { *hint = Some(fx); } @@ -6176,6 +6394,15 @@ impl WeaponRegistry { .insert((row.namespace, row.name.clone()), id as u32); self.by_name.entry(row.name.clone()).or_insert(id as u32); } + for row in &mut self.rows { + if let Some(name) = row.alternate_weapon.as_deref() { + row.alternate_index = self + .by_namespaced + .get(&(row.namespace, normalize_weapon_name(name))) + .copied() + .unwrap_or(0); + } + } } pub fn reticle_of(&self, index: u32) -> Option<&WeaponReticleAssets> { @@ -6210,6 +6437,17 @@ impl WeaponRegistry { .get(¬e.to_ascii_lowercase()) } + pub fn notetrack_actions_of( + &self, + index: u32, + ) -> impl Iterator { + self.rows + .get(index as usize) + .into_iter() + .flat_map(|row| row.notetrack_actions.iter()) + .map(|(note, action)| (note.as_str(), action)) + } + pub fn notetrack_sound_aliases_of(&self, index: u32) -> impl Iterator { self.rows .get(index as usize) @@ -6304,6 +6542,25 @@ impl WeaponRegistry { .ok() } + pub fn alternate_fpv_pairs(&self) -> impl Iterator + '_ { + self.alternate_fpv.keys().copied() + } + + pub fn fpv_assemblies_for( + &self, + index: u32, + parent: u32, + axis: bool, + ) -> Option<&crate::FpvSideAssemblies> { + if parent != 0 && self.facts_of(index).is_some_and(|f| f.inventory_type == 3) { + return self.alternate_fpv.get(&(index, parent))?[usize::from(axis)] + .as_ref()? + .as_ref() + .ok(); + } + self.fpv_assemblies_of(index, axis) + } + pub fn fpv_assemblies_of(&self, index: u32, axis: bool) -> Option<&crate::FpvSideAssemblies> { self.rows.get(index as usize)?.fpv_assemblies[usize::from(axis)] .as_ref()? @@ -6615,6 +6872,20 @@ impl WeaponRegistry { self.rows.get(index as usize).map(|row| row.facts) } + pub fn alternate_name_of(&self, index: u32) -> Option<&str> { + self.rows + .get(index as usize)? + .alternate_weapon + .as_deref() + .filter(|name| !name.is_empty()) + } + + pub fn alternate_of(&self, index: u32) -> u32 { + self.rows + .get(index as usize) + .map_or(0, |row| row.alternate_index) + } + pub fn resolve_index(&self, name: &str) -> Result, UnknownWeaponName> { if name.is_empty() { return Ok(None); @@ -7476,6 +7747,8 @@ fn iw5_anim_timer(facts: &mut WeaponBodyFacts, slot: usize) -> Option<&mut i32> weap_anim::RELOAD_EMPTY => &mut facts.reload_empty_time_ms, weap_anim::RELOAD_START => &mut facts.reload_start_time_ms, weap_anim::RELOAD_END => &mut facts.reload_end_time_ms, + weap_anim::ALT_RAISE => &mut facts.alternate_raise_time_ms, + weap_anim::ALT_DROP => &mut facts.alternate_drop_time_ms, weap_anim::RAISE => &mut facts.raise_time_ms, weap_anim::DROP => &mut facts.drop_time_ms, weap_anim::QUICK_RAISE => &mut facts.quick_raise_time_ms, diff --git a/crates/asset_material/src/material_catalog.rs b/crates/asset_material/src/material_catalog.rs index 02c0943..8c8ee2e 100644 --- a/crates/asset_material/src/material_catalog.rs +++ b/crates/asset_material/src/material_catalog.rs @@ -91,6 +91,7 @@ pub struct MaterialConstant { #[derive(Clone, Debug)] pub struct AuthoredShader { + pub namespace: crate::AssetNamespace, pub name: AssetRef, pub kind: AssetType, @@ -646,11 +647,11 @@ impl MaterialCatalog { } fn link_shader(&mut self, incoming: AuthoredShader) -> usize { - if let Some(index) = self - .shaders - .iter() - .position(|owned| owned.kind == incoming.kind && owned.name.same_name(&incoming.name)) - { + if let Some(index) = self.shaders.iter().position(|owned| { + owned.namespace == incoming.namespace + && owned.kind == incoming.kind + && owned.name.same_name(&incoming.name) + }) { let take_body = AssetRef::incoming_owns_slot(&self.shaders[index].name, &incoming.name) && !(incoming.program.is_empty() && !self.shaders[index].program.is_empty()); if take_body { @@ -1003,7 +1004,9 @@ impl MaterialCatalog { let shader_ids = shaders .into_iter() .map(|shader| { - if let Some(index) = self.real_shader_index(&shader.name, shader.kind) { + if let Some(index) = + self.real_shader_index(shader.namespace, &shader.name, shader.kind) + { return index; } self.link_shader(shader) @@ -1063,9 +1066,17 @@ impl MaterialCatalog { .position(|owned| owned.name.is_real() && owned.name.same_name(name)) } - fn real_shader_index(&self, name: &AssetRef, kind: AssetType) -> Option { + fn real_shader_index( + &self, + namespace: crate::AssetNamespace, + name: &AssetRef, + kind: AssetType, + ) -> Option { self.shaders.iter().position(|owned| { - owned.kind == kind && owned.name.is_real() && owned.name.same_name(name) + owned.namespace == namespace + && owned.kind == kind + && owned.name.is_real() + && owned.name.same_name(name) }) } @@ -1674,6 +1685,7 @@ impl MaterialCatalog { } } Some(self.take_shader_slot(AuthoredShader { + namespace: self.capture_ns, name, kind, program: bytes, @@ -2613,6 +2625,7 @@ impl MaterialCatalog { } } Some(self.take_shader_slot(AuthoredShader { + namespace: self.capture_ns, name, kind, program: bytes, @@ -2934,6 +2947,7 @@ impl MaterialCatalog { _ => return None, }; Some(self.take_shader_slot(AuthoredShader { + namespace: self.capture_ns, name, kind, program: bytes, diff --git a/crates/asset_transport/src/progress.rs b/crates/asset_transport/src/progress.rs index 42b0117..eb718b1 100644 --- a/crates/asset_transport/src/progress.rs +++ b/crates/asset_transport/src/progress.rs @@ -45,6 +45,7 @@ pub enum StageId { Pipelines, /// Consecutive frames drawn with nothing left compiling. RenderFrames, + FirstPerson, /// Converting the clips this match's aliases resolve to. Audio, /// Baking the bot navigation graph. @@ -54,7 +55,7 @@ pub enum StageId { } impl StageId { - pub const ALL: [Self; 16] = [ + pub const ALL: [Self; 17] = [ Self::MapAssets, Self::CommonAssets, Self::Localization, @@ -68,6 +69,7 @@ impl StageId { Self::GpuTextures, Self::Pipelines, Self::RenderFrames, + Self::FirstPerson, Self::Audio, Self::Navigation, Self::Admission, @@ -88,6 +90,7 @@ impl StageId { Self::GpuTextures => "gpu_textures", Self::Pipelines => "pipelines", Self::RenderFrames => "render_frames", + Self::FirstPerson => "first_person", Self::Audio => "audio", Self::Navigation => "navigation", Self::Admission => "admission", diff --git a/crates/asset_world/src/clip_collision.rs b/crates/asset_world/src/clip_collision.rs index 3af2e3c..11dda23 100644 --- a/crates/asset_world/src/clip_collision.rs +++ b/crates/asset_world/src/clip_collision.rs @@ -409,6 +409,16 @@ fn extract_mesh_tables( mesh.tri_indices.push(id); } } + if let Some(walk_ptr) = g.tri_edge_is_walkable { + let n = g.tri_count.saturating_mul(3).div_ceil(32) * 4; + mesh.tri_edge_is_walkable.reserve(n); + for i in 0..n { + let b = s + .u8_at(walk_ptr, i) + .map_err(|_| ClipCollisionError::Truncated)?; + mesh.tri_edge_is_walkable.push(b); + } + } extract_mesh_materials(s, g, out)?; extract_aabb_forest(s, g, out)?; Ok(()) @@ -1293,6 +1303,16 @@ fn extract_iw5_mesh_tables( mesh.tri_indices.push(id); } } + if let Some(walk_ptr) = g.tri_edge_is_walkable { + let n = g.tri_count.saturating_mul(3).div_ceil(32) * 4; + mesh.tri_edge_is_walkable.reserve(n); + for i in 0..n { + let b = s + .u8_at(walk_ptr, i) + .map_err(|_| ClipCollisionError::Truncated)?; + mesh.tri_edge_is_walkable.push(b); + } + } extract_iw5_mesh_materials(s, g, out)?; extract_iw5_aabb_forest(s, g, out)?; Ok(()) @@ -1776,6 +1796,16 @@ fn extract_t5_mesh_tables( mesh.tri_indices.push(id); } } + if let Some(walk_ptr) = g.tri_edge_is_walkable { + let n = g.tri_count.saturating_mul(3).div_ceil(32) * 4; + mesh.tri_edge_is_walkable.reserve(n); + for i in 0..n { + let b = s + .u8_at(walk_ptr, i) + .map_err(|_| ClipCollisionError::Truncated)?; + mesh.tri_edge_is_walkable.push(b); + } + } use fastfile_t5::size as t5; if let Some(materials) = g.materials { for i in 0..g.material_count { diff --git a/crates/assets/src/lane/sink.rs b/crates/assets/src/lane/sink.rs index 095b12b..e9ce3fe 100644 --- a/crates/assets/src/lane/sink.rs +++ b/crates/assets/src/lane/sink.rs @@ -508,7 +508,17 @@ impl fastfile_iw5::AssetLinkSink for CommonWalkSink { stream: &fastfile_iw5::ZoneStream<'_>, geometry: &fastfile_iw5::AttachmentGeometry, ) -> fastfile_iw5::Result<()> { - self.weapons.capture_iw5_attachment(stream, geometry); + let fx_name_at_slot = |mut slot| { + for _ in 0..8 { + if let Some(name) = self.fx_names_iw5.get(&slot) { + return Some(name.clone()); + } + slot = *self.fx_aliases_iw5.get(&slot)?; + } + None + }; + self.weapons + .capture_iw5_attachment(stream, geometry, &fx_name_at_slot); Ok(()) } diff --git a/crates/assets/src/session_load/common_cache.rs b/crates/assets/src/session_load/common_cache.rs index 6fe219c..5438c4e 100644 --- a/crates/assets/src/session_load/common_cache.rs +++ b/crates/assets/src/session_load/common_cache.rs @@ -598,6 +598,9 @@ async fn prepare_common(key: CommonKey) -> Arc { weapons.set_family_tables(cac_tables.clone()); let iw5_prepared = weapons.prepare_iw5_configurations(); weapons.resolve_fpv_mesh_edges(&fpv_meshes); + weapons.resolve_fpv_hands(&fpv_meshes, &crate::BodyMeshCatalog::default()); + weapons.resolve_world_model_edges(&world_weapons); + common_report.push(format!( "IW5 configurations: prepared={} refused={} {:?}", iw5_prepared.prepared, diff --git a/crates/audio/src/clip_store.rs b/crates/audio/src/clip_store.rs index f4e24c0..39df827 100644 --- a/crates/audio/src/clip_store.rs +++ b/crates/audio/src/clip_store.rs @@ -498,6 +498,7 @@ impl ClipStore { pub(crate) struct PendingOneshot { pub namespace: AssetNamespace, pub alias: String, + pub bound: Option, pub variant: usize, pub volume: f32, pub pitch: f32, @@ -596,6 +597,7 @@ pub(crate) fn clip_key_for_variant( bank: &SoundCatalog, ns: AssetNamespace, alias: &str, + bound: Option, variant: usize, loaded_name: Option<&str>, loaded_ns: Option, @@ -605,15 +607,21 @@ pub(crate) fn clip_key_for_variant( { return Some(ClipKey::Loaded(idx)); } - if let Some(idx) = bank - .sound_in(ns, alias) + let sound = match bound { + Some(index) => bank.sound_at(index), + None => bank.sound_in(ns, alias), + }; + if let Some(idx) = sound .and_then(|s| s.aliases.get(variant)) .and_then(|row| row.loaded.bound_index()) { return Some(ClipKey::Loaded(idx)); } - bank.streamed_for_variant(ns, alias, variant) - .map(|(sns, dir, name)| ClipKey::Streamed { ns: sns, dir, name }) + match bound { + Some(index) => bank.streamed_for_variant_at(index, variant), + None => bank.streamed_for_variant(ns, alias, variant), + } + .map(|(sns, dir, name)| ClipKey::Streamed { ns: sns, dir, name }) } static WORKERS: AtomicU64 = AtomicU64::new(0); diff --git a/crates/audio/src/entity_events.rs b/crates/audio/src/entity_events.rs index 251a98c..f58212f 100644 --- a/crates/audio/src/entity_events.rs +++ b/crates/audio/src/entity_events.rs @@ -1,4 +1,7 @@ -use assets::{LinkedNotetrackAction, PreparedWeapons, WeaponSoundSlot}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use assets::{PreparedWeapons, WeaponSoundSlot}; use bevy::prelude::*; use net::{CEntity, EntityEventKind, LocalPresentClient, PresentedSnapshot}; @@ -290,62 +293,223 @@ pub(crate) fn register_entity_event_audio(app: &mut App) { .add_observer(cg_grenade_contact); } +#[derive(Clone, Debug)] +enum NotetrackSound { + Bound(usize), + Unbound(String), +} + +#[derive(Clone, Debug)] +struct BoundNotetrack { + sound: Option, + rumble: Option, String>>, +} + +#[derive(Resource, Default)] +pub(crate) struct NotetrackSoundTable { + owner: Option<(Arc, Arc)>, + actions: HashMap<(u32, String), BoundNotetrack>, + reported: HashSet<(u32, String)>, +} + +impl NotetrackSoundTable { + fn owns( + &self, + bank: &Arc, + weapons: &Arc, + ) -> bool { + self.owner + .as_ref() + .is_some_and(|(b, w)| Arc::ptr_eq(b, bank) && Arc::ptr_eq(w, weapons)) + } + + fn bind(bank: &Arc, weapons: &Arc) -> Self { + let mut actions = HashMap::new(); + let mut unbound = 0usize; + let mut rumbles = HashMap::new(); + for weapon in 1..=weapons.len() as u32 { + let namespace = weapons + .namespace_of(weapon) + .unwrap_or(assets::AssetNamespace::Iw4); + for (note, action) in weapons.notetrack_actions_of(weapon) { + let sound = action.sound_alias.as_deref().map(|alias| { + match bank.index_in(namespace, alias) { + Some(index) => NotetrackSound::Bound(index), + None => { + unbound += 1; + NotetrackSound::Unbound(alias.to_owned()) + } + } + }); + actions.insert( + (weapon, note.to_owned()), + BoundNotetrack { + sound, + rumble: action.rumble_alias.as_ref().map(|alias| { + rumbles + .entry((namespace, alias.clone())) + .or_insert_with(|| { + crate::rumble::Rumble::prepare(bank, namespace, alias) + }) + .clone() + }), + }, + ); + } + } + for ((namespace, alias), rumble) in &rumbles { + if let Err(error) = rumble { + diag::warn!( + Audio, + "audio: {namespace:?} rumble `{alias}` refused during binding: {error}" + ); + } + } + diag::info!( + Audio, + "audio: viewmodel notetracks bound to the sound bank: actions={} unbound_sounds={unbound} rumbles={} refused_rumbles={}", + actions.len(), + rumbles.len(), + rumbles.values().filter(|value| value.is_err()).count() + ); + Self { + owner: Some((Arc::clone(bank), Arc::clone(weapons))), + actions, + reported: HashSet::new(), + } + } + + fn report_once(&mut self, weapon: u32, note: &str, message: impl FnOnce() -> String) { + if self.reported.insert((weapon, note.to_owned())) { + diag::warn!(Audio, "{}", message()); + } + } +} + +pub(crate) fn bind_notetrack_sounds( + bank: Option>, + weapons: Option>, + mut table: ResMut, +) { + let (Some(bank), Some(weapons)) = (bank, weapons) else { + if table.owner.is_some() { + *table = NotetrackSoundTable::default(); + } + return; + }; + if table.owns(&bank.0, &weapons.0) { + return; + } + *table = NotetrackSoundTable::bind(&bank.0, &weapons.0); +} + pub(crate) fn play_viewmodel_notetrack_messages( mut notes: MessageReader, + generation: Res, + presented: Res, + local: Res, weapons: Option>, - mut output: MessageWriter, + bank: Option>, + mut table: ResMut, + mut output: MessageWriter, + mut rumbles: MessageWriter, ) { + let bound_bank = bank + .as_deref() + .zip(weapons.as_deref()) + .filter(|(bank, weapons)| table.owns(&bank.0, &weapons.0)) + .map(|(bank, _)| Arc::clone(&bank.0)); for batch in notes.read() { - let namespace = weapons - .as_deref() - .and_then(|weapons| weapons.0.namespace_of(batch.weapon)) - .unwrap_or(assets::AssetNamespace::Iw4); + if batch.generation != *generation + || batch.client != local.0 + || !presented + .snapshot() + .and_then(|snapshot| snapshot.meta.for_client(local.0)) + .is_some_and(|meta| { + meta.life_sequence == batch.life + && meta.lifecycle == sim::ClientLifecycle::Alive + }) + { + continue; + } for name in &batch.names { - let action = weapons - .as_deref() - .and_then(|weapons| weapons.0.notetrack_action_of(batch.weapon, name)); - apply_viewmodel_notetrack(name, action, namespace, &mut output); + apply_viewmodel_notetrack( + batch.weapon, + name, + bound_bank.as_deref(), + &mut table, + &mut output, + &mut rumbles, + ); } } } fn apply_viewmodel_notetrack( + weapon: u32, note: &str, - action: Option<&LinkedNotetrackAction>, - namespace: assets::AssetNamespace, - output: &mut MessageWriter, + bank: Option<&assets::SoundCatalog>, + table: &mut NotetrackSoundTable, + output: &mut MessageWriter, + rumbles: &mut MessageWriter, ) { if note.eq_ignore_ascii_case("end") { return; } if note.eq_ignore_ascii_case("NVG_on_powerup") || note.eq_ignore_ascii_case("NVG_off_powerdown") { - diag::warn!( - Audio, - "audio: NVG notetrack `{note}` has no cgMedia alias (typed gap)" - ); + table.report_once(weapon, note, || { + format!("audio: NVG notetrack `{note}` has no cgMedia alias (typed gap)") + }); } - let rumble = action.and_then(|action| action.rumble_alias.as_deref()); - if let Some(rumble) = rumble { - diag::warn!( - Audio, - "audio: notetrack rumble `{rumble}` is not ported (typed gap)" - ); - } - if let Some(alias) = action.and_then(|action| action.sound_alias.as_deref()) { - output.write(WeaponSound { - namespace, - alias: alias.to_owned(), - origin_inches: None, - snd_ent: Some(crate::SND_ENT_LOCAL), + let Some(bank) = bank else { + table.report_once(weapon, note, || { + format!("audio: notetrack `{note}` dropped — notetracks are not bound to the installed sound bank") }); return; + }; + let key = (weapon, note.to_ascii_lowercase()); + let Some(action) = table.actions.get(&key).cloned() else { + table.report_once(weapon, note, || { + format!("audio: notetrack `{note}` has no sound or rumble mapping") + }); + return; + }; + if let Some(rumble) = &action.rumble { + match rumble { + Ok(rumble) => { + rumbles.write(crate::rumble::PlayRumble { + bank_revision: bank.revision(), + rumble: Arc::clone(rumble), + }); + } + Err(error) => table.report_once(weapon, note, || { + format!("audio: notetrack `{note}` rumble refused: {error}") + }), + } } - if rumble.is_none() { - diag::warn!( - Audio, - "audio: notetrack `{note}` has no sound or rumble mapping" - ); + match action.sound { + Some(NotetrackSound::Bound(index)) => { + output.write(crate::BoundWeaponSound { + bank_revision: bank.revision(), + index, + origin_inches: None, + snd_ent: Some(crate::SND_ENT_LOCAL), + }); + } + Some(NotetrackSound::Unbound(alias)) => { + table.report_once(weapon, note, || { + format!( + "audio: notetrack `{note}` sound `{alias}` is not in the sound bank (refused when bound)" + ) + }); + } + None if action.rumble.is_none() => { + table.report_once(weapon, note, || { + format!("audio: notetrack `{note}` has no sound or rumble mapping") + }); + } + None => {} } } diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index 9d117e2..a991cf9 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -11,6 +11,7 @@ mod pcm; mod playback; mod plugin; pub mod policy; +mod rumble; mod shellshock; mod space; mod start; @@ -30,8 +31,8 @@ pub use emit::{BobCycleTracker, emit_footstep_on_bob_wrap, emit_weapon_fire}; pub use frontend::FrontendAudio; pub use match_set::{AudioReady, AudioSilent}; pub use messages::{ - AliasCommand, Footstep, LandSound, PlayAlias, SND_ENT_LOCAL, ViewmodelNotetracks, WeaponSound, - snd_ent_from_number, + AliasCommand, BoundWeaponSound, Footstep, LandSound, PlayAlias, SND_ENT_LOCAL, + ViewmodelNotetracks, WeaponSound, snd_ent_from_number, }; pub use pcm::{LivePan, LoopingPcmAudio, PcmAudio, decode_audio_bytes}; pub use playback::{ @@ -49,3 +50,5 @@ pub use voice::VoiceOccupancy; mod destructible_loops; mod map_doors; mod match_set; + +mod weapon_lock; diff --git a/crates/audio/src/match_set.rs b/crates/audio/src/match_set.rs index 3211c40..38f1aae 100644 --- a/crates/audio/src/match_set.rs +++ b/crates/audio/src/match_set.rs @@ -225,6 +225,9 @@ fn queue_match_clips( } for alias in MATCH_HUD_PULSE .iter() + .chain([&"motiontracker_ping", &"motiontracker_pong"]) + .chain(crate::weapon_lock::ALIASES.iter()) + .chain([&gamemode_iw4::damage_feedback::HIT_ALERT_ALIAS]) .chain(MATCH_CLOCK) .chain(crate::objectives::EFFECTS) { diff --git a/crates/audio/src/messages.rs b/crates/audio/src/messages.rs index 7b0e8d1..ac2e667 100644 --- a/crates/audio/src/messages.rs +++ b/crates/audio/src/messages.rs @@ -17,6 +17,10 @@ pub struct PlayAlias { #[derive(Message, Clone, Debug)] pub enum AliasCommand { Play(PlayAlias), + PlayPitched { + sound: PlayAlias, + pitch: f32, + }, Stop { namespace: AssetNamespace, alias: String, @@ -40,8 +44,19 @@ pub struct WeaponSound { pub snd_ent: Option, } +#[derive(Message, Clone, Debug)] +pub struct BoundWeaponSound { + pub bank_revision: u64, + pub index: usize, + pub origin_inches: Option<[f32; 3]>, + pub snd_ent: Option, +} + #[derive(Message, Clone, Debug)] pub struct ViewmodelNotetracks { + pub generation: frame::WorldGeneration, + pub client: sim::ClientId, + pub life: sim::LifeSequence, pub weapon: u32, pub names: Vec, } diff --git a/crates/audio/src/playback.rs b/crates/audio/src/playback.rs index 3a47049..ed13d11 100644 --- a/crates/audio/src/playback.rs +++ b/crates/audio/src/playback.rs @@ -19,7 +19,8 @@ use crate::clip_store::{ clip_keys_for_alias, deadline_for, }; use crate::messages::{ - AliasCommand, Footstep, LandSound, PlayAlias, SND_ENT_LOCAL, ViewmodelNotetracks, WeaponSound, + AliasCommand, BoundWeaponSound, Footstep, LandSound, PlayAlias, SND_ENT_LOCAL, + ViewmodelNotetracks, WeaponSound, }; use crate::pcm::{LoopingPcmAudio, PcmAudio}; use crate::space::{distance_inches, transform_inches}; @@ -157,7 +158,9 @@ impl Plugin for PlayerSoundPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() + .init_resource::() .add_message::() .add_systems( Update, @@ -168,6 +171,7 @@ impl Plugin for PlayerSoundPlugin { .before(play_alias_messages) .before(play_footstep_messages) .before(play_weapon_sound_messages) + .before(play_bound_weapon_sounds) .before(play_land_sound_messages), drain_pending_oneshots .after(play_alias_messages) @@ -181,8 +185,10 @@ impl Plugin for PlayerSoundPlugin { play_alias_messages.after(FxSoundPublished), play_footstep_messages, crate::entity_events::play_viewmodel_notetrack_messages - .before(play_weapon_sound_messages), + .before(play_weapon_sound_messages) + .before(play_bound_weapon_sounds), play_weapon_sound_messages, + play_bound_weapon_sounds.after(drain_pending_oneshots), play_land_sound_messages, crate::map_doors::update.before(crate::ambient::update_map_emitter_gain), crate::destructible_loops::update @@ -199,6 +205,8 @@ impl Plugin for PlayerSoundPlugin { .after(crate::ambient::stop_map_ambient_on_match_end), crate::ambient::install_sound_bank .after(crate::ambient::start_sound_bank_compose), + crate::entity_events::bind_notetrack_sounds + .after(crate::ambient::install_sound_bank), crate::ambient::stop_map_ambient_on_match_end.after(SessionSwapApplied), reset_clip_prep_on_match_torn_down, ) @@ -312,8 +320,9 @@ fn play_alias_messages( let pose = listener_pose(&listeners); let iwd = iwd.as_deref().map(|s| s.0.as_ref()); for command in events.read() { - let event = match command { - AliasCommand::Play(event) => event, + let (event, pitch_scale) = match command { + AliasCommand::Play(event) => (event, 1.0), + AliasCommand::PlayPitched { sound, pitch } => (sound, *pitch), AliasCommand::Stop { namespace, alias, @@ -348,7 +357,7 @@ fn play_alias_messages( drop_without_bank(std::iter::once(event.alias.as_str()), &mut decisions); continue; }; - let outcome = play_alias_oneshot( + let outcome = play_oneshot_recorded( &mut commands, &mut pcm_assets, &mut shared, @@ -356,6 +365,7 @@ fn play_alias_messages( iwd, event.namespace, &event.alias, + None, event.origin_inches, pose, &mut pick, @@ -366,13 +376,14 @@ fn play_alias_messages( event.snd_ent, SoundClass::World, epoch.0, + pitch_scale, ); finish_binding_start( outcome, &event.alias, event.fallback.as_deref(), |fb| { - play_alias_oneshot( + play_oneshot_recorded( &mut commands, &mut pcm_assets, &mut shared, @@ -380,6 +391,7 @@ fn play_alias_messages( iwd, event.namespace, fb, + None, event.origin_inches, pose, &mut pick, @@ -390,6 +402,7 @@ fn play_alias_messages( event.snd_ent, SoundClass::World, epoch.0, + pitch_scale, ) }, &mut gaps, @@ -494,6 +507,63 @@ fn play_weapon_sound_messages( } } +fn play_bound_weapon_sounds( + mut events: MessageReader, + mut commands: Commands, + mut pcm_assets: ResMut>, + mut shared: ResMut, + mut pick: ResMut, + mut gaps: ResMut, + mut clips: Option>, + mut pending: ResMut, + mut occupancy: ResMut, + mut decisions: ResMut, + bank: Option>, + iwd: Option>, + epoch: Res, + listeners: Query<&Transform, With>, +) { + let Some(bank) = bank else { + for _ in events.read() {} + return; + }; + let pose = listener_pose(&listeners); + let iwd = iwd.as_deref().map(|s| s.0.as_ref()); + for event in events.read() { + if event.bank_revision != bank.0.revision() { + continue; + } + let Some(alias) = bank.0.name_at(event.index) else { + continue; + }; + let namespace = bank.0.namespace_of_alias(event.index); + let outcome = play_oneshot_recorded( + &mut commands, + &mut pcm_assets, + &mut shared, + &bank.0, + iwd, + namespace, + alias, + Some(event.index), + event.origin_inches, + pose, + &mut pick, + clips.as_deref_mut(), + &mut pending, + &mut occupancy, + &mut decisions, + event.snd_ent, + SoundClass::Weapon, + epoch.0, + 1.0, + ); + if outcome.allows_binding_fallback() { + gaps.record(alias); + } + } +} + fn play_land_sound_messages( mut events: MessageReader, mut commands: Commands, @@ -722,6 +792,7 @@ fn drain_pending_oneshots( iwd, entry.namespace, &entry.alias, + entry.bound, entry.origin_inches, pose, &mut pick, @@ -780,6 +851,51 @@ pub(crate) fn play_alias_oneshot( snd_ent: Option, class: SoundClass, epoch: u64, +) -> StartOutcome { + play_oneshot_recorded( + commands, + pcm_assets, + shared, + bank, + iwd, + namespace, + alias, + None, + origin_inches, + listener, + pick, + clips, + pending, + occupancy, + decisions, + snd_ent, + class, + epoch, + 1.0, + ) +} + +#[allow(clippy::too_many_arguments)] +fn play_oneshot_recorded( + commands: &mut Commands, + pcm_assets: &mut Assets, + shared: &mut SharedPlayAssets, + bank: &SoundCatalog, + iwd: Option<&NamespaceSoundIwd>, + namespace: AssetNamespace, + alias: &str, + bound: Option, + origin_inches: Option<[f32; 3]>, + listener: Option<(Vec3, Vec3)>, + pick: &mut SoundPickState, + clips: Option<&mut ClipStore>, + pending: &mut PendingStarts, + occupancy: &mut VoiceOccupancy, + decisions: &mut StartDecisions, + snd_ent: Option, + class: SoundClass, + epoch: u64, + pitch_scale: f32, ) -> StartOutcome { let started = play_alias_oneshot_at( commands, @@ -789,6 +905,7 @@ pub(crate) fn play_alias_oneshot( iwd, namespace, alias, + bound, origin_inches, listener, pick, @@ -799,6 +916,7 @@ pub(crate) fn play_alias_oneshot( 0, class, epoch, + pitch_scale, ); if let Some((sec, sec_out)) = &started.secondary && !sec_out.is_open() @@ -920,6 +1038,7 @@ fn play_alias_oneshot_at( iwd: Option<&NamespaceSoundIwd>, namespace: AssetNamespace, alias: &str, + bound: Option, origin_inches: Option<[f32; 3]>, listener: Option<(Vec3, Vec3)>, pick: &mut SoundPickState, @@ -930,12 +1049,17 @@ fn play_alias_oneshot_at( depth: u8, class: SoundClass, epoch: u64, + pitch_scale: f32, ) -> OneshotStart { let avoid = pick .last_variant .get(&(namespace, alias.to_owned())) .copied(); - let Some(outcome) = bank.pick_loaded_outcome(namespace, alias, &mut pick.lcg, avoid) else { + let picked = match bound { + Some(index) => bank.pick_loaded_outcome_at(index, &mut pick.lcg, avoid), + None => bank.pick_loaded_outcome(namespace, alias, &mut pick.lcg, avoid), + }; + let Some(outcome) = picked else { return OneshotStart::failed(StartFailure::MissingAlias); }; let variant_index = outcome.variant_index; @@ -948,6 +1072,7 @@ fn play_alias_oneshot_at( bank, namespace, alias, + bound, variant_index, loaded_name, loaded_ns, @@ -957,14 +1082,22 @@ fn play_alias_oneshot_at( let (volume, pitch, layer) = if let Some(picked) = &outcome.picked { (picked.volume, picked.pitch, picked.layer.clone()) } else { - let row = bank - .sound_in(namespace, alias) - .and_then(|s| s.aliases.get(variant_index)); + let row = match bound { + Some(index) => bank.sound_at(index), + None => bank.sound_in(namespace, alias), + } + .and_then(|s| s.aliases.get(variant_index)); let (volume, pitch) = row .map(|row| streamed_row_volume_pitch(row, &mut pick.lcg)) .unwrap_or((1.0, 1.0)); (volume, pitch, row.and_then(|r| r.secondary.clone())) }; + let pitch = pitch + * if pitch_scale.is_finite() && pitch_scale > 0.0 { + pitch_scale + } else { + 1.0 + }; let pcm = match take_or_pending_clip( bank, clips.as_deref_mut(), @@ -972,6 +1105,7 @@ fn play_alias_oneshot_at( clip.clone(), namespace, alias, + bound, variant_index, volume, pitch, @@ -1002,6 +1136,7 @@ fn play_alias_oneshot_at( iwd, namespace, alias, + bound, origin_inches, listener, pick, @@ -1034,6 +1169,7 @@ fn take_or_pending_clip( clip: ClipKey, namespace: AssetNamespace, alias: &str, + bound: Option, variant: usize, volume: f32, pitch: f32, @@ -1059,6 +1195,7 @@ fn take_or_pending_clip( pending.push_oneshot(PendingOneshot { namespace, alias: alias.to_owned(), + bound, variant, volume, pitch, @@ -1086,6 +1223,7 @@ fn submit_prepared_oneshot( iwd: Option<&NamespaceSoundIwd>, namespace: AssetNamespace, alias: &str, + bound: Option, origin_inches: Option<[f32; 3]>, listener: Option<(Vec3, Vec3)>, pick: &mut SoundPickState, @@ -1103,7 +1241,10 @@ fn submit_prepared_oneshot( pitch: f32, layer: Option<&str>, ) -> OneshotStart { - let sound = bank.sound_in(namespace, alias); + let sound = match bound { + Some(index) => bank.sound_at(index), + None => bank.sound_in(namespace, alias), + }; let row = sound.and_then(|s| s.aliases.get(variant_index)); let channel = sound.and_then(|s| s.ent_channel(variant_index)); let mut world_detail: Option = None; @@ -1299,6 +1440,7 @@ fn play_secondary_layer( iwd, namespace, sec, + None, origin_inches, listener, pick, @@ -1309,6 +1451,7 @@ fn play_secondary_layer( depth + 1, class, epoch, + 1.0, ); Some((sec.to_owned(), started.outcome)) } diff --git a/crates/audio/src/plugin.rs b/crates/audio/src/plugin.rs index 21fb899..0194aa8 100644 --- a/crates/audio/src/plugin.rs +++ b/crates/audio/src/plugin.rs @@ -12,5 +12,7 @@ impl Plugin for AudioPlugin { crate::frontend::register_frontend_audio(app); crate::policy::music::register_script_music(app); crate::entity_events::register_entity_event_audio(app); + crate::weapon_lock::register(app); + crate::rumble::register(app); } } diff --git a/crates/audio/src/rumble.rs b/crates/audio/src/rumble.rs new file mode 100644 index 0000000..689862b --- /dev/null +++ b/crates/audio/src/rumble.rs @@ -0,0 +1,224 @@ +use std::sync::Arc; + +use bevy::input::gamepad::{GamepadRumbleIntensity, GamepadRumbleRequest}; +use bevy::prelude::*; +use net::{CgFrameClock, LocalPresentClient, PresentedSnapshot}; + +#[derive(Clone, Debug)] +pub(crate) struct Rumble { + duration_ms: i32, + low: Vec<(f32, f32)>, + high: Vec<(f32, f32)>, +} + +impl Rumble { + pub(crate) fn prepare( + bank: &assets::SoundCatalog, + namespace: assets::AssetNamespace, + name: &str, + ) -> Result, String> { + let text = |name: &str| -> Result<&str, String> { + let key = format!("rumble/{name}"); + let bytes = bank + .rawfiles + .get(&(namespace, key.clone())) + .ok_or_else(|| format!("missing {key}"))?; + std::str::from_utf8(bytes).map_err(|_| format!("invalid text in {key}")) + }; + let definition = text(name)?.trim_end_matches('\0'); + let mut fields = definition.split('\\'); + if fields.next() != Some("RUMBLE") { + return Err(format!("invalid rumble header: {name}")); + } + let mut duration = None; + let mut low = None; + let mut high = None; + while let Some(key) = fields.next() { + let value = fields + .next() + .ok_or_else(|| format!("missing {key} value: {name}"))?; + match key { + "duration" => duration = value.parse::().ok(), + "lowRumbleFile" => low = Some(value), + "highRumbleFile" => high = Some(value), + _ => {} + } + } + let duration = duration + .filter(|d| d.is_finite() && *d > 0.0) + .ok_or_else(|| format!("invalid rumble duration: {name}"))?; + let graph = |name: Option<&str>| -> Result, String> { + let name = name + .filter(|name| !name.is_empty()) + .ok_or_else(|| "missing rumble graph name".to_owned())?; + let mut tokens = text(name)?.trim_end_matches('\0').split_whitespace(); + if tokens.next() != Some("RUMBLEGRAPHFILE") { + return Err(format!("invalid rumble graph header: {name}")); + } + let count = tokens + .next() + .and_then(|n| n.parse::().ok()) + .filter(|&n| n > 0 && n <= 16) + .ok_or_else(|| format!("invalid rumble graph count: {name}"))?; + let mut points = Vec::with_capacity(count); + for _ in 0..count { + let mut number = || { + tokens + .next() + .and_then(|n| n.parse::().ok()) + .filter(|v| v.is_finite()) + .ok_or_else(|| format!("invalid rumble graph point: {name}")) + }; + let x = number()?; + let y = number()?; + if points.last().is_some_and(|&(previous, _)| previous > x) { + return Err(format!("unordered rumble graph: {name}")); + } + points.push((x, y)); + } + Ok(points) + }; + Ok(Arc::new(Self { + duration_ms: (duration * 1000.0).max(1.0) as i32, + low: graph(low)?, + high: graph(high)?, + })) + } + + fn intensity(&self, elapsed: i32) -> GamepadRumbleIntensity { + let fraction = (elapsed as f32 / self.duration_ms as f32).clamp(0.0, 1.0); + GamepadRumbleIntensity { + strong_motor: sample(&self.low, fraction), + weak_motor: sample(&self.high, fraction), + } + } +} + +fn sample(points: &[(f32, f32)], fraction: f32) -> f32 { + let mut previous = points[0]; + if fraction <= previous.0 { + return previous.1.clamp(0.0, 1.0); + } + for &next in &points[1..] { + if fraction <= next.0 { + let t = if next.0 > previous.0 { + (fraction - previous.0) / (next.0 - previous.0) + } else { + 1.0 + }; + return (previous.1 + (next.1 - previous.1) * t).clamp(0.0, 1.0); + } + previous = next; + } + previous.1.clamp(0.0, 1.0) +} + +#[derive(Message)] +pub(crate) struct PlayRumble { + pub bank_revision: u64, + pub rumble: Arc, +} + +#[derive(Default)] +struct RumblePlayback { + owner: Option<( + frame::WorldGeneration, + sim::ClientId, + sim::LifeSequence, + u64, + )>, + last_time: i32, + active: Vec<(i32, Arc)>, + output: Option<(Entity, GamepadRumbleIntensity)>, +} + +pub(crate) fn register(app: &mut App) { + app.add_message::().add_systems( + Update, + update + .in_set(net::ClientSet::Effects) + .after(crate::entity_events::play_viewmodel_notetrack_messages), + ); +} + +fn update( + mut requests: MessageReader, + bank: Option>, + presented: Res, + local: Res, + generation: Res, + clock: Res, + gamepads: Query>, + mut state: Local, + mut output: MessageWriter, +) { + let now = clock.time(); + let owner = bank.as_ref().and_then(|bank| { + let meta = presented.snapshot()?.meta.for_client(local.0)?; + (meta.lifecycle == sim::ClientLifecycle::Alive).then_some(( + *generation, + local.0, + meta.life_sequence, + bank.0.revision(), + )) + }); + if owner != state.owner || now < state.last_time { + state.active.clear(); + if let Some((gamepad, _)) = state.output.take() { + output.write(GamepadRumbleRequest::Stop { gamepad }); + } + state.owner = owner; + } + state.last_time = now; + state + .active + .retain(|(start, rumble)| now.saturating_sub(*start) < rumble.duration_ms); + for request in requests.read() { + if owner.is_none_or(|owner| owner.3 != request.bank_revision) { + continue; + } + if state.active.len() == 32 { + if let Some((index, _)) = state + .active + .iter() + .enumerate() + .min_by_key(|(_, (start, rumble))| start.saturating_add(rumble.duration_ms)) + { + state.active.swap_remove(index); + } + } + state.active.push((now, Arc::clone(&request.rumble))); + } + let mut intensity = GamepadRumbleIntensity { + strong_motor: 0.0, + weak_motor: 0.0, + }; + for (start, rumble) in &state.active { + let current = rumble.intensity(now.saturating_sub(*start)); + intensity.strong_motor = intensity.strong_motor.max(current.strong_motor); + intensity.weak_motor = intensity.weak_motor.max(current.weak_motor); + } + let selected = gamepads.iter().min_by_key(|entity| entity.to_bits()); + if state + .output + .is_some_and(|(entity, _)| Some(entity) != selected) + { + if let Some((gamepad, _)) = state.output.take() { + output.write(GamepadRumbleRequest::Stop { gamepad }); + } + } + if let Some(gamepad) = selected { + let zero = intensity.strong_motor == 0.0 && intensity.weak_motor == 0.0; + if !zero || state.output != Some((gamepad, intensity)) { + output.write(GamepadRumbleRequest::Stop { gamepad }); + if !zero { + output.write(GamepadRumbleRequest::Add { + gamepad, + duration: std::time::Duration::from_millis(100), + intensity, + }); + } + } + state.output = Some((gamepad, intensity)); + } +} diff --git a/crates/audio/src/weapon_lock.rs b/crates/audio/src/weapon_lock.rs new file mode 100644 index 0000000..9c395f9 --- /dev/null +++ b/crates/audio/src/weapon_lock.rs @@ -0,0 +1,77 @@ +use bevy::prelude::*; +use net::{CgFrameClock, LocalPresentClient, PresentedSnapshot}; + +use crate::{AliasCommand, PlayAlias, SND_ENT_LOCAL}; + +pub(crate) const ALIASES: [&str; 2] = ["javelin_clu_aquiring_lock", "javelin_clu_lock"]; + +#[derive(Default)] +struct LockAudio { + owner: Option<( + frame::WorldGeneration, + sim::ClientId, + sim::LifeSequence, + u32, + )>, + stage: u8, + last_time: i32, + next_ping: i32, +} + +pub(crate) fn register(app: &mut App) { + app.add_systems(Update, present.in_set(net::ClientSet::Effects)); +} + +fn present( + presented: Res, + local: Res, + generation: Res, + clock: Res, + mut cursor: Local, + mut commands: MessageWriter, +) { + let state = presented.snapshot().and_then(|snapshot| { + let meta = snapshot.meta.for_client(local.0)?; + let ps = presented.player(local.0)?; + (meta.lifecycle == sim::ClientLifecycle::Alive && ps.other_flags & 0x400 == 0).then_some(( + (*generation, local.0, meta.life_sequence, ps.weapon), + if meta.weapon_lock.weapon == ps.weapon { + meta.weapon_lock.flags & 3 + } else { + 0 + }, + )) + }); + let now = clock.time(); + let owner = state.map(|(owner, _)| owner); + let stage = state.map_or(0, |(_, stage)| stage); + if cursor.owner != owner || cursor.stage != stage || now < cursor.last_time { + if cursor.stage != 0 { + for alias in ALIASES { + commands.write(AliasCommand::Stop { + namespace: assets::AssetNamespace::Iw4, + alias: alias.to_owned(), + snd_ent: Some(SND_ENT_LOCAL), + }); + } + } + cursor.owner = owner; + cursor.stage = stage; + cursor.next_ping = now; + } + cursor.last_time = now; + if stage != 0 && now >= cursor.next_ping { + commands.write(AliasCommand::Play(PlayAlias { + namespace: assets::AssetNamespace::Iw4, + alias: ALIASES[usize::from(stage & 2 != 0)].to_owned(), + fallback: None, + origin_inches: None, + snd_ent: Some(SND_ENT_LOCAL), + })); + cursor.next_ping = if stage & 2 != 0 { + i32::MAX + } else { + now.saturating_add(600) + }; + } +} diff --git a/crates/bots/src/nav.rs b/crates/bots/src/nav.rs index 4933bbd..857b91d 100644 --- a/crates/bots/src/nav.rs +++ b/crates/bots/src/nav.rs @@ -6,7 +6,7 @@ use sim::SimBrush; use crate::observation::ModeObjective; use crate::query::{QueryResult, QuerySubsystem, WalkSample, WorldQuery}; -pub const NAV_SCHEMA: u32 = 7; +pub const NAV_SCHEMA: u32 = 8; pub const GRID_IN: f32 = 48.0; pub const SNAP_IN: f32 = 256.0; pub const STEP_Z_IN: f32 = 18.0; diff --git a/crates/clipmap_iw4/src/mesh.rs b/crates/clipmap_iw4/src/mesh.rs index 58f06ef..61ce708 100644 --- a/crates/clipmap_iw4/src/mesh.rs +++ b/crates/clipmap_iw4/src/mesh.rs @@ -6,6 +6,8 @@ use crate::TraceExtents; const SURFACE_CLIP_EPSILON: f32 = 0.125; +const EQUAL_EPSILON: f32 = 0.001; + pub const VERTS_PER_SEGMENT: usize = 1024; const WALKABLE_NORMAL_Z: f32 = 0.7; const CONTENTS_SOLID: u32 = 1; @@ -47,6 +49,8 @@ pub struct ClipMeshTables { pub tri_indices: Vec, + pub tri_edge_is_walkable: Vec, + pub tri_surface_flags: Vec, pub tri_content_flags: Vec, @@ -62,6 +66,7 @@ impl ClipMeshTables { ClipMeshRef { verts: &self.verts, tri_indices: &self.tri_indices, + tri_edge_is_walkable: &self.tri_edge_is_walkable, tri_surface_flags: &self.tri_surface_flags, tri_content_flags: &self.tri_content_flags, aabb_trees: &self.aabb_trees, @@ -81,6 +86,10 @@ pub struct ClipMeshRef<'a> { pub tri_indices: &'a [u16], + /// One bit per triangle edge, `3 * tri + k`, where k = 0 is v1-v2, 1 is + /// v0-v2 and 2 is v0-v1. + pub tri_edge_is_walkable: &'a [u8], + pub tri_surface_flags: &'a [u32], pub tri_content_flags: &'a [u32], @@ -100,6 +109,7 @@ impl<'a> ClipMeshRef<'a> { Self { verts, tri_indices, + tri_edge_is_walkable: &[], tri_surface_flags, tri_content_flags: &[], aabb_trees: &[], @@ -245,7 +255,7 @@ pub fn trace_through_mesh_into( start[1] + delta[1] * best.fraction - cap.offset[1], start[2] + delta[2] * best.fraction - cap.offset[2], ]; - if best.walkable == 0 { + if best.walkable == 0 && best.startsolid == 0 { best.walkable = u8::from(best.normal[2] >= WALKABLE_NORMAL_Z); } } else { @@ -380,6 +390,12 @@ fn test_tri( cflags }; census.tris_tested = census.tris_tested.saturating_add(1); + let edge_walkable = [0, 1, 2].map(|k| { + let bit = ti * 3 + k; + mesh.tri_edge_is_walkable + .get(bit >> 3) + .is_some_and(|b| b & (1 << (bit & 7)) != 0) + }); capsule_through_triangle( cap, start, @@ -387,6 +403,7 @@ fn test_tri( v0, v1, v2, + edge_walkable, mesh.tri_surface_flags.get(ti).copied().unwrap_or(0), contents, best, @@ -425,6 +442,7 @@ fn capsule_through_triangle( v0: [f32; 3], v1: [f32; 3], v2: [f32; 3], + edge_walkable: [bool; 3], surface_flags: u32, contents: u32, trace: &mut Trace, @@ -484,6 +502,7 @@ fn capsule_through_triangle( let trace_plane = cross(delta, plane_pt); let mut missed_edge = false; let mut vert_to_check: Option<[f32; 3]> = None; + let mut vert_walkable = true; let neg_v = dot(trace_plane, v0_v1); if neg_v < 0.0 { @@ -498,9 +517,18 @@ fn capsule_through_triangle( contents, trace, ) { - EdgeHit::Hits => return, - EdgeHit::MayV0 => vert_to_check = Some(v0), - EdgeHit::MayV1 => vert_to_check = Some(v1), + EdgeHit::Hits => { + trace.walkable = u8::from(edge_walkable[2]); + return; + } + EdgeHit::MayV0 => { + vert_to_check = Some(v0); + vert_walkable &= edge_walkable[2]; + } + EdgeHit::MayV1 => { + vert_to_check = Some(v1); + vert_walkable &= edge_walkable[2]; + } EdgeHit::Miss => {} } } @@ -517,9 +545,18 @@ fn capsule_through_triangle( contents, trace, ) { - EdgeHit::Hits => return, - EdgeHit::MayV0 => vert_to_check = Some(v0), - EdgeHit::MayV1 => vert_to_check = Some(v2), + EdgeHit::Hits => { + trace.walkable = u8::from(edge_walkable[1]); + return; + } + EdgeHit::MayV0 => { + vert_to_check = Some(v0); + vert_walkable &= edge_walkable[1]; + } + EdgeHit::MayV1 => { + vert_to_check = Some(v2); + vert_walkable &= edge_walkable[1]; + } EdgeHit::Miss => {} } } @@ -536,9 +573,18 @@ fn capsule_through_triangle( contents, trace, ) { - EdgeHit::Hits => return, - EdgeHit::MayV0 => vert_to_check = Some(v1), - EdgeHit::MayV1 => vert_to_check = Some(v2), + EdgeHit::Hits => { + trace.walkable = u8::from(edge_walkable[0]); + return; + } + EdgeHit::MayV0 => { + vert_to_check = Some(v1); + vert_walkable &= edge_walkable[0]; + } + EdgeHit::MayV1 => { + vert_to_check = Some(v2); + vert_walkable &= edge_walkable[0]; + } EdgeHit::Miss => {} } } @@ -550,6 +596,7 @@ fn capsule_through_triangle( sphere_start, delta, vert, + vert_walkable, surface_flags, contents, trace, @@ -569,7 +616,7 @@ fn capsule_through_triangle( trace.surface_flags = surface_flags; trace.hit_type = HITTYPE_ENTITY; trace.hit_id = ENTITYNUM_WORLD; - trace.walkable = u8::from(normal[2] >= WALKABLE_NORMAL_Z); + trace.walkable = 0; } #[derive(Clone, Copy, PartialEq, Eq)] @@ -620,7 +667,32 @@ fn sphere_through_edge( return EdgeHit::Miss; } if frac_enter < 0.0 { - return EdgeHit::Miss; + let scaled_proj = -dot(start_delta, v0_v1); + if scaled_proj <= 0.0 { + return EdgeHit::MayV0; + } + if scaled_proj >= edge_len_sq { + return EdgeHit::MayV1; + } + let along = scaled_proj / edge_len_sq; + let mut normal = [ + along * v0_v1[0] + start_delta[0], + along * v0_v1[1] + start_delta[1], + along * v0_v1[2] + start_delta[2], + ]; + if dot(normal, delta) >= 0.0 { + return EdgeHit::Miss; + } + normalize_to(&mut normal); + let inner_disc = radius * radius * perp_len_sq - scaled_dist * scaled_dist; + trace.fraction = 0.0; + trace.normal = normal; + trace.startsolid = u8::from(edge_len_sq * inner_disc > t_scaled * t_scaled); + trace.contents = contents; + trace.surface_flags = surface_flags; + trace.hit_type = HITTYPE_ENTITY; + trace.hit_id = ENTITYNUM_WORLD; + return EdgeHit::Hits; } let hit_delta = [ frac_enter * delta[0] + start_delta[0], @@ -650,7 +722,6 @@ fn sphere_through_edge( trace.surface_flags = surface_flags; trace.hit_type = HITTYPE_ENTITY; trace.hit_id = ENTITYNUM_WORLD; - trace.walkable = u8::from(trace.normal[2] >= WALKABLE_NORMAL_Z); EdgeHit::Hits } @@ -659,6 +730,7 @@ fn sphere_through_vertex( sphere_start: [f32; 3], delta: [f32; 3], vert: [f32; 3], + is_walkable: bool, surface_flags: u32, contents: u32, trace: &mut Trace, @@ -668,34 +740,48 @@ fn sphere_through_vertex( sphere_start[1] - vert[1], sphere_start[2] - vert[2], ]; - let a = len_sq(delta); - if a <= 0.0 { + let b = dot(delta, start_delta); + if b >= 0.0 { return; } - let b = 2.0 * dot(start_delta, delta); + let start_len_sq = len_sq(start_delta); let r = radius + SURFACE_CLIP_EPSILON; - let c = len_sq(start_delta) - r * r; - let disc = b * b - 4.0 * a * c; - if disc < 0.0 { - return; - } - let frac = (-b - sqrtf(disc)) / (2.0 * a); - if frac < 0.0 || frac >= trace.fraction { - return; - } - let hit = [ - start_delta[0] + frac * delta[0], - start_delta[1] + frac * delta[1], - start_delta[2] + frac * delta[2], - ]; - let inv_r = 1.0 / r; + let c = start_len_sq - r * r; + let (frac, normal) = if c > 0.0 { + let a = len_sq(delta); + let b_sq = b * b; + let disc = b_sq - a * c; + if disc < b_sq * EQUAL_EPSILON { + return; + } + let frac = (-sqrtf(disc) - b) / a; + if frac >= trace.fraction { + return; + } + let inv_r = 1.0 / r; + let mut normal = [ + (start_delta[0] + frac * delta[0]) * inv_r, + (start_delta[1] + frac * delta[1]) * inv_r, + (start_delta[2] + frac * delta[2]) * inv_r, + ]; + let approx_recip_len = (3.0 - len_sq(normal)) * 0.5; + normal = normal.map(|c| c * approx_recip_len); + (frac, normal) + } else { + let mut normal = start_delta; + normalize_to(&mut normal); + if start_len_sq < radius * radius { + trace.startsolid = 1; + } + (0.0, normal) + }; trace.fraction = frac; - trace.normal = [hit[0] * inv_r, hit[1] * inv_r, hit[2] * inv_r]; + trace.normal = normal; trace.contents = contents; trace.surface_flags = surface_flags; trace.hit_type = HITTYPE_ENTITY; trace.hit_id = ENTITYNUM_WORLD; - trace.walkable = u8::from(trace.normal[2] >= WALKABLE_NORMAL_Z); + trace.walkable = u8::from(is_walkable); } fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { diff --git a/crates/clipmap_iw4/src/world_trace.rs b/crates/clipmap_iw4/src/world_trace.rs index a595b30..167a2d3 100644 --- a/crates/clipmap_iw4/src/world_trace.rs +++ b/crates/clipmap_iw4/src/world_trace.rs @@ -194,6 +194,7 @@ pub fn trace_leaf_mesh_into( let staged = ClipMeshRef { verts: mesh.verts, tri_indices: mesh.tri_indices, + tri_edge_is_walkable: mesh.tri_edge_is_walkable, tri_surface_flags: mesh.tri_surface_flags, tri_content_flags: mesh.tri_content_flags, aabb_trees: mesh.aabb_trees, diff --git a/crates/console/src/plugin.rs b/crates/console/src/plugin.rs index 6c3f120..e409c52 100644 --- a/crates/console/src/plugin.rs +++ b/crates/console/src/plugin.rs @@ -861,6 +861,25 @@ fn handle_input_commands( settings: Res, ) { for command in events.read() { + let mut command = command.clone(); + if matches!(command.name.as_str(), "hold" | "press" | "release") + && command + .args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "+actionslot" | "-actionslot")) + && command.args.len() >= 2 + { + let slot = command.args.remove(1); + command.args[0].push(' '); + command.args[0].push_str(&slot); + } else if matches!(command.name.as_str(), "+actionslot" | "-actionslot") + && command.args.len() == 1 + { + command.name.push(' '); + command.name.push_str(&command.args[0]); + command.args.clear(); + } + match command.name.as_str() { name if name.starts_with('+') && is_console_input(name) => { inputs.hold(name); @@ -2167,8 +2186,12 @@ fn mark_match_facts( out.push_str(&format!(" local={life}")); if let Some(meta) = meta { out.push_str(&format!( - " local_life={} local_deaths={}", - meta.life_sequence.0, meta.deaths + " local_life={} local_deaths={} local_cmds={} local_moving_cmds={} local_path={:.1}", + meta.life_sequence.0, + meta.deaths, + meta.input_receipt.applied_cmds, + meta.input_receipt.moving_cmds, + meta.input_receipt.path_units )); } if let Some(ps) = presented.and_then(|p| p.alive_player(local.0)) { diff --git a/crates/fastfile_iw4/src/load/clipmap.rs b/crates/fastfile_iw4/src/load/clipmap.rs index 6bc5eac..5ef633c 100644 --- a/crates/fastfile_iw4/src/load/clipmap.rs +++ b/crates/fastfile_iw4/src/load/clipmap.rs @@ -128,7 +128,8 @@ pub(super) fn load_clipmap(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink let verts = s.plain_array(p, s.layout(92, 176), 4, 12, vert_count)?; let index_count = 3 * tri_count; let tri_indices = s.plain_array(p, s.layout(100, 192), 2, 2, index_count)?; - s.plain_array(p, s.layout(104, 200), 1, 1, index_count.div_ceil(32) * 4)?; + let tri_edge_is_walkable = + s.plain_array(p, s.layout(104, 200), 1, 1, index_count.div_ceil(32) * 4)?; s.plain_array( p, s.layout(112, 216), @@ -259,6 +260,7 @@ pub(super) fn load_clipmap(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink leafbrush_nodes, verts, tri_indices, + tri_edge_is_walkable, collision_partitions, partition_count, collision_aabb_trees, diff --git a/crates/fastfile_iw4/src/load/weapon.rs b/crates/fastfile_iw4/src/load/weapon.rs index a5206a6..6d82abe 100644 --- a/crates/fastfile_iw4/src/load/weapon.rs +++ b/crates/fastfile_iw4/src/load/weapon.rs @@ -87,7 +87,7 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink) let display_name_at_0x8 = s.follow_string(p, s.layout(8, 16))?; let hide_tags = s.plain_array(p, s.layout(12, 24), 2, 2, 32)?; let sz_xanims = follow_string_array(s, p, s.layout(16, 32), sz::WEAPON_ANIM_COUNT)?; - follow_name(s, p, s.layout(60, 80))?; + let alternate_weapon_name = s.follow_string(p, s.layout(60, 80))?; let kill_icon_fresh = asset_ptr_at_linked(s, links, AssetType::Material, p.at(s.layout(72, 96)))?; @@ -327,6 +327,12 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink) }, }; s.record_weapon(WeaponGeometry { + alternate_weapon_name, + alternate_raise_time_ms: s.i32_at(p, s.layout(68, 92))?, + alternate_drop_time_ms: weap_def + .map(|body| s.i32_at(body, s.layout(0x290, 984))) + .transpose()? + .unwrap_or_default(), view_flash_slot: authored_material_slot(s, weap_def, s.layout(0x48, 112))?, world_flash_slot: authored_material_slot(s, weap_def, s.layout(0x4c, 120))?, view_shell_eject_slot: authored_material_slot(s, weap_def, s.layout(0x110, 512))?, diff --git a/crates/fastfile_iw4/src/zone.rs b/crates/fastfile_iw4/src/zone.rs index a17e7fe..a1624be 100644 --- a/crates/fastfile_iw4/src/zone.rs +++ b/crates/fastfile_iw4/src/zone.rs @@ -676,6 +676,8 @@ pub struct ClipMapGeometry { pub tri_indices: Option, + pub tri_edge_is_walkable: Option, + pub collision_partitions: Option, pub partition_count: usize, @@ -915,6 +917,9 @@ pub struct WeaponIdleCapture { #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct WeaponGeometry { pub name: Option, + pub alternate_weapon_name: Option, + pub alternate_raise_time_ms: i32, + pub alternate_drop_time_ms: i32, pub weap_def: Option, diff --git a/crates/fastfile_iw5/src/attachment.rs b/crates/fastfile_iw5/src/attachment.rs index 359be54..6b69d54 100644 --- a/crates/fastfile_iw5/src/attachment.rs +++ b/crates/fastfile_iw5/src/attachment.rs @@ -16,6 +16,7 @@ pub struct AttachmentFacts { pub aim_assist: Option, pub ammunition: Option, pub damage: Option, + pub projectile: Option, pub location_damage: Option<[f32; 19]>, pub idle_settings: Option, pub ads_settings: Option, @@ -63,6 +64,7 @@ pub struct AttachmentAddOns { #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct AttachmentGeneral { + pub body: Option, pub bolt_action: bool, pub inherits_perks: bool, pub enemy_crosshair_range: f32, @@ -98,6 +100,20 @@ pub struct AttachmentDamage { pub min_player_damage: i32, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AttachmentProjectile { + pub body: Ptr, + pub explosion_radius: i32, + pub explosion_inner_damage: i32, + pub explosion_outer_damage: i32, + pub speed: i32, + pub speed_up: i32, + pub activate_distance: i32, + pub explosion_type: i32, + pub impact_explode: bool, + pub model: Option, +} + #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct AttachmentIdleSettings { pub hip_idle_amount: f32, @@ -171,6 +187,23 @@ pub(crate) fn read_attachment_facts(s: &ZoneStream<'_>, header: Ptr) -> Attachme weapon_type: i32_at(12, 20), weapon_class: i32_at(16, 24), load_index: i32_at(156, 256), + projectile: body_at(104, 200).and_then(|body| { + Some(AttachmentProjectile { + body, + explosion_radius: s.i32_at(body, 0).ok()?, + explosion_inner_damage: s.i32_at(body, 4).ok()?, + explosion_outer_damage: s.i32_at(body, 8).ok()?, + speed: s.i32_at(body, 16).ok()?, + speed_up: s.i32_at(body, 20).ok()?, + activate_distance: s.i32_at(body, 24).ok()?, + explosion_type: s.i32_at(body, s.layout(36, 40)).ok()?, + impact_explode: s.u8_at(body, s.layout(60, 88)).ok()? != 0, + model: match s.ptr_at(body, 32).ok()? { + ZonePtr::Offset(p) => Some(s.resolve_alias(p)), + _ => None, + }, + }) + }), sight: body_at(sz::ATTACH_SIGHT_OFF, 64).and_then(|body| read_sight(s, body)), ammo_general: body_at(32, 56).and_then(|body| read_ammo_general(s, body)), reload: body_at(40, 72).and_then(|body| { @@ -275,6 +308,7 @@ fn read_ammo_general(s: &ZoneStream<'_>, body: Ptr) -> Option, body: Ptr) -> Option { Some(AttachmentGeneral { + body: Some(body), bolt_action: s.u8_at(body, 0).ok()? != 0, inherits_perks: s.u8_at(body, 1).ok()? != 0, enemy_crosshair_range: s.f32_at(body, 4).ok()?, diff --git a/crates/fastfile_iw5/src/lib.rs b/crates/fastfile_iw5/src/lib.rs index 71d15e7..f110a22 100644 --- a/crates/fastfile_iw5/src/lib.rs +++ b/crates/fastfile_iw5/src/lib.rs @@ -15,8 +15,8 @@ pub use asset_type::AssetType; pub use attachment::{ AttachmentAddOns, AttachmentAdsSettings, AttachmentAimAssist, AttachmentAmmoGeneral, AttachmentAmmunition, AttachmentDamage, AttachmentFacts, AttachmentGeneral, AttachmentGunKick, - AttachmentHipSpread, AttachmentIdleSettings, AttachmentReload, AttachmentScales, - AttachmentSight, + AttachmentHipSpread, AttachmentIdleSettings, AttachmentProjectile, AttachmentReload, + AttachmentScales, AttachmentSight, }; pub use content::{ AssetSink, AssetTable, ScriptStrings, XASSET_ENTRY_LEN, XASSET_LIST_LEN, load_zone, diff --git a/crates/fastfile_iw5/src/load/clipmap.rs b/crates/fastfile_iw5/src/load/clipmap.rs index c34b781..af16e40 100644 --- a/crates/fastfile_iw5/src/load/clipmap.rs +++ b/crates/fastfile_iw5/src/load/clipmap.rs @@ -60,7 +60,8 @@ pub(super) fn load_clipmap(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink let verts = s.plain_array(p, s.layout(0x68, 208), 4, 12, vert_count)?; let index_count = 3 * tri_count; let tri_indices = s.plain_array(p, s.layout(0x70, 224), 2, 2, index_count)?; - s.plain_array(p, s.layout(0x74, 232), 1, 1, index_count.div_ceil(32) * 4)?; + let tri_edge_is_walkable = + s.plain_array(p, s.layout(0x74, 232), 1, 1, index_count.div_ceil(32) * 4)?; let border_count = s.i32_at(p, s.layout(0x78, 240))?.max(0) as usize; s.plain_array( p, @@ -167,6 +168,7 @@ pub(super) fn load_clipmap(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink leafbrush_nodes: info.leafbrush_nodes, verts, tri_indices, + tri_edge_is_walkable, collision_partitions, collision_aabb_trees, cmodels, diff --git a/crates/fastfile_iw5/src/load/weapon.rs b/crates/fastfile_iw5/src/load/weapon.rs index d81e5d4..757ee0c 100644 --- a/crates/fastfile_iw5/src/load/weapon.rs +++ b/crates/fastfile_iw5/src/load/weapon.rs @@ -132,7 +132,7 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink) } } - follow_name(s, p, s.layout(116, 192))?; + let alternate_weapon_name = s.follow_string(p, s.layout(116, 192))?; // The complete def lives in TEMP and is overwritten by the next asset: keep a slot that outlives it. let kill_icon_cell = p.at(s.layout(sz::WEAPON_COMPLETE_KILL_ICON_OFF, 216)); let referenced = match s.ptr_at(kill_icon_cell, 0)? { @@ -199,6 +199,12 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink) _ => None, }; s.record_weapon(WeaponGeometry { + alternate_weapon_name, + alternate_raise_time_ms: s.i32_at(p, s.layout(124, 208))?, + alternate_drop_time_ms: weap_def + .map(|body| s.i32_at(body, s.layout(0x298, 1000))) + .transpose()? + .unwrap_or_default(), name, kill_icon_slot, kill_icon, diff --git a/crates/fastfile_iw5/src/weapon_override.rs b/crates/fastfile_iw5/src/weapon_override.rs index 21728de..f8b1307 100644 --- a/crates/fastfile_iw5/src/weapon_override.rs +++ b/crates/fastfile_iw5/src/weapon_override.rs @@ -92,7 +92,7 @@ impl WeaponGeometry { .i32_at(row, s.layout(sz::ANIM_OVERRIDE_ANIM_TIME_OFF, 28)) .ok()?, alt_time_ms: s - .i32_at(row, s.layout(sz::ANIM_OVERRIDE_ALT_TIME_OFF, 36)) + .i32_at(row, s.layout(sz::ANIM_OVERRIDE_ALT_TIME_OFF, 32)) .ok()?, }) }) diff --git a/crates/fastfile_iw5/src/zone.rs b/crates/fastfile_iw5/src/zone.rs index aad3afc..06607e1 100644 --- a/crates/fastfile_iw5/src/zone.rs +++ b/crates/fastfile_iw5/src/zone.rs @@ -336,6 +336,9 @@ pub struct AttachmentGeometry { #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct WeaponGeometry { pub name: Option, + pub alternate_weapon_name: Option, + pub alternate_raise_time_ms: i32, + pub alternate_drop_time_ms: i32, pub kill_icon_slot: Option, @@ -664,6 +667,8 @@ pub struct ClipMapGeometry { pub tri_indices: Option, + pub tri_edge_is_walkable: Option, + pub collision_partitions: Option, pub collision_aabb_trees: Option, diff --git a/crates/fastfile_t5/src/load/clipmap.rs b/crates/fastfile_t5/src/load/clipmap.rs index f07af5f..c74efcc 100644 --- a/crates/fastfile_t5/src/load/clipmap.rs +++ b/crates/fastfile_t5/src/load/clipmap.rs @@ -96,7 +96,7 @@ pub(super) fn load_clip_map(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSin always_array(s, p.at(0x5c), 4, 12 * num_brush_verts)?; always_array(s, p.at(0x64), 2, 2 * nuinds)?; let tri_indices = always_array(s, p.at(0x6c), 2, 2 * (3 * tri_count))?; - always_array(s, p.at(0x70), 1, 4 * ((3 * tri_count + 31) >> 5))?; + let tri_edge_is_walkable = always_array(s, p.at(0x70), 1, 4 * ((3 * tri_count + 31) >> 5))?; always_array(s, p.at(0x78), 4, sz::COLLISION_BORDER * border_count)?; let collision_partitions = @@ -184,6 +184,7 @@ pub(super) fn load_clip_map(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSin brushes, verts, tri_indices, + tri_edge_is_walkable, materials, material_count: num_materials, collision_partitions, diff --git a/crates/fastfile_t5/src/load/weapon.rs b/crates/fastfile_t5/src/load/weapon.rs index da9ce3e..e65f58f 100644 --- a/crates/fastfile_t5/src/load/weapon.rs +++ b/crates/fastfile_t5/src/load/weapon.rs @@ -25,7 +25,7 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink) follow_name(s, p, 0xc)?; - follow_name(s, p, 0x14)?; + let alternate_weapon_name = s.follow_string(p, 0x14)?; let sz_xanims = follow_string_array(s, p, 0x10, sz::WEAPON_XANIM_COUNT)?; @@ -57,6 +57,12 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink) _ => None, }; s.record_weapon(WeaponGeometry { + alternate_weapon_name, + alternate_raise_time_ms: s.i32_at(p, 0x3c)?, + alternate_drop_time_ms: weap_def + .map(|body| s.i32_at(body, 0x400)) + .transpose()? + .unwrap_or_default(), name, display_name, weap_def, diff --git a/crates/fastfile_t5/src/zone.rs b/crates/fastfile_t5/src/zone.rs index 1950509..e207d01 100644 --- a/crates/fastfile_t5/src/zone.rs +++ b/crates/fastfile_t5/src/zone.rs @@ -381,6 +381,9 @@ pub struct FxEffectDefGeometry { #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct WeaponGeometry { pub name: Option, + pub alternate_weapon_name: Option, + pub alternate_raise_time_ms: i32, + pub alternate_drop_time_ms: i32, pub display_name: Option, @@ -583,6 +586,8 @@ pub struct ClipMapGeometry { pub tri_indices: Option, + pub tri_edge_is_walkable: Option, + pub materials: Option, pub material_count: usize, diff --git a/crates/fx/src/draw.rs b/crates/fx/src/draw.rs index 4c6817b..3d08054 100644 --- a/crates/fx/src/draw.rs +++ b/crates/fx/src/draw.rs @@ -296,7 +296,7 @@ fn draw_effect_sprites( return; } - for class in [0usize, 2] { + for class in 0..3 { let mut handle = effect.first_elem_handle[class]; while handle != FX_ELEM_HANDLE_NONE { let next = elem_slot_for_handle(handle) diff --git a/crates/fx/src/system.rs b/crates/fx/src/system.rs index 9fcfaf5..5be9e9d 100644 --- a/crates/fx/src/system.rs +++ b/crates/fx/src/system.rs @@ -619,6 +619,24 @@ impl FxSystemHost { .filter(|e| e.ring_resident && e.has_refs()) } + pub fn stop_bolted(&mut self, def_name: &str, dobj: u32, bone: u16) { + let slots: Vec<_> = self + .effects + .iter() + .enumerate() + .filter_map(|(slot, effect)| { + (effect.ring_resident + && effect.def_name == def_name + && fx_iw4::fx_bolt_dobj(effect.bolt_packed) == dobj + && fx_iw4::fx_bolt_bone(effect.bolt_packed) == u32::from(bone)) + .then_some(slot) + }) + .collect(); + for slot in slots { + crate::spawn::stop_effect_non_recursive(self, slot); + } + } + pub fn refresh_bolt_poses( &mut self, mut resolve: impl FnMut(u32, u16) -> Option, diff --git a/crates/hud/Cargo.toml b/crates/hud/Cargo.toml index aeac0dc..c58d227 100644 --- a/crates/hud/Cargo.toml +++ b/crates/hud/Cargo.toml @@ -17,6 +17,7 @@ entity_iw4.workspace = true frame.workspace = true net = { path = "../net" } sim = { path = "../sim" } +math_iw4.workspace = true gamemode_iw4.workspace = true killcam_iw4 = { path = "../killcam_iw4" } diff --git a/crates/hud/src/flash.rs b/crates/hud/src/flash.rs index 4671a8a..658e11a 100644 --- a/crates/hud/src/flash.rs +++ b/crates/hud/src/flash.rs @@ -15,10 +15,13 @@ const FLASH_WHITE_IMAGE: &str = "white"; #[derive(Resource, Default)] pub(crate) struct FlashWhiteoutLatch { + pub(crate) saved: PackedList, pub(crate) packed: PackedList, - last_alpha: Option, + last_alpha: Option<(f32, f32)>, last_w: Option, last_h: Option, + last_start: Option, + pub(crate) save_sequence: u64, } #[derive(Resource, Default, Clone, Copy, PartialEq, Eq)] @@ -35,7 +38,7 @@ fn request_hide(job: &mut FlashGpuJob, packed_empty: bool) { } } -fn skip_rewrite(latch: &FlashWhiteoutLatch, alpha: f32, win_w: f32, win_h: f32) -> bool { +fn skip_rewrite(latch: &FlashWhiteoutLatch, alpha: (f32, f32), win_w: f32, win_h: f32) -> bool { !latch.packed.is_empty() && latch.last_alpha == Some(alpha) && latch.last_w == Some(win_w) @@ -60,7 +63,9 @@ pub(crate) fn update_flash_whiteout( for ev in started.read() { if ev.client == local.0.0 { latch.packed = PackedList::default(); + latch.saved = PackedList::default(); latch.last_alpha = None; + latch.last_start = None; } } gaps.clear(HudGap::FlashWhiteout); @@ -82,12 +87,18 @@ pub(crate) fn update_flash_whiteout( ps.shellshock_duration, SCREEN_BLEND_FLASHED, ); - let Some((white, _)) = + let Some((white, shot)) = cg_shellshock_flash_blend(remaining, FLASHBANG_WHITE_FADE_MS, FLASHBANG_SHOT_FADE_MS) else { + latch.last_start = None; request_hide(&mut job, latch.packed.is_empty()); return; }; + if latch.last_start != Some(ps.shellshock_time) { + latch.last_start = Some(ps.shellshock_time); + latch.save_sequence = latch.save_sequence.wrapping_add(1); + latch.last_alpha = None; + } let win_w = surface.width(); let win_h = surface.height(); let Some(color) = hud_images.get( @@ -102,11 +113,11 @@ pub(crate) fn update_flash_whiteout( request_hide(&mut job, latch.packed.is_empty()); return; }; - if skip_rewrite(&latch, white, win_w, win_h) { + if skip_rewrite(&latch, (white, shot), win_w, win_h) { *job = FlashGpuJob::Write; return; } - let list = Draw2dList { + let full_screen = |alpha: f32, site: &'static str| Draw2dList { cmds: vec![Draw2dCmd { material_namespace: crate::images::HUD_CHROME_NAMESPACE, x: 0.0, @@ -117,22 +128,30 @@ pub(crate) fn update_flash_whiteout( t0: 0.0, s1: 1.0, t1: 1.0, - color: [1.0, 1.0, 1.0, white], + color: [1.0, 1.0, 1.0, alpha], material: FLASH_WHITE_IMAGE.into(), op: Draw2dOp::StretchPic, - provenance: Draw2dProvenance::CgDraw { - site: "flash_whiteout", - }, + provenance: Draw2dProvenance::CgDraw { site }, layer: 0, }], }; - let Some(quad) = tessellate(&list).0.into_iter().next() else { + let (Some(saved_quad), Some(white_quad)) = ( + tessellate(&full_screen(shot, "flash_saved_screen")) + .0 + .into_iter() + .next(), + tessellate(&full_screen(white, "flash_whiteout")) + .0 + .into_iter() + .next(), + ) else { request_hide(&mut job, latch.packed.is_empty()); return; }; - latch.packed = crate::gpu_list::pack_modulate(&quad, color); + latch.saved = crate::gpu_list::pack_saved_screen(&saved_quad, color.clone()); + latch.packed = crate::gpu_list::pack_modulate(&white_quad, color); latch.last_w = Some(win_w); latch.last_h = Some(win_h); - latch.last_alpha = Some(white); + latch.last_alpha = Some((white, shot)); *job = FlashGpuJob::Write; } diff --git a/crates/hud/src/gpu_list.rs b/crates/hud/src/gpu_list.rs index 89c5749..910d361 100644 --- a/crates/hud/src/gpu_list.rs +++ b/crates/hud/src/gpu_list.rs @@ -32,6 +32,8 @@ pub enum HudTessTechnique { Modulate, SplatterAlt, + + SavedScreen, } #[derive(Clone, Debug)] @@ -58,6 +60,8 @@ pub struct HudTessGpuFrame { pub surface_w: f32, pub surface_h: f32, pub visible: bool, + + pub saved_screen_sequence: u64, } impl HudTessGpuFrame { @@ -461,6 +465,14 @@ pub(crate) fn pack_splatter_alt( packed } +pub(crate) fn pack_saved_screen(quad: &Draw2dQuad, stand_in: Handle) -> PackedList { + let mut packed = pack_modulate(quad, stand_in); + for batch in &mut packed.batches { + batch.technique = HudTessTechnique::SavedScreen; + } + packed +} + pub(crate) fn pack_modulate(quad: &Draw2dQuad, image: Handle) -> PackedList { let mut packed = PackedList::default(); packed.vertices.extend(pack_quad_verts(quad)); diff --git a/crates/hud/src/plugin.rs b/crates/hud/src/plugin.rs index 7a3261a..0fdd243 100644 --- a/crates/hud/src/plugin.rs +++ b/crates/hud/src/plugin.rs @@ -214,11 +214,13 @@ fn flush_flash_tess( mut frame: ResMut, ) { let _body = gpu_list::TessBody::open(); + frame.saved_screen_sequence = latch.save_sequence; match *job { FlashGpuJob::Write => { if latch.packed.is_empty() { return; } + frame.append_packed(&latch.saved); frame.append_packed(&latch.packed); } FlashGpuJob::Hide | FlashGpuJob::Idle => {} diff --git a/crates/hud/src/weaponbar.rs b/crates/hud/src/weaponbar.rs index 7629330..1db5270 100644 --- a/crates/hud/src/weaponbar.rs +++ b/crates/hud/src/weaponbar.rs @@ -72,6 +72,7 @@ fn hide(pass: &mut HudTessPass) { } struct WeaponbarExprHost<'a> { + weapons: Option<&'a PreparedWeapons>, ps: Option<&'a PlayerState>, input: Option<&'a frame::HudInputView>, ms: i32, @@ -110,15 +111,13 @@ impl ExprHost for WeaponbarExprHost<'_> { .ok() .and_then(|i| ps.action_slot_type.get(i)) }) - .is_some_and(|kind| !matches!(kind, 0 | 1)) + .is_some_and(|kind| !matches!(kind, 0 | 1 | 2)) { - return Err(ExprError::Host( - "alternate/nightvision action slot not hosted", - )); + return Err(ExprError::Host("nightvision action slot not hosted")); } Ok(i32::from( self.ps - .and_then(|ps| action_slot_weapon(ps, slot - 1)) + .and_then(|ps| action_slot_weapon(ps, slot - 1, self.weapons)) .is_some(), )) } @@ -182,12 +181,18 @@ impl ExprHost for WeaponbarExprHost<'_> { fn weapon_lock(&self) -> Result { self.lock.ok_or(ExprError::Host("weapon lock")) } + fn emp_jammed(&self) -> Result { + Ok(i32::from( + self.ps.is_some_and(|ps| ps.other_flags & 0x400 != 0), + )) + } fn dvar_int(&self, name: &str) -> Result { if name.eq_ignore_ascii_case("scr_gameended") { Ok(i32::from(self.game_ended)) } else if name.eq_ignore_ascii_case("g_hardcore") || name.eq_ignore_ascii_case("onlinegame") || name.eq_ignore_ascii_case("xblive_privatematch") + || name.eq_ignore_ascii_case("cg_thirdPersonSpectator") { Ok(0) } else if name.eq_ignore_ascii_case("scr_showperksonspawn") { @@ -480,7 +485,14 @@ fn background_stem(background: &str) -> Option { const WEAPOVERLAYINTERFACE_JAVELIN: i32 = 1; -fn weapon_lock_view(ps: &PlayerState, weapons: &PreparedWeapons) -> hud_iw4::WeaponLockView { +fn weapon_lock_view( + ps: &PlayerState, + weapons: &PreparedWeapons, + meta: Option<&sim::ClientSnapshotMeta>, + time_ms: i32, + projection: Option<&Projection>, + surface: &crate::surface::Hud2dSurface, +) -> hud_iw4::WeaponLockView { let viewmodel = bg_get_viewmodel_weapon_index(ps); let ads_javelin = viewmodel > 0 && ps.f_weapon_pos_frac == 1.0 @@ -488,9 +500,35 @@ fn weapon_lock_view(ps: &PlayerState, weapons: &PreparedWeapons) -> hud_iw4::Wea .0 .facts_of(viewmodel) .is_some_and(|facts| facts.overlay_interface == WEAPOVERLAYINTERFACE_JAVELIN); + let lock = meta + .map(|m| m.weapon_lock) + .filter(|lock| lock.weapon == ps.weapon && ps.health > 0) + .unwrap_or_default(); + let mut screen_pos = [0.0; 2]; + if lock.flags & 3 != 0 { + if let Some(Projection::Perspective(projection)) = projection { + let eye = Vec3::from_array(ps.origin) + Vec3::Z * ps.view_height_current; + let delta = Vec3::from_array(lock.target) - eye; + let (forward, right, up) = math_iw4::angle_vectors(ps.viewangles); + let depth = delta.dot(Vec3::from_array(forward)); + if depth > 0.0 { + let scale = surface.height() * 0.5 / ((projection.fov * 0.5).tan() * depth); + screen_pos = [ + surface.width() * 0.5 + delta.dot(Vec3::from_array(right)) * scale, + surface.height() * 0.5 - delta.dot(Vec3::from_array(up)) * scale, + ]; + } + } + } hud_iw4::WeaponLockView { ads_javelin, - ..Default::default() + time_ms, + attack_top: lock.flags & 4 != 0, + attack_direct: lock.flags & 8 != 0, + locking: lock.locking(), + locked: lock.locked(), + too_close: lock.too_close(), + screen_pos, } } @@ -499,9 +537,10 @@ fn ammo_hud_hidden(ps: &PlayerState) -> bool { } #[derive(bevy::ecs::system::SystemParam)] -pub(crate) struct WeaponbarInput<'w> { +pub(crate) struct WeaponbarInput<'w, 's> { select: Res<'w, CgWeaponSelect>, input: Option>, + cameras: Query<'w, 's, &'static Projection, With>, } #[allow(clippy::too_many_arguments)] @@ -566,6 +605,7 @@ pub(crate) fn update_weaponbar( .map(|w| offhand_ammo(ps, w, ps.offhand_secondary)) .unwrap_or(0); let mut host = WeaponbarExprHost { + weapons: weapons.as_deref(), ps: Some(ps), input: client_input.input.as_deref(), ms: sys_milliseconds() as i32, @@ -586,7 +626,16 @@ pub(crate) fn update_weaponbar( lock: Some( weapons .as_deref() - .map(|weapons| weapon_lock_view(ps, weapons)) + .map(|weapons| { + weapon_lock_view( + ps, + weapons, + meta, + cg_clock.time(), + client_input.cameras.iter().next(), + &surface, + ) + }) .unwrap_or_default(), ), frag_ammo, @@ -648,7 +697,10 @@ pub(crate) fn update_weaponbar( }); } - if let Some(menu) = catalog.get("dpad_hd") { + for name in ["dpad_hd", "javelin_overlay_hd"] { + let Some(menu) = catalog.get(name) else { + continue; + }; host.menu = Some(menu); let mut hook = |args: OwnerDrawArgs<'_>, frame: &mut ChromeFrame| { paint_owner(&owner_state, args, frame) @@ -787,9 +839,25 @@ pub(crate) fn update_weaponbar( pass.weaponbar = TessJob::Quads(quads); } -fn action_slot_weapon(ps: &PlayerState, slot: i32) -> Option { +fn action_slot_weapon( + ps: &PlayerState, + slot: i32, + weapons: Option<&PreparedWeapons>, +) -> Option { let slot = usize::try_from(slot).ok()?; - if ps.action_slot_type.get(slot) != Some(&1) || ps.weap_flags & 2 != 0 { + if ps.weap_flags & 2 != 0 { + return None; + } + if ps.action_slot_type.get(slot) == Some(&2) { + let weapons = &weapons?.0; + let weapon = if weapons.facts_of(ps.weapon)?.inventory_type == 3 { + ps.weapon_primary + } else { + weapons.alternate_of(ps.weapon) + }; + return (weapon != 0).then_some(weapon); + } + if ps.action_slot_type.get(slot) != Some(&1) { return None; } let weapon = *ps.action_slot_param.get(slot)?; @@ -805,11 +873,12 @@ fn paint_action_slot( .ps .action_slot_type .get((args.item.owner_draw - 171) as usize) - .is_some_and(|kind| !matches!(kind, 0 | 1)) + .is_some_and(|kind| !matches!(kind, 0 | 1 | 2)) { return OwnerDrawPaint::Gap(ChromeGapKind::OwnerDraw); } - let Some(weapon) = action_slot_weapon(state.ps, args.item.owner_draw - 171) else { + let Some(weapon) = action_slot_weapon(state.ps, args.item.owner_draw - 171, state.weapons) + else { return OwnerDrawPaint::Painted; }; let Some(weapons) = state.weapons else { diff --git a/crates/hud_iw4/src/expr.rs b/crates/hud_iw4/src/expr.rs index 680ae02..7744476 100644 --- a/crates/hud_iw4/src/expr.rs +++ b/crates/hud_iw4/src/expr.rs @@ -149,7 +149,7 @@ pub enum ExprError { pub struct WeaponLockView { pub ads_javelin: bool, - pub blink: bool, + pub time_ms: i32, pub attack_top: bool, @@ -638,12 +638,21 @@ fn run_op( data.push(Operand::Int(host.selecting_location()?)); Ok(()) } - OP_ADSJAVELIN | OP_WEAPLOCKBLINK | OP_WEAPATTACKTOP | OP_WEAPATTACKDIRECT - | OP_WEAPLOCKING | OP_WEAPLOCKED | OP_WEAPLOCKTOOCLOSE => { + OP_WEAPLOCKBLINK => { + let rate = source_float(&pop_data(data)?); + let lock = host.weapon_lock()?; + let period = (1000.0 / rate) as i32; + let visible = lock.locked + || (lock.locking + && (rate <= 0.0 || period <= 0 || (lock.time_ms / period) % 2 != 0)); + data.push(Operand::Int(i32::from(visible))); + Ok(()) + } + OP_ADSJAVELIN | OP_WEAPATTACKTOP | OP_WEAPATTACKDIRECT | OP_WEAPLOCKING | OP_WEAPLOCKED + | OP_WEAPLOCKTOOCLOSE => { let lock = host.weapon_lock()?; let flag = match op { OP_ADSJAVELIN => lock.ads_javelin, - OP_WEAPLOCKBLINK => lock.blink, OP_WEAPATTACKTOP => lock.attack_top, OP_WEAPATTACKDIRECT => lock.attack_direct, OP_WEAPLOCKING => lock.locking, diff --git a/crates/input_iw4/src/lib.rs b/crates/input_iw4/src/lib.rs index 9860755..d37db50 100644 --- a/crates/input_iw4/src/lib.rs +++ b/crates/input_iw4/src/lib.rs @@ -239,6 +239,7 @@ pub struct ClientInput { pub stance_latch: i32, pub weapon_cycles: Vec, + pub action_slots: Vec, pub offhand_hold_cancel: bool, } @@ -251,6 +252,7 @@ impl Default for ClientInput { using_ads: false, stance_latch: 0, weapon_cycles: Vec::new(), + action_slots: Vec::new(), offhand_hold_cancel: false, } } @@ -290,7 +292,11 @@ pub fn cl_input_cmd( apply_pair(&mut client.kb.speed, cmd_id, key, now_msec, frame_msec); apply_pair(&mut client.kb.throw_btn, cmd_id, key, now_msec, frame_msec); } - 15..=22 => panic!("+actionslot N not this slice"), + 15..=22 => { + if pair_down(cmd_id) { + client.action_slots.push(((cmd_id - 15) / 2) as usize); + } + } 23 | 24 => panic!("+stance writes the stance latch; not a movedown alias"), 25 | 26 => { apply_pair(&mut client.kb.gostand, cmd_id, key, now_msec, frame_msec); diff --git a/crates/input_iw4/src/weapon_select.rs b/crates/input_iw4/src/weapon_select.rs index d27cf00..02e212f 100644 --- a/crates/input_iw4/src/weapon_select.rs +++ b/crates/input_iw4/src/weapon_select.rs @@ -20,18 +20,21 @@ pub fn weapon_cycle_allowed( pub fn cycle_weapon( weapons: &[i32; 15], selected: u32, + mapped: u32, last_primary: u32, next: bool, mut inventory_type: impl FnMut(u32) -> i32, ) -> Option { + let selected = if selected != 0 && inventory_type(selected) == 3 { + mapped + } else { + selected + }; let current_type = if selected == 0 { 0 } else { inventory_type(selected) }; - if current_type == 3 { - panic!("alternate-mode parent selection and remembered alt mode"); - } if current_type != 0 && current_type != 4 && last_primary != 0 diff --git a/crates/net/src/client/runtime.rs b/crates/net/src/client/runtime.rs index 0c236e3..8fb1276 100644 --- a/crates/net/src/client/runtime.rs +++ b/crates/net/src/client/runtime.rs @@ -210,6 +210,7 @@ pub struct CgWeaponSelect { pub held_index: u32, pub last_primary: u32, + pub mapped_index: u32, } pub fn cg_follow_held_weapon_select( @@ -226,6 +227,7 @@ pub fn cg_follow_held_weapon_select( || (select.index == select.held_index && ps_weapon != select.held_index) { select.index = ps_weapon; + select.mapped_index = ps_weapon; select.last_primary = 0; select.life_sequence = life_sequence; select.time = cg_time; @@ -270,6 +272,7 @@ pub fn cg_cycle_weapon_select( let Some(target) = cycle_weapon( &ps.weapons, select.index, + select.mapped_index, select.last_primary, next, |weapon| facts(weapon).inventory_type, @@ -292,6 +295,7 @@ pub fn cg_cycle_weapon_select( select.last_primary = select.index; } select.index = target; + select.mapped_index = target; input_iw4::cl_set_ads(input, false); } } @@ -722,6 +726,7 @@ pub fn sample_client_input( push_phase(trace, "Input"); if !gate.local_cmds_enabled { actions.client.weapon_cycles.clear(); + actions.client.action_slots.clear(); return; } actions.frame_msec = key_frame_msec(time.delta_secs()); @@ -780,6 +785,60 @@ pub fn sample_client_input( .map(|meta| meta.life_sequence.0) .unwrap_or(0); cg_follow_held_weapon_select(&mut select, ps_weapon, life_sequence, clock.time()); + if let Some(ps) = ps { + if select.index == ps.weapon { + select.mapped_index = if prediction + .0 + .world() + .weapon_combat_row(ps.weapon) + .is_some_and(|facts| facts.inventory_type == 3) + { + ps.weapon_primary + } else { + ps.weapon + }; + } + } + let slots = std::mem::take(&mut actions.client.action_slots); + if let Some(ps) = ps.filter(|_| !frozen) { + for slot in slots { + if !input_iw4::weapon_select::weapon_cycle_allowed(ps, clock.time(), select.time, 0, 0) + { + continue; + } + let target = match ps.action_slot_type.get(slot).copied() { + Some(1) => ps.action_slot_param[slot].max(0) as u32, + Some(2) => match prediction.0.world().weapon_combat_row(select.index) { + Some(facts) if facts.inventory_type == 3 => select.mapped_index, + Some(facts) => facts.alternate_weapon, + None => 0, + }, + _ => 0, + }; + if target == 0 { + continue; + } + let parent = if ps.weapons.contains(&(target as i32)) { + target + } else { + select.mapped_index + }; + let owned = ps.weapons.contains(&(parent as i32)) + && (parent == target + || prediction + .0 + .world() + .weapon_combat_row(parent) + .is_some_and(|f| f.alternate_weapon == target)); + if !owned { + continue; + } + select.index = target; + select.mapped_index = parent; + select.time = clock.time(); + input_iw4::cl_set_ads(&mut actions.client, false); + } + } let cycles = std::mem::take(&mut actions.client.weapon_cycles); let in_killcam = view.is_some_and(|v| v.in_killcam()); if let Some(ps) = ps { @@ -808,7 +867,7 @@ pub fn sample_client_input( look.angles = cmd.angles; cmd.weapon = select.index as u16; - cmd.weapon_mapped = select.index as u16; + cmd.weapon_mapped = select.mapped_index as u16; if let Some(loadout) = presented .snapshot() .and_then(|snapshot| snapshot.meta.for_client(local.0)) diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index b79aca7..a5f53a7 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -169,4 +169,4 @@ pub use transport::udp_session::{CommittedAdmission, UdpAuthorityHub, UdpClientL pub use transport::udp_socket::{DEFAULT_RECV_BUDGET_PER_TICK, UdpDatagramSocket, UdpSendError}; pub use transport::wire::{WireError, WireReader, WireWriter}; -pub const PROTOCOL_VERSION: u32 = 76; +pub const PROTOCOL_VERSION: u32 = 77; diff --git a/crates/net/src/transport/meta_wire.rs b/crates/net/src/transport/meta_wire.rs index 38c3a0a..aebfa16 100644 --- a/crates/net/src/transport/meta_wire.rs +++ b/crates/net/src/transport/meta_wire.rs @@ -1203,6 +1203,18 @@ fn encode_client_meta(out: &mut WireWriter, meta: &ClientSnapshotMeta) { out.put_u32(meta.player_card_icon); out.put_u32(meta.player_card_title); out.put_u32(meta.player_card_nameplate); + let lock = meta.weapon_lock; + out.put_u32(lock.weapon); + out.put_u32(lock.life); + out.put_u8(lock.flags); + for value in lock.target.into_iter().chain(lock.point_sum) { + out.put_f32(value); + } + out.put_u8(lock.point_count); + out.put_u8(lock.misses); + out.put_i32(lock.sampled_at); + out.put_i32(lock.out_of_ads_at); + out.put_i32(lock.acquire_started_at); } fn decode_client_meta(input: &mut WireReader<'_>) -> Result { @@ -1266,7 +1278,20 @@ fn decode_client_meta(input: &mut WireReader<'_>) -> Result, + + pub left_clip_ammo: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -238,7 +240,7 @@ pub enum WeapAnimEdge { Idle, - EmptyIdle, + ForceIdle, Dispatch(usize), } @@ -382,22 +384,16 @@ pub fn tick_equipped_fpv_with_extra_events( present.last_weap_anim = None; } } - WeapAnimEdge::EmptyIdle => { - equipped.controller.apply_empty_idle_weap_anim(); + WeapAnimEdge::ForceIdle => { + equipped + .controller + .apply_force_idle_weap_anim(sample.clip_ammo == Some(0)); } WeapAnimEdge::Dispatch(slot) => { report_dispatch_slot(slot, equipped.controller.dispatch_sz_xanim_index(slot)); } } } - if let Some(sample) = sample { - reconcile_sprint_latch(&mut equipped.controller, sample.sprinting); - if let Some(left) = equipped.left.as_mut() - && sample.last_weapon_hand == 1 - { - reconcile_sprint_latch(left, sample.sprinting); - } - } let crate::AdvanceResult { notifies } = equipped.controller.advance(dt_secs); let ads_frac = sample .map(|s| s.ads_frac) @@ -410,12 +406,12 @@ pub fn tick_equipped_fpv_with_extra_events( match present.observe_weap_anim_secondary_edge(sample.weap_anim_secondary) { WeapAnimEdge::Unchanged => {} WeapAnimEdge::Idle => { - if !left.apply_idle_weap_anim(false) { + if !left.apply_idle_weap_anim(sample.left_clip_ammo == Some(0)) { present.last_weap_anim_secondary = None; } } - WeapAnimEdge::EmptyIdle => { - left.apply_empty_idle_weap_anim(); + WeapAnimEdge::ForceIdle => { + left.apply_force_idle_weap_anim(sample.left_clip_ammo == Some(0)); } WeapAnimEdge::Dispatch(slot) => { report_dispatch_slot(slot, left.dispatch_sz_xanim_index(slot)); @@ -429,21 +425,6 @@ pub fn tick_equipped_fpv_with_extra_events( (sample_pose(&equipped.controller), notifies) } -fn reconcile_sprint_latch(controller: &mut ViewmodelController, sprinting: bool) { - if sprinting { - return; - } - if matches!( - controller.state(), - WeaponState::SprintIn | WeaponState::SprintLoop - ) { - report_fpv_event_result( - PresentFpvEvent::SprintOut, - controller.handle(ViewmodelEvent::SprintOut), - ); - } -} - fn observe_weap_anim_edge_on(previous: &mut Option, raw: i32) -> WeapAnimEdge { let last = previous.replace(raw); if last == Some(raw) { @@ -452,7 +433,7 @@ fn observe_weap_anim_edge_on(previous: &mut Option, raw: i32) -> WeapAnimEd let masked = raw as u32 & weapon_iw4::WEAP_ANIM_EVENT_MASK; match masked { 0 => WeapAnimEdge::Idle, - 1 => WeapAnimEdge::EmptyIdle, + 1 => WeapAnimEdge::ForceIdle, _ => match weapon_iw4::slot_for_weap_anim_event(masked) { Some(slot) => WeapAnimEdge::Dispatch(slot), None => WeapAnimEdge::Idle, diff --git a/crates/render_anim/src/anim/fpv_host.rs b/crates/render_anim/src/anim/fpv_host.rs index 9a99c24..4a66fb2 100644 --- a/crates/render_anim/src/anim/fpv_host.rs +++ b/crates/render_anim/src/anim/fpv_host.rs @@ -1,15 +1,15 @@ -use std::collections::HashMap; +use std::sync::Arc; use bevy::math::{Mat4, Vec3}; use bevy::prelude::*; -use render_scene::SmodelPassMaterial; use crate::anim::fpv::{ EquippedFpv, FpvAuthoritySample, FpvPresentState, tick_equipped_fpv_with_predicted_fire, }; use crate::anim::fpv_pose::{FpvBoltFrame, PosedClip}; -use crate::anim::fpv_rig::{FpvHandPose, FpvRigInputs, PreparedFpvRig}; -use assets::{FpvClipTracks, FpvMeshCatalog, FpvMeshIndex, FpvSideAssemblies}; +use crate::anim::fpv_prepared::FpvRigSet; +use crate::anim::fpv_rig::{FpvHandPose, PreparedFpvRig}; +use assets::FpvMeshIndex; #[derive(Resource, Default)] pub struct FpvPresentCursor(pub FpvPresentState); @@ -22,6 +22,7 @@ pub struct PendingFpvSpawnRequest { pub gun_index: FpvMeshIndex, pub catalog_id: u64, pub weapon_id: u32, + pub parent_weapon: u32, } #[derive(Resource, Default)] @@ -39,6 +40,8 @@ pub struct FpvHeldLife(pub Option); #[derive(Resource, Default)] pub struct FpvBoltTargets { pub pose: [Option; 2], + pub tracker_screen: Option<[Vec3; 3]>, + pub tracker_light: Option, pub flash: [Option; 2], pub brass: [Option; 2], pub knife: [Option; 2], @@ -48,6 +51,8 @@ pub struct FpvBoltTargets { impl FpvBoltTargets { pub fn clear(&mut self) { self.pose = [None, None]; + self.tracker_screen = None; + self.tracker_light = None; self.flash = [None, None]; self.brass = [None, None]; self.knife = [None, None]; @@ -104,14 +109,9 @@ pub struct FpvPoseProduct { pub struct FpvGenerateArgs<'a> { pub dt: f32, pub equipped: &'a mut EquippedFpv, - pub rigs: &'a mut Vec, - pub active: &'a mut Option, + pub rigs: &'a FpvRigSet, + pub active: &'a mut Option>, pub cursor: &'a mut FpvPresentState, - pub catalog: &'a FpvMeshCatalog, - pub materials: &'a [SmodelPassMaterial], - pub material_by_authored: &'a HashMap, - pub assemblies: &'a FpvSideAssemblies, - pub clip_tracks: &'a FpvClipTracks, pub rocket: bool, pub sample: Option, pub predicted_fire: bool, @@ -126,11 +126,6 @@ pub fn generate_fpv_pose(args: FpvGenerateArgs<'_>) -> FpvPoseKind { rigs, active, cursor, - catalog, - materials, - material_by_authored, - assemblies, - clip_tracks, rocket, sample, predicted_fire, @@ -167,64 +162,18 @@ pub fn generate_fpv_pose(args: FpvGenerateArgs<'_>) -> FpvPoseKind { }; let dual_drawn = !left.is_empty(); - let rocket = rocket && assemblies.rocket.is_some(); - let assembly = assemblies.pick(rocket); - let left_orders: &[Option] = match (dual_drawn, equipped.left.as_ref()) { - (true, Some(left)) => left.weapon().clip_orders(), - _ => &[], - }; - let orders = [ - equipped.controller.weapon().clip_orders().as_slice(), - left_orders, - ]; - *active = rigs - .iter() - .position(|rig| rig.matches(assembly, dual_drawn, orders)); - if active.is_none() { - let rig_started = std::time::Instant::now(); - match PreparedFpvRig::build( - std::sync::Arc::clone(assembly), - dual_drawn, - FpvRigInputs { - catalog, - materials, - material_by_authored, - clip_tracks, - clip_orders: orders, - }, - ) { - Ok(prepared) => { - diag::info!( - Fpv, - "fpv: laid out `{}` in {:.2}ms — {} draws, {} vertices, {} surfaces skipped{}{}", - equipped.gun_xmodel, - rig_started.elapsed().as_secs_f64() * 1000.0, - prepared.geometry.plan_draw_n, - prepared.geometry.dest_n, - prepared.geometry.plan_skip_n, - if dual_drawn { ", dual" } else { "" }, - if rocket { ", rocket" } else { "" } - ); - rigs.push(prepared); - *active = Some(rigs.len() - 1); - } - Err(error) => { - diag::info!( - Fpv, - "fpv: cannot lay out `{}` — {error}", - equipped.gun_xmodel - ); - return FpvPoseKind::Refuse(FpvPoseRefuse::EyePoseFailed { - gun_xmodel: equipped.gun_xmodel.clone(), - }); - } - } - } - let Some(prepared) = active.and_then(|index| rigs.get_mut(index)) else { + let Some(prepared) = rigs.pick(rocket, dual_drawn) else { + *active = None; return FpvPoseKind::Refuse(FpvPoseRefuse::EyePoseFailed { gun_xmodel: equipped.gun_xmodel.clone(), }); }; + if active + .as_ref() + .is_none_or(|current| current.generation() != prepared.generation()) + { + *active = Some(Arc::clone(prepared)); + } let Some(right_pose) = prepared.pose_hand(0, &right, Vec3::ZERO) else { return FpvPoseKind::Refuse(FpvPoseRefuse::EyePoseFailed { diff --git a/crates/render_anim/src/anim/fpv_pose.rs b/crates/render_anim/src/anim/fpv_pose.rs index cc0c6b2..44e242b 100644 --- a/crates/render_anim/src/anim/fpv_pose.rs +++ b/crates/render_anim/src/anim/fpv_pose.rs @@ -48,6 +48,8 @@ pub struct FpvBoltTags { pub knife: Option, pub laser: Option, + pub tracker_screen: [Option; 3], + pub tracker_light: Option, } #[derive(Clone, Debug, Default)] diff --git a/crates/render_anim/src/anim/fpv_prepared.rs b/crates/render_anim/src/anim/fpv_prepared.rs new file mode 100644 index 0000000..0a206b0 --- /dev/null +++ b/crates/render_anim/src/anim/fpv_prepared.rs @@ -0,0 +1,971 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use assets::{ + AssetEdge, AssetNamespace, FpvHands, FpvMeshCatalog, FpvMeshIndex, FpvSideAssemblies, + PreparedFpvMeshes, PreparedWeapons, PreparedXAnims, TS_COLOR_MAP, WEAPON_ANIM_SLOTS, + WeaponAnimations, WeaponBodyFacts, WeaponRegistry, XAnimCatalog, +}; +use bevy::prelude::*; +use render_material::{RuntimeMaterialCatalog, RuntimeSortedMaterialTable}; +use render_scene::{SmodelPassMaterial, TessMaterials, WorldModelLightingAtlas}; + +use crate::anim::fpv_rig::{ + FpvMandatoryRefusal, FpvMaterialAdmission, FpvSurfaceVerdict, PreparedFpvComposition, + PreparedFpvModel, PreparedFpvRig, compose_clip_tracks, +}; +use crate::gaps::RenderGapCause; + +const PREPARE_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(6); + +const NO_COLOUR_MAP: &str = "technique samples no colour map"; + +#[derive(Default)] +pub struct FpvRigSet { + bare: [Option>; 2], + rocket: [Option>; 2], +} + +impl FpvRigSet { + pub fn pick(&self, rocket: bool, dual: bool) -> Option<&Arc> { + let hand = usize::from(dual); + match (rocket, &self.rocket[hand]) { + (true, Some(rig)) => Some(rig), + _ => self.bare[hand].as_ref(), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct FpvViewCensus { + pub gun_colormap_skip_n: u32, + pub gun_colormap_skip_names: Vec, + pub mat_hints: Vec, +} + +pub struct FpvWeaponView { + pub gun_name: String, + pub gun_index: FpvMeshIndex, + pub hands: FpvHands, + pub hands_index: FpvMeshIndex, + pub namespace: AssetNamespace, + pub assemblies: FpvSideAssemblies, + pub right: WeaponAnimations, + pub left: Option, + pub idle_name: Option, + pub rigs: FpvRigSet, + pub census: FpvViewCensus, +} + +pub enum FpvWeaponSlot { + Absent, + Ready(Arc), + Refused(RenderGapCause), +} + +static ABSENT_SLOT: FpvWeaponSlot = FpvWeaponSlot::Absent; + +#[derive(Clone, Copy, Debug, Default)] +pub struct FpvPreparationCensus { + pub models: usize, + pub layouts: usize, + pub compositions: usize, + pub rigs: usize, + pub materials: usize, + pub elapsed_ms: f64, +} + +pub struct FpvWeaponTable { + material_catalog: Arc, + catalog_id: u64, + facts: Vec>, + hud_iris: Vec, + guns: Vec>, + slots: Vec<[FpvWeaponSlot; 2]>, + alternate_slots: HashMap<(u32, u32), [FpvWeaponSlot; 2]>, + census: FpvPreparationCensus, +} + +impl FpvWeaponTable { + pub fn census(&self) -> FpvPreparationCensus { + self.census + } + + pub fn material_catalog(&self) -> &Arc { + &self.material_catalog + } + + pub fn catalog_id(&self) -> u64 { + self.catalog_id + } + + pub fn facts_of(&self, weapon: u32) -> Option { + self.facts.get(weapon as usize).copied().flatten() + } + + pub fn overlay_is_hud_iris(&self, weapon: u32) -> bool { + self.hud_iris.get(weapon as usize).copied().unwrap_or(false) + } + + pub fn gun_index(&self, weapon: u32) -> Option { + self.guns.get(weapon as usize).copied().flatten() + } + + pub fn slot(&self, weapon: u32, parent: u32, axis: bool) -> &FpvWeaponSlot { + if parent != 0 && self.facts_of(weapon).is_some_and(|f| f.inventory_type == 3) { + return self + .alternate_slots + .get(&(weapon, parent)) + .map_or(&ABSENT_SLOT, |s| &s[usize::from(axis)]); + } + self.slots + .get(weapon as usize) + .map_or(&ABSENT_SLOT, |sides| &sides[usize::from(axis)]) + } +} + +struct FpvPreparationOwner { + materials: Arc, + images: Arc>>>, + weapons: Arc, + catalog_id: u64, + atlas: Option>, +} + +impl FpvPreparationOwner { + fn same(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.materials, &other.materials) + && Arc::ptr_eq(&self.images, &other.images) + && Arc::ptr_eq(&self.weapons, &other.weapons) + && self.catalog_id == other.catalog_id + && self.atlas == other.atlas + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FpvPreparationStage { + Admit, + Layout, + Weapons, + Done, +} + +type RigKey = (usize, bool, Vec>, Vec>); + +struct FpvPreparationJob { + owner: FpvPreparationOwner, + stage: FpvPreparationStage, + started: std::time::Instant, + progress: Option, + work_total: u64, + work_done: u64, + + models: Vec, + next_model: usize, + admission: FpvMaterialAdmission, + image_cache: HashMap>, + + layouts_queue: Vec<(usize, Option<[u32; 6]>)>, + next_layout: usize, + layouts: HashMap<(usize, Option<[u32; 6]>), Result, String>>, + + next_weapon: u32, + alternate_queue: Vec<(u32, u32)>, + compositions: HashMap, String>>, + tracks: HashMap<(usize, usize), Option>>, + rigs: HashMap>, + facts: Vec>, + hud_iris: Vec, + guns: Vec>, + slots: Vec<[FpvWeaponSlot; 2]>, + alternate_slots: HashMap<(u32, u32), [FpvWeaponSlot; 2]>, + refused: Vec, +} + +#[derive(Resource, Default)] +pub struct PreparedFpv { + job: Option, + table: Option<(FpvPreparationOwner, Arc)>, +} + +impl PreparedFpv { + pub fn table(&self) -> Option<&Arc> { + self.table.as_ref().map(|(_, table)| table) + } + + pub fn settled_for(&self, materials: &Arc) -> bool { + self.job.is_none() + && self + .table() + .is_some_and(|table| Arc::ptr_eq(&table.material_catalog, materials)) + } + + pub fn clear(&mut self) { + if let Some(job) = self.job.take() + && let Some(stage) = job.progress + { + stage.cancel(); + } + self.table = None; + } +} + +fn assembly_key(assembly: &Arc) -> usize { + Arc::as_ptr(assembly) as usize +} + +fn composition_key(composition: &Arc) -> usize { + Arc::as_ptr(composition) as usize +} + +#[allow(clippy::too_many_arguments)] +fn admit_surface( + global: &RuntimeMaterialCatalog, + images: &[Option>], + entry: &assets::FpvMeshEntry, + surface_index: usize, + lighting: &WorldModelLightingAtlas, + admission: &mut FpvMaterialAdmission, + image_cache: &mut HashMap>, +) -> FpvSurfaceVerdict { + use lighting_iw4::{ + MODEL_LIGHTING_INV_ATLAS_WIDTH, MODEL_LIGHTING_VOLUME_W, model_lighting_inv_image_height, + model_lighting_lookup_scale, + }; + + let edge = entry + .material_edges + .get(surface_index) + .copied() + .unwrap_or(AssetEdge::Absent); + let leftover = entry + .material_names + .get(surface_index) + .and_then(|name| name.as_deref()) + .unwrap_or("-"); + let refused = |material: &str, cause: &'static str| FpvSurfaceVerdict::Refused { + material: material.to_owned(), + cause, + }; + let bound = match edge { + AssetEdge::Absent => return FpvSurfaceVerdict::Inapplicable("no material"), + AssetEdge::Unresolved(_) => return refused(leftover, edge.edge_kind()), + AssetEdge::Bound(_) => match edge.bound_index() { + Some(bound) => bound, + None => return refused(leftover, "bound edge without an index"), + }, + }; + let Some(present_name) = entry.material_present_name(surface_index) else { + return refused(leftover, "bound material has no name"); + }; + let Some(mat_i) = entry + .skel + .surface_materials + .get(surface_index) + .copied() + .flatten() + .map(|index| index.get()) + else { + return refused(present_name, "surface carries no authored material"); + }; + if let Some(&row) = admission.by_authored.get(&mat_i) { + return FpvSurfaceVerdict::Admitted(row); + } + let Some(authored) = global.materials.get(bound) else { + return refused(present_name, "material outside the session catalog"); + }; + let Some(ordinal) = global.ordinal_for_material_name(present_name) else { + return refused(present_name, "material has no sorted ordinal"); + }; + let Some(color_image) = authored + .textures + .iter() + .find_map(|(_, texture)| texture.filter(|binding| binding.semantic == TS_COLOR_MAP)) + .map(|binding| binding.image.0) + else { + return FpvSurfaceVerdict::Inapplicable(NO_COLOUR_MAP); + }; + let mut image = |image_index: u32| -> Option> { + if let Some(cached) = image_cache.get(&image_index) { + return Some(cached.clone()); + } + let handle = images.get(image_index as usize).cloned().flatten()?; + image_cache.insert(image_index, handle.clone()); + Some(handle) + }; + let Some(color) = image(color_image) else { + return refused(present_name, "colour map not uploaded"); + }; + let specular = authored + .textures + .iter() + .find_map(|(_, texture)| { + texture.filter(|binding| binding.semantic == assets::TS_SPECULAR_MAP) + }) + .and_then(|binding| image(binding.image.0)); + + const ENV_MAP_PARMS: u32 = 1_033_475_292; + let env_map_parms = authored + .constants + .iter() + .find(|(hash, _)| *hash == ENV_MAP_PARMS) + .map(|(_, words)| words.map(f32::from_bits)) + .unwrap_or([0.0; 4]); + let Some(inv_h) = model_lighting_inv_image_height(lighting.dims.image_height) else { + return refused(present_name, "model lighting atlas has no rows"); + }; + let scale = model_lighting_lookup_scale(inv_h); + let cull_mode = + authored + .state_bits_table + .first() + .and_then(|bits| match assets::cull_face_from_state_bits(*bits) { + assets::MaterialCullFace::Back => Some(bevy::render::render_resource::Face::Back), + assets::MaterialCullFace::Front => Some(bevy::render::render_resource::Face::Front), + assets::MaterialCullFace::None => None, + }); + let material = SmodelPassMaterial { + model_lighting_required: true, + color: Some(color), + specular, + probe: None, + atlas: Some(lighting.image.clone()), + alpha_mode: AlphaMode::Opaque, + draw_mode: None, + cull_mode, + env_map_parms, + lighting_lookup_scale: [scale.u, scale.v, scale.w, scale.q], + atlas_lookup: [ + MODEL_LIGHTING_INV_ATLAS_WIDTH as f32, + inv_h, + MODEL_LIGHTING_VOLUME_W, + 0.0, + ], + sort_key: authored.sort_key, + material_sorted_index: Some(ordinal.get()), + }; + let row = admission.materials.len() as u32; + admission.materials.push(material); + admission.by_authored.insert(mat_i, row); + FpvSurfaceVerdict::Admitted(row) +} + +impl FpvPreparationJob { + fn new( + owner: FpvPreparationOwner, + fpv: &FpvMeshCatalog, + progress: Option, + ) -> Self { + let registry = Arc::clone(&owner.weapons); + let weapon_n = registry.len() as u32; + let mut models: Vec = Vec::new(); + let mut seen_models: HashSet = HashSet::new(); + let mut layouts_queue: Vec<(usize, Option<[u32; 6]>)> = Vec::new(); + let mut seen_layouts: HashSet<(usize, Option<[u32; 6]>)> = HashSet::new(); + let mut seen_assemblies: HashSet = HashSet::new(); + for (id, parent) in (1..=weapon_n) + .map(|id| (id, 0)) + .chain(registry.alternate_fpv_pairs()) + { + for axis in [false, true] { + let Some(sides) = registry.fpv_assemblies_for(id, parent, axis) else { + continue; + }; + for assembly in std::iter::once(&sides.bare).chain(&sides.rocket) { + if !seen_assemblies.insert(assembly_key(assembly)) { + continue; + } + for part in &assembly.parts { + let order = part.model.order(); + if fpv.get_at(order).is_none() { + continue; + } + if seen_models.insert(order) { + models.push(order); + } + if seen_layouts.insert((order, part.hide)) { + layouts_queue.push((order, part.hide)); + } + } + } + } + } + let work_total = + (models.len() + layouts_queue.len() + registry.alternate_fpv_pairs().count()) as u64 + + u64::from(weapon_n); + if let Some(stage) = &progress { + stage.set_total(work_total); + } + Self { + owner, + stage: FpvPreparationStage::Admit, + started: std::time::Instant::now(), + progress, + work_total, + work_done: 0, + models, + next_model: 0, + admission: FpvMaterialAdmission::default(), + image_cache: HashMap::new(), + layouts_queue, + next_layout: 0, + layouts: HashMap::new(), + next_weapon: 0, + alternate_queue: { + let mut pairs: Vec<_> = registry.alternate_fpv_pairs().collect(); + pairs.sort_unstable(); + pairs.reverse(); + pairs + }, + compositions: HashMap::new(), + tracks: HashMap::new(), + rigs: HashMap::new(), + facts: Vec::with_capacity(weapon_n as usize + 1), + hud_iris: Vec::with_capacity(weapon_n as usize + 1), + guns: Vec::with_capacity(weapon_n as usize + 1), + slots: Vec::with_capacity(weapon_n as usize + 1), + alternate_slots: HashMap::new(), + refused: Vec::new(), + } + } + + fn tick(&mut self) { + self.work_done = self.work_done.saturating_add(1); + if let Some(stage) = &self.progress { + stage.set_completed(self.work_done.min(self.work_total)); + } + } + + fn advance( + &mut self, + deadline: std::time::Instant, + fpv: &FpvMeshCatalog, + xanims: &XAnimCatalog, + tess: &TessMaterials, + lighting: &WorldModelLightingAtlas, + ) { + while self.stage != FpvPreparationStage::Done { + if std::time::Instant::now() >= deadline { + return; + } + match self.stage { + FpvPreparationStage::Admit => self.admit_next(fpv, tess, lighting), + FpvPreparationStage::Layout => self.lay_out_next(fpv), + FpvPreparationStage::Weapons => self.prepare_next_weapon(fpv, xanims), + FpvPreparationStage::Done => {} + } + } + } + + fn admit_next( + &mut self, + fpv: &FpvMeshCatalog, + tess: &TessMaterials, + lighting: &WorldModelLightingAtlas, + ) { + let Some(&order) = self.models.get(self.next_model) else { + self.stage = FpvPreparationStage::Layout; + return; + }; + self.next_model += 1; + if let Some(entry) = fpv.get_at(order) { + let verdicts = entry + .skel + .surfaces_for_lod(0) + .map(|surface| { + let verdict = admit_surface( + &tess.catalog, + tess.material_images.as_ref(), + entry, + surface, + lighting, + &mut self.admission, + &mut self.image_cache, + ); + (surface, verdict) + }) + .collect(); + self.admission.record(order, verdicts); + } + self.tick(); + } + + fn lay_out_next(&mut self, fpv: &FpvMeshCatalog) { + let Some(&(order, hide)) = self.layouts_queue.get(self.next_layout) else { + self.stage = FpvPreparationStage::Weapons; + self.next_weapon = 0; + return; + }; + self.next_layout += 1; + let prepared = PreparedFpvModel::build(fpv, order, hide.as_ref(), &self.admission) + .map(Arc::new) + .map_err(|error| error.to_string()); + self.layouts.insert((order, hide), prepared); + self.tick(); + } + + fn composition( + &mut self, + assembly: &Arc, + ) -> Result, String> { + let key = assembly_key(assembly); + if let Some(done) = self.compositions.get(&key) { + return done.clone(); + } + let layouts = &self.layouts; + let composed = PreparedFpvComposition::compose(Arc::clone(assembly), |order, hide| { + layouts + .get(&(order, hide)) + .cloned() + .unwrap_or_else(|| Err("model missing from the first-person catalog".to_owned())) + }) + .map(Arc::new); + self.compositions.insert(key, composed.clone()); + composed + } + + fn clip_tracks( + &mut self, + composition: &PreparedFpvComposition, + orders: &[Option], + ) -> Vec>> { + let registry = Arc::clone(&self.owner.weapons); + let assembly = composition.assembly(); + let key = assembly_key(assembly); + orders + .iter() + .map(|order| { + let order = (*order)?; + self.tracks + .entry((key, order)) + .or_insert_with(|| { + compose_clip_tracks(assembly, order, registry.fpv_clip_tracks()) + }) + .clone() + }) + .collect() + } + + fn rig( + &mut self, + composition: &Arc, + dual: bool, + right: &[Option], + left: &[Option], + ) -> Arc { + let key: RigKey = ( + composition_key(composition), + dual, + right.to_vec(), + left.to_vec(), + ); + if let Some(rig) = self.rigs.get(&key) { + return Arc::clone(rig); + } + let tracks = [ + self.clip_tracks(composition, right), + self.clip_tracks(composition, left), + ]; + let rig = Arc::new(PreparedFpvRig::build( + Arc::clone(composition), + dual, + &self.admission, + tracks, + )); + self.rigs.insert(key, Arc::clone(&rig)); + rig + } + + fn census_of(&self, fpv: &FpvMeshCatalog, gun: FpvMeshIndex) -> FpvViewCensus { + let mut census = FpvViewCensus::default(); + let order = gun.order(); + let Some(entry) = fpv.get_at(order) else { + return census; + }; + for (surface, verdict) in self.admission.verdicts_of(order) { + let name = entry + .material_names + .get(*surface) + .and_then(|name| name.as_deref()); + match verdict { + FpvSurfaceVerdict::Admitted(_) => { + if let Some(name) = name + && census.mat_hints.len() < 12 + && !census.mat_hints.iter().any(|seen| seen == name) + { + census.mat_hints.push(name.to_owned()); + } + } + FpvSurfaceVerdict::Inapplicable(NO_COLOUR_MAP) => { + census.gun_colormap_skip_n = census.gun_colormap_skip_n.saturating_add(1); + if let Some(name) = name + && census.gun_colormap_skip_names.len() < 8 + && !census + .gun_colormap_skip_names + .iter() + .any(|seen| seen == name) + { + census.gun_colormap_skip_names.push(name.to_owned()); + } + } + _ => {} + } + } + census + } + + fn prepare_next_weapon(&mut self, fpv: &FpvMeshCatalog, xanims: &XAnimCatalog) { + let registry = Arc::clone(&self.owner.weapons); + let (id, parent) = if self.next_weapon as usize > registry.len() { + let Some(pair) = self.alternate_queue.pop() else { + self.stage = FpvPreparationStage::Done; + return; + }; + pair + } else { + let id = self.next_weapon; + self.next_weapon += 1; + self.facts.push(registry.facts_of(id)); + self.hud_iris.push(registry.overlay_is_hud_iris(id)); + (id, 0) + }; + let gun = registry + .gun_xmodel_edge_of(id) + .and_then(|edge| edge.bound_index()) + .filter(|&order| fpv.get_at(order).is_some()) + .map(FpvMeshIndex::from_order); + if parent == 0 { + self.guns.push(gun); + } + if id == 0 { + self.slots + .push([FpvWeaponSlot::Absent, FpvWeaponSlot::Absent]); + return; + } + let Some(gun_index) = gun else { + let cause = || RenderGapCause::FpvGunXModelUnresolved { weapon_id: id }; + let sides = [ + FpvWeaponSlot::Refused(cause()), + FpvWeaponSlot::Refused(cause()), + ]; + if parent == 0 { + self.slots.push(sides); + } else { + self.alternate_slots.insert((id, parent), sides); + } + self.tick(); + return; + }; + + let right_edges = registry.sz_xanim_right_edges_of(id); + let right_idle_bound = + right_edges.is_some_and(|edges| edges[assets::weap_anim::IDLE].is_bound()); + let no_dual = registry + .facts_of(id) + .map(|f| u8::from(f.no_dual_wield)) + .unwrap_or(1); + let create_lr = no_dual == 0 && right_idle_bound; + let right = if create_lr { + WeaponAnimations::from_registry_edges(®istry, id, right_edges, xanims) + } else { + WeaponAnimations::from_registry(®istry, id, xanims) + }; + let left_edges = registry.sz_xanim_left_edges_of(id); + let left_idle_bound = + left_edges.is_some_and(|edges| edges[assets::weap_anim::IDLE].is_bound()); + let left = (create_lr && left_idle_bound) + .then(|| WeaponAnimations::from_registry_edges(®istry, id, left_edges, xanims)); + let idle_name = registry + .sz_xanim_edges_of(id) + .and_then(|row| row[assets::weap_anim::IDLE].bound_index()) + .and_then(|order| xanims.clip_at(order)) + .map(|clip| clip.name.clone()); + let namespace = registry.namespace_of(id).unwrap_or(AssetNamespace::Iw4); + let gun_name = fpv + .get_at(gun_index.order()) + .map(|entry| entry.skel.name.clone()) + .unwrap_or_else(String::new); + let census = self.census_of(fpv, gun_index); + let right_orders: Vec> = right.clip_orders().to_vec(); + let left_orders: Vec> = left + .as_ref() + .map(|left| left.clip_orders().to_vec()) + .unwrap_or_else(Vec::new); + debug_assert_eq!(right_orders.len(), WEAPON_ANIM_SLOTS); + + let sides: [FpvWeaponSlot; 2] = [false, true].map(|axis| { + let (Some((hands, hands_index)), Some(assemblies)) = ( + registry.fpv_hands_of(id, axis), + registry.fpv_assemblies_for(id, parent, axis), + ) else { + return FpvWeaponSlot::Refused(RenderGapCause::FpvDependencyUnresolved { + weapon_id: id, + role: "FPV skeleton", + name: registry.fpv_assembly_gap_of(id, axis), + }); + }; + let bare = self.composition(&assemblies.bare); + let rocket = assemblies + .rocket + .as_ref() + .map(|rocket| self.composition(rocket)); + let (bare, rocket) = match (bare, rocket.transpose()) { + (Ok(bare), Ok(rocket)) => (bare, rocket), + (Err(error), _) | (_, Err(error)) => { + return FpvWeaponSlot::Refused(RenderGapCause::FpvDependencyUnresolved { + weapon_id: id, + role: "FPV layout", + name: error, + }); + } + }; + let refusal: Option = bare + .refusal() + .or_else(|| rocket.as_ref().and_then(|rocket| rocket.refusal())) + .cloned(); + if let Some(refusal) = refusal { + return FpvWeaponSlot::Refused(RenderGapCause::FpvDependencyUnresolved { + weapon_id: id, + role: "FPV material", + name: refusal.to_string(), + }); + } + let mut rigs = FpvRigSet::default(); + rigs.bare[0] = Some(self.rig(&bare, false, &right_orders, &[])); + if left.is_some() { + rigs.bare[1] = Some(self.rig(&bare, true, &right_orders, &left_orders)); + } + if let Some(rocket) = &rocket { + rigs.rocket[0] = Some(self.rig(rocket, false, &right_orders, &[])); + if left.is_some() { + rigs.rocket[1] = Some(self.rig(rocket, true, &right_orders, &left_orders)); + } + } + FpvWeaponSlot::Ready(Arc::new(FpvWeaponView { + gun_name: gun_name.clone(), + gun_index, + hands: hands.clone(), + hands_index, + namespace, + assemblies: assemblies.clone(), + right: right.clone(), + left: left.clone(), + idle_name: idle_name.clone(), + rigs, + census: census.clone(), + })) + }); + for (axis, side) in sides.iter().enumerate() { + if let FpvWeaponSlot::Refused(cause) = side + && self.refused.len() < 16 + { + self.refused.push(format!( + "{} ({}): {cause}", + registry.name_of(id), + if axis == 0 { "allies" } else { "axis" } + )); + } + } + if parent == 0 { + self.slots.push(sides); + } else { + self.alternate_slots.insert((id, parent), sides); + } + self.tick(); + } + + fn finish(self) -> (FpvPreparationOwner, FpvWeaponTable) { + let ready = self + .slots + .iter() + .chain(self.alternate_slots.values()) + .flatten() + .filter(|slot| matches!(slot, FpvWeaponSlot::Ready(_))) + .count(); + let refused_n = self + .slots + .iter() + .chain(self.alternate_slots.values()) + .flatten() + .filter(|slot| matches!(slot, FpvWeaponSlot::Refused(_))) + .count(); + let inapplicable = self + .models + .iter() + .flat_map(|&order| self.admission.verdicts_of(order)) + .filter(|(_, verdict)| matches!(verdict, FpvSurfaceVerdict::Inapplicable(_))) + .count(); + let census = FpvPreparationCensus { + models: self.models.len(), + layouts: self.layouts.len(), + compositions: self.compositions.len(), + rigs: self.rigs.len(), + materials: self.admission.materials.len(), + elapsed_ms: self.started.elapsed().as_secs_f64() * 1000.0, + }; + diag::info!( + Fpv, + "fpv prepared before Ready: models={} layouts={} compositions={} rigs={} materials={} inapplicable_surfaces={inapplicable} sides ready={ready} refused={refused_n} elapsed={:.1}ms", + census.models, + census.layouts, + census.compositions, + census.rigs, + census.materials, + census.elapsed_ms, + ); + for line in &self.refused { + diag::info!(Fpv, "fpv refused before use: {line}"); + } + if let Some(stage) = self.progress { + stage.done(); + } + let table = FpvWeaponTable { + material_catalog: Arc::clone(&self.owner.materials), + catalog_id: self.owner.catalog_id, + facts: self.facts, + hud_iris: self.hud_iris, + guns: self.guns, + slots: self.slots, + alternate_slots: self.alternate_slots, + census, + }; + (self.owner, table) + } +} + +fn refused_table( + owner: &FpvPreparationOwner, + fpv: &FpvMeshCatalog, + cause: RenderGapCause, +) -> FpvWeaponTable { + let registry = &owner.weapons; + let weapon_n = registry.len() as u32; + let mut table = FpvWeaponTable { + material_catalog: Arc::clone(&owner.materials), + catalog_id: owner.catalog_id, + facts: Vec::with_capacity(weapon_n as usize + 1), + hud_iris: Vec::with_capacity(weapon_n as usize + 1), + guns: Vec::with_capacity(weapon_n as usize + 1), + slots: Vec::with_capacity(weapon_n as usize + 1), + alternate_slots: HashMap::new(), + census: FpvPreparationCensus::default(), + }; + for id in 0..=weapon_n { + table.facts.push(registry.facts_of(id)); + table.hud_iris.push(registry.overlay_is_hud_iris(id)); + table.guns.push( + registry + .gun_xmodel_edge_of(id) + .and_then(|edge| edge.bound_index()) + .filter(|&order| fpv.get_at(order).is_some()) + .map(FpvMeshIndex::from_order), + ); + table.slots.push(if id == 0 { + [FpvWeaponSlot::Absent, FpvWeaponSlot::Absent] + } else { + [ + FpvWeaponSlot::Refused(cause.clone()), + FpvWeaponSlot::Refused(cause.clone()), + ] + }); + } + table +} + +#[derive(bevy::ecs::system::SystemParam)] +pub struct PrepareFpvInputs<'w> { + weapons: Option>, + fpv_meshes: Option>, + xanims: Option>, + tess: Res<'w, TessMaterials>, + lighting: Option>, + load: Option>, +} + +pub fn prepare_fpv_compositions(inputs: PrepareFpvInputs, mut prepared: ResMut) { + let PrepareFpvInputs { + weapons, + fpv_meshes, + xanims, + tess, + lighting, + load, + } = inputs; + let (Some(weapons), Some(fpv_meshes), Some(xanims)) = (weapons, fpv_meshes, xanims) else { + if prepared.table.is_some() || prepared.job.is_some() { + prepared.clear(); + } + return; + }; + if tess.material_images.is_empty() { + return; + } + let sorted_ready = matches!( + tess.catalog.sorted_materials, + RuntimeSortedMaterialTable::Ready { .. } + ); + let owner = FpvPreparationOwner { + materials: Arc::clone(&tess.catalog), + images: Arc::clone(&tess.material_images), + weapons: Arc::clone(&weapons.0), + catalog_id: fpv_meshes.0.identity(), + atlas: lighting.as_ref().map(|atlas| atlas.image.id()), + }; + if prepared.job.is_none() + && prepared + .table + .as_ref() + .is_some_and(|(settled, _)| settled.same(&owner)) + { + return; + } + let restart = prepared + .job + .as_ref() + .is_none_or(|job| !job.owner.same(&owner)); + if restart { + prepared.clear(); + let shared_refusal = if !sorted_ready { + Some(( + RenderGapCause::FpvCatalogMissing, + "the material generation has no sorted table", + )) + } else if lighting.is_none() { + Some(( + RenderGapCause::FpvNoLightingAtlas, + "no model lighting atlas in this world", + )) + } else { + None + }; + if let Some((cause, why)) = shared_refusal { + diag::info!( + Fpv, + "fpv: {why} — every first-person weapon refused before Ready" + ); + let table = Arc::new(refused_table(&owner, &fpv_meshes.0, cause)); + prepared.table = Some((owner, table)); + return; + } + let progress = load + .as_deref() + .filter(|process| !process.is_complete()) + .map(|process| process.progress.begin(assets::StageId::FirstPerson, None)); + prepared.job = Some(FpvPreparationJob::new(owner, &fpv_meshes.0, progress)); + } + let Some(lighting) = lighting.as_deref() else { + return; + }; + let deadline = std::time::Instant::now() + PREPARE_FRAME_BUDGET; + let done = { + let Some(job) = prepared.job.as_mut() else { + return; + }; + job.advance(deadline, &fpv_meshes.0, &xanims.0, &tess, lighting); + job.stage == FpvPreparationStage::Done + }; + if done && let Some(job) = prepared.job.take() { + let (owner, table) = job.finish(); + prepared.table = Some((owner, Arc::new(table))); + } +} diff --git a/crates/render_anim/src/anim/fpv_rig.rs b/crates/render_anim/src/anim/fpv_rig.rs index a4f1303..729f432 100644 --- a/crates/render_anim/src/anim/fpv_rig.rs +++ b/crates/render_anim/src/anim/fpv_rig.rs @@ -1,9 +1,5 @@ //! The prepared first-person rig. //! -//! The rig owns the whole first-person plan. Both hands of a dual-wield -//! composition are slots in one layout, so the left gun is a second destination -//! range over the same prepared model rather than a second pass over the same -//! decisions. use std::collections::HashMap; use std::sync::Arc; @@ -17,60 +13,79 @@ use crate::anim::fpv_pose::{ use crate::anim::xmodel_pose::{FpvSurfOwner, SkinLayout, build_skin_layout, skin_packed_into}; use crate::draw::FpvSurfaceDraw; use anim_iw4::dobj_surface_hidden; -use assets::{ - AnimInstance, FpvAssembly, FpvClipTracks, FpvMeshCatalog, FpvPartRole, FpvSkel, PartBits, -}; +use assets::{AnimInstance, FpvAssembly, FpvClipTracks, FpvMeshCatalog, FpvPartRole, PartBits}; -/// One model inside the combined skeleton: where its bones start, and how its -/// surfaces map onto a contiguous run of destination vertices. -struct PreparedModel { - catalog_entry: usize, - owner: FpvSurfOwner, - bone_base: usize, - posed_surface_n: usize, - layout: SkinLayout, +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FpvSurfaceVerdict { + Admitted(u32), + Inapplicable(&'static str), + Refused { + material: String, + cause: &'static str, + }, } -/// One model drawn for one hand. The same prepared model appears twice in a -/// dual-wield rig — once per hand — with a destination range of its own. -struct PlanSlot { - hand: usize, - model: usize, - dest_base: usize, -} - -/// The rows the plan publishes for as long as the composition holds. -pub struct PreparedFpvGeometry { - pub indices: Vec, - pub surface_ranges: Vec<(u32, u32)>, +#[derive(Default)] +pub struct FpvMaterialAdmission { pub materials: Vec, - pub draws: Vec, - pub dest_n: usize, - pub packed_ok: bool, - - pub hands_plan_n: u32, - pub gun_plan_n: u32, - pub scope_plan_n: u32, - pub scope_house_plan_n: u32, - pub scope_lens_plan_n: u32, - pub plan_draw_n: u32, - pub plan_skip_n: u32, + pub by_authored: HashMap, + verdicts: HashMap>, } -pub struct FpvHandPose { - skin: Vec, - eye_from_world: Mat4, - offset: Vec3, - pub lens: Mat4, - pub bolt: FpvBoltFrame, +impl FpvMaterialAdmission { + pub fn record(&mut self, catalog_entry: usize, verdicts: Vec<(usize, FpvSurfaceVerdict)>) { + self.verdicts.insert(catalog_entry, verdicts); + } + + pub fn verdict(&self, catalog_entry: usize, surface: usize) -> Option<&FpvSurfaceVerdict> { + self.verdicts + .get(&catalog_entry)? + .iter() + .find(|(index, _)| *index == surface) + .map(|(_, verdict)| verdict) + } + + pub fn verdicts_of(&self, catalog_entry: usize) -> &[(usize, FpvSurfaceVerdict)] { + self.verdicts + .get(&catalog_entry) + .map_or(&[], |verdicts| verdicts.as_slice()) + } } -pub struct FpvRigInputs<'a> { - pub catalog: &'a FpvMeshCatalog, - pub materials: &'a [SmodelPassMaterial], - pub material_by_authored: &'a HashMap, - pub clip_tracks: &'a FpvClipTracks, - pub clip_orders: [&'a [Option]; 2], +#[derive(Clone, Copy, Debug)] +struct PreparedFpvSurface { + index_start: u32, + index_count: u32, + material: u32, + authored: usize, + lens_named: bool, +} + +pub struct PreparedFpvModel { + catalog_entry: usize, + posed_surface_n: usize, + packed_ok: bool, + layout: SkinLayout, + surfaces: Vec, + refusal: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FpvMandatoryRefusal { + pub model: String, + pub surface: usize, + pub material: String, + pub cause: &'static str, +} + +impl core::fmt::Display for FpvMandatoryRefusal { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "{} surface {} `{}`: {}", + self.model, self.surface, self.material, self.cause + ) + } } #[derive(Debug)] @@ -88,47 +103,112 @@ impl core::fmt::Display for FpvRigError { } } -pub struct PreparedFpvRig { - assembly: Arc, - dual: bool, - generation: u64, - parts: PartBits, - tags: FpvBoltTags, - models: Vec, - slots: Vec, - orders: [Vec>; 2], - tracks: [Vec>>>; 2], - pub geometry: PreparedFpvGeometry, -} - pub fn leftover_scope_surf_is_lens(name: &str) -> bool { name.contains("lens") } -/// Which surfaces of one model reach the plan: not hidden by this weapon's hide -/// tags, and carrying an authored material the session admitted. -fn surface_admitted( - skel: &FpvSkel, - words: Option<&[u32; 6]>, - admitted_materials: &HashMap, - surface_index: usize, -) -> bool { - if let Some(words) = words - && let Some(bits) = skel.surface_part_bits.get(surface_index) - && dobj_surface_hidden(bits, words, 0) - { - return false; +impl PreparedFpvModel { + pub fn build( + catalog: &FpvMeshCatalog, + catalog_entry: usize, + hide: Option<&[u32; 6]>, + admission: &FpvMaterialAdmission, + ) -> Result { + let entry = catalog + .get_at(catalog_entry) + .ok_or(FpvRigError::Catalog("model"))?; + let skel = &entry.skel; + let lod_range = skel.surfaces_for_lod(0); + let first_surface = lod_range.start; + let hidden = |surface: usize| { + hide.is_some_and(|words| { + skel.surface_part_bits + .get(surface) + .is_some_and(|bits| dobj_surface_hidden(bits, words, 0)) + }) + }; + let admitted = |surface: usize| match admission.verdict(catalog_entry, surface) { + Some(FpvSurfaceVerdict::Admitted(row)) => Some(*row), + _ => None, + }; + let layout = build_skin_layout(skel, 0, |lod_local| { + let surface = first_surface + lod_local; + !hidden(surface) && admitted(surface).is_some() + }) + .ok_or(FpvRigError::Layout("skin layout refused the model"))?; + + let mut refusal = None; + for surface in lod_range.clone() { + if hidden(surface) { + continue; + } + if let Some(FpvSurfaceVerdict::Refused { material, cause }) = + admission.verdict(catalog_entry, surface) + { + refusal = Some(FpvMandatoryRefusal { + model: skel.name.clone(), + surface, + material: material.clone(), + cause: *cause, + }); + break; + } + } + + let mut surfaces = Vec::new(); + for surface in &layout.surfaces { + if !surface.visible || surface.index_count == 0 { + continue; + } + let Some(authored) = skel + .surface_materials + .get(surface.surface_index) + .copied() + .flatten() + .map(|index| index.get()) + else { + continue; + }; + let Some(material) = admitted(surface.surface_index) else { + continue; + }; + surfaces.push(PreparedFpvSurface { + index_start: surface.index_start, + index_count: surface.index_count, + material, + authored, + lens_named: entry + .material_names + .get(surface.surface_index) + .and_then(|name| name.as_deref()) + .is_some_and(leftover_scope_surf_is_lens), + }); + } + Ok(Self { + catalog_entry, + posed_surface_n: lod_range.len(), + packed_ok: skel.packed_vertices.len() == skel.positions.len(), + layout, + surfaces, + refusal, + }) } - let Some(authored) = skel - .surface_materials - .get(surface_index) - .copied() - .flatten() - .map(|index| index.get()) - else { - return false; - }; - admitted_materials.contains_key(&authored) + + pub fn refusal(&self) -> Option<&FpvMandatoryRefusal> { + self.refusal.as_ref() + } +} + +struct ComposedPart { + model: Arc, + owner: FpvSurfOwner, + bone_base: usize, +} + +pub struct PreparedFpvComposition { + assembly: Arc, + parts: Vec, + refusal: Option, } fn surf_owner(role: FpvPartRole) -> FpvSurfOwner { @@ -140,6 +220,112 @@ fn surf_owner(role: FpvPartRole) -> FpvSurfOwner { } } +impl PreparedFpvComposition { + pub fn compose( + assembly: Arc, + mut model_of: impl FnMut(usize, Option<[u32; 6]>) -> Result, String>, + ) -> Result { + let mut parts = Vec::with_capacity(assembly.parts.len()); + let mut refusal = None; + for part in &assembly.parts { + let model = model_of(part.model.order(), part.hide)?; + if refusal.is_none() { + refusal = model.refusal().cloned(); + } + parts.push(ComposedPart { + model, + owner: surf_owner(part.role), + bone_base: part.bone_base, + }); + } + Ok(Self { + assembly, + parts, + refusal, + }) + } + + pub fn assembly(&self) -> &Arc { + &self.assembly + } + + pub fn refusal(&self) -> Option<&FpvMandatoryRefusal> { + self.refusal.as_ref() + } +} + +pub fn compose_clip_tracks( + assembly: &FpvAssembly, + clip: usize, + tracks: &FpvClipTracks, +) -> Option> { + let composed = assembly.compose_tracks(clip, tracks)?; + Some( + composed + .into_iter() + .map(|bone| { + bone.and_then(|bone| u16::try_from(bone).ok()) + .unwrap_or(FpvClipTracks::NONE) + }) + .collect(), + ) +} + +/// One model drawn for one hand. The same prepared model appears twice in a +/// dual-wield rig — once per hand — with a destination range of its own. +struct PlanSlot { + hand: usize, + part: usize, + dest_base: usize, +} + +pub struct PreparedFpvGeometry { + segments: Vec<(Arc, u32)>, + pub index_n: usize, + pub surface_ranges: Vec<(u32, u32)>, + pub materials: Vec, + pub draws: Vec, + pub dest_n: usize, + pub packed_ok: bool, + + pub hands_plan_n: u32, + pub gun_plan_n: u32, + pub scope_plan_n: u32, + pub scope_house_plan_n: u32, + pub scope_lens_plan_n: u32, + pub plan_draw_n: u32, + pub plan_skip_n: u32, +} + +impl PreparedFpvGeometry { + pub fn write_indices(&self, out: &mut Vec) { + out.clear(); + out.reserve(self.index_n); + for (model, rebase) in &self.segments { + out.extend(model.layout.indices.iter().map(|index| index + rebase)); + } + } +} + +pub struct FpvHandPose { + skin: Vec, + eye_from_world: Mat4, + offset: Vec3, + pub lens: Mat4, + pub bolt: FpvBoltFrame, +} + +pub struct PreparedFpvRig { + composition: Arc, + dual: bool, + generation: u64, + parts: PartBits, + tags: FpvBoltTags, + slots: Vec, + tracks: [Vec>>; 2], + pub geometry: PreparedFpvGeometry, +} + impl PreparedFpvRig { /// A plan holding this generation is a plan whose indices, ranges, /// materials and draws are this rig's. @@ -147,53 +333,17 @@ impl PreparedFpvRig { self.generation } - pub fn matches( - &self, - assembly: &Arc, - dual: bool, - orders: [&[Option]; 2], - ) -> bool { - Arc::ptr_eq(&self.assembly, assembly) - && self.dual == dual - && self.orders[0] == orders[0] - && self.orders[1] == orders[1] + pub fn is_dual(&self) -> bool { + self.dual } pub fn build( - assembly: Arc, + composition: Arc, dual: bool, - inputs: FpvRigInputs<'_>, - ) -> Result { - let catalog = inputs.catalog; - let skel_of = |index: usize| catalog.get_at(index).map(|entry| &entry.skel); - - let mut models: Vec = Vec::with_capacity(assembly.parts.len()); - let mut packed_ok = true; - for part in &assembly.parts { - let index = part.model.order(); - let skel = skel_of(index).ok_or(FpvRigError::Catalog("model"))?; - let lod_range = skel.surfaces_for_lod(0); - let first_surface = lod_range.start; - let layout = build_skin_layout(skel, 0, |lod_local| { - surface_admitted( - skel, - part.hide.as_ref(), - inputs.material_by_authored, - first_surface + lod_local, - ) - }) - .ok_or(FpvRigError::Layout("skin layout refused the model"))?; - if skel.packed_vertices.len() != skel.positions.len() { - packed_ok = false; - } - models.push(PreparedModel { - catalog_entry: index, - owner: surf_owner(part.role), - bone_base: part.bone_base, - posed_surface_n: lod_range.len(), - layout, - }); - } + admission: &FpvMaterialAdmission, + tracks: [Vec>>; 2], + ) -> Self { + let parts_n = composition.parts.len(); // Every hand's view hands first, then every hand's gun and whatever // hangs off it. @@ -203,22 +353,23 @@ impl PreparedFpvRig { order.push((hand, 0)); } for hand in 0..hand_n { - for model in 1..models.len() { + for part in 1..parts_n { // The scope and the rocket hang off the right gun only. - if hand == 1 && model != 1 { + if hand == 1 && part != 1 { continue; } - order.push((hand, model)); + order.push((hand, part)); } } let mut geometry = PreparedFpvGeometry { - indices: Vec::new(), + segments: Vec::with_capacity(order.len()), + index_n: 0, surface_ranges: Vec::new(), materials: Vec::new(), draws: Vec::new(), dest_n: 0, - packed_ok, + packed_ok: true, hands_plan_n: 0, gun_plan_n: 0, scope_plan_n: 0, @@ -227,60 +378,29 @@ impl PreparedFpvRig { plan_draw_n: 0, plan_skip_n: 0, }; - let mut slots: Vec = Vec::new(); + let mut slots: Vec = Vec::with_capacity(order.len()); let mut material_key: HashMap = HashMap::new(); let mut dest_base = 0usize; let mut posed_surface_n = 0usize; - for (hand, model_index) in order { - let model = &models[model_index]; - let skel = skel_of(model.catalog_entry).ok_or(FpvRigError::Catalog("model"))?; - let entry = catalog - .get_at(model.catalog_entry) - .ok_or(FpvRigError::Catalog("model"))?; + for (hand, part_index) in order { + let part = &composition.parts[part_index]; + let model = &part.model; posed_surface_n += model.posed_surface_n; - for surface in &model.layout.surfaces { - if !surface.visible || surface.index_count == 0 { - continue; - } - let Some(authored) = skel - .surface_materials - .get(surface.surface_index) - .copied() - .flatten() - .map(|index| index.get()) - else { + geometry.packed_ok &= model.packed_ok; + let index_base = geometry.index_n as u32; + for surface in &model.surfaces { + let Some(material) = admission.materials.get(surface.material as usize) else { continue; }; - let Some(&session_index) = inputs.material_by_authored.get(&authored) else { - continue; - }; - let Some(material) = inputs.materials.get(session_index as usize).cloned() else { - continue; - }; - let is_scope = model.owner == FpvSurfOwner::Scope; - let is_lens = is_scope - && entry - .material_names - .get(surface.surface_index) - .and_then(|name| name.as_deref()) - .is_some_and(leftover_scope_surf_is_lens); - - let index_start = geometry.indices.len() as u32; - let src = surface.index_start as usize; - let end = src.saturating_add(surface.index_count as usize); - let rebase = dest_base as u32; - geometry.indices.extend( - model.layout.indices[src..end] - .iter() - .map(|index| index + rebase), - ); + let is_scope = part.owner == FpvSurfOwner::Scope; + let is_lens = is_scope && surface.lens_named; let range_index = geometry.surface_ranges.len() as u32; geometry .surface_ranges - .push((index_start, surface.index_count)); - let material_index = *material_key.entry(authored).or_insert_with(|| { + .push((index_base + surface.index_start, surface.index_count)); + let material_index = *material_key.entry(surface.authored).or_insert_with(|| { let index = geometry.materials.len() as u32; - geometry.materials.push(material); + geometry.materials.push(material.clone()); index }); geometry.draws.push(FpvSurfaceDraw { @@ -289,7 +409,7 @@ impl PreparedFpvRig { is_scope: is_scope && !is_lens, }); if hand == 0 { - match model.owner { + match part.owner { FpvSurfOwner::Hands => geometry.hands_plan_n += 1, _ => geometry.gun_plan_n += 1, } @@ -303,9 +423,13 @@ impl PreparedFpvRig { } } } + geometry + .segments + .push((Arc::clone(model), dest_base as u32)); + geometry.index_n += model.layout.indices.len(); slots.push(PlanSlot { hand, - model: model_index, + part: part_index, dest_base, }); dest_base += model.layout.dest_vertex_n; @@ -314,40 +438,34 @@ impl PreparedFpvRig { geometry.plan_draw_n = geometry.draws.len() as u32; geometry.plan_skip_n = posed_surface_n.saturating_sub(geometry.draws.len()) as u32; - let orders = inputs.clip_orders.map(<[Option]>::to_vec); - let tracks = [0, 1].map(|hand| { - orders[hand] - .iter() - .map(|order| assembly.compose_tracks((*order)?, inputs.clip_tracks)) - .collect() - }); + let assembly = &composition.assembly; let tags = FpvBoltTags { flash: assembly.tags.flash, flash_silenced: assembly.tags.flash_silenced, brass: assembly.tags.brass, knife: assembly.tags.knife, laser: assembly.tags.laser, + tracker_screen: assembly.tags.tracker_screen, + tracker_light: assembly.tags.tracker_light, }; + let parts = assembly.dobj.all_parts(); static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - let parts = assembly.dobj.all_parts(); - Ok(Self { - assembly, + Self { + composition, dual, generation: GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed), parts, tags, - models, slots, - orders, tracks, geometry, - }) + } } /// One hand's bones for this frame. No skeleton is built here. pub fn pose_hand( - &mut self, + &self, hand: usize, anims: &[PosedClip<'_>], offset: Vec3, @@ -355,40 +473,45 @@ impl PreparedFpvRig { if anims.is_empty() { return None; } - let instances: Vec> = anims + let bound: Vec<(&PosedClip<'_>, Vec>)> = anims .iter() .filter_map(|anim| { let tracks = self.tracks[hand].get(anim.node)?.as_ref()?; - Some(AnimInstance { - clip: anim.clip, - tracks, - time: anim.time, - weight: anim.weight, - parts: None, - }) + let bones = tracks + .iter() + .map(|&bone| (bone != FpvClipTracks::NONE).then_some(usize::from(bone))) + .collect(); + Some((anim, bones)) }) .collect(); - if !instances + if !bound .iter() - .any(|instance| instance.tracks.iter().any(Option::is_some)) + .any(|(_, bones)| bones.iter().any(Option::is_some)) { return None; } - let world = self - .assembly - .dobj - .pose(&instances, &self.parts, Mat4::IDENTITY); - let skin = self.assembly.dobj.skin_matrices(&world); - let eye_from_world = tag_view_to_bevy_camera() * world[self.assembly.view_bone].inverse(); - let lens = self - .assembly + let instances: Vec> = bound + .iter() + .map(|(anim, bones)| AnimInstance { + clip: anim.clip, + tracks: bones, + time: anim.time, + weight: anim.weight, + parts: None, + }) + .collect(); + let assembly = &self.composition.assembly; + let world = assembly.dobj.pose(&instances, &self.parts, Mat4::IDENTITY); + let skin = assembly.dobj.skin_matrices(&world); + let eye_from_world = tag_view_to_bevy_camera() * world[assembly.view_bone].inverse(); + let lens = assembly .camera_bone - .map(|camera| tag_camera_lens_local(world[self.assembly.view_bone], world[camera])) + .map(|camera| tag_camera_lens_local(world[assembly.view_bone], world[camera])) .unwrap_or(Mat4::IDENTITY); let published = if hand == 0 { world.len() } else { - self.assembly.paired_bones.min(world.len()) + assembly.paired_bones.min(world.len()) }; let shift = (offset != Vec3::ZERO).then(|| Mat4::from_translation(offset)); let bones = world[..published] @@ -425,7 +548,8 @@ impl PreparedFpvRig { let Some(pose) = poses[slot.hand].as_ref() else { continue; }; - let model = &self.models[slot.model]; + let part = &self.composition.parts[slot.part]; + let model = &part.model; let Some(skel) = catalog.get_at(model.catalog_entry).map(|entry| &entry.skel) else { continue; }; @@ -436,7 +560,7 @@ impl PreparedFpvRig { let rows = &mut dest[slot.dest_base..end]; skin_packed_into( skel, - |bone| pose.eye_from_world * pose.skin[model.bone_base + bone], + |bone| pose.eye_from_world * pose.skin[part.bone_base + bone], |_| false, 0, &model.layout, diff --git a/crates/render_anim/src/anim/mod.rs b/crates/render_anim/src/anim/mod.rs index 4165658..389a169 100644 --- a/crates/render_anim/src/anim/mod.rs +++ b/crates/render_anim/src/anim/mod.rs @@ -3,7 +3,9 @@ pub mod dobj_pose; pub mod fpv; pub mod fpv_host; pub mod fpv_pose; +pub mod fpv_prepared; pub mod fpv_rig; +pub mod model_materials; pub mod pose_types; pub mod remote_body; pub mod scene_submission; diff --git a/crates/render_anim/src/anim/model_materials.rs b/crates/render_anim/src/anim/model_materials.rs new file mode 100644 index 0000000..f9f3877 --- /dev/null +++ b/crates/render_anim/src/anim/model_materials.rs @@ -0,0 +1,305 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use bevy::prelude::*; +use render_material::RuntimeMaterialCatalog; +use render_scene::{SmodelPassMaterial, TessMaterials, WorldModelLightingAtlas}; + +type ModelMaterialsOwner = ( + Arc, + Option>, + Option, + Option, +); + +#[derive(Resource, Default)] +pub struct PreparedModelMaterials { + owner: Option, + by_name: HashMap, + by_authored: HashMap, + scene_dobjs: HashMap>, + projectile_materials: HashMap, + projectile_dobjs: HashMap>, +} + +impl PreparedModelMaterials { + fn owns(&self, owner: &ModelMaterialsOwner) -> bool { + self.owner + .as_ref() + .is_some_and(|(catalog, atlas, bodies, world)| { + Arc::ptr_eq(catalog, &owner.0) + && *atlas == owner.1 + && *bodies == owner.2 + && *world == owner.3 + }) + } + + pub fn settled_for(&self, catalog: &Arc) -> bool { + self.owner + .as_ref() + .is_some_and(|(owned, ..)| Arc::ptr_eq(owned, catalog)) + } + + pub fn material( + &self, + catalog: &Arc, + name: &str, + ) -> Option<&SmodelPassMaterial> { + if !self.settled_for(catalog) { + return None; + } + self.by_name.get(name) + } + + pub fn authored( + &self, + catalog: &Arc, + authored: assets::MaterialIndex, + ) -> Option<&SmodelPassMaterial> { + if !self.settled_for(catalog) { + return None; + } + self.by_authored.get(&authored) + } + + pub fn scene_dobj(&self, model: &str) -> Option<&Arc> { + self.scene_dobjs.get(model) + } + + pub fn projectile_material( + &self, + catalog: &Arc, + authored: assets::MaterialIndex, + ) -> Option<&SmodelPassMaterial> { + if !self.settled_for(catalog) { + return None; + } + self.projectile_materials.get(&authored) + } + + pub fn projectile_dobj(&self, model: &str) -> Option<&Arc> { + self.projectile_dobjs.get(model) + } + + pub fn clear(&mut self) { + *self = Self::default(); + } +} + +fn admit_names<'a>( + names: impl IntoIterator, + atlas: &WorldModelLightingAtlas, + catalog: &RuntimeMaterialCatalog, + by_name: &mut HashMap, + refused: &mut Vec, +) { + for name in names { + if by_name.contains_key(name) || refused.iter().any(|seen| seen == name) { + continue; + } + match crate::body_lit_pass_material(atlas, catalog, name) { + Some(material) => { + by_name.insert(name.to_owned(), material); + } + None => refused.push(name.to_owned()), + } + } +} + +pub fn scene_lit_pass_material( + atlas: &WorldModelLightingAtlas, + tess: &TessMaterials, + authored: assets::MaterialIndex, +) -> Option { + let world_material = tess.catalog.derived(authored)?; + let ordinal = tess + .catalog + .sorted_materials + .ordinal_for_asset_id(authored.order())?; + let maps = + render_scene::runtime_maps(Some(authored), &tess.catalog, tess.material_images.as_ref()); + let inv_h = lighting_iw4::model_lighting_inv_image_height(atlas.dims.image_height)?; + let scale = lighting_iw4::model_lighting_lookup_scale(inv_h); + Some(SmodelPassMaterial { + model_lighting_required: true, + color: maps.color, + specular: maps.specular, + probe: None, + atlas: Some(atlas.image.clone()), + 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: [scale.u, scale.v, scale.w, scale.q], + atlas_lookup: [ + lighting_iw4::MODEL_LIGHTING_INV_ATLAS_WIDTH as f32, + inv_h, + lighting_iw4::MODEL_LIGHTING_VOLUME_W, + 0.0, + ], + sort_key: world_material.sort_key, + material_sorted_index: Some(ordinal.get()), + }) +} + +fn present_names<'a>( + names: &'a [Option], + edges: &'a [assets::AssetEdge], +) -> impl Iterator { + names + .iter() + .zip(edges) + .filter(|(_, edge)| edge.is_bound()) + .filter_map(|(name, _)| name.as_deref()) +} + +pub fn prepare_model_materials( + tess: Res, + atlas: Option>, + bodies: Option>, + world_weapons: Option>, + scene_models: Option>, + projectiles: Option>, + mut prepared: ResMut, +) { + if tess.material_images.is_empty() { + return; + } + let owner: ModelMaterialsOwner = ( + Arc::clone(&tess.catalog), + atlas.as_ref().map(|atlas| atlas.image.id()), + bodies + .as_ref() + .map(|bodies| Arc::as_ptr(&bodies.0) as usize), + world_weapons.as_ref().map(|world| world.0.identity()), + ); + if prepared.owns(&owner) { + return; + } + let (Some(atlas), Some(bodies), Some(world)) = (atlas, bodies, world_weapons) else { + diag::info!( + World, + "model materials: nothing to admit (lighting atlas or model catalogs missing)" + ); + *prepared = PreparedModelMaterials { + owner: Some(owner), + ..Default::default() + }; + return; + }; + let started = std::time::Instant::now(); + let mut by_name = HashMap::new(); + let mut refused = Vec::new(); + for name in bodies.0.names() { + if let Some(entry) = bodies.0.get(name) { + admit_names( + present_names(&entry.material_names, &entry.material_edges), + &atlas, + &tess.catalog, + &mut by_name, + &mut refused, + ); + } + } + for index in 0..world.0.len() { + if let Some(entry) = world.0.get_at(index) { + admit_names( + present_names(&entry.material_names, &entry.material_edges), + &atlas, + &tess.catalog, + &mut by_name, + &mut refused, + ); + } + } + let mut by_authored = HashMap::new(); + let mut scene_dobjs = HashMap::new(); + if let Some(scene_models) = scene_models.as_deref() { + for (key, asset) in scene_models.iter() { + let skel = match asset { + assets::MapXModelSceneAsset::Iw4(skel) + | assets::MapXModelSceneAsset::Iw5(skel) + | assets::MapXModelSceneAsset::T5(skel) => skel, + assets::MapXModelSceneAsset::Unavailable { .. } => continue, + }; + for surface in 0..skel.surface_materials.len() { + let Some(authored) = scene_models.surface_material(key, surface) else { + continue; + }; + if by_authored.contains_key(&authored) { + continue; + } + if let Some(material) = scene_lit_pass_material(&atlas, &tess, authored) { + by_authored.insert(authored, material); + } + } + if let Some(pose) = skel.pose.as_ref() + && let Ok(dobj) = assets::DObj::build(&[(pose, None)]) + { + scene_dobjs.insert(key.0.clone(), Arc::new(dobj)); + } + } + } + let mut projectile_materials = HashMap::new(); + let mut projectile_dobjs = HashMap::new(); + if let Some(projectiles) = projectiles.as_deref() { + for name in projectiles.0.names() { + let Some(entry) = projectiles.0.get(name) else { + continue; + }; + for surface in 0..entry.material_edges.len() { + let Some(authored) = entry.material_index(surface) else { + continue; + }; + if projectile_materials.contains_key(&authored) { + continue; + } + if let Some(material) = crate::authored_lit_xmodel_pass_material( + &atlas, + &tess.catalog, + tess.material_images.as_ref(), + authored, + ) { + projectile_materials.insert(authored, material); + } + } + if let Some(pose) = entry.skel.pose.as_ref() + && let Ok(dobj) = assets::DObj::build(&[(pose, None)]) + { + projectile_dobjs.insert(name.to_owned(), Arc::new(dobj)); + } + } + } + diag::info!( + World, + "model materials prepared before Ready: admitted={} map_model_materials={} map_model_dobjs={} projectile_materials={} projectile_dobjs={} refused={} elapsed={:.1}ms{}", + by_name.len(), + by_authored.len(), + scene_dobjs.len(), + projectile_materials.len(), + projectile_dobjs.len(), + refused.len(), + started.elapsed().as_secs_f64() * 1000.0, + if refused.is_empty() { + String::new() + } else { + format!( + " first_refused=[{}]", + refused + .iter() + .take(8) + .cloned() + .collect::>() + .join(",") + ) + } + ); + *prepared = PreparedModelMaterials { + owner: Some(owner), + by_name, + by_authored, + scene_dobjs, + projectile_materials, + projectile_dobjs, + }; +} diff --git a/crates/render_anim/src/anim/remote_body.rs b/crates/render_anim/src/anim/remote_body.rs index 9fe047f..2e7ac09 100644 --- a/crates/render_anim/src/anim/remote_body.rs +++ b/crates/render_anim/src/anim/remote_body.rs @@ -14,7 +14,7 @@ use crate::anim::xmodel_pose::PosedSmodelSurface; #[derive(Clone, Debug)] pub struct PersistentRemoteTree { pub runtime: assets::dobj::XAnimTreeRuntime, - pub dobj: Option, + pub dobj: Option>, pub reuse_key: Option, pub legs: u16, pub torso: u16, @@ -557,10 +557,166 @@ pub fn kit_dobj_radius(radii: impl IntoIterator) -> Option { .fold(None::, |acc, r| Some(acc.map_or(r, |a| a.max(r)))) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KitSource<'a> { + Body(&'a str), + World(usize), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PreparedKitSource { + Body(String), + World(usize), +} + +pub struct PreparedRemoteKit { + sources: Vec, + pub radius: Option, + pub hide_part_bits: [u32; 6], + pub dobj: Option>, + pub bolt_bones: [Option; 4], + pub head_bone: Option, +} + +impl PreparedRemoteKit { + pub fn models<'a>( + &'a self, + bodies: &'a assets::PreparedBodies, + world: &'a assets::PreparedWorldWeapons, + ) -> Option>> { + self.sources + .iter() + .map(|source| match source { + PreparedKitSource::Body(key) => { + let entry = bodies.0.get(key)?; + Some(KitModel { + name: entry.skel.name.as_str(), + skel: &entry.skel, + hide_tags: Vec::new(), + source: KitSource::Body(key.as_str()), + }) + } + PreparedKitSource::World(index) => { + let entry = world.0.get_at(*index)?; + Some(KitModel { + name: entry.skel.name.as_str(), + skel: &entry.skel, + hide_tags: Vec::new(), + source: KitSource::World(*index), + }) + } + }) + .collect() + } +} + +#[derive(bevy::prelude::Resource, Default)] +pub struct PreparedRemoteKits { + owner: Option<( + std::sync::Arc, + std::sync::Arc, + u64, + )>, + kits: std::collections::HashMap<(bool, u32), PreparedRemoteKit>, +} + +impl PreparedRemoteKits { + pub fn owned_by( + &self, + bodies: &assets::PreparedBodies, + weapons: &assets::PreparedWeapons, + world_weapons: &assets::PreparedWorldWeapons, + ) -> bool { + self.owner.as_ref().is_some_and(|(b, w, catalog)| { + std::sync::Arc::ptr_eq(b, &bodies.0) + && std::sync::Arc::ptr_eq(w, &weapons.0) + && *catalog == world_weapons.0.identity() + }) + } + + pub fn get(&self, axis: bool, weapon: u32) -> Option<&PreparedRemoteKit> { + self.kits.get(&(axis, weapon)) + } + + pub fn prepare( + bodies: &assets::PreparedBodies, + weapons: &assets::PreparedWeapons, + world_weapons: &assets::PreparedWorldWeapons, + ) -> Self { + let mut kits = std::collections::HashMap::new(); + for axis in [false, true] { + for weapon in 0..=weapons.0.len() as u32 { + let Some((models, radius)) = occupy_remote_kit_dobj( + bodies, + Some(weapons), + Some(world_weapons), + axis, + weapon, + true, + ) else { + continue; + }; + let sources = models + .iter() + .map(|model| match model.source { + KitSource::Body(key) => PreparedKitSource::Body(key.to_owned()), + KitSource::World(index) => PreparedKitSource::World(index), + }) + .collect(); + let dobj = + select_remote_models(bodies, Some(weapons), Some(world_weapons), axis, weapon) + .ok() + .and_then(|set| assets::DObj::build(&set.dobj_models).ok()) + .map(std::sync::Arc::new); + kits.insert( + (axis, weapon), + PreparedRemoteKit { + sources, + radius, + hide_part_bits: kit_hide_part_bits(&models), + bolt_bones: [ + "tag_flash", + "tag_brass", + "tag_knife_fx", + fx_iw4::FX_LASER_TAG, + ] + .map(|tag| { + dobj.as_ref() + .and_then(|dobj| dobj.find(tag)) + .and_then(|bone| u16::try_from(bone).ok()) + }), + head_bone: dobj.as_ref().and_then(|dobj| dobj.find("j_head")), + dobj, + }, + ); + } + } + Self { + owner: Some(( + std::sync::Arc::clone(&bodies.0), + std::sync::Arc::clone(&weapons.0), + world_weapons.0.identity(), + )), + kits, + } + } +} + +pub fn kit_hide_part_bits(models: &[KitModel<'_>]) -> [u32; 6] { + let mut bits = [0u32; 6]; + let mut base = 0usize; + for model in models { + render_scene::hide_part_bits_from_tags(&mut bits, model.skel, base, &model.hide_tags); + base += model.skel.bones.len(); + } + bits +} + pub struct KitModel<'a> { pub name: &'a str, pub skel: &'a assets::ModelSkel, pub hide_tags: Vec, + pub source: KitSource<'a>, } pub fn occupy_remote_kit_dobj<'a>( @@ -569,6 +725,7 @@ pub fn occupy_remote_kit_dobj<'a>( world_weapons: Option<&'a assets::PreparedWorldWeapons>, axis: bool, weapon: u32, + with_hide_tags: bool, ) -> Option<(Vec>, Option)> { let kits = bodies.0.kits(); let kit = kits.kit(axis)?; @@ -581,6 +738,7 @@ pub fn occupy_remote_kit_dobj<'a>( name: body.skel.name.as_str(), skel: &body.skel, hide_tags: Vec::new(), + source: KitSource::Body(kit.body.as_str()), }]; if let Some(name) = kit.head.as_deref() { if let Some(entry) = bodies.0.get(name) { @@ -592,13 +750,20 @@ pub fn occupy_remote_kit_dobj<'a>( name: entry.skel.name.as_str(), skel: &entry.skel, hide_tags: Vec::new(), + source: KitSource::Body(name), }); } } } if weapon != 0 { if let (Some(registry), Some(catalog)) = (weapons, world_weapons) { - if let Some(entry) = registry.0.world_model_entry(weapon, &catalog.0) { + let gun_index = registry + .0 + .world_model_edge_of(weapon) + .and_then(|edge| edge.bound_index()); + if let (Some(entry), Some(gun_index)) = + (registry.0.world_model_entry(weapon, &catalog.0), gun_index) + { if let (Some(_gun_pose), Some(_tag)) = ( entry.skel.pose.as_ref(), assets::tp_weapon_attach_tag(&body.skel.bone_names), @@ -607,14 +772,17 @@ pub fn occupy_remote_kit_dobj<'a>( name: entry.skel.name.as_str(), skel: &entry.skel, hide_tags: weapons + .filter(|_| with_hide_tags) .map(|registry| assets::effective_hide_tags(®istry.0, weapon)) - .unwrap_or_default(), + .unwrap_or_else(Vec::new), + source: KitSource::World(gun_index), }); for attachment in world_attachments(®istry.0, &catalog.0, weapon) { skels.push(KitModel { name: attachment.entry.skel.name.as_str(), skel: &attachment.entry.skel, hide_tags: Vec::new(), + source: KitSource::World(attachment.index.order()), }); } } @@ -871,6 +1039,7 @@ pub fn ensure_remote_dobj( e_type: i32, persist_key: u32, trees: &mut RemoteBodyTrees, + prepared: Option<&std::sync::Arc>, ) -> Result<(), String> { let gun_name = models .gun @@ -890,8 +1059,12 @@ pub fn ensure_remote_dobj( ); let slot = trees.get_mut(persist_key).expect("tree slot inserted"); if !remote_dobj_reuses(slot.dobj.is_some(), slot.reuse_key, reuse_key) { - slot.dobj = - Some(assets::DObj::build(&models.dobj_models).map_err(|error| error.to_string())?); + slot.dobj = Some(std::sync::Arc::clone(prepared.ok_or_else(|| { + format!( + "remote representation not prepared: body={} head={} weapon={gun_name}", + models.body_name, models.head_name + ) + })?)); slot.reuse_key = Some(reuse_key); } Ok(()) diff --git a/crates/render_anim/src/anim/scene_submission.rs b/crates/render_anim/src/anim/scene_submission.rs index 04e934c..96e2320 100644 --- a/crates/render_anim/src/anim/scene_submission.rs +++ b/crates/render_anim/src/anim/scene_submission.rs @@ -30,9 +30,54 @@ pub struct AnimDObjSceneSubmission { #[derive(Resource, Default)] pub struct AnimDObjSceneSkels { by_name: HashMap>, + seeded_for: Option<(usize, u64)>, } impl AnimDObjSceneSkels { + pub fn seed(&mut self, bodies: &assets::PreparedBodies, world: &assets::PreparedWorldWeapons) { + let owner = (Arc::as_ptr(&bodies.0) as usize, world.0.identity()); + if self.seeded_for == Some(owner) { + return; + } + let kits = bodies.0.kits(); + for kit in [kits.kit(false), kits.kit(true)].into_iter().flatten() { + for name in std::iter::once(&kit.body).chain(kit.head.as_ref()) { + let Some(entry) = bodies.0.get(name) else { + continue; + }; + self.by_name + .entry(entry.skel.name.clone()) + .or_insert_with(|| Arc::new(entry.skel.clone())); + } + } + for index in 0..world.0.len() { + if let Some(entry) = world.0.get_at(index) { + self.by_name + .entry(entry.skel.name.clone()) + .or_insert_with(|| Arc::clone(&entry.skel)); + } + } + self.seeded_for = Some(owner); + } + + pub fn shared( + &mut self, + name: &str, + skel: &Arc, + lod: i8, + ) -> AnimDObjSceneModel { + let skel = Arc::clone( + self.by_name + .entry(name.to_owned()) + .or_insert_with(|| Arc::clone(skel)), + ); + AnimDObjSceneModel { + lod, + bone_count: u8::try_from(skel.bones.len()).unwrap_or(u8::MAX), + skel, + } + } + pub fn model(&mut self, name: &str, skel: &assets::ModelSkel, lod: i8) -> AnimDObjSceneModel { let skel = match self.by_name.get(name) { Some(skel) => Arc::clone(skel), diff --git a/crates/render_anim/src/anim/viewmodel_controller.rs b/crates/render_anim/src/anim/viewmodel_controller.rs index 6d8f4d1..110a5af 100644 --- a/crates/render_anim/src/anim/viewmodel_controller.rs +++ b/crates/render_anim/src/anim/viewmodel_controller.rs @@ -264,7 +264,14 @@ impl ViewmodelController { positive_ms(self.weapon.quick_raise_time_ms), ), - WeaponAnimSlot::EmptyRaise | WeaponAnimSlot::AltRaise => ( + WeaponAnimSlot::AltRaise => ( + WeaponState::Raising { + first: false, + quick: false, + }, + positive_ms(self.weapon.alternate_raise_time_ms), + ), + WeaponAnimSlot::EmptyRaise => ( WeaponState::Raising { first: false, quick: false, @@ -311,7 +318,11 @@ impl ViewmodelController { positive_ms(self.weapon.quick_drop_time_ms), ), - WeaponAnimSlot::EmptyDrop | WeaponAnimSlot::AltDrop => (WeaponState::Dropping, None), + WeaponAnimSlot::AltDrop => ( + WeaponState::Dropping, + positive_ms(self.weapon.alternate_drop_time_ms), + ), + WeaponAnimSlot::EmptyDrop => (WeaponState::Dropping, None), WeaponAnimSlot::SprintIn => ( WeaponState::SprintIn, positive_ms(self.weapon.sprint_raise_time_ms), @@ -360,8 +371,9 @@ impl ViewmodelController { true } - pub fn apply_empty_idle_weap_anim(&mut self) { - self.start_idle_family(true, true); + pub fn apply_force_idle_weap_anim(&mut self, empty_mag: bool) { + let interrupt = self.dispatch_range_unfinished(); + self.start_idle_family(empty_mag, interrupt); } pub fn settle_fire_to_idle(&mut self) { diff --git a/crates/render_anim/src/draw_build.rs b/crates/render_anim/src/draw_build.rs index 723a6b7..2e1477f 100644 --- a/crates/render_anim/src/draw_build.rs +++ b/crates/render_anim/src/draw_build.rs @@ -735,8 +735,7 @@ pub fn install_prepared_fpv_plan( geometry: &crate::anim::fpv_rig::PreparedFpvGeometry, lighting_handle: u32, ) { - plan.indices.clear(); - plan.indices.extend_from_slice(&geometry.indices); + geometry.write_indices(&mut plan.indices); plan.surface_ranges.clear(); plan.surface_ranges .extend_from_slice(&geometry.surface_ranges); diff --git a/crates/render_anim/src/lib.rs b/crates/render_anim/src/lib.rs index a983a1b..d54a8a3 100644 --- a/crates/render_anim/src/lib.rs +++ b/crates/render_anim/src/lib.rs @@ -14,6 +14,11 @@ pub use anim::fpv_host::{ FpvPoseRefuse, FpvPosedFrame, FpvPresentCursor, PendingFpvNotetracks, PendingFpvSpawn, PendingFpvSpawnRequest, generate_fpv_pose, }; +pub use anim::fpv_prepared::{ + FpvPreparationCensus, FpvRigSet, FpvWeaponSlot, FpvWeaponTable, FpvWeaponView, PreparedFpv, + prepare_fpv_compositions, +}; +pub use anim::model_materials::{PreparedModelMaterials, prepare_model_materials}; pub use anim::viewmodel_controller::{ AdvanceResult, EventResult, ViewmodelController, ViewmodelEvent, WeaponState, }; diff --git a/crates/render_anim/src/occupancy/dyn_ent.rs b/crates/render_anim/src/occupancy/dyn_ent.rs index 76458db..c909bc6 100644 --- a/crates/render_anim/src/occupancy/dyn_ent.rs +++ b/crates/render_anim/src/occupancy/dyn_ent.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use bevy::prelude::*; use dpvs_iw4::{ Bounds, DpvsPlanes, dyn_ent_cell_bits_len, dyn_ent_client_word_count, dyn_ent_in_cell, @@ -480,6 +478,7 @@ fn pose_dyn_ents( instances: Query<(Entity, &WorldDynEntInstance, &Transform, &Visibility)>, cameras: Query<(&GlobalTransform, &Projection, &Camera), With>, lod_skinned: Res, + prepared: Res, mut product: ResMut, ) { product.owners.clear(); @@ -526,10 +525,7 @@ fn pose_dyn_ents( { index } else { - let Some(pose) = skel.pose.as_ref() else { - continue; - }; - let Some(dobj) = assets::DObj::build(&[(pose, None)]).ok() else { + let Some(dobj) = prepared.scene_dobj(&inst.current_model.0) else { continue; }; let dobj_state = @@ -540,7 +536,7 @@ fn pose_dyn_ents( let Some((surfaces, authored)) = pose_script_dobj_with_materials( Some(catalog), &[skel], - &dobj, + dobj, &request, Some(DObjLodView { origin: inst.lighting_origin, @@ -581,6 +577,7 @@ fn append_dynent_draws( mut plan: ResMut, mut lighting_requests: ResMut, product: Res, + model_materials: Res, mut last_material_generation: Local>, mut draws: Local>, mut owners: Local>, @@ -593,8 +590,8 @@ fn append_dynent_draws( let catalog_changed = catalog.as_ref().is_some_and(|c| c.is_changed()); let atlas_changed = atlas.as_ref().is_some_and(|a| a.is_changed()); let tess_changed = tess.as_ref().is_some_and(|h| h.is_changed()); - let atlas = atlas_ref.expect("checked"); let tess = tess.as_deref().expect("checked"); + let tess_catalog = std::sync::Arc::clone(&tess.catalog); let material_generation = tess.catalog.generation_id; let catalog_reset = catalog_changed || atlas_changed @@ -610,7 +607,6 @@ fn append_dynent_draws( draws.clear(); owners.clear(); - let mut material_cache: HashMap = HashMap::new(); let mut drawn = 0u16; let mut packed_assets: Vec>> = vec![None; product.assets.len()]; @@ -626,45 +622,7 @@ fn append_dynent_draws( .iter() .zip(posed.authored.iter().copied()) .map(|(_surface, authored)| { - let authored = authored?; - if let Some(material) = material_cache.get(&authored) { - return Some(material.clone()); - } - let world_material = tess.catalog.derived(authored)?; - let ordinal = tess - .catalog - .sorted_materials - .ordinal_for_asset_id(authored.order())?; - let maps = render_scene::runtime_maps( - Some(authored), - &tess.catalog, - tess.material_images.as_ref(), - ); - let inv_h = - lighting_iw4::model_lighting_inv_image_height(atlas.dims.image_height)?; - let scale = lighting_iw4::model_lighting_lookup_scale(inv_h); - let material = SmodelPassMaterial { - model_lighting_required: true, - color: maps.color, - specular: maps.specular, - probe: None, - atlas: Some(atlas.image.clone()), - 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: [scale.u, scale.v, scale.w, scale.q], - atlas_lookup: [ - lighting_iw4::MODEL_LIGHTING_INV_ATLAS_WIDTH as f32, - inv_h, - lighting_iw4::MODEL_LIGHTING_VOLUME_W, - 0.0, - ], - sort_key: world_material.sort_key, - material_sorted_index: Some(ordinal.get()), - }; - material_cache.insert(authored, material.clone()); - Some(material) + model_materials.authored(&tess_catalog, authored?).cloned() }) .collect(); if materials.iter().all(Option::is_none) { diff --git a/crates/render_anim/src/occupancy/fpv_present.rs b/crates/render_anim/src/occupancy/fpv_present.rs index 69af395..58d9f7b 100644 --- a/crates/render_anim/src/occupancy/fpv_present.rs +++ b/crates/render_anim/src/occupancy/fpv_present.rs @@ -1,11 +1,7 @@ -use std::collections::HashMap; use std::sync::Arc; use anim_iw4::{DOBJ_RADIUS_PARENT_ROOT, dobj_compute_bounds_radius}; -use assets::{ - AssetEdge, AssetNamespace, FpvMeshIndex, MaterialSpace, PreparedFpvMeshes, PreparedWeapons, - PreparedXAnims, TS_COLOR_MAP, WeaponAnimations, WeaponRegistry, -}; +use assets::PreparedFpvMeshes; use bevy::ecs::system::SystemParam; use bevy::prelude::*; use frame::{LifeFrontPublished, PresentedPublished, ViewSubject, WorkerCmdSet}; @@ -18,6 +14,7 @@ use crate::anim::fpv::{ local_shot_identity, }; use crate::anim::fpv_host::{FpvGenerateArgs, FpvPoseKind, FpvPoseRefuse, generate_fpv_pose}; +use crate::anim::fpv_prepared::{FpvWeaponSlot, FpvWeaponTable, FpvWeaponView, PreparedFpv}; use crate::anim::fpv_rig::PreparedFpvRig; use crate::anim::scene_submission::{AnimDObjSceneSubmission, AnimSceneSubmit}; use crate::anim::viewmodel_controller::ViewmodelController; @@ -36,7 +33,7 @@ use hud_iw4::{ }; use math_iw4::angle_vectors; use playerstate_iw4::PlayerState; -use render_material::{RuntimeMaterialCatalog, RuntimeSortedMaterialTable}; +use render_material::RuntimeMaterialCatalog; use render_scene::WorldScriptModelInstance; use render_scene::{FlyCamera, FpvLens}; use render_scene::{HostGfxScene, scene_quat_from_viewmodel_axes}; @@ -75,33 +72,22 @@ pub struct SessionViewmodel(pub Option); pub struct SessionFpvMeshesHandles { pub weapon_id: u32, + pub parent_weapon: u32, pub catalog_id: u64, - pub assemblies: assets::FpvSideAssemblies, pub axis: bool, + pub view: Arc, pub fpv: EquippedFpv, - pub(crate) active_rig: Option, - pub(crate) materials: Vec, - pub(crate) material_by_authored: HashMap, + pub(crate) active_rig: Option>, pub(crate) material_catalog: Arc, } -#[derive(Resource, Default)] -pub struct FpvRigShelf { - owner: Option<(Arc, u64)>, - rigs: Vec, -} - -impl FpvRigShelf { - fn hold(&mut self, materials: &Arc, catalog_id: u64) { - let owned = self - .owner - .as_ref() - .is_some_and(|(owner, id)| Arc::ptr_eq(owner, materials) && *id == catalog_id); - if !owned { - self.rigs.clear(); - self.owner = Some((Arc::clone(materials), catalog_id)); +fn same_compositions(a: &assets::FpvSideAssemblies, b: &assets::FpvSideAssemblies) -> bool { + Arc::ptr_eq(&a.bare, &b.bare) + && match (&a.rocket, &b.rocket) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, } - } } fn same_material_catalog( @@ -165,20 +151,8 @@ impl core::fmt::Display for FpvState { } } -pub fn resolve_fpv_gun( - weapons: &assets::WeaponRegistry, - fpv: &assets::FpvMeshCatalog, - weapon_id: u32, -) -> Option { - let order = weapons - .gun_xmodel_edge_of(weapon_id) - .and_then(|edge| edge.bound_index())?; - fpv.get_at(order)?; - Some(FpvMeshIndex::from_order(order)) -} - fn fpv_rocket_should_attach( - weapons: &WeaponRegistry, + weapons: &FpvWeaponTable, weapon_id: u32, ps: Option<&PlayerState>, ) -> bool { @@ -202,10 +176,7 @@ fn fpv_rocket_should_attach( #[derive(SystemParam)] pub struct SpawnPendingFpvInputs<'w> { - weapons: Option>, - fpv_meshes: Option>, - xanims: Option>, - lighting: Option>, + prepared: Res<'w, PreparedFpv>, tess: Res<'w, render_scene::TessMaterials>, presented: Res<'w, PresentedSnapshot>, local: Res<'w, LocalPresentClient>, @@ -224,10 +195,7 @@ pub fn spawn_pending_fpv( gaps: Res, ) { let SpawnPendingFpvInputs { - weapons, - fpv_meshes, - xanims, - lighting, + prepared, tess, presented, local, @@ -235,6 +203,13 @@ pub fn spawn_pending_fpv( let Some(request) = pending.0.take() else { return; }; + let Some(table) = prepared + .table() + .filter(|table| same_material_catalog(table.material_catalog(), Some(&*tess))) + else { + pending.0 = Some(request); + return; + }; let instance_started = std::time::Instant::now(); cursor.0.forget_weap_anim(); for entity in &existing_fpv { @@ -242,65 +217,38 @@ pub fn spawn_pending_fpv( } session_vm.0 = None; - let idle_clip = weapons - .as_ref() - .zip(xanims.as_ref()) - .and_then(|(reg, cat)| { - let row = reg.0.sz_xanim_edges_of(request.weapon_id)?; - let order = row[assets::weap_anim::IDLE].bound_index()?; - cat.0.clip_at(order) - }); - let idle_from_table = idle_clip.is_some(); - let idle_name = idle_clip.as_ref().map(|clip| clip.name.clone()); - let idle_ns = weapons - .as_ref() - .and_then(|reg| reg.0.namespace_of(request.weapon_id)) - .unwrap_or(AssetNamespace::Iw4); - let Some(fpv_cat) = fpv_meshes.as_ref() else { - gaps.raise(RenderGapCause::FpvCatalogMissing); - status.0 = Some(FpvState::Blocked(RenderGapCause::FpvCatalogMissing)); - return; - }; - if fpv_cat.0.identity() != request.catalog_id - || weapons - .as_ref() - .and_then(|reg| resolve_fpv_gun(®.0, &fpv_cat.0, request.weapon_id)) - != Some(request.gun_index) + if table.catalog_id() != request.catalog_id + || table.gun_index(request.weapon_id) != Some(request.gun_index) { - gaps.raise(RenderGapCause::FpvGunXModelUnresolved { + let cause = RenderGapCause::FpvGunXModelUnresolved { weapon_id: request.weapon_id, - }); - status.0 = Some(FpvState::Blocked(RenderGapCause::FpvGunXModelUnresolved { - weapon_id: request.weapon_id, - })); + }; + gaps.raise(cause.clone()); + status.0 = Some(FpvState::Blocked(cause)); return; } - let Some(gun_entry) = fpv_cat.0.get_at(request.gun_index.order()) else { - return; - }; - let gun_name = gun_entry.skel.name.clone(); - let reg = &weapons.as_ref().expect("validated weapon catalog").0; let meta = presented .snapshot() .and_then(|snap| snap.meta.for_client(local.0)); let ffa_team = meta.and_then(|m| m.ffa_team); let client_state_team = meta.map(|m| m.client_state_team).unwrap_or(0); let axis = assets::kit_assignment_is_axis(client_state_team, ffa_team); - let (Some((hands, hands_index)), Some(assemblies)) = ( - reg.fpv_hands_of(request.weapon_id, axis), - reg.fpv_assemblies_of(request.weapon_id, axis), - ) else { - let cause = RenderGapCause::FpvDependencyUnresolved { - weapon_id: request.weapon_id, - role: "FPV skeleton", - name: reg.fpv_assembly_gap_of(request.weapon_id, axis), - }; - gaps.raise(cause.clone()); - status.0 = Some(FpvState::Blocked(cause)); - return; + let view = match table.slot(request.weapon_id, request.parent_weapon, axis) { + FpvWeaponSlot::Ready(view) => Arc::clone(view), + FpvWeaponSlot::Refused(cause) => { + gaps.raise(cause.clone()); + status.0 = Some(FpvState::Blocked(cause.clone())); + return; + } + FpvWeaponSlot::Absent => { + let cause = RenderGapCause::FpvGunXModelUnresolved { + weapon_id: request.weapon_id, + }; + gaps.raise(cause.clone()); + status.0 = Some(FpvState::Blocked(cause)); + return; + } }; - let assemblies = assemblies.clone(); - let hands = hands.clone(); let Ok(host) = cameras.single() else { diag::info!( Fpv, @@ -312,107 +260,6 @@ pub fn spawn_pending_fpv( return; }; - let Some(lighting) = lighting.as_ref() else { - diag::info!( - Fpv, - "fpv: no WorldSmodelLighting atlas — refuse Bevy-unlit glow (ModelLightingCache gap)" - ); - gaps.raise(RenderGapCause::FpvNoLightingAtlas); - status.0 = Some(FpvState::Blocked(RenderGapCause::FpvNoLightingAtlas)); - return; - }; - - if !matches!( - tess.catalog.sorted_materials, - RuntimeSortedMaterialTable::Ready { .. } - ) { - diag::info!( - Fpv, - "fpv: global sorted material table not READY — re-queue (clone-index is not an ordinal)" - ); - pending.0 = Some(request); - return; - } - - let mut image_cache: HashMap> = HashMap::new(); - let mut materials: Vec = Vec::new(); - let mut material_by_authored: HashMap = HashMap::new(); - let mut ordinal_admitted = 0u32; - let mut ordinal_refused: Vec = Vec::new(); - let mut gun_skip = FpvGunSkipCensus::default(); - let hands_entry = fpv_cat.0.get_at(hands_index.order()); - let gun_entry = Some(gun_entry); - let mut plan_mat_hints: Vec = Vec::new(); - - // Which materials this composition needs, walked off the models rather than - // off a pose: the rig the first frame prepares needs this table to decide - // the layout. - let mut admit_model = |label: &str, entry: Option<&assets::FpvMeshEntry>| { - let Some(entry) = entry else { - return; - }; - for surface_index in entry.skel.surfaces_for_lod(0) { - let edge = entry - .material_edges - .get(surface_index) - .copied() - .unwrap_or(AssetEdge::Absent); - let present_name = entry.material_present_name(surface_index); - let leftover_hint = entry - .material_names - .get(surface_index) - .and_then(|name| name.as_deref()); - let authored = entry - .skel - .surface_materials - .get(surface_index) - .copied() - .flatten() - .map(|index| index.get()); - let admitted = fpv_pass_material( - &tess.catalog, - tess.material_images.as_ref(), - authored, - edge, - present_name, - leftover_hint, - &mut image_cache, - &mut material_by_authored, - &mut materials, - lighting, - label, - surface_index, - &mut ordinal_admitted, - &mut ordinal_refused, - &mut gun_skip, - ); - if admitted.is_none() { - continue; - } - if let Some(hint) = leftover_hint.or(present_name) - && plan_mat_hints.len() < 12 - && !plan_mat_hints.iter().any(|seen| seen == hint) - { - plan_mat_hints.push(hint.to_owned()); - } - } - }; - let mut admitted_models: Vec = Vec::new(); - for assembly in std::iter::once(&assemblies.bare).chain(&assemblies.rocket) { - for part in &assembly.parts { - if admitted_models.contains(&part.model) { - continue; - } - admitted_models.push(part.model); - let label = match part.role { - assets::FpvPartRole::Hands => "fpv hands", - _ => "fpv gun", - }; - admit_model(label, fpv_cat.0.get_at(part.model.order())); - } - } - drop(admit_model); - commands.entity(host).with_children(|parent| { parent.spawn(( FpvViewmodel, @@ -422,328 +269,66 @@ pub fn spawn_pending_fpv( )); }); - // The plan belongs to the rig, and the rig is prepared by the first pose - // this composition takes. Until then the plan has nothing to publish. crate::clear_fpv_draw_plan(&mut fpv_plan, 0); - fpv_plan.gun_colormap_skip_n = Some(gun_skip.colormap_n); - fpv_plan.gun_ordinal_skip_n = Some(gun_skip.ordinal_n); - fpv_plan.gun_colormap_skip_names = - (!gun_skip.colormap_names.is_empty()).then(|| gun_skip.colormap_names.join(",")); - fpv_plan.plan_mat_hints = (!plan_mat_hints.is_empty()).then(|| plan_mat_hints.join(",")); + let census = &view.census; + fpv_plan.gun_colormap_skip_n = Some(census.gun_colormap_skip_n); + fpv_plan.gun_ordinal_skip_n = Some(0); + fpv_plan.gun_colormap_skip_names = (!census.gun_colormap_skip_names.is_empty()) + .then(|| census.gun_colormap_skip_names.join(",")); + fpv_plan.plan_mat_hints = (!census.mat_hints.is_empty()).then(|| census.mat_hints.join(",")); - let (controller, left) = match (weapons.as_ref(), xanims.as_ref()) { - (Some(reg), Some(cat)) => { - let right_idle_bound = reg - .0 - .sz_xanim_right_edges_of(request.weapon_id) - .is_some_and(|edges| edges[assets::weap_anim::IDLE].is_bound()); - let no_dual = reg - .0 - .facts_of(request.weapon_id) - .map(|f| u8::from(f.no_dual_wield)) - .unwrap_or(1); - let create_lr = no_dual == 0 && right_idle_bound; - let animations = if create_lr { - WeaponAnimations::from_registry_edges( - ®.0, - request.weapon_id, - reg.0.sz_xanim_right_edges_of(request.weapon_id), - &cat.0, - ) - } else { - WeaponAnimations::from_registry(®.0, request.weapon_id, &cat.0) - }; - let resolved = animations.resolved_count(); - let (fire_ms, raise_ms) = (animations.fire_time_ms, animations.raise_time_ms); - let switch_ms = format!( - "dropTime={}ms quickDropTime={}ms quickRaiseTime={}ms", - animations.drop_time_ms, - animations.quick_drop_time_ms, - animations.quick_raise_time_ms - ); - let idle_meta = animations - .clip(assets::WeaponAnimSlot::Idle) - .map(|clip| { - format!( - "idle=`{}` frames={} loop={} dur={:.2}s", - clip.name, - clip.numframes, - clip.looping, - clip.duration() - ) - }) - .unwrap_or_else(|| "idle=missing".into()); - let left_idle_bound = reg - .0 - .sz_xanim_left_edges_of(request.weapon_id) - .is_some_and(|edges| edges[assets::weap_anim::IDLE].is_bound()); - let left = if create_lr && left_idle_bound { - let left_anims = WeaponAnimations::from_registry_edges( - ®.0, - request.weapon_id, - reg.0.sz_xanim_left_edges_of(request.weapon_id), - &cat.0, - ); - let left_n = left_anims.resolved_count(); - diag::info!( - Fpv, - "fpv: viewmodel controller `{}` — {resolved} clips R, {left_n} clips L, fireTime={fire_ms}ms raiseTime={raise_ms}ms {switch_ms} {idle_meta} (dual DObj; action from weapAnim)", - animations.name - ); - Some(ViewmodelController::new(left_anims)) - } else { - diag::info!( - Fpv, - "fpv: viewmodel controller `{}` — {resolved} clips, fireTime={fire_ms}ms raiseTime={raise_ms}ms {switch_ms} {idle_meta} (action from weapAnim; Fire predicted)", - animations.name - ); - None - }; - (ViewmodelController::new(animations), left) - } - _ => { - diag::warn!( - Fpv, - "fpv: weapon/xanim catalog missing — static idle pose only" - ); - (ViewmodelController::new(WeaponAnimations::empty("")), None) - } + let controller = ViewmodelController::new(view.right.clone()); + let left = view.left.clone().map(ViewmodelController::new); + diag::info!( + Fpv, + "fpv: viewmodel controller `{}` — {} clips{}, fireTime={}ms raiseTime={}ms (action from weapAnim)", + view.right.name, + view.right.resolved_count(), + view.left + .as_ref() + .map(|left| format!(" R, {} clips L (dual DObj)", left.resolved_count())) + .unwrap_or_else(String::new), + view.right.fire_time_ms, + view.right.raise_time_ms, + ); + let idle_kind = match view.idle_name.as_deref() { + Some(name) => format!("idle `{name}` (szXAnims[IDLE])"), + None => "no szXAnims[IDLE] — guess forbidden".to_owned(), }; + let idle_sampled = view.idle_name.is_some(); + diag::info!( + Fpv, + "fpv: equipped `{}` ({}; prepared before Ready) — instance {:.2}ms", + view.gun_name, + idle_kind, + instance_started.elapsed().as_secs_f64() * 1000.0, + ); session_vm.0 = Some(SessionFpvMeshesHandles { weapon_id: request.weapon_id, + parent_weapon: request.parent_weapon, catalog_id: request.catalog_id, - assemblies, axis, fpv: EquippedFpv::new( - gun_name.clone(), - request.gun_index, - hands_index, - idle_ns, - hands, + view.gun_name.clone(), + view.gun_index, + view.hands_index, + view.namespace, + view.hands.clone(), controller, left, ), + view, active_rig: None, - materials, - material_by_authored, - material_catalog: Arc::clone(&tess.catalog), + material_catalog: Arc::clone(table.material_catalog()), }); - let idle_kind = if idle_from_table { - let name = idle_name.as_deref().unwrap_or("?"); - format!("idle `{name}` (szXAnims[IDLE])") - } else { - "no szXAnims[IDLE] — guess forbidden".to_owned() - }; - let refused_names = if ordinal_refused.is_empty() { - "none".to_owned() - } else { - ordinal_refused - .iter() - .take(8) - .cloned() - .collect::>() - .join(",") - }; - diag::info!( - Fpv, - "fpv: ordinal admission admitted={ordinal_admitted} refused={} names=[{refused_names}] (whole model incl. hideTags-hidden surfaces; global catalog, clone-index unused)", - ordinal_refused.len(), - ); - let vert_census = |entry: Option<&assets::FpvMeshEntry>| match entry { - Some(entry) => format!("{}/{}", entry.skel.rigid_verts, entry.skel.blend_verts), - None => "-".to_owned(), - }; - diag::info!( - Fpv, - "fpv: equipped `{}` ({}; {}; hands rigid/blend={} gun rigid/blend={}; skeleton prepared before Ready) — instance {:.2}ms on shared templates", - gun_name, - "linked gunXModel", - idle_kind, - vert_census(hands_entry), - vert_census(gun_entry), - instance_started.elapsed().as_secs_f64() * 1000.0, - ); - gaps.clear(RenderGap::FpvViewmodel); - status.0 = Some(FpvState::Drawn { - idle_sampled: idle_from_table, - }); -} - -#[derive(Default)] -struct FpvGunSkipCensus { - colormap_n: u32, - ordinal_n: u32, - colormap_names: Vec, -} - -impl FpvGunSkipCensus { - fn note_colormap(&mut self, is_gun: bool, name: Option<&str>) { - if !is_gun { - return; - } - self.colormap_n = self.colormap_n.saturating_add(1); - let Some(name) = name else { - return; - }; - if self.colormap_names.len() < 8 && !self.colormap_names.iter().any(|seen| seen == name) { - self.colormap_names.push(name.to_owned()); - } - } - - fn note_ordinal(&mut self, is_gun: bool) { - if is_gun { - self.ordinal_n = self.ordinal_n.saturating_add(1); - } - } -} - -fn fpv_pass_material( - global: &RuntimeMaterialCatalog, - images: &[Option>], - material_index: Option, - edge: AssetEdge, - present_name: Option<&str>, - leftover_hint: Option<&str>, - image_cache: &mut HashMap>, - material_by_authored: &mut HashMap, - materials: &mut Vec, - lighting: &render_scene::WorldModelLightingAtlas, - label: &str, - surface_index: usize, - ordinal_admitted: &mut u32, - ordinal_refused: &mut Vec, - gun_skip: &mut FpvGunSkipCensus, -) -> Option { - use lighting_iw4::{ - MODEL_LIGHTING_INV_ATLAS_WIDTH, MODEL_LIGHTING_VOLUME_W, model_lighting_inv_image_height, - model_lighting_lookup_scale, - }; - use render_scene::SmodelPassMaterial; - - let is_gun = label == "fpv gun"; - if !edge.is_bound() { - diag::info!( - Fpv, - "{label} surface {surface_index}: materialHandles {} leftover=`{}` — skip (leftover name is not identity)", - edge.edge_kind(), - leftover_hint.unwrap_or("-") - ); - return None; - } - let Some(present_name) = present_name else { - diag::info!( - Fpv, - "{label} surface {surface_index}: Bound materialHandles has no present name — skip" - ); - return None; - }; - - let mat_i = material_index?; - if let Some(&idx) = material_by_authored.get(&mat_i) { - return materials.get(idx as usize).cloned(); - } - let bound = edge.bound_index()?; - let authored = global.materials.get(bound)?; - let Some(ordinal) = global.ordinal_for_material_name(present_name) else { - diag::info!( - Fpv, - "{label} surface {surface_index}: Bound `{present_name}` global-index={bound} has no sorted ordinal — skip (not packed as 0)" - ); - if !ordinal_refused.iter().any(|name| name == present_name) { - ordinal_refused.push(present_name.to_owned()); - } - gun_skip.note_ordinal(is_gun); - return None; - }; - *ordinal_admitted = ordinal_admitted.saturating_add(1); - let color_texture = { - let image_index = authored - .textures - .iter() - .find_map(|(_, texture)| texture.filter(|binding| binding.semantic == TS_COLOR_MAP))? - .image - .0; - if let Some(cached) = image_cache.get(&image_index) { - Some(cached.clone()) - } else { - let Some(handle) = images.get(image_index as usize).cloned().flatten() else { - diag::info!( - Fpv, - "{label} surface {surface_index}: no uploaded color map — skip (no unlit stand-in)" - ); - gun_skip.note_colormap(is_gun, Some(present_name).or(leftover_hint)); - return None; - }; - image_cache.insert(image_index, handle.clone()); - Some(handle) - } - }; - let specular_texture = authored - .textures - .iter() - .find_map(|(_, texture)| { - texture.filter(|binding| binding.semantic == assets::TS_SPECULAR_MAP) - }) - .and_then(|binding| { - let image_index = binding.image.0; - if let Some(cached) = image_cache.get(&image_index) { - return Some(cached.clone()); - } - let handle = images.get(image_index as usize).cloned().flatten()?; - image_cache.insert(image_index, handle.clone()); - Some(handle) - }); - - const ENV_MAP_PARMS: u32 = 1_033_475_292; - let env_map_parms = authored - .constants - .iter() - .find(|(hash, _)| *hash == ENV_MAP_PARMS) - .map(|(_, words)| words.map(f32::from_bits)) - .unwrap_or([0.0; 4]); - let alpha_mode = AlphaMode::Opaque; - let inv_h = model_lighting_inv_image_height(lighting.dims.image_height)?; - let scale = model_lighting_lookup_scale(inv_h); - let cull_mode = - authored - .state_bits_table - .first() - .and_then(|bits| match assets::cull_face_from_state_bits(*bits) { - assets::MaterialCullFace::Back => Some(bevy::render::render_resource::Face::Back), - assets::MaterialCullFace::Front => Some(bevy::render::render_resource::Face::Front), - assets::MaterialCullFace::None => None, - }); - let mat = SmodelPassMaterial { - model_lighting_required: true, - color: color_texture, - specular: specular_texture, - probe: None, - atlas: Some(lighting.image.clone()), - alpha_mode, - draw_mode: None, - cull_mode, - env_map_parms, - lighting_lookup_scale: [scale.u, scale.v, scale.w, scale.q], - atlas_lookup: [ - MODEL_LIGHTING_INV_ATLAS_WIDTH as f32, - inv_h, - MODEL_LIGHTING_VOLUME_W, - 0.0, - ], - sort_key: authored.sort_key, - material_sorted_index: Some(ordinal.get()), - }; - let idx = materials.len() as u32; - materials.push(mat.clone()); - material_by_authored.insert(mat_i, idx); - Some(mat) + status.0 = Some(FpvState::Drawn { idle_sampled }); } fn viewweapon_drawgun_admit( ps: &PlayerState, - weapons: Option<&WeaponRegistry>, + weapons: Option<&FpvWeaponTable>, b_position_to_ads: bool, ) -> Option<(bool, Option<&'static str>)> { let reg = weapons?; @@ -755,15 +340,7 @@ fn viewweapon_drawgun_admit( ads_zoom_in_frac: facts.ads_zoom_in_frac, ads_zoom_out_frac: facts.ads_zoom_out_frac, overlay_material: u32::from(hud_iris), - overlay_reticle: if hud_iris { - if facts.overlay_reticle != 0 { - facts.overlay_reticle - } else { - 1 - } - } else { - 0 - }, + overlay_reticle: facts.overlay_reticle, ads_overlay_width: facts.ads_overlay_width, ads_overlay_height: facts.ads_overlay_height, ..WeaponAdsOverlayFacts::default() @@ -777,7 +354,7 @@ fn viewweapon_drawgun_admit( fn viewweapon_drawgun_value( ps: &PlayerState, - weapons: Option<&WeaponRegistry>, + weapons: Option<&FpvWeaponTable>, b_position_to_ads: bool, ) -> Option { let (admit, _skip) = viewweapon_drawgun_admit(ps, weapons, b_position_to_ads)?; @@ -810,7 +387,7 @@ pub fn occupy_fpv_scene( presented: Res, local: Res, view: Res, - weapons: Option>, + prepared: Res, kick: Option>, session_vm: Option>, tess: Option>, @@ -824,7 +401,7 @@ pub fn occupy_fpv_scene( }; let admit = viewweapon_drawgun_admit( ps, - weapons.as_ref().map(|w| &*w.0), + prepared.table().map(|table| &**table), kick.as_ref().map(|k| k.b_position_to_ads).unwrap_or(true), ); if !admit.is_some_and(|(ok, _)| ok) { @@ -899,7 +476,7 @@ pub fn tick_fpv_viewmodel( presented: Res, local: Res, mut cursor: ResMut, - weapons: Option>, + prepared: Res, fpv_meshes: Option>, mut session_vm: ResMut, tess: Option>, @@ -910,8 +487,8 @@ pub fn tick_fpv_viewmodel( mut bolts: ResMut, kick: Option>, view: Res, - mut shelf: ResMut, ) { + let table = prepared.table().map(|table| &**table); pending_notes.weapon = 0; pending_notes.names.clear(); bolts.clear(); @@ -920,7 +497,7 @@ pub fn tick_fpv_viewmodel( if !presented_is_third_person(&presented, local.0, view.in_killcam()) { product.drawgun = viewweapon_drawgun_value( ps, - weapons.as_ref().map(|w| &*w.0), + table, kick.as_ref().map(|k| k.b_position_to_ads).unwrap_or(true), ); } @@ -961,26 +538,27 @@ pub fn tick_fpv_viewmodel( if let Some(ps) = presented.viewweapon_player(local.0) { let weapon = bg_get_viewmodel_weapon_index(ps); - if weapon != 0 && weapon != session.weapon_id { - if let Some(reg) = weapons.as_ref() { - match resolve_fpv_gun(®.0, &fpv.0, weapon) { + if weapon != 0 + && (weapon != session.weapon_id || ps.weapon_primary != session.parent_weapon) + { + if let Some(table) = table { + match table.gun_index(weapon) { Some(gun_index) if gun_index == session.fpv.gun_index => { - let axis = session.axis; - let same = reg.0.fpv_assemblies_of(weapon, axis).is_some_and(|next| { - Arc::ptr_eq(&next.bare, &session.assemblies.bare) - && match (&next.rocket, &session.assemblies.rocket) { - (Some(a), Some(b)) => Arc::ptr_eq(a, b), - (None, None) => true, - _ => false, - } - }); + let same = match table.slot(weapon, ps.weapon_primary, session.axis) { + FpvWeaponSlot::Ready(next) => { + same_compositions(&next.assemblies, &session.view.assemblies) + } + _ => false, + }; if same { session.weapon_id = weapon; + session.parent_weapon = ps.weapon_primary; } else { pending.0 = Some(PendingFpvSpawnRequest { gun_index, catalog_id: fpv.0.identity(), weapon_id: weapon, + parent_weapon: ps.weapon_primary, }); product.kind = FpvPoseKind::Hide; return; @@ -991,6 +569,7 @@ pub fn tick_fpv_viewmodel( gun_index, catalog_id: fpv.0.identity(), weapon_id: weapon, + parent_weapon: ps.weapon_primary, }); diag::info!( Fpv, @@ -1022,12 +601,12 @@ pub fn tick_fpv_viewmodel( .unwrap_or(false); let ads_frac = ps.map(|p| p.f_weapon_pos_frac).unwrap_or(0.0); let weap_anim = ps.map(|p| p.weap_anim).unwrap_or(0); - let clip_ammo = match (ps, weapons.as_ref()) { - (Some(ps), Some(reg)) => { + let clip_ammo = |hand| match (ps, table) { + (Some(ps), Some(table)) => { let viewmodel = bg_get_viewmodel_weapon_index(ps); - reg.0.facts_of(viewmodel).map(|facts| { + table.facts_of(viewmodel).map(|facts| { let key = bg_clip_table_key(facts.clip_index, viewmodel); - bg_get_clip_for_hand(&ps.ammoclip, key, 0) + bg_get_clip_for_hand(&ps.ammoclip, key, hand) }) } _ => None, @@ -1061,24 +640,23 @@ pub fn tick_fpv_viewmodel( weap_anim_secondary: ps.map(|p| p.weap_anim_secondary).unwrap_or(0), last_weapon_hand: ps.map(|p| p.last_weapon_hand).unwrap_or(0), perks0: ps.map(|p| p.perks[0]).unwrap_or(0), - clip_ammo, + clip_ammo: clip_ammo(0), + left_clip_ammo: clip_ammo(1), }), predicted_fire, ) } None => (None, false), }; - let rocket_visible = weapons.as_ref().is_some_and(|reg| { - fpv_rocket_should_attach(®.0, session.weapon_id, presented.player(local.0)) + let rocket_visible = table.is_some_and(|table| { + fpv_rocket_should_attach(table, session.weapon_id, presented.player(local.0)) }); let dual = presented .viewweapon_player(local.0) .is_some_and(|ps| ps.last_weapon_hand == 1); let dual_offset = if dual { presented.viewweapon_player(local.0).and_then(|ps| { - weapons - .as_ref()? - .0 + table? .facts_of(bg_get_viewmodel_weapon_index(ps)) .map(|f| f.dual_wield_view_model_offset) }) @@ -1086,30 +664,18 @@ pub fn tick_fpv_viewmodel( None }; let weapon_id = session.weapon_id; - shelf.hold(&session.material_catalog, session.catalog_id); let SessionFpvMeshesHandles { fpv: equipped, active_rig, - materials, - material_by_authored, - assemblies, + view: equipped_view, .. } = session; - let Some(reg) = weapons.as_ref() else { - product.kind = FpvPoseKind::Refuse(FpvPoseRefuse::CatalogMissing); - return; - }; let mut kind = generate_fpv_pose(FpvGenerateArgs { dt, equipped, - rigs: &mut shelf.rigs, + rigs: &equipped_view.rigs, active: active_rig, cursor: &mut cursor.0, - catalog: &fpv.0, - materials, - material_by_authored, - assemblies, - clip_tracks: reg.0.fpv_clip_tracks(), rocket: rocket_visible, sample, predicted_fire, @@ -1136,7 +702,6 @@ pub fn tick_fpv_viewmodel( fn skin_fpv_geometry( product: Res, session_vm: Option>, - shelf: Res, tess: Option>, fpv_meshes: Option>, mut fpv_plan: ResMut, @@ -1169,9 +734,7 @@ fn skin_fpv_geometry( crate::clear_fpv_draw_plan(&mut fpv_plan, handle); return; } - let rig = session - .and_then(|session| session.active_rig) - .and_then(|index| shelf.rigs.get(index)); + let rig = session.and_then(|session| session.active_rig.as_ref()); let (Some(rig), Some(catalog)) = (rig, fpv_meshes.as_ref()) else { crate::clear_fpv_draw_plan(&mut fpv_plan, handle); return; @@ -1229,6 +792,8 @@ pub fn publish_fpv_dobj_pose( bolts.clear(); return; } + bolts.tracker_screen = None; + bolts.tracker_light = None; for hand in 0..2usize { let Some(frame) = bolts.pose[hand].take() else { continue; @@ -1252,6 +817,18 @@ pub fn publish_fpv_dobj_pose( }) }; + if hand == 0 && fpv_plan.placement_ok { + bolts.tracker_light = target(frame.tags.tracker_light); + bolts.tracker_screen = (|| { + let mut points = [Vec3::ZERO; 3]; + for (point, bone) in points.iter_mut().zip(frame.tags.tracker_screen) { + *point = fpv_plan + .world_from_local + .transform_point3(frame.bones.get(usize::from(bone?))?.w_axis.truncate()); + } + Some(points) + })(); + } bolts.flash[hand] = target(frame.tags.flash); bolts.brass[hand] = target(frame.tags.brass); bolts.knife[hand] = target(frame.tags.knife); @@ -1264,7 +841,7 @@ pub fn apply_fpv_placement( clock: Res, presented: Res, local: Res, - weapons: Option>, + prepared: Res, mut kick: ResMut, cg_gun: Res, mut aim: ResMut, @@ -1290,11 +867,11 @@ pub fn apply_fpv_placement( if presented_is_third_person(&presented, local.0, view.in_killcam()) { return; } - let Some(reg) = weapons.as_ref() else { + let Some(table) = prepared.table() else { return; }; let viewmodel = bg_get_viewmodel_weapon_index(ps); - let Some(facts) = reg.0.facts_of(viewmodel) else { + let Some(facts) = table.facts_of(viewmodel) else { return; }; @@ -1319,7 +896,7 @@ pub fn apply_fpv_placement( let overlay_reticle = if facts.overlay_reticle != 0 { facts.overlay_reticle - } else if reg.0.overlay_is_hud_iris(viewmodel) { + } else if table.overlay_is_hud_iris(viewmodel) { 1 } else { 0 @@ -1486,13 +1063,25 @@ fn flush_fpv_spawn(world: &mut World) { } fn publish_fpv_notetracks( + generation: Res, + presented: Res, + local: Res, pending: Res, mut notes: MessageWriter, ) { if pending.weapon == 0 || pending.names.is_empty() { return; } + let Some(meta) = presented + .snapshot() + .and_then(|snapshot| snapshot.meta.for_client(local.0)) + else { + return; + }; notes.write(audio::ViewmodelNotetracks { + generation: *generation, + client: local.0, + life: meta.life_sequence, weapon: pending.weapon, names: pending.names.clone(), }); @@ -1501,7 +1090,8 @@ fn publish_fpv_notetracks( pub fn register_fpv_present_systems(app: &mut App) { app.init_resource::() .init_resource::() - .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/render_anim/src/occupancy/held_sync.rs b/crates/render_anim/src/occupancy/held_sync.rs index 118a256..0eb8cc8 100644 --- a/crates/render_anim/src/occupancy/held_sync.rs +++ b/crates/render_anim/src/occupancy/held_sync.rs @@ -1,12 +1,12 @@ -use assets::{PreparedFpvMeshes, PreparedWeapons}; use bevy::prelude::*; use frame::{LaunchReport, LifeEnded, LifeFrontPublished, LifeStarted}; use net::{ClientSet, LocalPresentClient, PresentedSnapshot}; +use crate::anim::fpv_prepared::PreparedFpv; use crate::gaps::{RenderGap, RenderGapCause, RenderPresentationGaps}; use crate::occupancy::fpv_present::{ FpvHeldLife, FpvHeldSettled, FpvPlacementRoot, FpvState, FpvStatusGap, PendingFpvSpawn, - PendingFpvSpawnRequest, SessionViewmodel, resolve_fpv_gun, + PendingFpvSpawnRequest, SessionViewmodel, }; use weapon_iw4::bg_get_viewmodel_weapon_index; @@ -14,8 +14,7 @@ pub fn sync_fpv_to_held_weapon( mut commands: Commands, presented: Res, local: Res, - weapons: Option>, - fpv_meshes: Option>, + prepared: Res, mut pending_fpv: ResMut, mut session_vm: ResMut, mut settled: ResMut, @@ -133,15 +132,13 @@ pub fn sync_fpv_to_held_weapon( if pending_fpv .0 .as_ref() - .is_some_and(|req| req.weapon_id == held) + .is_some_and(|req| req.weapon_id == held && req.parent_weapon == ps.weapon_primary) { return; } - if session_vm - .0 - .as_ref() - .is_some_and(|session| session.weapon_id == held) - { + if session_vm.0.as_ref().is_some_and(|session| { + session.weapon_id == held && session.parent_weapon == ps.weapon_primary + }) { settled.0 = Some(held); return; } @@ -150,37 +147,36 @@ pub fn sync_fpv_to_held_weapon( return; } - let state = match ( - weapons.as_ref().map(|r| &r.0), - fpv_meshes.as_ref().map(|r| &r.0), - ) { - (Some(reg), Some(fpv)) => match resolve_fpv_gun(reg, fpv, held) { - Some(gun_index) => { - for entity in &existing_fpv { - commands.entity(entity).try_despawn(); - } - session_vm.0 = None; - pending_fpv.0 = Some(PendingFpvSpawnRequest { - gun_index, - catalog_id: fpv.identity(), - weapon_id: held, - }); - settled.0 = None; - FpvState::Queued + let Some(table) = prepared.table() else { + let waiting = FpvState::Blocked(RenderGapCause::FpvCatalogMissing); + if status.0.as_ref() != Some(&waiting) { + status.0 = Some(waiting); + } + return; + }; + let state = match table.gun_index(held) { + Some(gun_index) => { + for entity in &existing_fpv { + commands.entity(entity).try_despawn(); } - None => { - for entity in &existing_fpv { - commands.entity(entity).try_despawn(); - } - session_vm.0 = None; - pending_fpv.0 = None; - settled.0 = Some(held); - FpvState::Blocked(RenderGapCause::FpvGunXModelUnresolved { weapon_id: held }) + session_vm.0 = None; + pending_fpv.0 = Some(PendingFpvSpawnRequest { + gun_index, + catalog_id: table.catalog_id(), + weapon_id: held, + parent_weapon: ps.weapon_primary, + }); + settled.0 = None; + FpvState::Queued + } + None => { + for entity in &existing_fpv { + commands.entity(entity).try_despawn(); } - }, - _ => { + session_vm.0 = None; + pending_fpv.0 = None; settled.0 = Some(held); - FpvState::Blocked(RenderGapCause::FpvCatalogMissing) + FpvState::Blocked(RenderGapCause::FpvGunXModelUnresolved { weapon_id: held }) } }; diff --git a/crates/render_anim/src/occupancy/item.rs b/crates/render_anim/src/occupancy/item.rs index e099bfe..85e6fdc 100644 --- a/crates/render_anim/src/occupancy/item.rs +++ b/crates/render_anim/src/occupancy/item.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use bevy::prelude::*; use crate::anim::scene_submission::{AnimDObjSceneSkels, AnimDObjSceneSubmission, AnimSceneSubmit}; @@ -7,7 +5,7 @@ use crate::anim::xmodel_pose::PosedModelSurface; use crate::occupancy::script_model::pose_script_dobj_with_materials; use crate::{ ItemAssetDraw, ItemDrawPlan, ItemOwnerDraw, XMODEL_OBJECT_ID_ITEM_BASE, append_item_surfaces, - body_lit_pass_material, dobj_lighting_box_half, stamp_plan_geometry, topology_fingerprint, + dobj_lighting_box_half, stamp_plan_geometry, topology_fingerprint, }; use anim_iw4::DOBJ_RADIUS_PARENT_ROOT; use entity_iw4::{ET_ITEM, Trajectory, bg_evaluate_trajectory}; @@ -19,6 +17,106 @@ use render_scene::{ pub const ITEM_LIGHTING_Z_OFS: f32 = 4.0; +struct ItemComposition { + name: String, + model: assets::WorldWeaponIndex, + attachments: Vec, + key: String, + dobj: assets::DObj, +} + +#[derive(Resource, Default)] +struct PreparedItemCompositions { + owner: Option<(usize, u64)>, + by_weapon: std::collections::HashMap>, +} + +impl PreparedItemCompositions { + fn owned_by( + &self, + weapons: &assets::PreparedWeapons, + world: &assets::PreparedWorldWeapons, + ) -> bool { + self.owner + == Some(( + std::sync::Arc::as_ptr(&weapons.0) as usize, + world.0.identity(), + )) + } +} + +fn compose_item( + registry: &assets::WeaponRegistry, + catalog: &assets::WorldWeaponCatalog, + weapon: u32, +) -> Option { + let entry = registry.world_model_entry(weapon, catalog)?; + let model_index = registry.world_model_edge_of(weapon)?.bound_index()?; + let pose = entry.skel.pose.as_ref()?; + let attachments = crate::anim::remote_body::world_attachments(registry, catalog, weapon); + let mut key = entry.skel.name.clone(); + let mut dobj_models = vec![(pose, None)]; + let mut attachment_models = Vec::with_capacity(attachments.len()); + for attachment in &attachments { + let Some(pose) = attachment.entry.skel.pose.as_ref() else { + continue; + }; + key.push('+'); + key.push_str(&attachment.entry.skel.name); + attachment_models.push(attachment.index); + dobj_models.push(( + pose, + Some(assets::Attach { + parent_model: 0, + tag: attachment.tag.to_owned(), + }), + )); + } + let dobj = assets::DObj::build(&dobj_models).ok()?; + Some(ItemComposition { + name: entry.skel.name.clone(), + model: assets::WorldWeaponIndex::from_order(model_index), + attachments: attachment_models, + key, + dobj, + }) +} + +fn prepare_item_compositions( + weapons: Option>, + world_weapons: Option>, + mut prepared: ResMut, +) { + let (Some(weapons), Some(world)) = (weapons, world_weapons) else { + return; + }; + if prepared.owned_by(&weapons, &world) { + return; + } + let started = std::time::Instant::now(); + let mut by_weapon = std::collections::HashMap::new(); + if !world.0.is_empty() { + for weapon in 1..=weapons.0.len() as u32 { + if let Some(composition) = compose_item(&weapons.0, &world.0, weapon) { + by_weapon.insert(weapon, std::sync::Arc::new(composition)); + } + } + } + diag::info!( + World, + "dropped items: {} weapon compositions prepared in {:.1}ms", + by_weapon.len(), + started.elapsed().as_secs_f64() * 1000.0 + ); + *prepared = PreparedItemCompositions { + owner: Some(( + std::sync::Arc::as_ptr(&weapons.0) as usize, + world.0.identity(), + )), + by_weapon, + }; +} + #[derive(Resource, Default)] struct ItemOccupancy { rows: Vec, @@ -29,10 +127,7 @@ struct OccupiedItem { entnum: i32, origin: [f32; 3], angles: [f32; 3], - name: String, - model: assets::WorldWeaponIndex, - attachments: Vec<(assets::WorldWeaponIndex, String)>, - key: String, + composition: std::sync::Arc, lighting_origin: [f32; 3], } @@ -77,6 +172,11 @@ fn item_scene_slot(scene: &render_scene::GfxScene, entnum: u32) -> ItemSceneSlot pub fn register_item_systems(app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() + .add_systems( + Update, + prepare_item_compositions.in_set(net::ClientSet::Load), + ) .init_resource::() .add_systems( Update, @@ -147,6 +247,7 @@ fn occupy_item_scene_ents( local_client: Option>, weapons: Option>, world_weapons: Option>, + compositions: Res, mut occupancy: ResMut, mut scene_skels: ResMut, mut scene_submissions: MessageWriter, @@ -165,7 +266,13 @@ fn occupy_item_scene_ents( .filter(|clock| clock.started()) .map(|clock| clock.time()) .unwrap_or_else(|| sim::level_time_ms(snapshot.tick)); - let weapons_reg = weapons.as_ref().map(|w| &w.0).filter(|reg| !reg.is_empty()); + let live = match (weapons.as_deref(), world_weapons.as_deref()) { + (Some(weapons), Some(world)) => compositions.owned_by(weapons, world), + _ => false, + }; + if !live { + return; + } let catalog = world_weapons .as_ref() .map(|prepared| &prepared.0) @@ -190,34 +297,16 @@ fn occupy_item_scene_ents( if hide_scavenger && item_is_scavenger(snapshot, es.number) { continue; } - let Some(entry) = weapons_reg.and_then(|reg| reg.world_model_entry(weapon, catalog)) else { + let Some(composition) = compositions.by_weapon.get(&weapon) else { continue; }; - let Some(model_index) = weapons_reg - .and_then(|reg| reg.world_model_edge_of(weapon)) - .and_then(|edge| edge.bound_index()) - else { + let Some(entry) = catalog.get_at(composition.model.order()) else { continue; }; - let name = entry.skel.name.as_str(); - let Some(_pose) = entry.skel.pose.as_ref() else { - continue; - }; - let attachments = weapons_reg - .map(|reg| crate::anim::remote_body::world_attachments(reg, catalog, weapon)) - .unwrap_or_default(); - let mut key = name.to_owned(); - for attachment in &attachments { - key.push('+'); - key.push_str(&attachment.entry.skel.name); - } - let mut models = vec![scene_skels.model(name, &entry.skel, 0)]; - models.extend(attachments.iter().map(|attachment| { - scene_skels.model( - attachment.entry.skel.name.as_str(), - &attachment.entry.skel, - 0, - ) + let mut models = vec![scene_skels.shared(&composition.name, &entry.skel, 0)]; + models.extend(composition.attachments.iter().filter_map(|index| { + let attachment = catalog.get_at(index.order())?; + Some(scene_skels.shared(attachment.skel.name.as_str(), &attachment.skel, 0)) })); let origin = evaluate_origin(es, at_time); let lighting_origin = item_lighting_origin(origin); @@ -248,13 +337,7 @@ fn occupy_item_scene_ents( entnum: es.number, origin, angles, - name: name.to_owned(), - model: assets::WorldWeaponIndex::from_order(model_index), - attachments: attachments - .iter() - .map(|attachment| (attachment.index, attachment.tag.to_owned())) - .collect(), - key, + composition: std::sync::Arc::clone(composition), lighting_origin, }); } @@ -287,69 +370,58 @@ fn pose_items( if slot.hidden { continue; } - let asset_index = - if let Some(index) = product.assets.iter().position(|asset| asset.key == row.key) { - index - } else { - let Some(entry) = catalog.get_at(row.model.order()) else { - continue; + let composition = &row.composition; + let asset_index = if let Some(index) = product + .assets + .iter() + .position(|asset| asset.key == composition.key) + { + index + } else { + let mut skels = Vec::with_capacity(1 + composition.attachments.len()); + let mut model_indices = Vec::with_capacity(1 + composition.attachments.len()); + for index in std::iter::once(&composition.model).chain(&composition.attachments) { + let Some(entry) = catalog.get_at(index.order()) else { + break; }; - let Some(pose) = entry.skel.pose.as_ref() else { - continue; - }; - let mut skels = vec![&*entry.skel]; - let mut model_indices = vec![row.model]; - let mut dobj_models = vec![(pose, None)]; - for (model, tag) in &row.attachments { - let Some(attachment) = catalog.get_at(model.order()) else { - continue; - }; - let Some(pose) = attachment.skel.pose.as_ref() else { - continue; - }; - skels.push(&*attachment.skel); - model_indices.push(*model); - dobj_models.push(( - pose, - Some(assets::Attach { - parent_model: 0, - tag: tag.clone(), - }), - )); - } - let Some(dobj) = assets::DObj::build(&dobj_models).ok() else { - continue; - }; - let dobj_state = assets::dobj::DObjSemanticState::bind_pose(row.name.clone(), 1, 1); - let Ok(request) = dobj_state.resolve_request(|_| None) else { - continue; - }; - let Some((surfaces, _)) = pose_script_dobj_with_materials( - None, - &skels, - &dobj, - &request, - None, - slot.skin_entries, - ) else { - continue; - }; - let index = product.assets.len(); - product.assets.push(ItemPosedAsset { - key: row.key.clone(), - models: model_indices, - surfaces, - }); - index + skels.push(&*entry.skel); + model_indices.push(*index); + } + if skels.len() != 1 + composition.attachments.len() { + continue; + } + let dobj = &composition.dobj; + let dobj_state = + assets::dobj::DObjSemanticState::bind_pose(composition.name.clone(), 1, 1); + let Ok(request) = dobj_state.resolve_request(|_| None) else { + continue; }; + let Some((surfaces, _)) = pose_script_dobj_with_materials( + None, + &skels, + dobj, + &request, + None, + slot.skin_entries, + ) else { + continue; + }; + let index = product.assets.len(); + product.assets.push(ItemPosedAsset { + key: composition.key.clone(), + models: model_indices, + surfaces, + }); + index + }; product.owners.push(ItemPosedOwner { asset_index, index: row.index, entnum, origin: row.origin, angles: row.angles, - name: row.name.clone(), - model: row.model, + name: composition.name.clone(), + model: composition.model, lighting_origin: row.lighting_origin, }); } @@ -364,8 +436,8 @@ fn append_item_draws( facts: Res, mut plan: ResMut, mut lighting_requests: ResMut, + model_materials: Res, mut last_material_generation: Local>, - mut material_cache: Local>, mut draws: Local>, mut owners: Local>, ) { @@ -382,7 +454,6 @@ fn append_item_draws( plan.revision = revision; plan.generation = generation; plan.publish_no_rows(); - material_cache.clear(); *last_material_generation = None; return; } @@ -391,7 +462,7 @@ fn append_item_draws( .is_some_and(|value| value.is_changed()); let atlas_changed = atlas.as_ref().is_some_and(|value| value.is_changed()); let tess_changed = tess.as_ref().is_some_and(|value| value.is_changed()); - let (Some(catalog), Some(atlas), Some(tess)) = (catalog, atlas_ref, tess.as_deref()) else { + let (Some(catalog), Some(_), Some(tess)) = (catalog, atlas_ref, tess.as_deref()) else { unreachable!("required item draw resources checked above"); }; let material_generation = tess.catalog.generation_id; @@ -406,7 +477,6 @@ fn append_item_draws( plan.revision = revision; plan.generation = generation; plan.revisions.bump_admission(); - material_cache.clear(); *last_material_generation = Some(material_generation); } for row in &product.owners { @@ -425,12 +495,9 @@ fn append_item_draws( let entry = catalog.get_at(posed.models.get(usize::from(surface.model))?.order())?; let present_name = entry.material_present_name(surface.surface_index)?; - if let Some(material) = material_cache.get(present_name) { - return Some(material.clone()); - } - let material = body_lit_pass_material(atlas, &tess.catalog, present_name)?; - material_cache.insert(present_name.to_owned(), material.clone()); - Some(material) + model_materials + .material(&tess.catalog, present_name) + .cloned() }) .collect(); if materials.iter().all(Option::is_none) { diff --git a/crates/render_anim/src/occupancy/match_reset.rs b/crates/render_anim/src/occupancy/match_reset.rs index cd7ffb8..fb355a1 100644 --- a/crates/render_anim/src/occupancy/match_reset.rs +++ b/crates/render_anim/src/occupancy/match_reset.rs @@ -2,6 +2,7 @@ use bevy::prelude::*; use frame::{MatchInstalled, MatchTornDown, SessionSwapApplied}; use net::ClientSet; +use crate::anim::fpv_prepared::PreparedFpv; use crate::anim::scene_submission::AnimDObjSceneSkels; use crate::occupancy::dyn_ent::{DynEntPhysClip, DynEntPhysWorld}; use crate::occupancy::fpv_present::{ @@ -17,6 +18,8 @@ pub fn reset_anim_for_match( mut cursor: ResMut, mut pending: ResMut, mut viewmodel: ResMut, + mut prepared_fpv: ResMut, + mut model_materials: ResMut, mut settled: ResMut, mut held_life: ResMut, mut scene_skels: ResMut, @@ -32,6 +35,8 @@ pub fn reset_anim_for_match( cursor.0.clear(); pending.0 = None; *viewmodel = SessionViewmodel::default(); + prepared_fpv.clear(); + model_materials.clear(); settled.0 = None; held_life.0 = None; *scene_skels = AnimDObjSceneSkels::default(); diff --git a/crates/render_anim/src/occupancy/missile.rs b/crates/render_anim/src/occupancy/missile.rs index 733a13b..4747ced 100644 --- a/crates/render_anim/src/occupancy/missile.rs +++ b/crates/render_anim/src/occupancy/missile.rs @@ -7,7 +7,7 @@ use crate::anim::xmodel_pose::PosedModelSurface; use crate::occupancy::script_model::pose_script_dobj_with_materials; use crate::{ MissileDrawPlan, MissileOwnerDraw, XMODEL_OBJECT_ID_MISSILE_BASE, append_missile_surfaces, - authored_lit_xmodel_pass_material, dobj_lighting_box_half, + dobj_lighting_box_half, }; use anim_iw4::DOBJ_RADIUS_PARENT_ROOT; use render_scene::{ @@ -187,7 +187,7 @@ fn occupy_missile_scene_ents( entnum, quat: Some(scene_quat_from_angles(angles)), occupy_model_n: 1, - models: vec![scene_skels.model(name, &entry.skel, 0)], + models: vec![scene_skels.shared(name, &entry.skel, 0)], hide_part_bits: [0; 6], store_skin: true, }); @@ -213,29 +213,19 @@ fn occupy_missile_scene_ents( fn publish_missile_dobj_poses( occupancy: Res, - projectile_meshes: Option>, + prepared: Res, mut dobj_poses: ResMut, ) { - let catalog = missile_pose_catalog(projectile_meshes.as_deref()); - let Some(catalog) = catalog else { - return; - }; for row in &occupancy.rows { let Some(id) = row.id else { continue; }; - let Some(entry) = catalog.get(&row.name) else { - continue; - }; - let Some(pose) = entry.skel.pose.as_ref() else { - continue; - }; - let Ok(dobj) = assets::DObj::build(&[(pose, None)]) else { + let Some(dobj) = prepared.projectile_dobj(&row.name) else { continue; }; let entity_world = missile_world_from_local(row.origin, row.angles); let Ok(local) = assets::dobj::pose_dobj_with_controller( - &dobj, + dobj, &assets::dobj::DObjPoseRequest::bind_pose(), Mat4::IDENTITY, |_, _, _| {}, @@ -251,6 +241,7 @@ fn pose_missiles( occupancy: Res, projectile_meshes: Option>, gfx: Res, + prepared: Res, mut product: ResMut, ) { product.rows.clear(); @@ -267,10 +258,7 @@ fn pose_missiles( let Some(entry) = catalog.get(&row.name) else { continue; }; - let Some(pose) = entry.skel.pose.as_ref() else { - continue; - }; - let Some(dobj) = assets::DObj::build(&[(pose, None)]).ok() else { + let Some(dobj) = prepared.projectile_dobj(&row.name) else { continue; }; let dobj_state = assets::dobj::DObjSemanticState::bind_pose(row.name.clone(), 1, 1); @@ -280,7 +268,7 @@ fn pose_missiles( let Some((surfaces, _)) = pose_script_dobj_with_materials( None, &[&entry.skel], - &dobj, + dobj, &request, None, slot.skin_entries, @@ -309,6 +297,7 @@ fn append_missile_draws( facts: Res, mut plan: ResMut, mut lighting_requests: ResMut, + prepared: Res, mut staged: Local, mut draws: Local>, mut owners: Local>, @@ -326,10 +315,9 @@ fn append_missile_draws( plan.publish_rebuild(staging, &mut draws, &mut owners); return; } - let (Some(catalog), Some(atlas), Some(tess)) = (catalog, atlas_ref, tess.as_deref()) else { + let (Some(catalog), Some(_), Some(tess)) = (catalog, atlas_ref, tess.as_deref()) else { unreachable!("required missile draw resources checked above"); }; - let mut material_cache: HashMap = HashMap::new(); for row in &product.rows { let entnum = row.id.unwrap_or(0); let Some(entry) = catalog.get(&row.name) else { @@ -340,17 +328,9 @@ fn append_missile_draws( .iter() .map(|surface| { let authored = entry.material_index(surface.surface_index)?; - if let Some(material) = material_cache.get(&authored) { - return Some(material.clone()); - } - let material = authored_lit_xmodel_pass_material( - atlas, - &tess.catalog, - tess.material_images.as_ref(), - authored, - )?; - material_cache.insert(authored, material.clone()); - Some(material) + prepared + .projectile_material(&tess.catalog, authored) + .cloned() }) .collect(); if materials.iter().all(Option::is_none) { diff --git a/crates/render_anim/src/occupancy/remote_body.rs b/crates/render_anim/src/occupancy/remote_body.rs index c364c36..48e34ba 100644 --- a/crates/render_anim/src/occupancy/remote_body.rs +++ b/crates/render_anim/src/occupancy/remote_body.rs @@ -1,21 +1,21 @@ use crate::anim::remote_body::{ - AdvancedRemoteTree, CpuBodyGeom, CpuSurfMeta, PendingGunSkin, RemoteBodySkinnedItem, - RemoteBodySkinnedQueue, RemoteBodyTrees, RemoteModelLods, RemoteSkinModels, - RemoteSkinPoseHashes, SkinAfterPose, WorldGunGap, advance_remote_tree, bind_remote_skin_models, - clone_corpse_tree_from_victim, commit_assembled_body, dobj_radii, ensure_remote_dobj, - hash_skin_matrices, occupy_lod_byte, occupy_remote_kit_dobj, packed_anim, pose_remote_dobj, - push_cached_surfaces, remote_player_controller, select_remote_lods, select_remote_models, - skin_after_pose, skin_slot_need, skip_frozen_corpse_dobj, take_unique_geom, - validate_remote_tracks, zero_anim, + AdvancedRemoteTree, CpuBodyGeom, CpuSurfMeta, PendingGunSkin, PreparedRemoteKits, + RemoteBodySkinnedItem, RemoteBodySkinnedQueue, RemoteBodyTrees, RemoteModelLods, + RemoteSkinModels, RemoteSkinPoseHashes, SkinAfterPose, WorldGunGap, advance_remote_tree, + bind_remote_skin_models, clone_corpse_tree_from_victim, commit_assembled_body, dobj_radii, + ensure_remote_dobj, hash_skin_matrices, occupy_lod_byte, occupy_remote_kit_dobj, packed_anim, + pose_remote_dobj, push_cached_surfaces, remote_player_controller, select_remote_lods, + select_remote_models, skin_after_pose, skin_slot_need, skip_frozen_corpse_dobj, + take_unique_geom, validate_remote_tracks, zero_anim, }; use crate::anim::scene_submission::{AnimDObjSceneSkels, AnimDObjSceneSubmission, AnimSceneSubmit}; use crate::anim::xmodel_pose::{build_skin_layout, skin_packed_into, stream_lod_surface_rigid}; use crate::dobj_lighting_box_half; use crate::gaps::{RenderGap, RenderGapCause, RenderPresentationGaps}; use crate::{ - RemoteBodyDrawPlan, append_remote_body_cpu_blob, body_lit_pass_material, - finish_remote_body_draw_plan, install_body_packed_session, push_remote_body_cpu_draw, - take_body_packed_session, topology_fingerprint, + RemoteBodyDrawPlan, append_remote_body_cpu_blob, finish_remote_body_draw_plan, + install_body_packed_session, push_remote_body_cpu_draw, take_body_packed_session, + topology_fingerprint, }; use anim_iw4::{PLAYER_ANIM_RAW_MASK, PlayerAnimValue}; use assets::{ModelSkel, PreparedBodies, PreparedWeapons, PreparedWorldWeapons}; @@ -71,6 +71,8 @@ pub fn register_remote_body_systems(app: &mut App) { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .add_systems(Update, prepare_remote_kits.in_set(ClientSet::Load)) .init_resource::() .init_resource::() .add_systems( @@ -131,6 +133,28 @@ pub fn register_remote_body_systems(app: &mut App) { ); } +fn prepare_remote_kits( + bodies: Option>, + weapons: Option>, + world_weapons: Option>, + mut kits: ResMut, +) { + let (Some(bodies), Some(weapons), Some(world)) = (bodies, weapons, world_weapons) else { + return; + }; + if kits.owned_by(&bodies, &weapons, &world) { + return; + } + let started = std::time::Instant::now(); + *kits = PreparedRemoteKits::prepare(&bodies, &weapons, &world); + diag::info!( + World, + "remote kits: prepared for {} weapons in {:.1}ms", + weapons.0.len(), + started.elapsed().as_secs_f64() * 1000.0 + ); +} + fn finish_body_draw_plan(mut plan: ResMut) { finish_remote_body_draw_plan(&mut plan); } @@ -148,6 +172,7 @@ fn occupy_remote_scene_ents( bodies: Option>, weapons: Option>, world_weapons: Option>, + prepared_kits: Res, remotes: Query< (&CEntity, &CEntityRuntime, &Transform), ( @@ -160,6 +185,15 @@ fn occupy_remote_scene_ents( let Some(bodies) = bodies.as_deref() else { return; }; + if let Some(world) = world_weapons.as_deref() { + scene_skels.seed(bodies, world); + } + let live_kits = match (weapons.as_deref(), world_weapons.as_deref()) { + (Some(weapons), Some(world)) if prepared_kits.owned_by(bodies, weapons, world) => { + Some((&*prepared_kits, world)) + } + _ => None, + }; let kits = bodies.0.kits(); if kits.allies.is_none() && kits.axis.is_none() { return; @@ -195,14 +229,31 @@ fn occupy_remote_scene_ents( let ffa_team = meta.and_then(|m| m.ffa_team); let client_state_team = meta.map(|m| m.client_state_team).unwrap_or(0); let axis = assets::kit_assignment_is_axis(client_state_team, ffa_team); - let Some((kit_models, radius)) = occupy_remote_kit_dobj( - bodies, - weapons.as_deref(), - world_weapons.as_deref(), - axis, - remote_pose_sample(runtime).weapon, - ) else { - continue; + let held = remote_pose_sample(runtime).weapon; + let (kit_models, radius, hide_part_bits) = match live_kits { + Some((kits, world)) => { + let Some(kit) = kits.get(axis, held) else { + continue; + }; + let Some(models) = kit.models(bodies, world) else { + continue; + }; + (models, kit.radius, kit.hide_part_bits) + } + None => { + let Some((models, radius)) = occupy_remote_kit_dobj( + bodies, + weapons.as_deref(), + world_weapons.as_deref(), + axis, + held, + true, + ) else { + continue; + }; + let bits = crate::anim::remote_body::kit_hide_part_bits(&models); + (models, radius, bits) + } }; let origin = transform.translation.to_array(); let quat = [ @@ -218,19 +269,10 @@ fn occupy_remote_scene_ents( .iter() .map(|model| occupy_lod_byte(skel_camera_lod(model.skel, origin, eye, ramp))) .collect(); - let mut hide_part_bits = [0u32; 6]; - let mut base = 0usize; let models: Vec<_> = kit_models .iter() .enumerate() .map(|(i, model)| { - render_scene::hide_part_bits_from_tags( - &mut hide_part_bits, - model.skel, - base, - &model.hide_tags, - ); - base += model.skel.bones.len(); scene_skels.model( model.name, model.skel, @@ -388,6 +430,7 @@ struct RemotePoseFrame<'a> { bodies: &'a PreparedBodies, weapons: Option<&'a PreparedWeapons>, world_weapons: Option<&'a PreparedWorldWeapons>, + kits: Option<&'a PreparedRemoteKits>, dt: f32, eye: Option, ramp: LodRampArgs, @@ -436,6 +479,7 @@ fn pose_remote_bodies( bodies: Option>, weapons: Option>, world_weapons: Option>, + prepared_kits: Res, mut trees: ResMut, mut pose_hashes: ResMut, mut submit: ResMut, @@ -555,6 +599,12 @@ fn pose_remote_bodies( bodies, weapons: weapons.as_deref(), world_weapons: world_weapons.as_deref(), + kits: match (weapons.as_deref(), world_weapons.as_deref()) { + (Some(weapons), Some(world)) if prepared_kits.owned_by(bodies, weapons, world) => { + Some(&*prepared_kits) + } + _ => None, + }, dt: cg_clock .as_ref() .map(|clock| clock.frametime_secs()) @@ -687,6 +737,7 @@ impl<'a> RemotePoseFrame<'a> { let bodies = self.bodies; let weapons = self.weapons; let world_weapons = self.world_weapons; + let kits = self.kits; let weapon = sample.weapon; let view_pitch_deg = sample.view_pitch_deg; let prone = sample.prone; @@ -750,10 +801,14 @@ impl<'a> RemotePoseFrame<'a> { push_cached_surfaces(persist_key, transform, pose_hashes, submit); return Ok(PoseOneOutcome::Posed); } - ensure_remote_dobj(&model_set, e_type, persist_key, trees)?; + let kit = kits + .and_then(|kits| kits.get(axis, weapon)) + .ok_or_else(|| format!("remote kit not prepared: axis={axis} weapon={weapon}"))?; + let prepared_dobj = kit.dobj.as_ref(); + ensure_remote_dobj(&model_set, e_type, persist_key, trees, prepared_dobj)?; let dobj = trees .get(persist_key) - .and_then(|slot| slot.dobj.as_ref()) + .and_then(|slot| slot.dobj.as_deref()) .expect("composed"); validate_remote_tracks(dobj, clips.as_ref(), body, &model_set.body_name)?; @@ -764,6 +819,7 @@ impl<'a> RemotePoseFrame<'a> { )?; let skin = publish_remote_dobj( dobj, + kit.bolt_bones, &world, persist_key, runtime, @@ -772,7 +828,13 @@ impl<'a> RemotePoseFrame<'a> { dobj_poses, )?; if !is_corpse { - publish_posed_player_head(dobj, &world, identity, transform, posed_players)?; + publish_posed_player_head( + kit.head_bone, + &world, + identity, + transform, + posed_players, + )?; } let hash = hash_skin_matrices(&skin); let pose_same = pose_hashes.remember_pose_hash(persist_key, hash); @@ -848,6 +910,7 @@ fn remote_skin_action<'a>( fn publish_remote_dobj( dobj: &assets::DObj, + bolt_bones: [Option; 4], world: &[Mat4], persist_key: u32, runtime: &CEntityRuntime, @@ -864,8 +927,8 @@ fn publish_remote_dobj( world, ) .map_err(|error| format!("DObj bone publication failed: {error:?}"))?; - let target = |tag: &str| -> Option { - let bone = u16::try_from(dobj.find(tag)?).ok()?; + let target = |bone: Option| -> Option { + let bone = bone?; let orientation = dobj_poses.resolve(persist_key, i32::from(bone)).ok()?; Some(fx::FxBoltTarget { dobj: persist_key, @@ -877,21 +940,21 @@ fn publish_remote_dobj( orientation, }) }; - bolts.flash = target("tag_flash"); - bolts.brass = target("tag_brass"); - bolts.knife = target("tag_knife_fx"); - bolts.laser = target(fx_iw4::FX_LASER_TAG); + bolts.flash = target(bolt_bones[0]); + bolts.brass = target(bolt_bones[1]); + bolts.knife = target(bolt_bones[2]); + bolts.laser = target(bolt_bones[3]); Ok(dobj.skin_matrices(world)) } fn publish_posed_player_head( - dobj: &assets::DObj, + head_bone: Option, world: &[Mat4], identity: &CEntity, transform: &Transform, frame: &mut crate::anim::dobj_pose::PosedPlayerFrame, ) -> Result<(), String> { - let head = match dobj.find("j_head") { + let head = match head_bone { None => crate::anim::dobj_pose::PosedPlayerHead::NoDObjOrHead, Some(index) => { let bone = world.get(index).ok_or_else(|| { @@ -1165,6 +1228,7 @@ fn submit_remote_bodies( resolved: Res, atlas: Option>, tess: Option>, + model_materials: Res, mut last_catalog: Local>>, ) { let items = submit.take(); @@ -1176,12 +1240,12 @@ fn submit_remote_bodies( *last_catalog = None; return; } - let Some(atlas) = atlas else { + if atlas.is_none() { plan.clear_geometry(); *last_catalog = None; gaps.raise(RenderGapCause::RemoteBodyLightingAllocFailed); return; - }; + } let Some(tess) = tess else { plan.clear_geometry(); *last_catalog = None; @@ -1276,7 +1340,7 @@ fn submit_remote_bodies( let mat_idx = if let Some(&idx) = mat_by_name.get(name) { idx } else { - let Some(material) = body_lit_pass_material(&atlas, &tess.catalog, name) else { + let Some(material) = model_materials.material(&tess.catalog, name).cloned() else { any_missing_material = true; last_cause = Some(RenderGapCause::RemoteBodyMaterialMissing { name: name.to_owned(), diff --git a/crates/render_anim/src/occupancy/script_model.rs b/crates/render_anim/src/occupancy/script_model.rs index 42b91fc..a7ccbcb 100644 --- a/crates/render_anim/src/occupancy/script_model.rs +++ b/crates/render_anim/src/occupancy/script_model.rs @@ -29,7 +29,7 @@ struct ScriptModelDobjs { } struct PersistentScriptDobj { - dobj: assets::DObj, + dobj: std::sync::Arc, reuse_key: assets::dobj::DObjReuseKey, } @@ -74,12 +74,11 @@ struct ScriptModelPoseLocals<'w, 's> { struct ScriptModelTessLocals<'w, 's> { last_material_generation: Local<'s, Option>, last_unbound_materials: Local<'s, usize>, - material_cache: Local<'s, HashMap>, + model_materials: Res<'w, crate::anim::model_materials::PreparedModelMaterials>, live_plan: Local<'s, Vec>, product_to_plan: Local<'s, Vec>, keep: Local<'s, Vec>, remap: Local<'s, Vec>>, - _marker: std::marker::PhantomData<&'w ()>, } struct ScriptOwnerRow { @@ -508,6 +507,7 @@ fn pose_script_models( cameras: Query<(&GlobalTransform, &Projection, &Camera), With>, owners: Query<(Entity, &WorldScriptModelInstance, &Transform, &Visibility)>, mut persist: ResMut, + prepared: Res, mut product: ResMut, select: Res, mut focus: ResMut, @@ -599,7 +599,8 @@ fn pose_script_models( .collect(); let asset_index = if let Some(id) = owner_id { - if compose_or_reuse_script_dobj(&mut persist, id, &assets, &owner.dobj_state).is_none() + if compose_or_reuse_script_dobj(&mut persist, id, &assets, &owner.dobj_state, &prepared) + .is_none() { persist.by_id.remove(&id); continue; @@ -732,12 +733,11 @@ fn commit_script_model_draw_plan( let ScriptModelTessLocals { mut last_material_generation, mut last_unbound_materials, - mut material_cache, + model_materials, mut live_plan, mut product_to_plan, mut keep, mut remap, - _marker: _, } = locals; if product.producer_unavailable { plan.publish_no_rows(); @@ -764,7 +764,6 @@ fn commit_script_model_draw_plan( plan.revision = revision; plan.generation = generation; plan.revisions = revisions; - material_cache.clear(); *last_material_generation = Some(material_generation); } draws.clear(); @@ -783,45 +782,7 @@ fn commit_script_model_draw_plan( .iter() .zip(posed.authored.iter().copied()) .map(|(_surface, authored)| { - let authored = authored?; - if let Some(material) = material_cache.get(&authored) { - return Some(material.clone()); - } - let world_material = tess.catalog.derived(authored)?; - let ordinal = tess - .catalog - .sorted_materials - .ordinal_for_asset_id(authored.order())?; - let maps = render_scene::runtime_maps( - Some(authored), - &tess.catalog, - tess.material_images.as_ref(), - ); - let inv_h = - lighting_iw4::model_lighting_inv_image_height(atlas.dims.image_height)?; - let scale = lighting_iw4::model_lighting_lookup_scale(inv_h); - let material = SmodelPassMaterial { - model_lighting_required: true, - color: maps.color, - specular: maps.specular, - probe: None, - atlas: Some(atlas.image.clone()), - 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: [scale.u, scale.v, scale.w, scale.q], - atlas_lookup: [ - lighting_iw4::MODEL_LIGHTING_INV_ATLAS_WIDTH as f32, - inv_h, - lighting_iw4::MODEL_LIGHTING_VOLUME_W, - 0.0, - ], - sort_key: world_material.sort_key, - material_sorted_index: Some(ordinal.get()), - }; - material_cache.insert(authored, material.clone()); - Some(material) + model_materials.authored(&tess.catalog, authored?).cloned() }) .collect::>(); append_or_overwrite_script_model( @@ -1108,6 +1069,7 @@ fn compose_or_reuse_script_dobj( id: u32, catalog: &assets::MapXModelSceneCatalog, state: &assets::dobj::DObjSemanticState, + prepared: &crate::anim::model_materials::PreparedModelMaterials, ) -> Option<()> { let key = script_dobj_reuse_key(state); let reuse = persist @@ -1118,7 +1080,14 @@ fn compose_or_reuse_script_dobj( return Some(()); } let (specs, _) = collect_presented_models(catalog, state)?; - let dobj = assets::DObj::build(&specs).ok()?; + let single = match state.composition.models.as_slice() { + [only] => prepared.scene_dobj(&only.model).cloned(), + _ => None, + }; + let dobj = match single { + Some(dobj) => dobj, + None => std::sync::Arc::new(assets::DObj::build(&specs).ok()?), + }; persist.by_id.insert( id, PersistentScriptDobj { diff --git a/crates/render_backend/src/material_exec.rs b/crates/render_backend/src/material_exec.rs index 8418842..42cebf7 100644 --- a/crates/render_backend/src/material_exec.rs +++ b/crates/render_backend/src/material_exec.rs @@ -135,6 +135,7 @@ impl ShellInternKey { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum ExecutionShareExtra { Identity, + ViewmodelCodeMesh, LightingHandle(u32), Instance { depth_hack: bool }, @@ -151,6 +152,7 @@ struct OverlayKeepKey { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum OverlayKeepExtra { World, + ViewmodelCodeMesh, Lighting { handle: u32, packed: Option<[u8; 4]>, @@ -188,6 +190,9 @@ fn execution_share_extra(draw: &RetainedDrawItem) -> ExecutionShareExtra { RetainedDrawKind::CodeMesh { draw, arg_count, .. } if arg_count != 0 => ExecutionShareExtra::Unique(draw), + RetainedDrawKind::CodeMesh { + viewmodel: true, .. + } => ExecutionShareExtra::ViewmodelCodeMesh, RetainedDrawKind::CodeMesh { .. } => ExecutionShareExtra::Identity, } } @@ -244,6 +249,9 @@ fn smodel_code_world_from_local(kind: &RetainedDrawKind) -> Mat4 { fn overlay_keep_key(draw: &RetainedDrawItem) -> OverlayKeepKey { let scene_light = dpvs_iw4::GfxDrawSurf { packed: draw.key }.scene_light_index(); let extra = match draw.kind { + RetainedDrawKind::CodeMesh { + viewmodel: true, .. + } => OverlayKeepExtra::ViewmodelCodeMesh, RetainedDrawKind::World { .. } | RetainedDrawKind::CodeMesh { .. } => { OverlayKeepExtra::World } @@ -371,9 +379,22 @@ fn overlay_draw_material( ); } RetainedDrawKind::CodeMesh { - args, arg_count, .. - } if arg_count != 0 => { - overlay_code_mesh_tess_code_constants(scratch, &args[..usize::from(arg_count)]); + args, + arg_count, + viewmodel, + .. + } => { + if arg_count != 0 { + overlay_code_mesh_tess_code_constants(scratch, &args[..usize::from(arg_count)]); + } + if viewmodel && let Some(clip) = runtime.frame.viewmodel_clip_from_world { + overlay_viewmodel_depth_hack( + scratch, + clip, + runtime.frame.view_origin, + Mat4::IDENTITY, + ); + } } RetainedDrawKind::Glass { lighting_handle, .. @@ -458,9 +479,22 @@ fn overlay_draw_obj_only( ); } RetainedDrawKind::CodeMesh { - args, arg_count, .. - } if arg_count != 0 => { - overlay_code_mesh_tess_code_constants(scratch, &args[..usize::from(arg_count)]); + args, + arg_count, + viewmodel, + .. + } => { + if arg_count != 0 { + overlay_code_mesh_tess_code_constants(scratch, &args[..usize::from(arg_count)]); + } + if viewmodel && let Some(clip) = runtime.frame.viewmodel_clip_from_world { + overlay_viewmodel_depth_hack( + scratch, + clip, + runtime.frame.view_origin, + Mat4::IDENTITY, + ); + } } _ => {} } diff --git a/crates/render_frame/src/retained.rs b/crates/render_frame/src/retained.rs index df7f57a..e5bef08 100644 --- a/crates/render_frame/src/retained.rs +++ b/crates/render_frame/src/retained.rs @@ -59,6 +59,7 @@ pub enum RetainedDrawKind { }, CodeMesh { + viewmodel: bool, draw: u32, material: u32, arg_count: u8, diff --git a/crates/render_frontend/src/adapters/fx/system.rs b/crates/render_frontend/src/adapters/fx/system.rs index 7683b88..60421f9 100644 --- a/crates/render_frontend/src/adapters/fx/system.rs +++ b/crates/render_frontend/src/adapters/fx/system.rs @@ -98,6 +98,7 @@ impl FxSceneAccess<'_> { pub(crate) fn register_combat_fx_systems(app: &mut App) { app.add_message::() + .init_resource::() .init_resource::() .init_resource::() .add_systems(Update, latch_authority_load_hold.in_set(ClientSet::Load)) @@ -120,11 +121,15 @@ pub(crate) fn register_combat_fx_systems(app: &mut App) { ) .add_systems( crate::assemble::StaticSunAndFx, - generate_fx_transaction.pipe(commit_fx_transaction), + ( + generate_fx_transaction.pipe(commit_fx_transaction), + super::super::motion_tracker::draw_motion_tracker, + ) + .chain(), ) .add_systems( Update, - (drain_bullet_hit_fx, drain_pellet_fx) + (drain_bullet_hit_fx, drain_pellet_fx, present_tracker_light) .chain() .after(render_anim::occupancy::fpv_present::publish_fpv_dobj_pose) .after(frame::WorkerCmdSet::SkinModel) @@ -143,6 +148,78 @@ pub(crate) fn register_combat_fx_systems(app: &mut App) { .add_observer(cg_melee_blood); } +#[derive(Default)] +struct TrackerLight { + owner: Option<( + frame::WorldGeneration, + sim::ClientId, + sim::LifeSequence, + u32, + )>, + bolt: Option<(u32, u16)>, +} + +#[allow(clippy::too_many_arguments)] +fn present_tracker_light( + presented: Res, + local: Res, + generation: Res, + prepared: Res, + bolts: Res, + catalog: Option>, + mut elem_infos: ResMut, + mut host: ResMut, + fx_world: FxSceneAccess, + mut light: Local, +) { + const EFFECT: &str = "misc/light_motion_tracker"; + let owner = presented.snapshot().and_then(|snapshot| { + let meta = snapshot.meta.for_client(local.0)?; + let ps = presented.player(local.0)?; + let weapon = weapon_iw4::bg_get_viewmodel_weapon_index(ps); + (meta.lifecycle == sim::ClientLifecycle::Alive + && ps.other_flags & 0x400 == 0 + && prepared.table()?.facts_of(weapon).is_some_and(|facts| { + facts.motion_tracker + || (facts.inventory_type == 3 + && prepared + .table() + .and_then(|t| t.facts_of(ps.weapon_primary)) + .is_some_and(|parent| parent.motion_tracker)) + })) + .then_some((*generation, local.0, meta.life_sequence, weapon)) + }); + if owner != light.owner { + if light.owner.is_some_and(|old| old.0 == *generation) { + if let Some((dobj, bone)) = light.bolt { + host.0.stop_bolted(EFFECT, dobj, bone); + } + } + light.owner = owner; + light.bolt = None; + } + if owner.is_none() || light.bolt.is_some() { + return; + } + let (Some(target), Some(catalog)) = (bolts.tracker_light, catalog) else { + return; + }; + elem_infos.0.sync(&catalog.0); + if render_fx::present::play_named_bolted_in_world( + &mut host.0, + &catalog.0, + &elem_infos.0, + EFFECT, + target, + fx_world.view().as_ref().map(|scene| scene as &dyn FxScene), + ) + .and_then(PlayResult::handle) + .is_some() + { + light.bolt = Some((target.dobj, target.bone)); + } +} + fn queue_tag_lasers( post_lights: &mut HostFxPostLights, fpv_bolts: &crate::adapters::anim::fpv_present::FpvBoltTargets, @@ -418,16 +495,16 @@ fn tick_fx_remaining_update( } #[derive(SystemParam)] -struct FxPresentEnv<'w, 's> { +struct FxPresentEnv<'w> { outdoor: Option>, dlights: ResMut<'w, HostFxDlights>, post_lights: ResMut<'w, HostFxPostLights>, models: Option>, + model_geometry: Option>, atlas: Option>, atpoint: Res<'w, render_scene::DynAtPointLookup>, lod_skinned: Res<'w, LodRampSkinnedDvar>, - /// This frame's fx model rebuild, before the plan publishes it. - staged_models: Local<'s, FxModelDrawPlan>, + staged_models: ResMut<'w, render_fx::FxModelStaging>, } #[allow(clippy::too_many_arguments)] @@ -553,9 +630,12 @@ fn commit_fx_transaction( _present_span, } = transaction; clear_fx_owned_plans(&mut plan, &mut spark_plan, &mut mark_plan); - // The fx model rows are rebuilt from nothing every frame, so the rebuild - // goes to staging and `model_plan` only moves when the rows differ. - env.staged_models.clear(); + env.staged_models.0.clear(); + if let Some(prepared) = &env.model_geometry { + env.staged_models.0.use_prepared(&prepared.0); + } else { + env.staged_models.0 = FxModelDrawPlan::default(); + } env.dlights.scene.clear(); env.dlights.cap_full = 0; env.post_lights.queued.clear(); @@ -604,12 +684,11 @@ fn commit_fx_transaction( cam_tf.translation, planes, env.lod_skinned.args(), - &runtime, lighting_cache.as_deref(), &mut lighting_requests, - &mut env.staged_models, + &mut env.staged_models.0, ); - model_plan.publish_rebuild(&mut env.staged_models); + model_plan.publish_rebuild(&mut env.staged_models.0); let spot_cone = lighting_iw4::SpotLightConeDvars::register_defaults(); for light in &out.spot_lights { match lighting_iw4::r_add_omni_light_to_scene_allows( @@ -1086,7 +1165,6 @@ fn fill_fx_model_plan( camera_origin: Vec3, frustum_planes: &[[f32; 4]], lod_ramp: crate::assemble::drawsurf::tess::smodel::LodRampArgs, - runtime: &MaterialGeneration, cache: Option<&WorldModelLightingCache>, lighting_requests: &mut ModelLightingRequests, plan: &mut FxModelDrawPlan, @@ -1105,7 +1183,7 @@ fn fill_fx_model_plan( dims: world.model_lighting_dims?, }) }); - let Some(atlas) = atlas.or(scene_atlas.as_ref()) else { + let Some(_) = atlas.or(scene_atlas.as_ref()) else { plan.skipped_no_lighting = plan.generated; return; }; @@ -1147,71 +1225,11 @@ fn fill_fx_model_plan( plan.skipped_culled = plan.skipped_culled.saturating_add(1); continue; } - let asset_surfaces = if let Some(asset) = plan.asset(instance.model_index, lod) { - asset.surfaces.clone() - } else { - let Some(pose) = entry.skel.pose.as_ref() else { - plan.skipped_no_pose = plan.skipped_no_pose.saturating_add(1); - continue; - }; - let Ok(dobj) = assets::DObj::build(&[(pose, None)]) else { - plan.skipped_no_pose = plan.skipped_no_pose.saturating_add(1); - continue; - }; - let state = assets::dobj::DObjSemanticState::bind_pose( - models - .name_at(instance.model_index) - .unwrap_or(instance.def_name.as_str()) - .to_owned(), - 1, - 1, - ); - let Ok(request) = state.resolve_request(|_| None) else { - plan.skipped_no_pose = plan.skipped_no_pose.saturating_add(1); - continue; - }; - let Some((surfaces, _)) = - crate::adapters::anim::script_model::pose_script_dobj_with_materials( - None, - &[entry.skel.as_ref()], - &dobj, - &request, - None, - &[], - ) - else { - plan.skipped_no_pose = plan.skipped_no_pose.saturating_add(1); - continue; - }; - let lod_surfaces = entry.skel.surfaces_for_lod(lod); - let surfaces: Vec<_> = surfaces - .into_iter() - .filter(|surface| lod_surfaces.contains(&surface.surface_index)) - .collect(); - let materials: Vec<_> = surfaces - .iter() - .map(|surface| { - let material = entry.material_index(surface.surface_index)?; - crate::assemble::drawsurf::tess::xmodel::bound_lit_xmodel_pass_material( - atlas, - &runtime.catalog, - material, - ) - }) - .collect(); - let appended = crate::assemble::drawsurf::tess::xmodel::append_fx_model_asset( - plan, - instance.model_index, - lod, - &surfaces, - &materials, - ); - if appended.is_empty() { - plan.skipped_no_material = plan.skipped_no_material.saturating_add(1); - continue; - } - appended + let Some(asset) = plan.asset(instance.model_index, lod) else { + plan.skipped_no_material = plan.skipped_no_material.saturating_add(1); + continue; }; + let asset_surfaces = asset.surfaces.clone(); let box_half = entry.skel.radius.and_then(|radius| { crate::prepare::scene::model_lighting_cache::dobj_lighting_box_half( diff --git a/crates/render_frontend/src/adapters/mod.rs b/crates/render_frontend/src/adapters/mod.rs index 75787ed..fd71c69 100644 --- a/crates/render_frontend/src/adapters/mod.rs +++ b/crates/render_frontend/src/adapters/mod.rs @@ -2,12 +2,14 @@ use bevy::prelude::*; pub mod anim; pub mod fx; +pub(crate) mod motion_tracker; pub struct RenderAdaptersPlugin; impl Plugin for RenderAdaptersPlugin { fn build(&self, app: &mut App) { crate::adapters::fx::system::register_combat_fx_systems(app); + motion_tracker::register(app); crate::adapters::anim::dyn_ent::register_dyn_ent_frontend(app); crate::adapters::anim::dyn_ent_brush::register_dyn_ent_brush_systems(app); } diff --git a/crates/render_frontend/src/adapters/motion_tracker.rs b/crates/render_frontend/src/adapters/motion_tracker.rs new file mode 100644 index 0000000..544f803 --- /dev/null +++ b/crates/render_frontend/src/adapters/motion_tracker.rs @@ -0,0 +1,337 @@ +use std::collections::HashMap; + +use bevy::prelude::*; +use net::{CgFrameClock, LocalPresentClient, PresentedSnapshot}; +use render_anim::{FpvBoltTargets, PreparedFpv}; +use render_fx::{FxCodeMeshPlan, FxWorldColorImages}; + +pub(crate) const MATERIALS: [&str; 4] = [ + "motiontracker3d_bg", + "motiontracker3d_sweep", + "motiontracker3d_ping_enemy_mp", + "motiontracker3d_ping_friendly_mp", +]; + +const RANGE: f32 = 1600.0; +const SWEEP_MS: i32 = 3000; +const SWEEP_SPEED: f32 = 2000.0; +const FADE_MS: i32 = 3000; +const CENTER_Y: f32 = -0.37; +const PING_SIZE: f32 = 0.2; + +#[derive(Clone, Copy)] +struct Ping { + distance: f32, + yaw: f32, + start_angle: f32, + end_angle: f32, + time: i32, + enemy: bool, +} + +#[derive(Resource, Default)] +pub(crate) struct MotionTracker { + owner: Option<(u64, sim::ClientId, sim::LifeSequence)>, + last_time: Option, + previous_origin: Vec2, + phase_ms: i32, + active: bool, + ping_played: bool, + changed_at: i32, + contacts: HashMap; 2]>, +} + +pub(crate) fn register(app: &mut App) { + app.init_resource::(); +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn draw_motion_tracker( + mut tracker: ResMut, + presented: Res, + local: Res, + clock: Res, + mut sound: MessageWriter, + prepared: Res, + bolts: Res, + colors: Res, + mut plan: ResMut, +) { + let Some(snapshot) = presented.snapshot() else { + *tracker = MotionTracker::default(); + return; + }; + let Some(ps) = presented.player(local.0) else { + *tracker = MotionTracker::default(); + return; + }; + let Some(meta) = snapshot + .meta + .clients + .iter() + .find(|(id, _)| *id == local.0) + .map(|(_, m)| m) + else { + return; + }; + let Some(table) = prepared.table() else { + *tracker = MotionTracker::default(); + return; + }; + let now = clock.time(); + let owner = (table.catalog_id(), local.0, meta.life_sequence); + if tracker.owner != Some(owner) || tracker.last_time.is_some_and(|t| now < t) { + *tracker = MotionTracker { + owner: Some(owner), + ..Default::default() + }; + } + let weapon = weapon_iw4::bg_get_viewmodel_weapon_index(ps); + let active = table.facts_of(weapon).is_some_and(|f| { + f.motion_tracker + || (f.inventory_type == 3 + && table + .facts_of(ps.weapon_primary) + .is_some_and(|parent| parent.motion_tracker)) + }) && ps.other_flags & (1 << 10) == 0 + && meta.lifecycle == sim::ClientLifecycle::Alive; + let origin = Vec2::new(ps.origin[0], ps.origin[1]); + let dt = tracker.last_time.map_or(0, |t| now.saturating_sub(t)); + tracker.last_time = Some(now); + if active != tracker.active { + tracker.active = active; + tracker.changed_at = now; + if active { + tracker.phase_ms = 0; + tracker.ping_played = false; + tracker.previous_origin = origin; + } + } + let mut play = |alias: &str, pitch: f32| { + sound.write(audio::AliasCommand::PlayPitched { + sound: audio::PlayAlias { + namespace: assets::AssetNamespace::Iw4, + alias: alias.to_owned(), + fallback: None, + origin_inches: None, + snd_ent: Some(audio::SND_ENT_LOCAL), + }, + pitch, + }); + }; + if dt > 0 && (active || now - tracker.changed_at < 500) { + let view_yaw = ps.viewangles[1].to_radians(); + for history in tracker.contacts.values_mut() { + if let Some(ping) = &mut history[0] { + let current = (ping.yaw - view_yaw).rem_euclid(std::f32::consts::TAU); + let span = (ping.end_angle - ping.start_angle).rem_euclid(std::f32::consts::TAU); + if span <= (current - ping.start_angle).rem_euclid(std::f32::consts::TAU) { + let backwards = (ping.start_angle - current).rem_euclid(std::f32::consts::TAU); + let forwards = (current - ping.end_angle).rem_euclid(std::f32::consts::TAU); + if forwards <= backwards { + ping.end_angle += forwards; + } else { + ping.start_angle -= backwards; + } + } + } + } + let previous = tracker.phase_ms; + let elapsed = previous.saturating_add(dt); + tracker.phase_ms = elapsed.rem_euclid(SWEEP_MS); + if elapsed >= SWEEP_MS { + tracker.ping_played = false; + if active { + play("motiontracker_ping", 1.0); + } else { + tracker.phase_ms = 0; + } + } + let radius = tracker.phase_ms as f32 * SWEEP_SPEED / 1000.0; + let previous_radius = previous as f32 * SWEEP_SPEED / 1000.0; + let yaw = ps.viewangles[1].to_radians(); + let forward = Vec2::new(yaw.cos(), yaw.sin()); + for (id, state) in snapshot + .players + .iter() + .filter(|_| active || elapsed < SWEEP_MS) + { + if now.saturating_sub(sim::level_time_ms(snapshot.tick)) > 500 + || *id == local.0 + || state.health <= 0 + || state.perks[0] & (1 << 28) != 0 + { + continue; + } + let Some(actor) = snapshot + .meta + .clients + .iter() + .find(|(c, _)| c == id) + .map(|(_, m)| m) + else { + continue; + }; + if actor.lifecycle != sim::ClientLifecycle::Alive || actor.client_state_team == 3 { + continue; + } + let position = Vec2::new(state.origin[0], state.origin[1]); + let delta = position - origin; + if delta.length_squared() > radius * radius + || (elapsed < SWEEP_MS + && (position - tracker.previous_origin).length_squared() + <= previous_radius * previous_radius) + || delta.dot(forward) < 0.0 + { + continue; + } + let ping = Ping { + distance: delta.length(), + yaw: delta.y.atan2(delta.x), + start_angle: (delta.y.atan2(delta.x) - yaw).rem_euclid(std::f32::consts::TAU), + end_angle: (delta.y.atan2(delta.x) - yaw).rem_euclid(std::f32::consts::TAU), + time: now, + enemy: !matches!(meta.client_state_team, 1 | 2) + || meta.client_state_team != actor.client_state_team, + }; + if ping.enemy && !tracker.ping_played { + if let Some([tl, bl, br]) = bolts.tracker_screen { + let aspect = (br - bl).length() / (tl - bl).length(); + let angle = ping.yaw - yaw; + let x = -angle.sin() * ping.distance / RANGE / aspect; + let y = angle.cos() * ping.distance / RANGE + CENTER_Y; + if x.abs() <= 0.5 && y.abs() <= 0.5 { + let max_distance = RANGE * Vec2::new(0.5 * aspect, 0.5 - CENTER_Y).length(); + play("motiontracker_pong", 2.0 - ping.distance / max_distance); + tracker.ping_played = true; + } + } + } + let history = tracker.contacts.entry(*id).or_insert([None, None]); + history[1] = history[0]; + history[0] = Some(ping); + } + tracker.previous_origin = origin; + } + tracker + .contacts + .retain(|_, history| history.iter().flatten().any(|p| now - p.time < FADE_MS)); + let fade = ((now - tracker.changed_at) as f32 / 500.0).clamp(0.0, 1.0); + let alpha = if active { fade } else { 1.0 - fade }; + if alpha <= 0.0 { + return; + } + let Some([tl, bl, br]) = bolts.tracker_screen else { + return; + }; + let right = br - bl; + let up = tl - bl; + let width = right.length(); + let height = up.length(); + if width <= 0.0 || height <= 0.0 { + return; + } + let right = right / width; + let up = up / height; + let normal = right.cross(up).normalize_or_zero(); + let rotation = Quat::from_mat3(&Mat3::from_cols(right, up, normal)); + let center = (tl + br) * 0.5; + let mut quad = |material: usize, offset: Vec2, size: Vec2, opacity: f32| { + let Some(binding) = &colors.motion_tracker[material] else { + return; + }; + let slot = plan.begin_material_draw( + binding.color.clone(), + binding.sort_key, + binding.material_sorted_index, + ); + plan.draws[slot as usize].viewmodel = true; + plan.push_quad( + Transform { + translation: center + right * offset.x + up * offset.y, + rotation, + scale: Vec3::new(size.x, size.y, 1.0), + }, + [255, 255, 255, (opacity.clamp(0.0, 1.0) * 255.0) as u8], + fx_iw4::FxSpriteAtlasUv::FULL, + ); + plan.end_material_draw(slot); + }; + quad(0, Vec2::ZERO, Vec2::new(width, height), alpha); + let visible = tracker + .contacts + .values() + .flatten() + .flatten() + .filter(|ping| (0..FADE_MS).contains(&(now - ping.time))) + .count(); + let max_points = if visible == 0 { + 32 + } else { + (634 / visible).min(32).max(1) + }; + for history in tracker.contacts.values() { + for ping in history.iter().flatten() { + let age = now - ping.time; + if !(0..FADE_MS).contains(&age) { + continue; + } + let span = (ping.end_angle - ping.start_angle).rem_euclid(std::f32::consts::TAU); + let length = ping.distance / RANGE * span; + let mut points = (length / (PING_SIZE * 0.25) + 0.5).floor().max(1.0) as usize; + if length > 0.0 { + points = points.max(3); + } + points = points.min(max_points); + let edge = if length < 0.15 { length * 2.0 } else { 0.3 }; + let opacity = + (alpha * (1.0 - age as f32 / FADE_MS as f32) * (age as f32 / 100.0).min(1.0) + / points as f32 + / (1.0 - edge)) + .min(1.0); + for point in 0..points { + let fraction = (point as f32 + 0.5) / points as f32; + let angle = ping.start_angle + fraction * span; + let position = Vec2::new( + -angle.sin() * ping.distance / RANGE / (width / height), + angle.cos() * ping.distance / RANGE + CENTER_Y, + ); + if position.x.abs() > 0.5 + PING_SIZE || position.y.abs() > 0.5 + PING_SIZE { + continue; + } + let taper = if fraction < edge { + fraction / edge + } else if fraction > 1.0 - edge { + (1.0 - fraction) / edge + } else { + 1.0 + }; + quad( + if ping.enemy { 2 } else { 3 }, + position * Vec2::new(width, height), + Vec2::splat(PING_SIZE * height), + opacity * taper, + ); + } + } + } + let phase = tracker.phase_ms as f32 / SWEEP_MS as f32; + if phase < 0.25 { + let prior = SWEEP_SPEED * (phase + 1.0) * SWEEP_MS as f32 / 1000.0 / RANGE; + quad( + 1, + Vec2::new(0.0, (CENTER_Y + 0.5 * prior) * height), + Vec2::new(2.0 * prior * height, prior * height), + alpha * (1.0 - 4.0 * phase), + ); + } + let sweep_range = SWEEP_SPEED * phase * SWEEP_MS as f32 / 1000.0 / RANGE; + quad( + 1, + Vec2::new(0.0, (CENTER_Y + 0.5 * sweep_range) * height), + Vec2::new(2.0 * sweep_range * height, sweep_range * height), + alpha, + ); + plan.bump(); + plan.publish_share(); +} diff --git a/crates/render_frontend/src/assemble/drawsurf/depth_range.rs b/crates/render_frontend/src/assemble/drawsurf/depth_range.rs index 763d15d..4df67ff 100644 --- a/crates/render_frontend/src/assemble/drawsurf/depth_range.rs +++ b/crates/render_frontend/src/assemble/drawsurf/depth_range.rs @@ -40,6 +40,9 @@ pub fn depth_range_type_for_draw(kind: &RetainedDrawKind, key: u64) -> i32 { let object_id = dpvs_iw4::GfxDrawSurf { packed: key }.object_id(); depth_range_type_from_hack_flags(host_viewmodel_render_fx_flags(object_id)) } + RetainedDrawKind::CodeMesh { + viewmodel: true, .. + } => GFX_DEPTH_RANGE_VIEWMODEL, _ => GFX_DEPTH_RANGE_SCENE, } } diff --git a/crates/render_frontend/src/assemble/drawsurf/frame_products.rs b/crates/render_frontend/src/assemble/drawsurf/frame_products.rs index a375eee..3ce5b45 100644 --- a/crates/render_frontend/src/assemble/drawsurf/frame_products.rs +++ b/crates/render_frontend/src/assemble/drawsurf/frame_products.rs @@ -483,8 +483,11 @@ fn logical_input_key(draw: &RetainedDrawItem, tech: TechType) -> LogicalInputKey surface, object_id, .. } => (2, surface, u32::from(object_id)), RetainedDrawKind::CodeMesh { - draw, arg_count, .. - } => (3, draw, u32::from(arg_count)), + draw, + arg_count, + viewmodel, + .. + } => (3, draw, u32::from(arg_count) | (u32::from(viewmodel) << 8)), RetainedDrawKind::ParticleCloud { draw, .. } => (4, draw, 0), RetainedDrawKind::MarkMesh { draw, .. } => (5, draw, 0), RetainedDrawKind::Glass { diff --git a/crates/render_frontend/src/assemble/drawsurf/material_compile.rs b/crates/render_frontend/src/assemble/drawsurf/material_compile.rs index 755f872..76909a7 100644 --- a/crates/render_frontend/src/assemble/drawsurf/material_compile.rs +++ b/crates/render_frontend/src/assemble/drawsurf/material_compile.rs @@ -491,6 +491,11 @@ impl MaterialProgramCompile { techs.extend(lighting_iw4::LIT_TECH_NO_SHADOW_LOCAL_SLOTS); techs.extend(lighting_iw4::LIT_TECH_SHADOW_DIR_SLOTS); techs.extend(lighting_iw4::LIT_TECH_SHADOW_SPOT_SLOTS); + techs.extend([ + lighting_iw4::TECHNIQUE_LIGHT_SPOT, + lighting_iw4::TECHNIQUE_LIGHT_OMNI, + lighting_iw4::TECHNIQUE_LIGHT_SPOT_SHADOW, + ]); techs.push(SUN_SHADOW_CASTER_TECH); techs.push(EMISSIVE_TECH_TYPE); let reachable: HashSet = catalog diff --git a/crates/render_frontend/src/assemble/drawsurf/retained_list.rs b/crates/render_frontend/src/assemble/drawsurf/retained_list.rs index 29264f4..bd60297 100644 --- a/crates/render_frontend/src/assemble/drawsurf/retained_list.rs +++ b/crates/render_frontend/src/assemble/drawsurf/retained_list.rs @@ -411,8 +411,16 @@ pub(crate) fn mix_draw_membership(id: &mut u64, item: &RetainedDrawItem) { surface, object_id, .. } => (2, u64::from(surface), u64::from(object_id), 0), RetainedDrawKind::CodeMesh { - draw, arg_count, .. - } => (3, u64::from(draw), u64::from(arg_count), 0), + draw, + arg_count, + viewmodel, + .. + } => ( + 3, + u64::from(draw), + u64::from(arg_count), + u64::from(viewmodel), + ), RetainedDrawKind::ParticleCloud { draw, .. } => (4, u64::from(draw), 0, 0), RetainedDrawKind::MarkMesh { draw, .. } => (5, u64::from(draw), 0, 0), RetainedDrawKind::Glass { @@ -477,6 +485,7 @@ fn fx_lane_layout_hash( } super::list::mix_content_id(&mut id, i as u64); super::list::mix_content_id(&mut id, u64::from(draw.material)); + super::list::mix_content_id(&mut id, u64::from(draw.viewmodel)); let mat = plan.materials.get(draw.material as usize); super::list::mix_content_id(&mut id, u64::from(mat.map(|m| m.sort_key).unwrap_or(0))); super::list::mix_content_id( @@ -1969,6 +1978,7 @@ pub(crate) fn rebuild_fx_draw_lane( key, material_sorted_index, RetainedDrawKind::CodeMesh { + viewmodel: draw.viewmodel, draw: i as u32, material: draw.material, arg_count: n as u8, diff --git a/crates/render_frontend/src/assemble/drawsurf/state.rs b/crates/render_frontend/src/assemble/drawsurf/state.rs index c597a73..e97c7cb 100644 --- a/crates/render_frontend/src/assemble/drawsurf/state.rs +++ b/crates/render_frontend/src/assemble/drawsurf/state.rs @@ -182,6 +182,7 @@ impl ChangeState0Host { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ChangeState1Host { + pub stencil: u32, pub depth_write: bool, pub depth_test_enable: bool, @@ -303,13 +304,14 @@ impl GfxPassState { || !colour.dst.is_known() || (alpha_blend_op != 0 && (!alpha.src.is_known() || !alpha.dst.is_known()))), unknown_blend_operation: blend_op > 5 || (blend_op != 0 && alpha_blend_op > 5), - stencil: self.word1 & 0xc0 != 0, + stencil: false, }; fields.any().then_some(fields) } pub fn apply_change_state_1_host(self) -> ChangeState1Host { ChangeState1Host { + stencil: self.word1 & 0xffff_ffc0, depth_write: assets::depth_write_enable(self.word1), depth_test_enable: assets::depth_test_enable(self.word1), depth_func: ((self.word1 >> 2) & 3) as u8, diff --git a/crates/render_frontend/src/prepare/mod.rs b/crates/render_frontend/src/prepare/mod.rs index 6bd0f5d..e6be868 100644 --- a/crates/render_frontend/src/prepare/mod.rs +++ b/crates/render_frontend/src/prepare/mod.rs @@ -92,6 +92,8 @@ impl Plugin for RenderPreparePlugin { arm_world_spawn_on_install.before(spawn_world), spawn_world, spawn_world_finish.after(spawn_world), + render_anim::prepare_fpv_compositions.after(spawn_world_finish), + render_anim::prepare_model_materials.after(spawn_world_finish), crate::assemble::drawsurf::tess::glass::apply_cg_glass_tess .after(spawn_world) .after(WorkerCmdSet::FxNonDependent), diff --git a/crates/render_frontend/src/prepare/scene/spawn.rs b/crates/render_frontend/src/prepare/scene/spawn.rs index 2e6c76a..501d81a 100644 --- a/crates/render_frontend/src/prepare/scene/spawn.rs +++ b/crates/render_frontend/src/prepare/scene/spawn.rs @@ -182,7 +182,13 @@ pub(crate) fn spawn_world( fx_catalog: Option>, report: Option>, present_ack: Res, + prepared: ( + Res, + Res, + Option>, + ), ) { + let (fpv, model_materials, fx_models) = prepared; // Pacing belongs to the load that is still running, not to the screen that // happens to be drawing it: a run without an overlay must spawn the world // the same way this one does. @@ -199,7 +205,15 @@ pub(crate) fn spawn_world( let gap_ms = job.slice_gap_ms(std::time::Instant::now()); let spawn = job.spawn; let gpu_ready = gpu.as_deref(); - if super::world_gpu::poll(&mut job.gpu_wait, spawn, gpu_ready, gap_ms) { + let gpu_done = super::world_gpu::poll(&mut job.gpu_wait, spawn, gpu_ready, gap_ms); + let fpv_done = fpv.settled_for(&tess.catalog) && model_materials.settled_for(&tess.catalog); + if gpu_done && !fpv_done { + diag::info!( + World, + "world spawn: GPU ready; overlay holds for model and first-person preparation" + ); + } + if gpu_done && fpv_done { let quiet = job.gpu_wait.quiet(); let elapsed_ms = job.gpu_wait.elapsed().as_secs_f32() * 1000.0; finish_world_spawn(&mut scene, &mut job, &mut commands); @@ -211,6 +225,7 @@ pub(crate) fn spawn_world( gpu_ready.map(|g| i32::from(g.pipelines)).unwrap_or(-1), elapsed_ms, ); + log_load_ledger(&job, gpu_ready, &fpv, elapsed_ms); } return; } @@ -675,6 +690,10 @@ pub(crate) fn spawn_world( tracers.as_deref(), fx_catalog.as_deref(), ); + let fx_geometry = + super::world_plan::prepare_fx_model_geometry(&scene, fx_models.as_deref()); + commands.insert_resource(fx_geometry.0.clone()); + commands.insert_resource(fx_geometry); tess.material_images = std::sync::Arc::new(job.images.exact_handles().to_vec()); if let Some(host) = fx_host.as_mut() { match scene.fx_glass.as_ref() { @@ -794,6 +813,37 @@ impl WorldPresentAck { #[derive(Resource, Default)] struct ExtractedWorldPresent(Option); +fn log_load_ledger( + job: &WorldSpawnJob, + gpu: Option<&WorldGpuReady>, + fpv: &render_anim::PreparedFpv, + gpu_wait_ms: f32, +) { + let images = &job.images; + let pipelines_added = gpu.and_then(|gpu| job.gpu_wait.pipelines_added(gpu.pipeline_n)); + let fpv_census = fpv.table().map(|table| table.census()); + diag::info!( + World, + "load ledger: images handed={} handed_bytes={} reused_slot={} reused_slot_bytes={} reused_common={} reused_common_bytes={} resident_map={} | pipelines cached={} compiled_this_load={} | fpv models={} rigs={} materials={} built_ms={} | gpu_wait_ms={gpu_wait_ms:.1}", + images + .done + .saturating_sub(images.skipped) + .saturating_sub(images.reused_handles), + images.handed_bytes, + images.reused_handles, + images.reused_handle_bytes, + images.reused_common_handles, + images.reused_common_bytes, + images.resident, + gpu.map_or_else(|| "?".to_owned(), |gpu| gpu.pipeline_n.to_string()), + pipelines_added.map_or_else(|| "?".to_owned(), |n| n.to_string()), + fpv_census.map_or_else(|| "?".to_owned(), |c| c.models.to_string()), + fpv_census.map_or_else(|| "?".to_owned(), |c| c.rigs.to_string()), + fpv_census.map_or_else(|| "?".to_owned(), |c| c.materials.to_string()), + fpv_census.map_or_else(|| "?".to_owned(), |c| format!("{:.1}", c.elapsed_ms)), + ); +} + fn finish_world_spawn(scene: &mut WorldScene, job: &mut WorldSpawnJob, commands: &mut Commands) { job.phase = WorldSpawnPhase::Done; scene.spawned = true; diff --git a/crates/render_frontend/src/prepare/scene/world_gpu.rs b/crates/render_frontend/src/prepare/scene/world_gpu.rs index 45ceba9..51eba5a 100644 --- a/crates/render_frontend/src/prepare/scene/world_gpu.rs +++ b/crates/render_frontend/src/prepare/scene/world_gpu.rs @@ -15,6 +15,7 @@ pub(crate) fn overlay_is_quiet(ready: bool, quiet: u32) -> bool { pub struct WorldGpuWait { started: Option, quiet: u32, + pipelines_at_arm: Option, stage: Option, images_stage: Option, pipelines_stage: Option, @@ -24,6 +25,7 @@ impl WorldGpuWait { pub fn arm(&mut self, progress: Option<&assets::LoadProgress>) { self.started = Some(std::time::Instant::now()); self.quiet = 0; + self.pipelines_at_arm = None; // A wait that never reached its gate before the world changed under it // was interrupted; it did not quietly succeed. for stage in [ @@ -50,6 +52,10 @@ impl WorldGpuWait { self.quiet } + pub(crate) fn pipelines_added(&self, now: u32) -> Option { + self.pipelines_at_arm.map(|at| now.saturating_sub(at)) + } + pub(crate) fn elapsed(&self) -> std::time::Duration { self.started .map(|at| at.elapsed()) @@ -215,6 +221,9 @@ pub(crate) fn poll( gap_ms: f32, ) -> bool { if let Some(gpu) = gpu { + if wait.pipelines_at_arm.is_none() { + wait.pipelines_at_arm = Some(gpu.pipeline_n); + } // Residency and warmth are gauges: what is on the GPU right now. They // may fall between frames, and it is the existing gate — `gpu.images`, // `gpu.pipelines` — that says the wait is over, never count equality. diff --git a/crates/render_frontend/src/prepare/scene/world_plan.rs b/crates/render_frontend/src/prepare/scene/world_plan.rs index c71f381..fd4447c 100644 --- a/crates/render_frontend/src/prepare/scene/world_plan.rs +++ b/crates/render_frontend/src/prepare/scene/world_plan.rs @@ -123,6 +123,18 @@ pub(crate) fn fx_world_color_images( } } + for (asset_id, material) in scene.runtime_material_catalog.materials.iter().enumerate() { + if crate::adapters::motion_tracker::MATERIALS.contains(&material.name.as_str()) { + stitch_fx_color_by_asset( + &mut colors_by_asset, + &mut keys_by_asset, + exact_material_handles, + scene, + asset_id, + ); + } + } + for (asset_id, handle) in colors_by_asset.iter().chain(mark_colors_by_asset.iter()) { let Some(material) = scene .runtime_material_catalog @@ -189,7 +201,23 @@ pub(crate) fn fx_world_color_images( ); } } + let motion_tracker = crate::adapters::motion_tracker::MATERIALS.map(|name| { + let catalog = &scene.runtime_material_catalog; + let ordinal = catalog.ordinal_for_material_name(name)?; + let material = catalog + .materials + .iter() + .find(|material| material.name == name)?; + Some(render_fx::drawsurf::tess::fx::FxPassMaterial { + color: fx_color_by_name.get(name).cloned(), + sort_key: material + .baked_draw_surf + .map(|key| dpvs_iw4::GfxDrawSurf { packed: key }.primary_sort_key())?, + material_sorted_index: Some(ordinal.get()), + }) + }); FxWorldColorImages { + motion_tracker, colors: fx_color_by_name, keys: fx_keys, colors_by_asset, @@ -199,6 +227,98 @@ pub(crate) fn fx_world_color_images( } } +pub(crate) fn prepare_fx_model_geometry( + scene: &WorldScene, + models: Option<&render_fx::PreparedFxModels>, +) -> render_fx::PreparedFxModelGeometry { + let mut plan = render_fx::FxModelDrawPlan::default(); + plan.generation = scene.runtime_material_catalog.generation_id.0; + let Some(models) = models else { + return render_fx::PreparedFxModelGeometry(plan); + }; + let Some((image, dims)) = scene + .model_lighting_image + .clone() + .zip(scene.model_lighting_dims) + else { + return render_fx::PreparedFxModelGeometry(plan); + }; + let atlas = super::model_lighting_atlas::WorldModelLightingAtlas { image, dims }; + let models = &models.0; + for index in 0..models.len() { + let Some(entry) = models.get_at(index) else { + continue; + }; + let name = models.name_at(index).unwrap_or_default(); + let binding = (|| { + let pose = entry.skel.pose.as_ref()?; + let dobj = assets::DObj::build(&[(pose, None)]).ok()?; + let state = assets::dobj::DObjSemanticState::bind_pose(name.to_owned(), 1, 1); + let request = state.resolve_request(|_| None).ok()?; + let world = assets::dobj::pose_dobj(&dobj, &request, Mat4::IDENTITY).ok()?; + let skin = dobj.skin_matrices(&world); + Some((dobj, request, skin)) + })(); + let Some((dobj, request, skin)) = binding else { + diag::warn!(World, "FX model `{name}` refused: bind pose unavailable"); + continue; + }; + for lod in 0..entry + .skel + .lod + .map_or(1, |selector| selector.num_lods().clamp(0, 4) as u8) + { + let Some(lod_surfaces) = render_anim::anim::xmodel_pose::skin_model_filtered( + &entry.skel, + |bone| skin[bone], + &[], + |surface| { + dobj.surface_visible( + 0, + &entry.skel.surface_part_bits[surface], + &request.hide_part_bits, + ) + }, + |surface| { + render_anim::anim::xmodel_pose::stream_lod_surface_rigid( + &entry.skel, + lod, + &[], + 0, + surface, + ) + }, + lod, + ) else { + diag::warn!( + World, + "FX model `{name}` LOD {lod} refused: geometry unavailable" + ); + continue; + }; + let materials: Vec<_> = lod_surfaces + .iter() + .map(|surface| { + crate::assemble::drawsurf::tess::xmodel::bound_lit_xmodel_pass_material( + &atlas, + &scene.runtime_material_catalog, + entry.material_index(surface.surface_index)?, + ) + }) + .collect(); + if materials.iter().any(Option::is_none) { + diag::warn!( + World, + "FX model `{name}` LOD {lod} refused: material binding unavailable" + ); + continue; + } + render_fx::append_fx_model_asset(&mut plan, index, lod, &lod_surfaces, &materials); + } + } + render_fx::PreparedFxModelGeometry(plan) +} + pub fn install( commands: &mut Commands, scene: &WorldScene, diff --git a/crates/render_fx/src/drawsurf/tess/fx.rs b/crates/render_fx/src/drawsurf/tess/fx.rs index 1286951..8498e50 100644 --- a/crates/render_fx/src/drawsurf/tess/fx.rs +++ b/crates/render_fx/src/drawsurf/tess/fx.rs @@ -25,6 +25,7 @@ pub struct FxPassMaterial { #[derive(Clone, Copy, Debug)] pub struct FxSurfaceDraw { + pub viewmodel: bool, pub material: u32, pub index_start: u32, pub index_count: u32, @@ -220,6 +221,7 @@ impl FxCodeMeshPlan { }); let index_start = self.indices.len() as u32; self.draws.push(FxSurfaceDraw { + viewmodel: false, material, index_start, index_count: 0, diff --git a/crates/render_fx/src/host.rs b/crates/render_fx/src/host.rs index d3204f7..3f39410 100644 --- a/crates/render_fx/src/host.rs +++ b/crates/render_fx/src/host.rs @@ -21,6 +21,7 @@ pub struct PreparedImpactFx(pub Option); #[derive(Resource, Default)] pub struct FxWorldColorImages { + pub motion_tracker: [Option; 4], pub colors: HashMap>, pub keys: HashMap, pub colors_by_asset: HashMap>, diff --git a/crates/render_fx/src/lib.rs b/crates/render_fx/src/lib.rs index 5a80536..e66d0c1 100644 --- a/crates/render_fx/src/lib.rs +++ b/crates/render_fx/src/lib.rs @@ -31,3 +31,5 @@ pub use tracer::{ PreparedTracers, QueuedBeam, TracerDrawGate, TracerSpawnSkip, TracerWorld, tick_tracer_beams, try_spawn_tracer, }; + +pub use model_draw::{FxModelStaging, PreparedFxModelGeometry}; diff --git a/crates/render_fx/src/model_append.rs b/crates/render_fx/src/model_append.rs index 79c5610..9609530 100644 --- a/crates/render_fx/src/model_append.rs +++ b/crates/render_fx/src/model_append.rs @@ -102,15 +102,16 @@ pub fn append_fx_model_asset( surfaces: &[render_anim::fpv_pose::PosedModelSurface], materials: &[Option], ) -> Vec<(u32, u32)> { + let geometry = std::sync::Arc::make_mut(&mut plan.geometry); let mut asset_surfaces = Vec::new(); - let vertices_empty = plan.vertices.is_empty(); + let vertices_empty = geometry.vertices.is_empty(); let mut packed_ok = vertices_empty || matches!( - plan.packed_vertices, + geometry.packed_vertices, assets::RetailPackedVertexPayload::Iw4(_) ); let mut packed = match std::mem::replace( - &mut plan.packed_vertices, + &mut geometry.packed_vertices, assets::RetailPackedVertexPayload::Unavailable { source_layout: XMODEL_PACKED_UNAVAILABLE, }, @@ -120,40 +121,44 @@ pub fn append_fx_model_asset( }; for (surface, material) in surfaces.iter().zip(materials) { let Some(material) = material else { continue }; - let before = plan.vertices.len(); + let before = geometry.vertices.len(); let Some((start, count)) = - append_mesh(&surface.mesh, &mut plan.vertices, &mut plan.indices) + append_mesh(&surface.mesh, &mut geometry.vertices, &mut geometry.indices) else { continue; }; - let decoded = plan.vertices.len() - before; + let decoded = geometry.vertices.len() - before; if packed_ok && surface.packed_vertices.len() == decoded { packed.extend_from_slice(&surface.packed_vertices); } else { packed_ok = false; packed.clear(); } - let surface_index = plan.surface_ranges.len() as u32; - plan.surface_ranges.push((start, count)); - let material_index = plan.materials.len() as u32; - plan.materials.push(material.clone()); + let surface_index = geometry.surface_ranges.len() as u32; + geometry.surface_ranges.push((start, count)); + let material_index = geometry.materials.len() as u32; + geometry.materials.push(material.clone()); asset_surfaces.push((surface_index, material_index)); } - plan.packed_vertices = install_retained_packed( + geometry.packed_vertices = install_retained_packed( packed_ok, packed, - plan.vertices.len(), + geometry.vertices.len(), XMODEL_PACKED_EMPTY_PLAN, XMODEL_PACKED_UNAVAILABLE, ); - plan.assets.push(FxModelAssetDraw { + geometry.assets.push(FxModelAssetDraw { model_index, lod, surfaces: asset_surfaces.clone(), }); let topology = { let mut revisions = render_frame::SourceRevisions::default(); - revisions.set_topology_from(&plan.indices, &plan.surface_ranges, plan.vertices.len()); + revisions.set_topology_from( + &geometry.indices, + &geometry.surface_ranges, + geometry.vertices.len(), + ); revisions.topology }; let mut rev = plan.revision; diff --git a/crates/render_fx/src/model_draw.rs b/crates/render_fx/src/model_draw.rs index 54ccc9c..fd10894 100644 --- a/crates/render_fx/src/model_draw.rs +++ b/crates/render_fx/src/model_draw.rs @@ -1,6 +1,7 @@ use bevy::prelude::*; use render_frame::{SmodelVertex, SourceRevisions}; use render_scene::{SmodelPassMaterial, XModelSurfaceDraw}; +use std::sync::Arc; pub const XMODEL_OBJECT_ID_FX_BASE: u16 = 0x700; @@ -11,13 +12,25 @@ pub struct FxModelAssetDraw { pub surfaces: Vec<(u32, u32)>, } -#[derive(Resource, Clone, Debug, Default)] -pub struct FxModelDrawPlan { +#[derive(Clone, Debug, Default)] +pub(crate) struct FxModelGeometry { pub(crate) vertices: Vec, pub(crate) indices: Vec, pub(crate) surface_ranges: Vec<(u32, u32)>, pub(crate) materials: Vec, pub(crate) assets: Vec, + pub(crate) packed_vertices: assets::RetailPackedVertexPayload, +} + +#[derive(Resource, Default)] +pub struct FxModelStaging(pub FxModelDrawPlan); + +#[derive(Resource, Clone, Debug, Default)] +pub struct PreparedFxModelGeometry(pub FxModelDrawPlan); + +#[derive(Resource, Clone, Debug, Default)] +pub struct FxModelDrawPlan { + pub(crate) geometry: Arc, pub(crate) draws: Vec, pub generated: u32, pub skipped_no_catalog: u32, @@ -30,7 +43,6 @@ pub struct FxModelDrawPlan { pub revision: u64, pub generation: u64, pub revisions: SourceRevisions, - pub(crate) packed_vertices: assets::RetailPackedVertexPayload, } impl FxModelDrawPlan { @@ -38,42 +50,31 @@ impl FxModelDrawPlan { &self.draws } - /// Adds one row to a rebuild in progress. The revision does not move here: - /// it moves once, in `publish_rebuild`, and only if the finished rebuild - /// differs from what this plan already published. pub fn push_draw(&mut self, draw: XModelSurfaceDraw) { self.draws.push(draw); } pub fn materials(&self) -> &[SmodelPassMaterial] { - &self.materials + &self.geometry.materials } pub fn vertices(&self) -> &[SmodelVertex] { - &self.vertices + &self.geometry.vertices } pub fn indices(&self) -> &[u32] { - &self.indices + &self.geometry.indices } pub fn surface_ranges(&self) -> &[(u32, u32)] { - &self.surface_ranges + &self.geometry.surface_ranges } pub fn packed_vertices(&self) -> &assets::RetailPackedVertexPayload { - &self.packed_vertices + &self.geometry.packed_vertices } - /// Empties a staging plan for this frame's rebuild, keeping its allocations - /// and its revisions. This is not a publication: the revision moves when the - /// rebuild that follows changes something, never because a frame passed. pub fn clear(&mut self) { - self.vertices.clear(); - self.indices.clear(); - self.surface_ranges.clear(); - self.materials.clear(); - self.assets.clear(); self.draws.clear(); self.generated = 0; self.skipped_no_catalog = 0; @@ -83,35 +84,25 @@ impl FxModelDrawPlan { self.skipped_no_material = 0; self.skipped_no_lighting = 0; self.skipped_render_fx_flags = 0; - match &mut self.packed_vertices { - assets::RetailPackedVertexPayload::Iw4(rows) => rows.clear(), - unavailable @ assets::RetailPackedVertexPayload::Unavailable { .. } => { - *unavailable = assets::RetailPackedVertexPayload::default(); - } - } } - /// Takes this frame's rebuild and reports what actually moved. Counters are - /// this frame's either way; the revisions move only when the rows do. + pub fn use_prepared(&mut self, prepared: &Self) { + self.geometry = Arc::clone(&prepared.geometry); + self.generation = prepared.generation; + } + pub fn publish_rebuild(&mut self, staged: &mut Self) { - let mut geometry = render_frame::publish_rows(&mut self.indices, &mut staged.indices); - geometry |= - render_frame::publish_rows(&mut self.surface_ranges, &mut staged.surface_ranges); - geometry |= render_frame::publish_rows(&mut self.materials, &mut staged.materials); - geometry |= render_frame::publish_rows(&mut self.assets, &mut staged.assets); - geometry |= self.vertices.len() != staged.vertices.len(); + self.generation = staged.generation; + let geometry = !Arc::ptr_eq(&self.geometry, &staged.geometry); if geometry { - std::mem::swap(&mut self.vertices, &mut staged.vertices); - self.packed_vertices = - std::mem::replace(&mut staged.packed_vertices, Default::default()); + self.geometry = Arc::clone(&staged.geometry); self.revisions.set_topology_from( - &self.indices, - &self.surface_ranges, - self.vertices.len(), + &self.geometry.indices, + &self.geometry.surface_ranges, + self.geometry.vertices.len(), ); self.revisions.bump_packed_write(&mut self.revision); } - staged.vertices.clear(); if render_frame::publish_rows(&mut self.draws, &mut staged.draws) || geometry { self.revisions.bump_draws(); } @@ -126,7 +117,8 @@ impl FxModelDrawPlan { } pub fn asset(&self, model_index: usize, lod: u8) -> Option<&FxModelAssetDraw> { - self.assets + self.geometry + .assets .iter() .find(|asset| asset.model_index == model_index && asset.lod == lod) } diff --git a/crates/render_fx/src/system.rs b/crates/render_fx/src/system.rs index cad40f3..4bb92ba 100644 --- a/crates/render_fx/src/system.rs +++ b/crates/render_fx/src/system.rs @@ -83,6 +83,9 @@ fn kill_fx_on_match_torn_down( *entity_marks = EntityMarks::default(); commands.remove_resource::(); commands.remove_resource::(); + commands.remove_resource::(); + commands.insert_resource(crate::FxModelDrawPlan::default()); + commands.insert_resource(crate::FxModelStaging::default()); commands.remove_resource::(); commands.remove_resource::(); } diff --git a/crates/render_gpu/src/drawsurf/colour_submit/mod.rs b/crates/render_gpu/src/drawsurf/colour_submit/mod.rs index c1f5d5c..0465d4d 100644 --- a/crates/render_gpu/src/drawsurf/colour_submit/mod.rs +++ b/crates/render_gpu/src/drawsurf/colour_submit/mod.rs @@ -1,9 +1,10 @@ +use crate::drawsurf::scene_depth::{SCENE_DEPTH_FORMAT, SceneDepthTexture}; use std::collections::{BTreeMap, BTreeSet}; use std::num::NonZeroU64; use std::sync::Arc; use std::time::Instant; -use bevy::core_pipeline::core_3d::{CORE_3D_DEPTH_FORMAT, main_opaque_pass_3d}; +use bevy::core_pipeline::core_3d::main_opaque_pass_3d; use bevy::core_pipeline::upscaling::ViewUpscalingPipeline; use bevy::core_pipeline::{Core3d, Core3dSystems}; use bevy::mesh::VertexBufferLayout; @@ -25,7 +26,7 @@ use bevy::render::render_resource::{ VertexStepMode, }; use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue, ViewQuery}; -use bevy::render::view::{ExtractedView, Msaa, ViewDepthTexture, ViewTarget}; +use bevy::render::view::{ExtractedView, Msaa, ViewTarget}; use bevy::render::{Render, RenderSystems}; use super::ExtractedRenderFrameProducts; @@ -587,7 +588,7 @@ fn exact_pipeline_plan( format: key.depth_format, depth_write_enabled: Some(key.state1.depth_write), depth_compare, - stencil: Default::default(), + stencil: super::scene_depth::stencil_state(key.state1.stencil), bias: if key.forward_z { polygon_offset_bias_forward_z(key.state1.polyoffset_level) } else { @@ -3202,7 +3203,7 @@ fn submit_exact_draws<'a>( encoder: &mut CommandEncoder, device: &RenderDevice, target: &ViewTarget, - depth: &ViewDepthTexture, + depth: &SceneDepthTexture, extracted_view: &ExtractedView, geometry: &ExactColourGeometry, smodel_cache_gpu: &SmodelCacheGpu, @@ -3309,7 +3310,7 @@ fn submit_exact_draw_run<'a>( encoder: &mut CommandEncoder, device: &RenderDevice, attachment: RenderPassColorAttachment<'_>, - depth: &ViewDepthTexture, + depth: &SceneDepthTexture, extracted_view: &ExtractedView, geometry: &ExactColourGeometry, smodel_cache_gpu: &SmodelCacheGpu, @@ -4329,7 +4330,14 @@ fn record_shadowmap_draws<'a>( }, store: StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(Operations { + load: if cleared { + LoadOp::Clear(0) + } else { + LoadOp::Load + }, + store: StoreOp::Store, + }), }), timestamp_writes: None, occlusion_query_set: None, @@ -4460,7 +4468,10 @@ fn clear_empty_shadowmap_sun_partition_zero( load: LoadOp::Clear(super::backend::SHADOWMAP_CLEAR_Z), store: StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(Operations { + load: LoadOp::Clear(0), + store: StoreOp::Store, + }), }), timestamp_writes: None, occlusion_query_set: None, @@ -6870,6 +6881,7 @@ pub(super) fn register(app: &mut App) { prepare_camera::install_shared_colour_pass .in_set(RenderSystems::Prepare) .after(geometry::upload_exact_geometry) + .after(super::scene_depth::prepare_scene_depth) .after(pipeline::kick_extracted_colour_pipelines) .after(super::gpu_resources::prepare_uploaded_image_registry), prepare_camera::prepare_colour_lanes diff --git a/crates/render_gpu/src/drawsurf/colour_submit/pipeline.rs b/crates/render_gpu/src/drawsurf/colour_submit/pipeline.rs index 8879623..d5a89b3 100644 --- a/crates/render_gpu/src/drawsurf/colour_submit/pipeline.rs +++ b/crates/render_gpu/src/drawsurf/colour_submit/pipeline.rs @@ -256,7 +256,7 @@ fn schedule_admitted_pipelines( depth_format: if shadow { SHADOWMAP_SUN_DEPTH_FORMAT } else { - CORE_3D_DEPTH_FORMAT + SCENE_DEPTH_FORMAT }, samples: if shadow { 1 } else { samples }, state0, diff --git a/crates/render_gpu/src/drawsurf/colour_submit/prepare_camera.rs b/crates/render_gpu/src/drawsurf/colour_submit/prepare_camera.rs index 47abcd5..4716d7b 100644 --- a/crates/render_gpu/src/drawsurf/colour_submit/prepare_camera.rs +++ b/crates/render_gpu/src/drawsurf/colour_submit/prepare_camera.rs @@ -15,7 +15,7 @@ pub(super) fn prepare_colour_lanes( views: Query< ( &ViewTarget, - &ViewDepthTexture, + &SceneDepthTexture, &ExtractedView, Option<&Msaa>, ), @@ -408,7 +408,7 @@ pub(super) fn prepare_camera_colour(lane: CameraLane<'_>) { let prepare_target = ExactPrepareTarget { color: target.main_texture_format(), samples, - depth: CORE_3D_DEPTH_FORMAT, + depth: SCENE_DEPTH_FORMAT, forward_z: false, use_world_pretess: true, }; @@ -902,7 +902,7 @@ pub(super) fn install_shared_colour_pass( views: Query< ( &ViewTarget, - &ViewDepthTexture, + &SceneDepthTexture, &ExtractedView, Option<&Msaa>, ), @@ -1010,7 +1010,7 @@ pub(super) fn install_shared_colour_pass( type CameraTargetView<'a> = ( &'a ViewTarget, - &'a ViewDepthTexture, + &'a SceneDepthTexture, &'a ExtractedView, Option<&'a Msaa>, ); diff --git a/crates/render_gpu/src/drawsurf/colour_submit/record.rs b/crates/render_gpu/src/drawsurf/colour_submit/record.rs index 6090079..e2809ca 100644 --- a/crates/render_gpu/src/drawsurf/colour_submit/record.rs +++ b/crates/render_gpu/src/drawsurf/colour_submit/record.rs @@ -3,7 +3,7 @@ use super::*; pub(super) fn draw_exact_colour( view: ViewQuery<( &ViewTarget, - &ViewDepthTexture, + &SceneDepthTexture, &ExtractedView, Option<&Msaa>, )>, diff --git a/crates/render_gpu/src/drawsurf/depth_range.rs b/crates/render_gpu/src/drawsurf/depth_range.rs index e7c9e4b..355824b 100644 --- a/crates/render_gpu/src/drawsurf/depth_range.rs +++ b/crates/render_gpu/src/drawsurf/depth_range.rs @@ -25,6 +25,9 @@ pub fn depth_range_type_for_draw(kind: &RetainedDrawKind, key: u64) -> i32 { GFX_DEPTH_RANGE_SCENE } } + RetainedDrawKind::CodeMesh { + viewmodel: true, .. + } => GFX_DEPTH_RANGE_VIEWMODEL, _ => GFX_DEPTH_RANGE_SCENE, } } diff --git a/crates/render_gpu/src/drawsurf/draw.rs b/crates/render_gpu/src/drawsurf/draw.rs index 324895d..0e64758 100644 --- a/crates/render_gpu/src/drawsurf/draw.rs +++ b/crates/render_gpu/src/drawsurf/draw.rs @@ -22,6 +22,10 @@ pub(crate) fn register_drawsurf_render(app: &mut App) { super::gpu_resources::prepare_uploaded_image_registry .in_set(RenderSystems::PrepareResources), ); + render_app.add_systems( + Render, + super::scene_depth::prepare_scene_depth.in_set(RenderSystems::PrepareResources), + ); super::colour_submit::register(app); super::sun_effects::register(app); super::postfx::register(app); diff --git a/crates/render_gpu/src/drawsurf/geometry_diagnostic.rs b/crates/render_gpu/src/drawsurf/geometry_diagnostic.rs index 7dc4ea6..28d2c9d 100644 --- a/crates/render_gpu/src/drawsurf/geometry_diagnostic.rs +++ b/crates/render_gpu/src/drawsurf/geometry_diagnostic.rs @@ -1,8 +1,9 @@ +use crate::drawsurf::scene_depth::{SCENE_DEPTH_FORMAT, SceneDepthTexture}; use std::collections::HashSet; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Instant; -use bevy::core_pipeline::core_3d::{CORE_3D_DEPTH_FORMAT, main_opaque_pass_3d}; +use bevy::core_pipeline::core_3d::main_opaque_pass_3d; use bevy::core_pipeline::{Core3d, Core3dSystems}; use bevy::mesh::VertexBufferLayout; use bevy::prelude::*; @@ -18,7 +19,7 @@ use bevy::render::render_resource::{ }; use bevy::render::renderer::{RenderContext, RenderDevice, ViewQuery}; use bevy::render::view::{ - ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms, + ExtractedView, Msaa, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms, }; use bevy::render::{Render, RenderSystems}; use bevy::shader::Shader; @@ -199,7 +200,7 @@ impl SpecializedRenderPipeline for DiagnosticPipeline { ..Default::default() }, depth_stencil: Some(DepthStencilState { - format: CORE_3D_DEPTH_FORMAT, + format: SCENE_DEPTH_FORMAT, depth_write_enabled: Some(true), depth_compare: Some(CompareFunction::GreaterEqual), stencil: Default::default(), @@ -472,7 +473,7 @@ fn prepare_views( fn draw_geometry_diagnostic( view: ViewQuery<( &ViewTarget, - &ViewDepthTexture, + &SceneDepthTexture, &ExtractedView, &ViewUniformOffset, &DiagnosticViewBindGroup, diff --git a/crates/render_gpu/src/drawsurf/hud_blood.rs b/crates/render_gpu/src/drawsurf/hud_blood.rs index 740b01d..d796349 100644 --- a/crates/render_gpu/src/drawsurf/hud_blood.rs +++ b/crates/render_gpu/src/drawsurf/hud_blood.rs @@ -173,7 +173,11 @@ impl BloodPortGpu { mask: &GpuImage, ) -> BindGroup { let key = (color.texture_view.id(), mask.texture_view.id()); - if self.textures.as_ref().is_none_or(|(cached, _)| *cached != key) { + if self + .textures + .as_ref() + .is_none_or(|(cached, _)| *cached != key) + { let group = table.views_bind_group( device, &cache.get_bind_group_layout(&self.textures_layout), diff --git a/crates/render_gpu/src/drawsurf/iw_tess.rs b/crates/render_gpu/src/drawsurf/iw_tess.rs index 35dda4d..4401faa 100644 --- a/crates/render_gpu/src/drawsurf/iw_tess.rs +++ b/crates/render_gpu/src/drawsurf/iw_tess.rs @@ -10,10 +10,11 @@ use bevy::render::render_resource::binding_types::{sampler, texture_2d, uniform_ use bevy::render::render_resource::{ BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries, BlendComponent, BlendFactor, BlendOperation, BlendState, Buffer, BufferDescriptor, BufferUsages, - ColorTargetState, ColorWrites, FragmentState, FrontFace, IndexFormat, PipelineCache, - PrimitiveState, RenderPipelineDescriptor, SamplerBindingType, ShaderStages, - SpecializedRenderPipeline, SpecializedRenderPipelines, TextureFormat, TextureSampleType, - VertexAttribute, VertexFormat, VertexState, VertexStepMode, + ColorTargetState, ColorWrites, Extent3d, FragmentState, FrontFace, IndexFormat, PipelineCache, + PrimitiveState, RenderPipelineDescriptor, Sampler, SamplerBindingType, SamplerDescriptor, + ShaderStages, SpecializedRenderPipeline, SpecializedRenderPipelines, Texture, + TextureDescriptor, TextureDimension, TextureFormat, TextureSampleType, TextureUsages, + TextureView, TextureViewDescriptor, VertexAttribute, VertexFormat, VertexState, VertexStepMode, }; use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue, ViewQuery}; use bevy::render::texture::GpuImage; @@ -94,11 +95,27 @@ struct ExtractedTessFrame { surface_w: f32, surface_h: f32, visible: bool, + saved_screen_sequence: u64, } #[derive(Resource, Default)] struct ExtractedIwTess(ExtractedTessFrame); +struct SavedScreenCopy { + texture: Texture, + view: TextureView, + sampler: Sampler, + format: TextureFormat, + size: Extent3d, +} + +#[derive(Resource, Default)] +struct SavedScreenGpu { + copy: Option, + sequence: u64, + captured: bool, +} + #[derive(Resource)] struct IwTessPipeline { shader: Handle, @@ -250,6 +267,7 @@ fn extract_iw_tess(mut extracted: ResMut, frame: Extract, device: Res, images: Res>, - postfx: Res, - queue: Res, mut texture_table: ResMut, - mut blood_port: Local>, - mut blood_refusal: Local>, + mut blood: ResMut, + mut saved: ResMut, mut context: RenderContext, stages: Option>, ) { + let (target, _extracted_view) = view.into_inner(); + let format = target.main_texture_format(); + let scene_size = target.main_texture().size(); + if extracted.0.saved_screen_sequence != saved.sequence { + saved.captured = false; + if let Some(copy) = saved + .copy + .as_ref() + .filter(|copy| copy.format == format && copy.size == scene_size) + { + context.command_encoder().copy_texture_to_texture( + target.main_texture().as_image_copy(), + copy.texture.as_image_copy(), + scene_size, + ); + saved.sequence = extracted.0.saved_screen_sequence; + saved.captured = true; + } + } if !extracted.0.visible { return; } @@ -442,40 +477,12 @@ fn draw_iw_tess( } return; } - let (target, _extracted_view) = view.into_inner(); - let samples = 1; - let format = target.main_texture_format(); - let has_blood = meta.draws.iter().any(|geom| { - extracted - .0 - .batches - .get(geom.batch_i) - .is_some_and(|batch| batch.technique == HudTessTechnique::SplatterAlt) - }); - let blood_ready = has_blood - && match prepare_blood( - &mut blood_port, - postfx.blood.as_ref(), - format, - &device, - &cache, - &queue, - extracted.0.surface_w, - extracted.0.surface_h, - ) { - Ok(ready) => { - *blood_refusal = None; - ready - } - Err(cause) => { - if blood_refusal.as_ref() != Some(&cause) { - diag::warn!(World, "hud blood port: RED cause={cause:?}"); - *blood_refusal = Some(cause); - } - false - } - }; + let saved_copy = saved + .copy + .as_ref() + .filter(|copy| saved.captured && copy.format == format && copy.size == scene_size); + let blood_ready = blood.uploaded; let mut prepared = Vec::with_capacity(meta.draws.len()); for geom in &meta.draws { let Some(batch) = extracted.0.batches.get(geom.batch_i) else { @@ -507,11 +514,36 @@ fn draw_iw_tess( ); (id, bind, None) } + HudTessTechnique::SavedScreen => { + let Some(copy) = saved_copy else { + continue; + }; + let id = specialized.specialize( + &cache, + &pipeline, + IwTessPipelineKey { + target: format, + samples, + state_bits: batch.state_bits, + }, + ); + let layout = cache.get_bind_group_layout(&pipeline.modulate_layout); + let bind = device.create_bind_group( + "iw_tess_saved_screen", + &layout, + &BindGroupEntries::sequential(( + pipeline.params.as_entire_buffer_binding(), + ©.view, + ©.sampler, + )), + ); + (id, bind, None) + } HudTessTechnique::SplatterAlt => { let Some(gpu_mask) = batch.mask.as_ref().and_then(|mask| images.get(mask)) else { continue; }; - let Some(port) = blood_port.as_mut().filter(|_| blood_ready) else { + let Some(port) = blood.port.as_mut().filter(|_| blood_ready) else { continue; }; let textures = @@ -581,31 +613,126 @@ fn draw_iw_tess( } } -#[allow(clippy::too_many_arguments)] -fn prepare_blood( - port: &mut Option, - blood: Option<&super::postfx::ExtractedBlood>, - format: TextureFormat, - device: &RenderDevice, - cache: &PipelineCache, - queue: &RenderQueue, - surface_w: f32, - surface_h: f32, -) -> Result { - let Some(blood) = blood else { - *port = None; - return Ok(false); - }; - if port.as_ref().is_none_or(|port| !port.matches(blood, format)) { - *port = None; - *port = Some(super::hud_blood::BloodPortGpu::create( - blood, format, device, cache, - )?); +#[derive(Resource, Default)] +pub(super) struct HudBloodGpu { + port: Option, + refusal: Option, + uploaded: bool, +} + +impl HudBloodGpu { + fn refuse(&mut self, cause: super::hud_blood::BloodGpuRefusal) { + if self.refusal.as_ref() != Some(&cause) { + diag::warn!(World, "hud blood port: RED cause={cause:?}"); + self.refusal = Some(cause); + } + } +} + +fn prepare_saved_screen( + views: Query<&ViewTarget, With>, + device: Res, + cache: Res, + pipeline: Res, + mut specialized: ResMut>, + mut saved: ResMut, +) { + let Some(target) = views.iter().next() else { + return; + }; + let format = target.main_texture_format(); + let size = target.main_texture().size(); + specialized.specialize( + &cache, + &pipeline, + IwTessPipelineKey { + target: format, + samples: 1, + state_bits: None, + }, + ); + if saved + .copy + .as_ref() + .is_some_and(|copy| copy.format == format && copy.size == size) + { + return; + } + let texture = device.create_texture(&TextureDescriptor { + label: Some("iw_tess_saved_screen"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = texture.create_view(&TextureViewDescriptor::default()); + let sampler = device.create_sampler(&SamplerDescriptor::default()); + // Resizing invalidates the frozen image; only a new flash may capture again. + saved.captured = false; + saved.copy = Some(SavedScreenCopy { + texture, + view, + sampler, + format, + size, + }); +} + +fn prepare_hud_blood( + postfx: Res, + extracted: Res, + views: Query<&ViewTarget, With>, + device: Res, + cache: Res, + queue: Res, + mut gpu: ResMut, +) { + gpu.uploaded = false; + let Some(blood) = postfx.blood.as_ref() else { + gpu.port = None; + return; + }; + let Some(format) = views.iter().next().map(ViewTarget::main_texture_format) else { + return; + }; + if gpu + .port + .as_ref() + .is_none_or(|port| !port.matches(blood, format)) + { + gpu.port = None; + match super::hud_blood::BloodPortGpu::create(blood, format, &device, &cache) { + Ok(port) => { + gpu.port = Some(port); + gpu.refusal = None; + } + Err(cause) => { + gpu.refuse(cause); + return; + } + } + } + let drawn = extracted.0.visible + && extracted + .0 + .batches + .iter() + .any(|batch| batch.technique == HudTessTechnique::SplatterAlt); + if !drawn { + return; + } + let uploaded = gpu + .port + .as_ref() + .map(|port| port.upload(&queue, extracted.0.surface_w, extracted.0.surface_h)); + match uploaded { + Some(Ok(())) => gpu.uploaded = true, + Some(Err(cause)) => gpu.refuse(cause), + None => {} } - port.as_ref() - .expect("built above") - .upload(queue, surface_w, surface_h)?; - Ok(true) } pub(super) fn register(app: &mut App) { @@ -615,12 +742,18 @@ pub(super) fn register(app: &mut App) { }; render_app .init_resource::() + .init_resource::() + .init_resource::() .init_resource::>() .add_systems(RenderStartup, init_pipeline) .add_systems(ExtractSchedule, extract_iw_tess) .add_systems( Render, - prepare_iw_tess.in_set(RenderSystems::PrepareResources), + ( + prepare_iw_tess.in_set(RenderSystems::PrepareResources), + prepare_hud_blood.in_set(RenderSystems::PrepareResources), + prepare_saved_screen.in_set(RenderSystems::PrepareResources), + ), ) .add_systems( Core3d, diff --git a/crates/render_gpu/src/drawsurf/mod.rs b/crates/render_gpu/src/drawsurf/mod.rs index 87696f7..cb8075d 100644 --- a/crates/render_gpu/src/drawsurf/mod.rs +++ b/crates/render_gpu/src/drawsurf/mod.rs @@ -14,6 +14,7 @@ mod postfx; mod postfx_dof; mod products; mod resolved_scene; +pub(crate) mod scene_depth; mod shadowmap_spot_gpu; mod shadowmap_sun_gpu; mod sm3_wgsl; diff --git a/crates/render_gpu/src/drawsurf/scene_depth.rs b/crates/render_gpu/src/drawsurf/scene_depth.rs new file mode 100644 index 0000000..1e6d867 --- /dev/null +++ b/crates/render_gpu/src/drawsurf/scene_depth.rs @@ -0,0 +1,142 @@ +use bevy::camera::{Camera3d, Camera3dDepthLoadOp}; +use bevy::prelude::*; +use bevy::render::camera::ExtractedCamera; +use bevy::render::render_resource::*; +use bevy::render::renderer::RenderDevice; +use bevy::render::texture::TextureCache; +use bevy::render::view::{Msaa, ViewDepthTexture}; + +pub const SCENE_DEPTH_FORMAT: TextureFormat = TextureFormat::Depth24PlusStencil8; + +#[derive(Component)] +pub struct SceneDepthTexture { + pub texture: Texture, + attachment: ViewDepthTexture, + sampled: TextureView, +} + +impl SceneDepthTexture { + pub fn view(&self) -> &TextureView { + &self.sampled + } + + pub fn get_attachment(&self, store: StoreOp) -> RenderPassDepthStencilAttachment<'_> { + let mut attachment = self.attachment.get_attachment(store); + attachment.stencil_ops = Some(Operations { + load: if attachment + .depth_ops + .as_ref() + .is_some_and(|ops| matches!(ops.load, LoadOp::Clear(_))) + { + LoadOp::Clear(0) + } else { + LoadOp::Load + }, + store: StoreOp::Store, + }); + attachment + } +} + +pub(crate) fn prepare_scene_depth( + mut commands: Commands, + device: Res, + mut cache: ResMut, + mut sampled_views: Local>, + views: Query<(Entity, &ExtractedCamera, &Camera3d, &Msaa)>, +) { + let mut textures = std::collections::HashMap::new(); + let mut live = Vec::new(); + for (entity, camera, settings, msaa) in &views { + let Some(size) = camera.physical_target_size else { + continue; + }; + let texture = textures + .entry((camera.target.clone(), *msaa)) + .or_insert_with(|| { + cache.get( + &device, + TextureDescriptor { + label: Some("scene_depth_stencil"), + size: Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: msaa.samples(), + dimension: TextureDimension::D2, + format: SCENE_DEPTH_FORMAT, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ) + }) + .clone(); + let id = texture.texture.id(); + live.push(id); + let sampled = sampled_views + .entry(id) + .or_insert_with(|| { + texture.texture.create_view(&TextureViewDescriptor { + aspect: TextureAspect::DepthOnly, + ..Default::default() + }) + }) + .clone(); + let raw = texture.texture.clone(); + commands.entity(entity).insert(SceneDepthTexture { + texture: raw, + sampled, + attachment: ViewDepthTexture::new( + texture, + match settings.depth_load_op { + Camera3dDepthLoadOp::Clear(value) => Some(value), + Camera3dDepthLoadOp::Load => None, + }, + ), + }); + } + sampled_views.retain(|id, _| live.contains(id)); +} + +pub(crate) fn stencil_state(bits: u32) -> StencilState { + if bits & 0x40 == 0 { + return StencilState::default(); + } + let op = |value: u32| match value & 7 { + 0 => StencilOperation::Keep, + 1 => StencilOperation::Zero, + 2 => StencilOperation::Replace, + 3 => StencilOperation::IncrementClamp, + 4 => StencilOperation::DecrementClamp, + 5 => StencilOperation::Invert, + 6 => StencilOperation::IncrementWrap, + _ => StencilOperation::DecrementWrap, + }; + let face = |shift: u32| { + let value = bits >> shift; + StencilFaceState { + pass_op: op(value), + fail_op: op(value >> 3), + depth_fail_op: op(value >> 6), + compare: match (value >> 9) & 7 { + 0 => CompareFunction::Never, + 1 => CompareFunction::Less, + 2 => CompareFunction::Equal, + 3 => CompareFunction::LessEqual, + 4 => CompareFunction::Greater, + 5 => CompareFunction::NotEqual, + 6 => CompareFunction::GreaterEqual, + _ => CompareFunction::Always, + }, + } + }; + let front = face(8); + StencilState { + front, + back: if bits & 0x80 != 0 { face(20) } else { front }, + read_mask: 0xff, + write_mask: 0xff, + } +} diff --git a/crates/render_gpu/src/drawsurf/shadowmap_spot_gpu.rs b/crates/render_gpu/src/drawsurf/shadowmap_spot_gpu.rs index 005df84..2990cff 100644 --- a/crates/render_gpu/src/drawsurf/shadowmap_spot_gpu.rs +++ b/crates/render_gpu/src/drawsurf/shadowmap_spot_gpu.rs @@ -10,7 +10,7 @@ use super::shadowmap_sun_gpu::upload_shadow_index_epochs; pub const SHADOWMAP_SPOT_COLOR_FORMAT: TextureFormat = TextureFormat::R32Float; -pub const SHADOWMAP_SPOT_DEPTH_FORMAT: TextureFormat = TextureFormat::Depth24Plus; +pub const SHADOWMAP_SPOT_DEPTH_FORMAT: TextureFormat = TextureFormat::Depth24PlusStencil8; pub const SHADOWMAP_SPOT_RT10_LABEL: &str = "iw4_shadowmap_spot_rt10"; pub const SHADOWMAP_SPOT_RT11_LABEL: &str = "iw4_shadowmap_spot_rt11"; diff --git a/crates/render_gpu/src/drawsurf/shadowmap_sun_gpu.rs b/crates/render_gpu/src/drawsurf/shadowmap_sun_gpu.rs index b4da4df..e96da7e 100644 --- a/crates/render_gpu/src/drawsurf/shadowmap_sun_gpu.rs +++ b/crates/render_gpu/src/drawsurf/shadowmap_sun_gpu.rs @@ -10,7 +10,7 @@ use render_frame::SUN_SHADOW_FORCED_PROFILE; pub const SHADOWMAP_SUN_COLOR_FORMAT: TextureFormat = TextureFormat::R32Float; -pub const SHADOWMAP_SUN_DEPTH_FORMAT: TextureFormat = TextureFormat::Depth24Plus; +pub const SHADOWMAP_SUN_DEPTH_FORMAT: TextureFormat = TextureFormat::Depth24PlusStencil8; struct ShadowmapSunGpuTarget { _color: Texture, diff --git a/crates/render_gpu/src/drawsurf/state.rs b/crates/render_gpu/src/drawsurf/state.rs index c597a73..e97c7cb 100644 --- a/crates/render_gpu/src/drawsurf/state.rs +++ b/crates/render_gpu/src/drawsurf/state.rs @@ -182,6 +182,7 @@ impl ChangeState0Host { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ChangeState1Host { + pub stencil: u32, pub depth_write: bool, pub depth_test_enable: bool, @@ -303,13 +304,14 @@ impl GfxPassState { || !colour.dst.is_known() || (alpha_blend_op != 0 && (!alpha.src.is_known() || !alpha.dst.is_known()))), unknown_blend_operation: blend_op > 5 || (blend_op != 0 && alpha_blend_op > 5), - stencil: self.word1 & 0xc0 != 0, + stencil: false, }; fields.any().then_some(fields) } pub fn apply_change_state_1_host(self) -> ChangeState1Host { ChangeState1Host { + stencil: self.word1 & 0xffff_ffc0, depth_write: assets::depth_write_enable(self.word1), depth_test_enable: assets::depth_test_enable(self.word1), depth_func: ((self.word1 >> 2) & 3) as u8, diff --git a/crates/render_gpu/src/drawsurf/sun_effects.rs b/crates/render_gpu/src/drawsurf/sun_effects.rs index 52e5010..6e9ced2 100644 --- a/crates/render_gpu/src/drawsurf/sun_effects.rs +++ b/crates/render_gpu/src/drawsurf/sun_effects.rs @@ -1,3 +1,4 @@ +use crate::drawsurf::scene_depth::SceneDepthTexture; use bevy::core_pipeline::tonemapping::tonemapping; use bevy::core_pipeline::{Core3d, Core3dSystems}; use bevy::prelude::*; @@ -16,7 +17,7 @@ use bevy::render::render_resource::{ TextureView, TextureViewDescriptor, VertexState, }; use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue, ViewQuery}; -use bevy::render::view::{ExtractedView, ViewDepthTexture, ViewTarget}; +use bevy::render::view::{ExtractedView, ViewTarget}; use bevy::render::{Render, RenderApp, RenderSystems}; use bevy::shader::Shader; use render_frame::SunEffectsFrame; @@ -371,7 +372,7 @@ fn bind_sun<'a>( } fn draw_sun_effects( - view: ViewQuery<(&ViewTarget, &ExtractedView, &ViewDepthTexture)>, + view: ViewQuery<(&ViewTarget, &ExtractedView, &SceneDepthTexture)>, frame: Res, gpu: Option>, cache: Res, diff --git a/crates/render_material/src/execute.rs b/crates/render_material/src/execute.rs index 9ce6fd0..8885df0 100644 --- a/crates/render_material/src/execute.rs +++ b/crates/render_material/src/execute.rs @@ -43,7 +43,7 @@ impl GfxPassStateBits { || !colour_dst.is_known() || (alpha_blend_op != 0 && (!alpha_src.is_known() || !alpha_dst.is_known()))), unknown_blend_operation: blend_op > 5 || (blend_op != 0 && alpha_blend_op > 5), - stencil: self.word1 & 0xc0 != 0, + stencil: false, }; fields.any().then_some(fields) } diff --git a/crates/session/src/combat_table.rs b/crates/session/src/combat_table.rs index 09ab434..b0e6458 100644 --- a/crates/session/src/combat_table.rs +++ b/crates/session/src/combat_table.rs @@ -30,6 +30,9 @@ pub(crate) fn validated_facts( fire_delay_ms: f.fire_delay_ms, raise_time_ms: f.raise_time_ms, drop_time_ms: f.drop_time_ms, + alternate_weapon: 0, + alternate_raise_time_ms: f.alternate_raise_time_ms, + alternate_drop_time_ms: f.alternate_drop_time_ms, reload_time_ms: f.reload_time_ms, reload_empty_time_ms: f.reload_empty_time_ms, clip_size: f.clip_size, @@ -136,8 +139,10 @@ pub fn from_registry( .and_then(|t| t.get(8)) .and_then(|s| s.as_ref()) .is_some_and(|s| !s.is_empty()); - validated_facts(f, charge_anim, global_location) - .unwrap_or_else(|_| WeaponCombatFacts::none()) + let mut facts = validated_facts(f, charge_anim, global_location) + .unwrap_or_else(|_| WeaponCombatFacts::none()); + facts.alternate_weapon = weapons.alternate_of(i as u32); + facts }) .collect() } diff --git a/crates/session/src/content_manifest.rs b/crates/session/src/content_manifest.rs index c38a810..f8e057f 100644 --- a/crates/session/src/content_manifest.rs +++ b/crates/session/src/content_manifest.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use assets::{AssetKey, AssetKind, WeaponRegistry}; use bevy::prelude::Resource; -pub const SESSION_MANIFEST_SCHEME: u32 = 1; +pub const SESSION_MANIFEST_SCHEME: u32 = 2; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RuntimeRuleset { @@ -41,6 +41,7 @@ pub struct SessionWeaponManifestRow { pub id: SessionWeaponId, pub key: AssetKey, pub attachments: Vec, + pub alternate: bool, pub authority: ManifestFact, pub presentation: ManifestFact, @@ -62,17 +63,17 @@ pub struct SessionContentManifest { #[derive(Clone, Debug, PartialEq, Eq)] pub enum SessionManifestError { MissingWeaponKey(SessionWeaponId), - DuplicateWeaponKey(AssetKey, Vec), + DuplicateWeaponKey(AssetKey, Vec, bool), } impl core::fmt::Display for SessionManifestError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::MissingWeaponKey(id) => write!(f, "session weapon {} has no durable key", id.0), - Self::DuplicateWeaponKey(key, attachments) => { + Self::DuplicateWeaponKey(key, attachments, alternate) => { write!( f, - "duplicate durable session weapon key `{key}` [{}]", + "duplicate durable session weapon key `{key}` [{}] alternate={alternate}", attachments.join(" ") ) } @@ -106,8 +107,15 @@ impl SessionContentManifest { .key_of(raw_id) .ok_or(SessionManifestError::MissingWeaponKey(id))?; let attachments = registry.prepared_attachments_of(raw_id).to_vec(); - if !keys.insert((key.clone(), attachments.clone())) { - return Err(SessionManifestError::DuplicateWeaponKey(key, attachments)); + let alternate = registry + .facts_of(raw_id) + .is_some_and(|facts| facts.inventory_type == 3); + if !keys.insert((key.clone(), attachments.clone(), alternate)) { + return Err(SessionManifestError::DuplicateWeaponKey( + key, + attachments, + alternate, + )); } let authority = if combat .get(raw_id as usize) @@ -128,6 +136,7 @@ impl SessionContentManifest { id, key, attachments, + alternate, authority, presentation: ManifestFact::Unavailable( ManifestGap::AtomicWeaponPresentationBundleNotInstalled, @@ -178,6 +187,7 @@ impl SessionContentManifest { for name in &row.attachments { digest.bytes(name.as_bytes()); } + digest.byte(u8::from(row.alternate)); digest.authority_fact(&row.authority); digest.u64_fact(&row.presentation); } diff --git a/crates/session/src/match_apply.rs b/crates/session/src/match_apply.rs index 53e58ca..1d6d5df 100644 --- a/crates/session/src/match_apply.rs +++ b/crates/session/src/match_apply.rs @@ -489,6 +489,7 @@ pub fn apply_prepared_match( ) { Ok(plan) => plan, Err(refusal) => { + diag::warn!(World, "match install refused: {}", refusal.error); if let Some(probe) = probe.as_deref_mut() { if let Some(gap) = refusal.sim_gap { probe.sim_gap = gap; @@ -891,6 +892,7 @@ pub fn apply_prepared_match( } } Err(refusal) => { + diag::warn!(World, "match install refused: {}", refusal.error); if let Some(stage) = install_stage { stage.fail(); } diff --git a/crates/sim/src/combat.rs b/crates/sim/src/combat.rs index 38513fe..2242e80 100644 --- a/crates/sim/src/combat.rs +++ b/crates/sim/src/combat.rs @@ -154,6 +154,35 @@ pub(crate) fn advance_weapon_command( { return accepted; } + if let Some(mut ps) = world.player(*id).copied() { + let parent = u32::from(cmd.weapon_mapped); + let target = u32::from(cmd.weapon); + if target != 0 + && parent != target + && ps.weapons.contains(&(parent as i32)) + && world + .combat_facts_for(parent) + .is_some_and(|f| f.alternate_weapon == target) + { + if let Some(facts) = world.combat_facts_for(target) { + let ammo = weapon_iw4::bg_ammo_table_key(facts.ammo_index, target); + let clip = weapon_iw4::bg_clip_table_key(facts.clip_index, target); + let (initial_clip, _, initial_stock) = weapon_iw4::spawn_clip_stock(&facts, 0); + if !bg_ammo_row_present(&ps.ammo, ammo) { + bg_set_ammo_not_in_clip(&mut ps.ammo, ammo, initial_stock); + } + if !bg_clip_row_present(&ps.ammoclip, clip) { + bg_set_clip_for_hand(&mut ps.ammoclip, clip, 0, initial_clip); + } + world.client_meta_mut(*id).set_ammo( + target, + bg_get_clip_for_hand(&ps.ammoclip, clip, 0), + bg_get_ammo_not_in_clip(&ps.ammo, ammo), + ); + *world.player_mut(*id).expect("present player") = ps; + } + } + } let old_buttons = world .old_buttons_mut() .iter() @@ -331,12 +360,23 @@ pub(crate) fn advance_weapon_command( } else { ps.weapon }; - w == 0 || ps.weapons.iter().any(|&slot| slot == w as i32) + w == 0 + || ps.weapons.contains(&(w as i32)) + || (ps.weapons.contains(&(i32::from(cmd.weapon_mapped))) + && world + .combat_facts_for(u32::from(cmd.weapon_mapped)) + .is_some_and(|f| f.alternate_weapon == w)) }, cmd_weapon_pistol_quick: world .combat_facts_for(u32::from(cmd.weapon)) .is_some_and(|f| f.weap_class == 5), + alternate_switch: cmd.weapon != 0 + && (facts.alternate_weapon == u32::from(cmd.weapon) + || (facts.inventory_type == 3 && ps.weapon_primary == u32::from(cmd.weapon))), + switch_alternate_raise_time_ms: world + .combat_facts_for(u32::from(cmd.weapon)) + .map_or(0, |f| f.alternate_raise_time_ms), switch_raise_time_ms: { let w = if cmd.weapon != 0 { u32::from(cmd.weapon) @@ -408,6 +448,16 @@ pub(crate) fn advance_weapon_command( if let Some(ps_mut) = world.player_mut(*id) { ps_mut.weapon = hand0.weapon; + if hand0.weapon != started_weapon { + if hand0.weaponstate == weapon_iw4::WeaponState::RaisingAltswitch as i32 { + ps_mut.aim_spread_scale = ps_mut.aim_spread_scale.max(128.0); + } + ps_mut.weapon_primary = if hand0.weapon == u32::from(cmd.weapon) { + u32::from(cmd.weapon_mapped) + } else { + 0 + }; + } ps_mut.weaponstate_primary = hand0.weaponstate; ps_mut.weapon_time = hand0.weapon_time; ps_mut.weapon_delay = hand0.weapon_delay; @@ -706,6 +756,15 @@ pub(crate) fn advance_weapon_command( ); } } + WeaponTickEvent::AlternateStarted => { + if let Some(ps) = world.player_mut(*id) { + movement_iw4::add_predictable_event( + ps, + entity_iw4::EntityEventKind::WEAPON_ALT.0, + i32::from(cmd.weapon), + ); + } + } WeaponTickEvent::PutawayStarted => { if let Some(ps) = world.player_mut(*id) { movement_iw4::add_predictable_event( diff --git a/crates/sim/src/content.rs b/crates/sim/src/content.rs index 3cda5e7..31edefe 100644 --- a/crates/sim/src/content.rs +++ b/crates/sim/src/content.rs @@ -67,6 +67,9 @@ fn hash_combat(h: &mut Digest, combat: &[WeaponCombatFacts]) { h.i32(row.fire_delay_ms); h.i32(row.raise_time_ms); h.i32(row.drop_time_ms); + h.u32(row.alternate_weapon); + h.i32(row.alternate_raise_time_ms); + h.i32(row.alternate_drop_time_ms); h.i32(row.quick_drop_time_ms); h.i32(row.inventory_type); h.i32(row.weap_class); diff --git a/crates/sim/src/item.rs b/crates/sim/src/item.rs index e925b0a..aa65b5c 100644 --- a/crates/sim/src/item.rs +++ b/crates/sim/src/item.rs @@ -105,8 +105,9 @@ fn take_player_weapon(ps: &mut PlayerState, weapon: u32) { *slot = 0; } } - if ps.weapon == weapon { + if ps.weapon == weapon || ps.weapon_primary == weapon { ps.weapon = 0; + ps.weapon_primary = 0; } } @@ -173,7 +174,14 @@ fn add_ammo_on_ps( } fn current_primary_weapon(world: &FrameWorld, ps: &PlayerState) -> u32 { - let weapon = ps.weapon; + let weapon = if world + .combat_facts_for(ps.weapon) + .is_some_and(|f| f.inventory_type == 3) + { + ps.weapon_primary + } else { + ps.weapon + }; if weapon == 0 { return 0; } @@ -812,7 +820,9 @@ fn selected_item(world: &FrameWorld, walker: ClientId, ps: &PlayerState) -> Opti if primary_count(world, ps) >= 2 && current_primary_weapon(world, ps) == 0 { continue; } - let delta: [f32; 3] = core::array::from_fn(|i| item.origin[i] + 0.5 - eye[i]); + let center: [f32; 3] = + core::array::from_fn(|i| item.origin[i] + (ITEM_MINS[i] + ITEM_MAXS[i]) * 0.5); + let delta: [f32; 3] = core::array::from_fn(|i| center[i] - eye[i]); let distance = delta.iter().map(|v| v * v).sum::().sqrt(); if distance > 128.0 { continue; @@ -823,7 +833,7 @@ fn selected_item(world: &FrameWorld, walker: ClientId, ps: &PlayerState) -> Opti 0.0 }; if world - .trace_world(eye, item.origin, [0.0; 3], [0.0; 3], 0x11) + .trace_world(eye, center, [0.0; 3], [0.0; 3], 0x11) .fraction < 1.0 { diff --git a/crates/sim/src/lib.rs b/crates/sim/src/lib.rs index e40de12..b924b57 100644 --- a/crates/sim/src/lib.rs +++ b/crates/sim/src/lib.rs @@ -20,6 +20,8 @@ mod item; mod mantle_xanim; pub mod match_state; mod missile; +mod weapon_lock; +pub use weapon_lock::WeaponLock; pub mod player_anim_script; pub mod rules; mod score; @@ -117,8 +119,8 @@ pub use match_state::{ CLASS_CATALOG_STEADY_AIM, CLASS_CATALOG_STOPPING_POWER, ClassDef, ClassRejectReason, ClientLifecycle, ClientSnapshotMeta, ConfigurationChangeRejectReason, DroppedItemAmmo, EntityEventPayload, EntityEventRecord, EventAudience, EventRecord, GiveRejectReason, - HealthRegenCensus, ItemPickupRecord, KillcamHud, LoadoutSpec, MatchEndReason, PelletFxRecord, - RngDebugMeta, SIM_EVENT_ROSTER, SimEvent, SimEventRow, SnapshotMeta, + HealthRegenCensus, InputReceipt, ItemPickupRecord, KillcamHud, LoadoutSpec, MatchEndReason, + PelletFxRecord, RngDebugMeta, SIM_EVENT_ROSTER, SimEvent, SimEventRow, SnapshotMeta, UNRELIABLE_SIM_EVENT_COUNT, class_catalog_has, class_catalog_radar_jam_e_flags, perk_bits_from_class_catalog, sim_event_is_reliable, }; diff --git a/crates/sim/src/match_state.rs b/crates/sim/src/match_state.rs index f3bba5e..5cddd6e 100644 --- a/crates/sim/src/match_state.rs +++ b/crates/sim/src/match_state.rs @@ -388,6 +388,7 @@ pub struct KillcamHud { #[derive(Clone, Debug, Default, PartialEq)] pub struct ClientSnapshotMeta { + pub weapon_lock: crate::WeaponLock, pub killcam_hud: Option, pub lifecycle: ClientLifecycle, pub loadout: Option, @@ -541,9 +542,32 @@ pub struct HealthRegenCensus { pub named_sound: Option<&'static str>, } +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct InputReceipt { + pub applied_cmds: u32, + pub moving_cmds: u32, + pub path_units: f32, +} + +impl InputReceipt { + pub(crate) fn record(&mut self, moving: bool, from: [f32; 3], to: [f32; 3]) { + self.applied_cmds = self.applied_cmds.wrapping_add(1); + if moving { + self.moving_cmds = self.moving_cmds.wrapping_add(1); + } + let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + let step = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + if step.is_finite() { + self.path_units += step; + } + } +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct ClientMatchState { + pub weapon_lock: crate::WeaponLock, pub lifecycle: ClientLifecycle, + pub input_receipt: InputReceipt, pub loadout: Option, pub life_sequence: LifeSequence, @@ -678,6 +702,7 @@ impl ClientMatchState { pub(crate) fn to_snapshot_meta(&self) -> ClientSnapshotMeta { ClientSnapshotMeta { + weapon_lock: self.weapon_lock, killcam_hud: None, lifecycle: self.lifecycle, loadout: self.loadout.clone(), @@ -710,6 +735,7 @@ impl ClientMatchState { } pub(crate) fn adopt_snapshot_meta(&mut self, meta: &ClientSnapshotMeta) { + self.weapon_lock = meta.weapon_lock; self.lifecycle = meta.lifecycle; self.loadout = meta.loadout.clone(); self.life_sequence = meta.life_sequence; diff --git a/crates/sim/src/step.rs b/crates/sim/src/step.rs index c6dec2b..2d773e6 100644 --- a/crates/sim/src/step.rs +++ b/crates/sim/src/step.rs @@ -660,6 +660,7 @@ fn run_players_system(ecs: &mut World) { crate::damage::shellshock_dump_affects_movement(ps.shellshock_index), ); let mut cmd = *cmd; + let commanded_move = cmd.forwardmove != 0 || cmd.rightmove != 0; let linked_brushes: Vec = world .entity_collision_capabilities() .iter() @@ -681,7 +682,7 @@ fn run_players_system(ecs: &mut World) { let script = world.player_anim_script(); let mantle = world.mantle_xanims(); let applied_mt = Cell::new(None); - let (walking, linked_bounds, anim_movetype, view_w, primary) = { + let (walking, linked_bounds, anim_movetype, view_w, primary, moved_from, moved_to) = { let ps = world .player_mut(*id) .expect("Alive client has a player row"); @@ -689,6 +690,7 @@ fn run_players_system(ecs: &mut World) { if ps.shellshock_time.wrapping_add(ps.shellshock_duration) < level_time { ps.pm_flags &= !playerstate_iw4::pm_flags::SHELLSHOCKED; } + let moved_from = ps.origin; let result = pm_move( ps, &mut cmd, @@ -705,14 +707,21 @@ fn run_players_system(ecs: &mut World) { pml.almost_ground_plane != 0, ); let (view_w, primary) = crate::pmove_anim_weapon_ids(ps); + let moved_to = ps.origin; ( pml.walking as i32, result.bounds, anim_movetype, view_w, primary, + moved_from, + moved_to, ) }; + world + .client_meta_mut(*id) + .input_receipt + .record(commanded_move, moved_from, moved_to); if let Some(movetype) = anim_movetype { let view_facts = world.combat_facts_for(view_w); let primary_facts = world.combat_facts_for(primary); @@ -737,6 +746,7 @@ fn run_players_system(ecs: &mut World) { world.set_pmove_walking(*id, walking); world.link_player_area(*id, linked_bounds); + crate::weapon_lock::update(&mut world, *id, level_time); let shots = advance_weapon_command(&mut world, tick, *id, cmd, delta.min(200)); for shot in shots { crate::missile::fire_accepted_shot(&mut world, tick, &shot); @@ -1559,6 +1569,7 @@ fn apply_configuration_change( next.weapon_data[slot * 5..slot * 5 + 5].fill(0); weapon_iw4::bg_latch_weapon_dual_wield(&next.weapons, &mut next.weapon_data, to, akimbo); next.weapon = to; + next.weapon_primary = to; next.last_weapon_hand = weapon_iw4::pm_num_hands_for_held(&next.weapons, &next.weapon_data, to); if old_ammo_key != new_ammo_key { for row in next.ammo.chunks_exact_mut(8) { @@ -1581,6 +1592,84 @@ fn apply_configuration_change( reject(world, Reason::AmmoTableFull); return; } + let alternate_ammo = match ( + world.combat_facts_for(old.alternate_weapon), + world.combat_facts_for(new.alternate_weapon), + ) { + (Some(old_alt), Some(new_alt)) + if old.alternate_weapon != 0 + && new.alternate_weapon != 0 + && old_alt.weap_type == new_alt.weap_type + && old_alt.weap_class == new_alt.weap_class => + { + let old_key = weapon_iw4::bg_ammo_table_key(old_alt.ammo_index, old.alternate_weapon); + let old_clip = weapon_iw4::bg_clip_table_key(old_alt.clip_index, old.alternate_weapon); + let new_key = weapon_iw4::bg_ammo_table_key(new_alt.ammo_index, new.alternate_weapon); + let new_clip = weapon_iw4::bg_clip_table_key(new_alt.clip_index, new.alternate_weapon); + if old_key != old_ammo_key + && old_clip != old_clip_key + && new_key != new_ammo_key + && new_clip != new_clip_key + && weapon_iw4::bg_ammo_row_present(&ps.ammo, old_key) + && weapon_iw4::bg_clip_row_present(&ps.ammoclip, old_clip) + { + let shared = ps + .weapons + .iter() + .filter(|&&w| w > 0 && w != from as i32) + .any(|&w| { + let alt = world + .combat_facts_for(w as u32) + .map_or(0, |f| f.alternate_weapon); + [w as u32, alt].into_iter().filter(|&w| w != 0).any(|w| { + world.combat_facts_for(w).is_some_and(|f| { + let ammo = weapon_iw4::bg_ammo_table_key(f.ammo_index, w); + let clip = weapon_iw4::bg_clip_table_key(f.clip_index, w); + (old_key != new_key && (ammo == old_key || ammo == new_key)) + || (old_clip != new_clip + && (clip == old_clip || clip == new_clip)) + }) + }) + }); + if shared { + reject(world, Reason::SharedAmmoConflict); + return; + } + let (clip, _, stock) = configuration_change_ammo( + weapon_iw4::bg_get_clip_for_hand(&ps.ammoclip, old_clip, 0), + 0, + weapon_iw4::bg_get_ammo_not_in_clip(&ps.ammo, old_key), + new_alt.clip_size, + new_alt.max_ammo, + false, + ); + if old_key != new_key { + for row in next.ammo.chunks_exact_mut(8) { + if row[..4] == old_key.to_le_bytes() { + row.fill(0); + } + } + } + if old_clip != new_clip { + for row in next.ammoclip.chunks_exact_mut(12) { + if row[..4] == old_clip.to_le_bytes() { + row.fill(0); + } + } + } + if !weapon_iw4::bg_set_ammo_not_in_clip(&mut next.ammo, new_key, stock) + || !weapon_iw4::bg_set_clip_for_hand(&mut next.ammoclip, new_clip, 0, clip) + { + reject(world, Reason::AmmoTableFull); + return; + } + Some((new.alternate_weapon, clip, stock)) + } else { + None + } + } + _ => None, + }; let hand = weapon_iw4::spawn_weapon_hand(to, &new); next.weaponstate_primary = hand.weaponstate; next.weapon_time = hand.weapon_time; @@ -1597,6 +1686,11 @@ fn apply_configuration_change( | playerstate_iw4::weap_flags::RECOIL_SCALE); *world.player_mut(id).expect("validated alive player") = next; let meta = world.client_meta_mut(id); + if let Some((alternate, clip, stock)) = alternate_ammo { + meta.ammo_by_weapon + .retain(|row| row.0 != old.alternate_weapon); + meta.set_ammo(alternate, clip, stock); + } let quick_reload_ready = meta.quick_reload_ready(from); meta.ammo_by_weapon.retain(|row| row.0 != from); meta.set_ammo(to, next_clip0, next_stock); @@ -1700,7 +1794,15 @@ fn apply_give_weapon( return; }; - let outgoing = next.weapon; + let outgoing = if world + .combat_facts_for(next.weapon) + .is_some_and(|facts| facts.inventory_type == 3) + && next.weapons.contains(&(next.weapon_primary as i32)) + { + next.weapon_primary + } else { + next.weapon + }; let mut replaced = false; if !next.weapons.contains(&(weapon as i32)) { if let Some(slot) = next @@ -2253,6 +2355,7 @@ fn resolve_pending_spawns(world: &mut FrameWorld, tick: Tick) { let class_id = loadout.class_id; let mut ps = spawn_player_state(decision.traced_origin, decision.raw_angles); + ps.action_slot_type[1] = 2; if let Some(cmd) = world.old_cmd_angles(id) { ps.delta_angles = std::array::from_fn(|axis| { decision.raw_angles[axis] - cmd[axis] as f32 * SHORT2ANGLE @@ -2646,6 +2749,7 @@ fn arm_held_weapon( weapon: u32, facts: &weapon_iw4::WeaponCombatFacts, ) -> (i32, i32) { + ps.weapon_primary = weapon; let last_hand = weapon_iw4::pm_num_hands_for_held(&ps.weapons, &ps.weapon_data, weapon); ps.last_weapon_hand = last_hand; let (clip0, clip1, stock) = weapon_iw4::spawn_clip_stock(facts, last_hand); diff --git a/crates/sim/src/weapon_lock.rs b/crates/sim/src/weapon_lock.rs new file mode 100644 index 0000000..2e98556 --- /dev/null +++ b/crates/sim/src/weapon_lock.rs @@ -0,0 +1,132 @@ +use glam::Vec3; + +use crate::frame::FrameWorld; +use crate::{ClientId, ClientLifecycle}; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct WeaponLock { + pub weapon: u32, + pub life: u32, + pub flags: u8, + pub target: [f32; 3], + pub point_sum: [f32; 3], + pub point_count: u8, + pub misses: u8, + pub sampled_at: i32, + pub out_of_ads_at: i32, + pub acquire_started_at: i32, +} + +impl WeaponLock { + pub fn locking(self) -> bool { + self.flags & 3 == 1 + } + + pub fn locked(self) -> bool { + self.flags & 2 != 0 + } + + pub fn too_close(self) -> bool { + self.flags & 16 != 0 + } +} + +pub(crate) fn update(world: &mut FrameWorld, id: ClientId, now: i32) { + let Some(ps) = world.player(id).copied() else { + return; + }; + let Some(meta) = world.client_meta(id) else { + return; + }; + let life = meta.life_sequence.0; + let mut lock = meta.weapon_lock; + let reset = WeaponLock { + weapon: ps.weapon, + life, + sampled_at: now, + out_of_ads_at: now, + ..Default::default() + }; + if lock.weapon != ps.weapon || lock.life != life || now < lock.sampled_at { + lock = reset; + } + if !world.weapon_script_name(ps.weapon).contains("javelin") + || meta.lifecycle != ClientLifecycle::Alive + || ps.other_flags & (1 << 10) != 0 + || ps.f_weapon_pos_frac < 0.95 + { + world.client_meta_mut(id).weapon_lock = reset; + return; + } + if now.saturating_sub(lock.sampled_at) < 50 { + return; + } + lock.sampled_at = now; + let origin = Vec3::from_array(ps.origin); + let eye = origin + Vec3::Z * ps.view_height_current; + let (forward, right, up) = math_iw4::angle_vectors(ps.viewangles); + let forward = Vec3::from_array(forward); + if lock.flags & 3 != 0 { + let delta = Vec3::from_array(lock.target) - eye; + let depth = delta.dot(forward); + let scale = 320.0 / (65.0_f32.to_radians() * 0.5).tan(); + let x = delta.dot(Vec3::from_array(right)) * scale / depth; + let y = delta.dot(Vec3::from_array(up)) * scale / depth; + if depth <= 0.0 || x * x + y * y >= 45.0 * 45.0 { + lock = WeaponLock { + out_of_ads_at: lock.out_of_ads_at, + ..reset + }; + } else { + lock.flags = (lock.flags & !16) + | if Vec3::from_array(lock.target).distance(origin) < 1100.0 { + 16 + } else { + 0 + }; + if now.saturating_sub(lock.acquire_started_at) >= 1150 { + lock.flags |= 2 | 4; + } + } + } else if lock.misses >= 4 { + lock = WeaponLock { + out_of_ads_at: lock.out_of_ads_at, + ..reset + }; + } else if now.saturating_sub(lock.out_of_ads_at) >= 100 { + let end = eye + forward * 15000.0; + let hit = world.trace_world( + eye.to_array(), + end.to_array(), + [0.0; 3], + [0.0; 3], + crate::bullet_collision::MASK_BULLET_WORLD, + ); + if hit.fraction >= 1.0 + || hit.startsolid != 0 + || trace_iw4::surface_type_from_flags(hit.surface_flags) == 0 + { + lock.misses += 1; + } else if Vec3::from_array(hit.endpos).distance(origin) < 1100.0 { + lock.flags |= 16; + } else { + lock.flags &= !16; + let point = Vec3::from_array(hit.endpos); + let sum = Vec3::from_array(lock.point_sum); + if lock.point_count != 0 && (sum / f32::from(lock.point_count)).distance(point) > 400.0 + { + lock.misses += 1; + } else { + lock.point_sum = (sum + point).to_array(); + lock.point_count += 1; + lock.misses = 0; + if lock.point_count >= 12 { + lock.target = ((sum + point) / f32::from(lock.point_count)).to_array(); + lock.flags = 1 | 64; + lock.acquire_started_at = now; + } + } + } + } + world.client_meta_mut(id).weapon_lock = lock; +} diff --git a/crates/sim/src/world.rs b/crates/sim/src/world.rs index b01d615..c732c4e 100644 --- a/crates/sim/src/world.rs +++ b/crates/sim/src/world.rs @@ -3874,6 +3874,7 @@ pub(crate) fn clip_trace( let mesh_ref = clipmap_iw4::ClipMeshRef { verts: &mesh.tables.verts, tri_indices: &mesh.tables.tri_indices, + tri_edge_is_walkable: &mesh.tables.tri_edge_is_walkable, tri_surface_flags: &mesh.tables.tri_surface_flags, tri_content_flags: &mesh.tables.tri_content_flags, aabb_trees: &mesh.tables.aabb_trees, @@ -3962,6 +3963,7 @@ pub(crate) fn gsc_give_weapon_is_akimbo(script_name: &str) -> bool { pub(crate) fn give_weapon_to_ps_akimbo(ps: &mut PlayerState, weapon: u32, akimbo: bool) { if weapon == 0 { ps.weapon = 0; + ps.weapon_primary = 0; ps.last_weapon_hand = 0; return; } @@ -3978,6 +3980,7 @@ pub(crate) fn give_weapon_to_ps_akimbo(ps: &mut PlayerState, weapon: u32, akimbo } } ps.weapon = weapon; + ps.weapon_primary = weapon; ps.last_weapon_hand = weapon_iw4::pm_num_hands_for_held(&ps.weapons, &ps.weapon_data, weapon); } diff --git a/crates/ui/src/class_store.rs b/crates/ui/src/class_store.rs index 9df355f..5ba8e15 100644 --- a/crates/ui/src/class_store.rs +++ b/crates/ui/src/class_store.rs @@ -80,3 +80,191 @@ pub(crate) fn sync_host_class_loadouts( store.commit_slots(&scratch); host.slots = scratch.slots.iter().map(HostClassSlot::from).collect(); } + +const CLASS_FILE_HEADER: &str = "iw4l-classes 1"; + +#[derive(Resource, Default)] +pub(crate) struct ClassStoreFile { + path: Option, + loaded: bool, + written: Option, + retry_at: Option, +} + +fn clean_field(value: &str) -> String { + value.replace(['\t', '\n', '\r'], " ") +} + +fn encode_slots(slots: &[ClassSlotState]) -> String { + let mut out = String::from(CLASS_FILE_HEADER); + out.push('\n'); + for slot in slots { + let attachments = |list: &[String]| { + list.iter() + .map(|value| clean_field(value).replace(',', " ")) + .collect::>() + .join(",") + }; + let fields = [ + clean_field(&slot.name), + clean_field(&slot.primary), + attachments(&slot.primary_attachments), + clean_field(&slot.secondary), + attachments(&slot.secondary_attachments), + clean_field(&slot.lethal), + clean_field(&slot.tactical), + clean_field(&slot.perk1), + clean_field(&slot.perk2), + clean_field(&slot.perk3), + clean_field(&slot.deathstreak), + ]; + out.push_str(&fields.join("\t")); + out.push('\n'); + } + out +} + +fn decode_slots(text: &str) -> Option> { + let mut lines = text.lines(); + if lines.next()? != CLASS_FILE_HEADER { + return None; + } + let attachments = |field: &str| -> Vec { + field + .split(',') + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect() + }; + let mut slots = Vec::new(); + for line in lines.filter(|line| !line.is_empty()) { + let fields: Vec<&str> = line.split('\t').collect(); + let [ + name, + primary, + primary_attachments, + secondary, + secondary_attachments, + lethal, + tactical, + perk1, + perk2, + perk3, + deathstreak, + ] = fields.as_slice() + else { + return None; + }; + slots.push(ClassSlotState { + name: (*name).to_owned(), + primary: (*primary).to_owned(), + primary_attachments: attachments(*primary_attachments), + secondary: (*secondary).to_owned(), + secondary_attachments: attachments(*secondary_attachments), + lethal: (*lethal).to_owned(), + tactical: (*tactical).to_owned(), + perk1: (*perk1).to_owned(), + perk2: (*perk2).to_owned(), + perk3: (*perk3).to_owned(), + deathstreak: (*deathstreak).to_owned(), + lock_reason: None, + }); + } + (!slots.is_empty()).then_some(slots) +} + +pub(crate) fn load_class_store( + identity: Option>, + mut file: ResMut, + mut scratch: ResMut, +) { + if file.loaded { + return; + } + let Some(identity) = identity else { + return; + }; + if identity.artifacts.as_os_str().is_empty() { + return; + } + let path = identity.artifacts.join("profile").join("classes.txt"); + file.loaded = true; + match std::fs::read_to_string(&path) { + Ok(text) => match decode_slots(&text) { + Some(slots) => { + diag::info!(Ui, "classes: {} read from {}", slots.len(), path.display()); + file.written = Some(encode_slots(&slots)); + scratch.slots = slots; + scratch.reset_navigation(); + } + None => { + diag::warn!( + Ui, + "classes: {} is not a class file this build reads; file preserved", + path.display() + ); + return; + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + diag::warn!( + Ui, + "classes: cannot read {}: {error}; file preserved", + path.display() + ); + return; + } + } + file.path = Some(path); +} + +fn write_class_file(path: &std::path::Path, contents: &str) -> std::io::Result<()> { + use std::io::Write; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let pending = path.with_extension(format!("{}.tmp", std::process::id())); + let result = (|| { + let mut output = std::fs::File::create(&pending)?; + output.write_all(contents.as_bytes())?; + output.sync_all()?; + drop(output); + std::fs::rename(&pending, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&pending); + } + result +} + +pub(crate) fn save_class_store(store: Res, mut file: ResMut) { + if !file.loaded + || file + .retry_at + .is_some_and(|at| std::time::Instant::now() < at) + { + return; + } + if !store.is_changed() && file.retry_at.is_none() && file.written.is_some() { + return; + } + let Some(path) = file.path.clone() else { + return; + }; + let contents = encode_slots(&store.slots); + if file.written.as_ref() == Some(&contents) { + return; + } + match write_class_file(&path, &contents) { + Ok(()) => { + file.written = Some(contents); + file.retry_at = None; + } + Err(error) => { + diag::warn!(Ui, "classes: cannot write {}: {error}", path.display()); + file.retry_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); + } + } +} diff --git a/crates/ui/src/load_table.rs b/crates/ui/src/load_table.rs index 4237268..2b207bf 100644 --- a/crates/ui/src/load_table.rs +++ b/crates/ui/src/load_table.rs @@ -102,6 +102,7 @@ fn stage_name(id: StageId) -> &'static str { StageId::GpuTextures => "GPU textures", StageId::Pipelines => "Pipelines", StageId::RenderFrames => "Render frames", + StageId::FirstPerson => "First person", StageId::Audio => "Audio", StageId::Navigation => "Navigation", StageId::Admission => "Admission", @@ -113,6 +114,7 @@ fn count_unit(id: StageId) -> &'static str { match id { StageId::Navigation => "edges", StageId::RenderFrames => "frames", + StageId::FirstPerson => "models", StageId::Images | StageId::WorldImages | StageId::GpuTextures => "images", StageId::Pipelines => "pipelines", StageId::Shaders | StageId::Programs | StageId::ProgramMerge => "programs", diff --git a/crates/ui/src/menu.rs b/crates/ui/src/menu.rs index c600f72..2d33620 100644 --- a/crates/ui/src/menu.rs +++ b/crates/ui/src/menu.rs @@ -5,7 +5,9 @@ use frame::ClientSet; use crate::GameUiFont; use crate::class_setup::{ClassLoadoutCatalog, ClassSetupScratch}; -use crate::class_store::{SessionClassStore, sync_host_class_loadouts}; +use crate::class_store::{ + ClassStoreFile, SessionClassStore, load_class_store, save_class_store, sync_host_class_loadouts, +}; use crate::nav::{ ActivatePulse, Focus, Hover, MenuShellCmd, PointerActivation, drive_control_axes, paint_focus_help, paint_selection_bars, play_focus_sound, sync_hover_and_nav, @@ -102,6 +104,7 @@ impl Plugin for MenuPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -151,7 +154,9 @@ impl Plugin for MenuPlugin { crate::options::apply_option_intents, crate::class_setup::apply_cac_intents, crate::options::apply_window_settings, + load_class_store, sync_host_class_loadouts, + save_class_store, ) .chain(), ) diff --git a/crates/weapon_iw4/src/pm_weapon.rs b/crates/weapon_iw4/src/pm_weapon.rs index 9b00b5a..64be0d2 100644 --- a/crates/weapon_iw4/src/pm_weapon.rs +++ b/crates/weapon_iw4/src/pm_weapon.rs @@ -34,6 +34,9 @@ pub struct CapturedCombatInput { pub fire_delay_ms: i32, pub raise_time_ms: i32, pub drop_time_ms: i32, + pub alternate_weapon: u32, + pub alternate_raise_time_ms: i32, + pub alternate_drop_time_ms: i32, pub reload_time_ms: i32, pub reload_empty_time_ms: i32, pub clip_size: i32, @@ -145,6 +148,9 @@ pub struct WeaponCombatFacts { pub fire_delay_ms: i32, pub raise_time_ms: i32, pub drop_time_ms: i32, + pub alternate_weapon: u32, + pub alternate_raise_time_ms: i32, + pub alternate_drop_time_ms: i32, pub reload_time_ms: i32, pub reload_empty_time_ms: i32, pub clip_size: i32, @@ -275,6 +281,9 @@ impl WeaponCombatFacts { fire_delay_ms: 0, raise_time_ms: 0, drop_time_ms: 0, + alternate_weapon: 0, + alternate_raise_time_ms: 0, + alternate_drop_time_ms: 0, reload_time_ms: 0, reload_empty_time_ms: 0, clip_size: 0, @@ -385,6 +394,9 @@ impl WeaponCombatFacts { fire_delay_ms: input.fire_delay_ms, raise_time_ms: input.raise_time_ms, drop_time_ms: input.drop_time_ms, + alternate_weapon: input.alternate_weapon, + alternate_raise_time_ms: input.alternate_raise_time_ms, + alternate_drop_time_ms: input.alternate_drop_time_ms, reload_time_ms: input.reload_time_ms, reload_empty_time_ms: input.reload_empty_time_ms, clip_size: input.clip_size, @@ -623,6 +635,8 @@ pub struct WeaponCmd { pub cmd_weapon_pistol_quick: bool, pub switch_raise_time_ms: i32, + pub switch_alternate_raise_time_ms: i32, + pub alternate_switch: bool, pub switch_quick_raise_time_ms: i32, @@ -656,6 +670,8 @@ impl Default for WeaponCmd { cmd_weapon_owned: false, cmd_weapon_pistol_quick: false, switch_raise_time_ms: 0, + switch_alternate_raise_time_ms: 0, + alternate_switch: false, switch_quick_raise_time_ms: 0, offhand: crate::offhand::OffhandCmd::default(), perks0: 0, @@ -731,6 +747,7 @@ pub enum WeaponTickEvent { DropFinished, PutawayStarted, + AlternateStarted, RaiseStarted, @@ -879,11 +896,13 @@ pub fn pm_weapon_ordinary( event = Some(WeaponTickEvent::DropFinished); } else { crate::weapon_change::finish_putaway_to_cmd(hand, cmd); - event = if hand.weapon != 0 { - Some(WeaponTickEvent::RaiseStarted) - } else { - Some(WeaponTickEvent::DropFinished) - }; + if event != Some(WeaponTickEvent::AlternateStarted) { + event = if hand.weapon != 0 { + Some(WeaponTickEvent::RaiseStarted) + } else { + Some(WeaponTickEvent::DropFinished) + }; + } } } } diff --git a/crates/weapon_iw4/src/weapon_change.rs b/crates/weapon_iw4/src/weapon_change.rs index 5b4c852..156c602 100644 --- a/crates/weapon_iw4/src/weapon_change.rs +++ b/crates/weapon_iw4/src/weapon_change.rs @@ -91,7 +91,17 @@ pub fn pm_weapon_check_for_change( } if cmd_w == 0 || cmd.cmd_weapon_owned { let quick = cmd.mantle_quick_raise || cmd.cmd_weapon_pistol_quick; - return pm_begin_weapon_change(hand, facts, cmd_w, quick, cmd.pm_flags); + let event = pm_begin_weapon_change(hand, facts, cmd_w, quick, cmd.pm_flags); + if cmd.alternate_switch + && event.is_some() + && cmd.pm_flags & crate::sprint::PMF_SPRINTING == 0 + { + hand.weaponstate = WeaponState::DroppingAltswitch as i32; + hand.weapon_time = facts.alternate_drop_time_ms; + crate::weap_anim::pm_start_weapon_anim(&mut hand.weap_anim, 0x11); + return Some(WeaponTickEvent::AlternateStarted); + } + return event; } return None; } @@ -180,16 +190,27 @@ pub fn finish_putaway_to_cmd(hand: &mut WeaponHandState, cmd: &WeaponCmd) { } let quick = hand.weaponstate == WeaponState::DroppingQuick as i32; + let alternate = hand.weaponstate == WeaponState::DroppingAltswitch as i32; hand.weapon = new_weapon; hand.weapon_delay = 0; hand.shot_count = 0; hand.burst_latch = false; hand.rechamber_pending = false; - hand.weaponstate = WeaponState::Raising as i32; - hand.weapon_time = raise_time_for_cmd(cmd, quick); + hand.weaponstate = if alternate { + WeaponState::RaisingAltswitch + } else { + WeaponState::Raising + } as i32; + hand.weapon_time = if alternate { + cmd.switch_alternate_raise_time_ms + } else { + raise_time_for_cmd(cmd, quick) + }; crate::weap_anim::pm_start_weapon_anim( &mut hand.weap_anim, - if quick { + if alternate { + 0x12 + } else if quick { crate::weap_anim::weap_anim_event::QUICK_RAISE } else { crate::weap_anim::weap_anim_event::RAISE diff --git a/crates/xmodel_runtime/src/dobj.rs b/crates/xmodel_runtime/src/dobj.rs index 34f9a20..3477af0 100644 --- a/crates/xmodel_runtime/src/dobj.rs +++ b/crates/xmodel_runtime/src/dobj.rs @@ -1,7 +1,6 @@ use anim_iw4::{ Local, PartBits, QUAT_IDENTITY, VEC3_ZERO, compose_translation, dobj_surface_hidden, - hide_part_bit, normalize, quat_add_weighted, quat_dot, quat_neg, quat16, set_hide_part_bit, - xmodel_no_scale_bit, + hide_part_bit, normalize, quat_add_weighted, quat16, set_hide_part_bit, xmodel_no_scale_bit, }; use glam::{Mat4, Quat, Vec3}; @@ -346,11 +345,6 @@ impl DObj { trans_weight[bone] += anim.weight; let q = sample.rotation.unwrap_or(QUAT_IDENTITY); - let q = if quat_dot(rotation[bone], q) < 0.0 { - quat_neg(q) - } else { - q - }; rotation[bone] = quat_add_weighted(rotation[bone], q, anim.weight); if let Some(t) = sample.translation { diff --git a/docs/ANIM.md b/docs/ANIM.md index 8580a9b..38a6f79 100644 --- a/docs/ANIM.md +++ b/docs/ANIM.md @@ -45,5 +45,10 @@ gap, not "ordinary blending". FPV actions come from the presented `weapAnim`, **never** from the keyboard; the identity of the FPV mesh is `weapon_iw4::bg_get_viewmodel_weapon_index`. + The FPV consumer reads `PreparedFpv` only, never the weapon registry: views, + rigs and material bindings are built before Ready; an equip picks one, and a + mandatory material that failed is a refusal naming model, surface and + material. Remote kits (`PreparedRemoteKits`: models, hide bits, shared DObj) + and dropped items are composed at install, not on first sight. To watch it live: `IW4L_PERF=1` / [`PERF.md`](PERF.md). diff --git a/docs/MAP-LOAD.md b/docs/MAP-LOAD.md index 6191281..6bd5a8b 100644 --- a/docs/MAP-LOAD.md +++ b/docs/MAP-LOAD.md @@ -42,6 +42,16 @@ The walk graph is cached (`nav`), keyed by the content digest with the bake's schema and hull, so the second start of a map reads it back instead of walking the grid again; a match teardown drops the in-memory copy, not the file. +`MatchLoadOutcome::Ready` is the CPU package, not render-ready. The loading +screen also holds for GPU images, every queued pipeline (the HUD blood film is +queued as soon as its material exists) and the `first_person` stage: every +weapon's first-person materials admitted and compositions laid out +(`render_anim::PreparedFpv`), and every body, world-weapon, map-model and +projectile material plus the single-model DObjs (`PreparedModelMaterials`). +Remote kits and dropped-item compositions are built at install. +`load ledger:` in the log is one line per load of +what was handed over or compiled new against what an earlier load left behind. + ## Cache, and poking it: `iw4l-artifacts/cache///` Content-addressed leaf in `asset_transport::artifact_cache` (`cache_get` / diff --git a/docs/RUN.md b/docs/RUN.md index 0f3ae9b..d1b8296 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -56,3 +56,5 @@ received plus its own presented state; `demo LATEST` plays it back. magazine will not reload itself (`press +attack` ×N, then `press +reload`); * `look` without `LookState` only writes `ps.viewangles` — no aiming; * `give` takes a namespace: `give t5:weapon/psg1_acog`, `give iw5:weapon/msr`. +* custom classes live in `iw4l-artifacts/profile/classes.txt` (one tab-separated + row per class); delete it to get the presets back.