From 63e9344a301a70f40da84150e8a4f42bba963f51 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Mon, 12 Oct 2026 09:58:43 -0230 Subject: [PATCH 1/8] init --- game_patch/CMakeLists.txt | 2 + game_patch/misc/alpine_settings.cpp | 5 + game_patch/misc/alpine_settings.h | 1 + game_patch/multi/demo/demo_playback.cpp | 2 + .../multi/vehicles/physics/vphys_internal.h | 2 - game_patch/multi/vehicles/vehicle.cpp | 4 + game_patch/multi/vehicles/vehicle_fx.cpp | 946 ++++++++++++++++++ game_patch/multi/vehicles/vehicle_fx.h | 11 + game_patch/multi/vehicles/vehicle_physics.h | 5 + game_patch/rf/particle_emitter.h | 23 + game_patch/rf/physics.h | 16 + 11 files changed, 1015 insertions(+), 2 deletions(-) create mode 100644 game_patch/multi/vehicles/vehicle_fx.cpp create mode 100644 game_patch/multi/vehicles/vehicle_fx.h diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index 9bd979efa..1e97e923b 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -192,6 +192,8 @@ set(SRCS multi/vehicles/vehicle_markers.cpp multi/vehicles/vehicle_tracers.h multi/vehicles/vehicle_tracers.cpp + multi/vehicles/vehicle_fx.cpp + multi/vehicles/vehicle_fx.h multi/vehicles/vehicle_horn.h multi/vehicles/vehicle_horn.cpp multi/vehicles/vehicle_physics.h diff --git a/game_patch/misc/alpine_settings.cpp b/game_patch/misc/alpine_settings.cpp index b0ca30254..e742bf22a 100644 --- a/game_patch/misc/alpine_settings.cpp +++ b/game_patch/misc/alpine_settings.cpp @@ -1060,6 +1060,10 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.vehicle_tracers = std::stoi(settings["VehicleTracers"]); processed_keys.insert("VehicleTracers"); } + if (settings.count("VehicleEffects")) { + g_alpine_game_config.vehicle_effects = std::stoi(settings["VehicleEffects"]); + processed_keys.insert("VehicleEffects"); + } if (settings.count("VehicleTracerFrequency")) { g_alpine_game_config.set_vehicle_tracer_frequency(std::stoi(settings["VehicleTracerFrequency"])); processed_keys.insert("VehicleTracerFrequency"); @@ -1737,6 +1741,7 @@ void alpine_player_settings_save(rf::Player* player) file << "VehicleCamBounce=" << g_alpine_game_config.vehicle_cam_bounce << "\n"; file << "VehicleCamLag=" << g_alpine_game_config.vehicle_cam_lag << "\n"; file << "VehicleTracers=" << g_alpine_game_config.vehicle_tracers << "\n"; + file << "VehicleEffects=" << g_alpine_game_config.vehicle_effects << "\n"; file << "VehicleTracerFrequency=" << g_alpine_game_config.vehicle_tracer_frequency << "\n"; file << "VehicleTracerColor=" << format_hex_color_string(g_alpine_game_config.vehicle_tracer_color) << "\n"; file << "VehicleTracerLength=" << g_alpine_game_config.vehicle_tracer_length << "\n"; diff --git a/game_patch/misc/alpine_settings.h b/game_patch/misc/alpine_settings.h index 8f525149a..2ade13c04 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -191,6 +191,7 @@ struct AlpineGameSettings } bool vehicle_tracers = true; + bool vehicle_effects = true; static constexpr int default_vehicle_tracer_frequency = 1; static constexpr int min_vehicle_tracer_frequency = 1; static constexpr int max_vehicle_tracer_frequency = 10; diff --git a/game_patch/multi/demo/demo_playback.cpp b/game_patch/multi/demo/demo_playback.cpp index 05712a6bd..c80fe90dd 100644 --- a/game_patch/multi/demo/demo_playback.cpp +++ b/game_patch/multi/demo/demo_playback.cpp @@ -20,6 +20,7 @@ #include "../awards.h" #include "../vehicles/vehicle.h" #include "../vehicles/vehicle_tracers.h" +#include "../vehicles/vehicle_fx.h" #include "../../misc/misc.h" #include "../../misc/alpine_settings.h" #include "../../hud/multi_spectate.h" @@ -754,6 +755,7 @@ namespace rf::particle_level_release(); explosion_flash_lights_destroy_all(); vehicle_tracers_clear(); + vehicle_fx_clear(); } // A burst that ends between a fire-ON and fire-OFF obj_update leaves the entity's diff --git a/game_patch/multi/vehicles/physics/vphys_internal.h b/game_patch/multi/vehicles/physics/vphys_internal.h index cea1fd737..508cff8cd 100644 --- a/game_patch/multi/vehicles/physics/vphys_internal.h +++ b/game_patch/multi/vehicles/physics/vphys_internal.h @@ -535,8 +535,6 @@ void vehicle_physics_notify_geomod(const rf::Vector3& pos, float radius); bool mover_brush_is_solid(const rf::Object& mb); // ---- vphys_car.cpp ---- -// collide_linesegment_world plus the mover-brush solidity rule above. `flags` are rf::CollideFlags. -bool vphys_collide_solid_segment(const rf::Vector3& a, const rf::Vector3& b, int flags, rf::PCollisionOut& out); void car_teardown(VehicleSimBody& b); HullBox hull_contact_box(const HullBox& base, const VehiclePhysicsParams& p, float bottom_raise); // Splits the trimmed contact box into the chassis's collision parts; returns how many (1 or 2). diff --git a/game_patch/multi/vehicles/vehicle.cpp b/game_patch/multi/vehicles/vehicle.cpp index a0183e235..972282de2 100644 --- a/game_patch/multi/vehicles/vehicle.cpp +++ b/game_patch/multi/vehicles/vehicle.cpp @@ -16,6 +16,7 @@ #include "vehicle_render.h" #include "vehicle_spawn.h" #include "vehicle_tracers.h" +#include "vehicle_fx.h" #include "../alpine_packets.h" #include "../multi.h" #include "../server_internal.h" @@ -428,6 +429,7 @@ void vehicle_client_do_frame() if (!rf::is_dedicated_server) { vehicle_update_jeep_wheels(); vehicle_update_treads(); + vehicle_fx_do_frame(); // Ahead of the engine frame the hook wraps (0x004B2D90), so the cockpit is stepped first. vehicle_process_spectated_cockpit(); } @@ -697,6 +699,7 @@ void vehicle_level_init() vehicle_view_level_init(); vehicle_markers_level_init(); vehicle_tracers_level_init(); + vehicle_fx_level_init(); // The bm cache is rebuilt per level, so a handle resolved for the last one means nothing here. vehicle_tread_runtime_reset(); vehicle_ammo_regen_runtime_reset(); @@ -928,5 +931,6 @@ void vehicle_apply_patches() vehicle_spawn_install(); vehicle_tracers_install(); + vehicle_fx_install(); vehicle_horn_install(); } diff --git a/game_patch/multi/vehicles/vehicle_fx.cpp b/game_patch/multi/vehicles/vehicle_fx.cpp new file mode 100644 index 000000000..b10493a6f --- /dev/null +++ b/game_patch/multi/vehicles/vehicle_fx.cpp @@ -0,0 +1,946 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "vehicle.h" +#include "vehicle_fx.h" +#include "vehicle_internal.h" +#include "vehicle_physics.h" +#include "../demo/demo.h" +#include "../../graphics/weather.h" +#include "../../main/main.h" +#include "../../misc/alpine_settings.h" +#include "../../misc/level.h" +#include "../../os/console.h" +#include "../../rf/bmpman.h" +#include "../../rf/collide.h" +#include "../../rf/entity.h" +#include "../../rf/geometry.h" +#include "../../rf/multi.h" +#include "../../rf/object.h" +#include "../../rf/os/console.h" +#include "../../rf/os/frametime.h" +#include "../../rf/particle_emitter.h" +#include "../../rf/physics.h" +#include "../../rf/player/camera.h" +#include "../../rf/player/player.h" + +namespace +{ + // Every emitter's particles live in pool 1; the effects leave most of it to the level's own. + constexpr int fx_particle_pool = 1; + constexpr int fx_max_live_particles = 400; + constexpr float fx_pool_headroom = 0.8f; + // The engine's emitter records serve everything, entity fire (four per burning entity) and the + // flamethrower included, and those silently go without when none is free. + constexpr int fx_max_emitters = 40; + constexpr int fx_min_free_engine_emitters = 32; + // An emitter with nothing left to draw is handed back after this long. + constexpr float fx_emitter_idle_s = 1.0f; + + // Beyond these camera distances a hull emits nothing (smoke and fire carry further). + constexpr float fx_near_dist = 40.0f; + constexpr float fx_far_dist = 120.0f; + constexpr float fx_smoke_far_dist = 200.0f; + + constexpr float fx_probe_interval = 0.05f; + // A step faster than any hull moves is a placement or a network correction, not driving. + constexpr float fx_max_speed = 80.0f; + + constexpr float fx_impact_min_dv = 6.0f; + constexpr float fx_impact_full_dv = 20.0f; + constexpr float fx_impact_cooldown = 0.4f; + constexpr float fx_impact_miss_cooldown = 0.1f; + // A landing needs the hull to have been off the ground this recently; the foot of a slope is not one. + constexpr float fx_landing_airborne_window = 0.3f; + constexpr float fx_ground_slack = 0.5f; + + constexpr float fx_downwash_height = 10.0f; + // particle_create applies gravity as gravity_scale * 9.8 (0x00589858). + constexpr float fx_gravity = 9.8f; + + enum FxBitmap + { + FXB_SMOKE, + FXB_DARK_SMOKE, + FXB_SPARK, + FXB_FIRE, + FXB_BUBBLE, + FXB_COUNT, + }; + + // Stock bitmaps the stock emitters.tbl already uses. + constexpr const char* fx_bitmap_filenames[FXB_COUNT] = { + "smoketest.tga", + "smokedark.tga", + "Flame01_02.tga", + "Fire01.tga", + "tinybubbles_A.tga", + }; + + struct ParticleSpec + { + FxBitmap bitmap; + rf::Color clr; + rf::Color clr_dest; + float min_radius; + float max_radius; + float growth_rate; + float min_life; + float max_life; + float gravity_scale; // 0: no gravity + float acceleration; // 0: speed kept + unsigned flags; // on top of colour fade, gravity and acceleration + }; + + constexpr ParticleSpec spark_spec{ + FXB_SPARK, {255, 255, 255, 255}, {128, 128, 128, 0}, 0.05f, 0.08f, 0.0f, 0.15f, 0.35f, 2.0f, 0.0f, + rf::PTF_GLOW | rf::PTF_VEL_STRETCH, + }; + // The bubble bitmap's white specks read as droplets; the stock corona has an orange core. + constexpr ParticleSpec spray_spec{ + FXB_BUBBLE, {220, 235, 255, 220}, {220, 235, 255, 0}, 0.12f, 0.25f, -0.1f, 0.4f, 0.8f, 1.0f, 0.0f, + rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT, + }; + constexpr ParticleSpec mist_spec{ + FXB_SMOKE, {225, 235, 245, 110}, {225, 235, 245, 0}, 0.3f, 0.6f, 1.5f, 0.8f, 1.4f, -0.05f, -1.5f, + rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT, + }; + constexpr ParticleSpec smoke_spec{ + FXB_SMOKE, {120, 120, 120, 90}, {90, 90, 90, 0}, 0.3f, 0.5f, 0.4f, 1.2f, 2.0f, -0.12f, -1.0f, + rf::PTF_RANDOM_ORIENT, + }; + constexpr ParticleSpec dark_smoke_spec{ + FXB_DARK_SMOKE, {255, 255, 255, 120}, {255, 255, 255, 0}, 0.4f, 0.6f, 0.5f, 1.4f, 2.2f, -0.15f, -1.0f, + rf::PTF_RANDOM_ORIENT, + }; + constexpr ParticleSpec fire_spec{ + FXB_FIRE, {255, 255, 255, 170}, {255, 255, 255, 0}, 0.25f, 0.45f, -0.3f, 0.3f, 0.7f, -0.6f, -3.0f, + rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT, + }; + constexpr ParticleSpec bubble_spec{ + FXB_BUBBLE, {255, 255, 255, 160}, {255, 255, 255, 0}, 0.05f, 0.12f, 0.0f, 1.0f, 2.0f, -0.5f, 0.0f, 0, + }; + + constexpr int fx_num_layers = FXB_COUNT * 2; + + int fx_layer(const ParticleSpec& s) + { + return s.bitmap * 2 + ((s.flags & rf::PTF_GLOW) ? 1 : 0); + } + + // How a surface answers a wheel or a blow. Dust colour is the start colour; it fades to clear. + struct SurfaceFx + { + float dust; // 0 kicks up nothing + rf::Color dust_clr; + bool sparks; // hard enough to strike sparks + }; + + SurfaceFx surface_fx(int material) + { + switch (material) { + case rf::MATERIAL_ROCK: + return {0.8f, {200, 190, 180, 150}, true}; + case rf::MATERIAL_METAL: + return {0.0f, {}, true}; + case rf::MATERIAL_SOLID: + return {0.5f, {200, 200, 200, 130}, true}; + case rf::MATERIAL_SAND: + return {1.3f, {225, 200, 160, 170}, false}; + case rf::MATERIAL_ICE: + return {1.0f, {240, 245, 255, 170}, false}; + case rf::MATERIAL_FLESH: + case rf::MATERIAL_WATER: + case rf::MATERIAL_LAVA: + case rf::MATERIAL_GLASS: + return {0.0f, {}, false}; + default: + return {1.0f, {210, 185, 150, 150}, false}; + } + } + + // Dust alternates two layers. The stock smoke bitmaps are mid-grey at best, so a blended puff gives the + // cloud its body but can only darken, and an additive one (like the stock fighter engine steam) lifts + // it to the surface's colour. + ParticleSpec dust_spec(const SurfaceFx& s, float size, bool haze) + { + rf::Color clr = haze ? s.dust_clr : rf::Color{255, 250, 240, static_cast(s.dust_clr.alpha / 3)}; + rf::Color dest = clr; + dest.alpha = 0; + const unsigned flags = haze ? rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT : rf::PTF_RANDOM_ORIENT; + return {FXB_SMOKE, clr, dest, 0.25f * size, 0.45f * size, 0.8f, 0.6f, 1.1f, -0.05f, -2.0f, flags}; + } + + struct GroundProbe + { + bool hit = false; + float dist = 0.0f; // from the hull origin along hull-down + int material = rf::MATERIAL_DEFAULT; + rf::GRoom* room = nullptr; + }; + + // What a fighter's thrust is blowing on, if anything is close enough. + struct WashTarget + { + bool valid = false; + bool water = false; + float surface_y = 0.0f; + int material = rf::MATERIAL_DEFAULT; + }; + + struct HullFx + { + // One per bitmap and blend, so each draws as a single batch: the renderer flushes on every + // texture or mode change, and one mixed list changes on nearly every particle. + std::array emitters{}; + std::array emitter_idle{}; + rf::Timestamp emitter_retry; + rf::GRoom* room = nullptr; + uint32_t spawn_seq = 0; + + rf::Vector3 last_pos{}; + bool has_last = false; + rf::Vector3 vel{}; // fast, what the hull is doing now + rf::Vector3 vel_slow{}; // what it was doing a moment ago, for impacts + + float probe_timer = 0.0f; + GroundProbe ground; + WashTarget wash; + float airborne_age = fx_landing_airborne_window; // since the probe last saw the hull off the ground + float scrape_timer = 0.0f; + float impact_cooldown = 0.0f; + bool bottom_in_water = false; + + float dust_accum = 0.0f; + float spray_accum = 0.0f; + float wash_accum = 0.0f; + float smoke_accum = 0.0f; + float fire_accum = 0.0f; + float bubble_accum = 0.0f; + }; + + std::unordered_map g_hull_fx; + int g_fx_bitmaps[FXB_COUNT] = {-1, -1, -1, -1, -1}; + bool g_fx_bitmaps_loaded = false; + int g_fx_live_particles = 0; + int g_fx_num_emitters = 0; + // This frame's simulated time: a paused demo stops the particles, and a demo timescale moves the hulls. + float g_fx_dt = 0.0f; + + float fx_rand(float lo, float hi) + { + return std::uniform_real_distribution{lo, hi}(g_rng); + } + + rf::Vector3 fx_rand_dir() + { + rf::Vector3 v{fx_rand(-1.0f, 1.0f), fx_rand(-1.0f, 1.0f), fx_rand(-1.0f, 1.0f)}; + v.normalize_safe(); + return v; + } + + // Random, but never into the surface it came off. + rf::Vector3 fx_rand_dir_off(const rf::Vector3& normal) + { + rf::Vector3 v = fx_rand_dir(); + const float d = v.dot_prod(normal); + if (d < 0.0f) { + v -= normal * (2.0f * d); + } + return v; + } + + // Whole spawns this frame from a rate; the remainder carries to the next frame. + int fx_take(float& accum, float rate) + { + accum += std::max(rate, 0.0f) * g_fx_dt; + const int n = static_cast(accum); + accum -= static_cast(n); + return n; + } + + // Alternates spawn sites across frames: at these rates one frame rarely spawns more than one. + uint32_t fx_next(HullFx& fx) + { + return fx.spawn_seq++; + } + + void fx_load_bitmaps() + { + if (g_fx_bitmaps_loaded) { + return; + } + g_fx_bitmaps_loaded = true; + for (int i = 0; i < FXB_COUNT; ++i) { + g_fx_bitmaps[i] = rf::bm::load(fx_bitmap_filenames[i], -1, true); + if (g_fx_bitmaps[i] < 0) { + xlog::warn("[vehicle_fx] failed to load bitmap '{}'", fx_bitmap_filenames[i]); + } + } + } + + bool fx_budget_left() + { + const rf::ParticlePool& pool = rf::particle_pools[fx_particle_pool]; + return g_fx_live_particles < fx_max_live_particles + && pool.num_live < static_cast(static_cast(pool.capacity) * fx_pool_headroom); + } + + // Created on the first particle a layer actually spawns, so a quiet hull holds no engine record. + rf::ParticleEmitter* fx_ensure_emitter(HullFx& fx, int layer) + { + rf::ParticleEmitter*& emitter = fx.emitters[layer]; + if (emitter) { + return emitter; + } + if (!fx.room || g_fx_num_emitters >= fx_max_emitters + || rf::particle_emitter_num_used >= rf::particle_emitter_capacity - fx_min_free_engine_emitters + || (fx.emitter_retry.valid() && !fx.emitter_retry.elapsed())) { + return nullptr; + } + // A container only: never switched on and never updated, so it spawns nothing itself. Its + // particles come from particle_create, and the engine simulates, culls and draws them as its own. + rf::ParticleEmitterType type{}; + type.min_spawn_delay = 1.0f; + type.max_spawn_delay = 1.0f; + type.bitmap = g_fx_bitmaps[FXB_SMOKE]; + type.num_frames = 1; + rf::Vector3 pos = fx.last_pos; + // Unparented and carried by hand: particle_emitter_create never links an emitter for ticking. + emitter = rf::particle_emitter_create(0, type, fx.room, pos, false); + if (!emitter) { + fx.emitter_retry.set(1000); + return nullptr; + } + emitter->pos = fx.last_pos; + emitter->room = fx.room; + fx.emitter_idle[layer] = 0.0f; + ++g_fx_num_emitters; + return emitter; + } + + // Destroying hands the live particles to the engine's orphan list, which still simulates and + // draws them, so nothing on screen is cut short. + void fx_destroy_emitter(HullFx& fx, int layer) + { + if (rf::ParticleEmitter*& emitter = fx.emitters[layer]) { + emitter->destroy(); + emitter = nullptr; + --g_fx_num_emitters; + } + } + + void fx_destroy_emitters(HullFx& fx) + { + for (int layer = 0; layer < fx_num_layers; ++layer) { + fx_destroy_emitter(fx, layer); + } + } + + void fx_spawn(HullFx& fx, const ParticleSpec& s, const rf::Vector3& pos, const rf::Vector3& vel, + float size = 1.0f, float max_life = 0.0f) + { + if (g_fx_bitmaps[s.bitmap] < 0 || !fx_budget_left()) { + return; + } + rf::ParticleEmitter* const emitter = fx_ensure_emitter(fx, fx_layer(s)); + if (!emitter || !emitter->room) { + return; + } + rf::ParticleCreateInfo pci{}; + pci.pos = pos; + pci.vel = vel; + pci.radius = fx_rand(s.min_radius, s.max_radius) * size; + pci.growth_rate = s.growth_rate * size; + pci.acceleration = s.acceleration; + pci.gravity_scale = s.gravity_scale; + pci.lifetime_seconds = fx_rand(s.min_life, s.max_life); + if (max_life > 0.0f) { + pci.lifetime_seconds = std::min(pci.lifetime_seconds, max_life); + } + pci.bitmap_handle = g_fx_bitmaps[s.bitmap]; + pci.num_frames = 1; + pci.clr = s.clr; + pci.clr_dest = s.clr_dest; + pci.flags = s.flags | rf::PTF_CLR_CHANGE; + if (s.gravity_scale != 0.0f) { + pci.flags |= rf::PTF_GRAVITY; + } + if (s.acceleration != 0.0f) { + pci.flags |= rf::PTF_ACCELERATE; + } + // Decoration only: never a damaging particle. + pci.flags2 = 0; + // Linked into the hull's emitter, so it is drawn whenever that emitter's room is. + rf::particle_create(fx_particle_pool, pci, emitter->room, &pci.pos, 0, nullptr, emitter); + ++g_fx_live_particles; + } + + // A bubble that pops at the surface instead of rising out of it. + void fx_bubble(HullFx& fx, const rf::Vector3& pos, const rf::Vector3& vel, float liquid_y) + { + const float depth = liquid_y - pos.y; + if (depth <= 0.05f) { + return; + } + const float rise_accel = -bubble_spec.gravity_scale * fx_gravity; + const float time_to_surface = (std::sqrt(vel.y * vel.y + 2.0f * rise_accel * depth) - vel.y) / rise_accel; + fx_spawn(fx, bubble_spec, pos, vel, 1.0f, 0.9f * time_to_surface); + } + + void fx_burst_off(HullFx& fx, const ParticleSpec& s, int count, const rf::Vector3& pos, const rf::Vector3& normal, + float speed) + { + for (int i = 0; i < count; ++i) { + fx_spawn(fx, s, pos, fx_rand_dir_off(normal) * (speed * fx_rand(0.4f, 1.0f))); + } + } + + // A ring on the surface at `center`, thrown outward and a little up. + void fx_ring(HullFx& fx, const ParticleSpec& s, int count, const rf::Vector3& center, float radius, + float out_speed, float up_speed, float size = 1.0f) + { + for (int i = 0; i < count; ++i) { + const float a = fx_rand(0.0f, 6.2831853f); + const rf::Vector3 out{std::cos(a), 0.0f, std::sin(a)}; + const rf::Vector3 pos = center + out * (radius * fx_rand(0.3f, 1.0f)) + rf::Vector3{0.0f, 0.1f, 0.0f}; + const rf::Vector3 vel = + out * (out_speed * fx_rand(0.5f, 1.0f)) + rf::Vector3{0.0f, up_speed * fx_rand(0.4f, 1.0f), 0.0f}; + fx_spawn(fx, s, pos, vel, size); + } + } + + bool fx_cast(const rf::Vector3& from, const rf::Vector3& to, rf::PCollisionOut& out) + { + return vphys_collide_solid_segment(from, to, rf::CF_PROCESS_INVISIBLE_FACES, out); + } + + rf::GRoom* fx_hit_room(const rf::PCollisionOut& o) + { + return o.hit_face ? o.hit_face->which_room : nullptr; + } + + // The liquid surface around the hull, from its room or the room the ground probe hit. + std::optional fx_liquid_surface(const rf::Entity& ep, const GroundProbe& ground) + { + float y = 0.0f; + if (room_liquid_surface_y(ep.room, y)) { + return y; + } + if (ground.hit && room_liquid_surface_y(ground.room, y)) { + return y; + } + return std::nullopt; + } + + void fx_probe_ground(rf::Entity& ep, HullFx& fx) + { + const rf::Vector3 down = ep.orient.uvec * -1.0f; + const float reach_down = vehicle_collision_reach_along(&ep, down); + rf::PCollisionOut o{}; + GroundProbe g; + if (fx_cast(ep.pos, ep.pos + down * (reach_down + 1.5f), o)) { + g.hit = true; + g.dist = (o.hit_point - ep.pos).dot_prod(down); + g.material = o.material; + g.room = fx_hit_room(o); + } + fx.ground = g; + if (!g.hit || g.dist > reach_down + fx_ground_slack) { + fx.airborne_age = 0.0f; + } + } + + bool fx_grounded(rf::Entity& ep, const HullFx& fx) + { + return fx.ground.hit + && fx.ground.dist <= vehicle_collision_reach_along(&ep, ep.orient.uvec * -1.0f) + fx_ground_slack; + } + + void fx_probe_wash(rf::Entity& ep, HullFx& fx) + { + fx.wash = {}; + const float reach_down = vehicle_collision_reach_along(&ep, rf::Vector3{0.0f, -1.0f, 0.0f}); + rf::PCollisionOut o{}; + const bool hit = fx_cast(ep.pos, ep.pos - rf::Vector3{0.0f, reach_down + fx_downwash_height, 0.0f}, o); + // The ray passes through water and stops on the bed, whose room carries the surface. + float liquid_y = 0.0f; + if ((room_liquid_surface_y(ep.room, liquid_y) || (hit && room_liquid_surface_y(fx_hit_room(o), liquid_y))) + && liquid_y < ep.pos.y && (!hit || liquid_y > o.hit_point.y)) { + fx.wash = {true, true, liquid_y, rf::MATERIAL_WATER}; + } + else if (hit) { + fx.wash = {true, false, o.hit_point.y, o.material}; + } + } + + bool fx_local_player_rides(const rf::Entity& ep) + { + return rf::local_player_entity && rf::local_player_entity->host_handle == ep.handle; + } + + void fx_impact(rf::Entity& ep, HullFx& fx, const rf::PCollisionOut& hit, float strength, float lod, + std::optional water_y) + { + if (water_y) { + const rf::Vector3 surface{hit.hit_point.x, *water_y, hit.hit_point.z}; + fx_ring(fx, spray_spec, static_cast((12.0f + 30.0f * strength) * lod), surface, 2.0f, + 3.0f + 4.0f * strength, 3.0f + 4.0f * strength); + fx_ring(fx, mist_spec, static_cast((4.0f + 8.0f * strength) * lod), surface, 2.0f, 1.5f, 1.0f, 1.5f); + } + else { + const SurfaceFx s = surface_fx(hit.material); + if (s.dust > 0.0f) { + const int count = static_cast((3.0f + 9.0f * strength) * s.dust * lod); + for (const bool haze : {false, true}) { + fx_ring(fx, dust_spec(s, 1.0f + strength, haze), count, hit.hit_point, 2.0f, + 2.0f + 4.0f * strength, 0.5f + 1.0f * strength); + } + } + if (s.sparks) { + fx_burst_off(fx, spark_spec, static_cast((6.0f + 18.0f * strength) * lod), hit.hit_point, + hit.hit_normal, 6.0f + 8.0f * strength); + } + } + if (fx_local_player_rides(ep) && rf::local_player && rf::local_player->cam) { + rf::camera_shake(rf::local_player->cam, 0.002f + 0.006f * strength, 0.35f); + } + } + + // A sudden loss of the speed the hull had a moment ago: a landing or a wall. + void fx_detect_impacts(rf::Entity& ep, HullFx& fx, bool flyer, float lod, std::optional liquid_y) + { + fx.impact_cooldown = std::max(fx.impact_cooldown - g_fx_dt, 0.0f); + if (fx.impact_cooldown > 0.0f) { + return; + } + const bool was_airborne = fx.airborne_age < fx_landing_airborne_window; + const float land_dv = was_airborne && fx.vel_slow.y < 0.0f ? std::max(fx.vel.y - fx.vel_slow.y, 0.0f) : 0.0f; + const rf::Vector3 slow_h{fx.vel_slow.x, 0.0f, fx.vel_slow.z}; + const float slow_h_speed = slow_h.len(); + float wall_dv = 0.0f; + rf::Vector3 wall_dir{}; + if (slow_h_speed > fx_impact_min_dv) { + wall_dir = slow_h * (1.0f / slow_h_speed); + wall_dv = slow_h_speed - std::max(rf::Vector3{fx.vel.x, 0.0f, fx.vel.z}.dot_prod(wall_dir), 0.0f); + } + const float dv = std::max(land_dv, wall_dv); + if (dv < fx_impact_min_dv) { + return; + } + const float strength = + std::clamp((dv - fx_impact_min_dv) / (fx_impact_full_dv - fx_impact_min_dv), 0.15f, 1.0f); + + const rf::Vector3 dir = land_dv >= wall_dv ? rf::Vector3{0.0f, -1.0f, 0.0f} : wall_dir; + rf::PCollisionOut o{}; + if (!fx_cast(ep.pos, ep.pos + dir * (vehicle_collision_reach_along(&ep, dir) + 1.5f), o)) { + fx.impact_cooldown = fx_impact_miss_cooldown; + return; + } + const bool water = liquid_y && o.hit_point.y <= *liquid_y + 0.3f; + fx.impact_cooldown = fx_impact_cooldown; + fx.vel_slow = fx.vel; + // A ground hull's water entry is fx_plunge's. + if (water && !flyer) { + return; + } + fx_impact(ep, fx, o, strength, lod, water ? liquid_y : std::nullopt); + } + + // Body contact while moving along a wall (and, for flyers, a ceiling, or a floor while airborne). + void fx_scrape(rf::Entity& ep, HullFx& fx, bool flyer, float lod) + { + fx.scrape_timer -= g_fx_dt; + if (fx.scrape_timer > 0.0f) { + return; + } + fx.scrape_timer = fx_probe_interval; + if (fx.vel.len() < 3.0f) { + return; + } + const rf::Vector3 dirs[] = {ep.orient.rvec, ep.orient.rvec * -1.0f, ep.orient.uvec, ep.orient.uvec * -1.0f}; + // A fighter resting or taxiing on its belly is not scraping it. + const int num_dirs = !flyer ? 2 : fx_grounded(ep, fx) ? 3 : 4; + for (int i = 0; i < num_dirs; ++i) { + const rf::Vector3& dir = dirs[i]; + rf::PCollisionOut o{}; + if (!fx_cast(ep.pos, ep.pos + dir * (vehicle_collision_reach_along(&ep, dir) + 0.15f), o)) { + continue; + } + // A side ray tipped into the ground by a rolled hull finds the floor, not a wall. + if (i < 2 && o.hit_normal.dot_prod(ep.orient.uvec) > 0.5f) { + continue; + } + const rf::Vector3 along = fx.vel - o.hit_normal * fx.vel.dot_prod(o.hit_normal); + const float along_speed = along.len(); + if (along_speed < 3.0f) { + continue; + } + const SurfaceFx s = surface_fx(o.material); + const int count = static_cast(std::min(along_speed * 0.15f, 3.0f) * lod + 0.5f); + if (s.sparks) { + for (int k = 0; k < count; ++k) { + const rf::Vector3 vel = along * -fx_rand(0.2f, 0.5f) + o.hit_normal * fx_rand(1.0f, 3.0f) + + fx_rand_dir_off(o.hit_normal) * 1.5f; + fx_spawn(fx, spark_spec, o.hit_point, vel); + } + } + else if (s.dust > 0.0f) { + fx_spawn(fx, dust_spec(s, 0.8f, fx_next(fx) % 2 != 0), o.hit_point, + o.hit_normal * 0.8f + along * -0.1f); + } + } + } + + // Wheels and tracks: dust behind them on dry ground, spray where they run through water. + void fx_ground_contact(rf::Entity& ep, HullFx& fx, float lod, std::optional liquid_y) + { + const float reach_down = vehicle_collision_reach_along(&ep, ep.orient.uvec * -1.0f); + const rf::Vector3 vel_h{fx.vel.x, 0.0f, fx.vel.z}; + const float speed = vel_h.len(); + const float side_vel = fx.vel.dot_prod(ep.orient.rvec); + + const float reach_r = vehicle_collision_reach_along(&ep, ep.orient.rvec) * 0.75f; + const float reach_l = vehicle_collision_reach_along(&ep, ep.orient.rvec * -1.0f) * 0.75f; + const float reach_b = vehicle_collision_reach_along(&ep, ep.orient.fvec * -1.0f) * 0.8f; + const float reach_f = vehicle_collision_reach_along(&ep, ep.orient.fvec) * 0.8f; + const rf::Vector3 bottom = ep.pos - ep.orient.uvec * (reach_down * 0.9f); + // Rear right, rear left, front right, front left. + const rf::Vector3 contacts[] = { + bottom + ep.orient.rvec * reach_r - ep.orient.fvec * reach_b, + bottom - ep.orient.rvec * reach_l - ep.orient.fvec * reach_b, + bottom + ep.orient.rvec * reach_r + ep.orient.fvec * reach_f, + bottom - ep.orient.rvec * reach_l + ep.orient.fvec * reach_f, + }; + + if (liquid_y && bottom.y < *liquid_y) { + fx.dust_accum = 0.0f; + // Fording: the wheels are under the surface but the hull is not. Deeper, nothing shows. + if (ep.pos.y <= *liquid_y - 0.5f) { + fx.spray_accum = 0.0f; + return; + } + const int n = fx_take(fx.spray_accum, std::clamp((speed - 1.5f) * 4.0f, 0.0f, 50.0f) * lod); + for (int i = 0; i < n; ++i) { + const uint32_t seq = fx_next(fx); + const rf::Vector3& c = contacts[seq % 4]; + const rf::Vector3 side = ep.orient.rvec * ((seq % 2) ? -1.0f : 1.0f); + fx_spawn(fx, spray_spec, rf::Vector3{c.x, *liquid_y, c.z}, + side * fx_rand(1.0f, 2.5f) + vel_h * 0.3f + + rf::Vector3{0.0f, fx_rand(2.0f, 3.5f) + speed * 0.1f, 0.0f}); + } + return; + } + fx.spray_accum = 0.0f; + if (!fx_grounded(ep, fx)) { + fx.dust_accum = 0.0f; + return; + } + const SurfaceFx s = surface_fx(fx.ground.material); + if (s.dust <= 0.0f) { + return; + } + const float rate = (std::clamp((speed - 2.0f) * 1.2f, 0.0f, 16.0f) + std::min(std::abs(side_vel) * 1.5f, 10.0f)) + * s.dust * lod; + const int n = fx_take(fx.dust_accum, rate); + const float size = 1.0f + std::min(speed / 40.0f, 0.4f); + for (int i = 0; i < n; ++i) { + // Both layers at every wheel; rear wheels three times as often as the front, left and right alike. + const uint32_t seq = fx_next(fx); + const uint32_t k = seq >> 1; + const int w = static_cast(k % 8 < 6 ? k % 2 : 2 + k % 2); + const rf::Vector3 vel = vel_h * -0.12f + rf::Vector3{0.0f, fx_rand(0.4f, 1.4f), 0.0f} + + fx_rand_dir() * 0.8f + ep.orient.rvec * (side_vel * -0.2f); + fx_spawn(fx, dust_spec(s, size, (seq & 1) != 0), contacts[w] + ep.orient.uvec * 0.3f, vel); + } + } + + // The fighter's thrust flattening water or blowing dust off the ground below it. + void fx_downwash(rf::Entity& ep, HullFx& fx, float lod) + { + // A fighter sitting on its belly with a pilot aboard is not holding itself up on thrust. + if (!vehicle_driver_entity(&ep) || !fx.wash.valid || fx_grounded(ep, fx)) { + fx.wash_accum = 0.0f; + return; + } + const float reach_down = vehicle_collision_reach_along(&ep, rf::Vector3{0.0f, -1.0f, 0.0f}); + const float h = ep.pos.y - reach_down - fx.wash.surface_y; + const float closeness = std::clamp(1.0f - h / fx_downwash_height, 0.0f, 1.0f); + const SurfaceFx s = fx.wash.water ? SurfaceFx{1.0f, {}, false} : surface_fx(fx.wash.material); + if (closeness <= 0.0f || s.dust <= 0.0f) { + fx.wash_accum = 0.0f; + return; + } + const float intensity = closeness * closeness; + const rf::Vector3 center{ep.pos.x, fx.wash.surface_y, ep.pos.z}; + const float radius = 1.5f + 1.5f * intensity; + const int n = fx_take(fx.wash_accum, 45.0f * intensity * lod * s.dust); + for (int i = 0; i < n; ++i) { + if (!fx.wash.water) { + fx_ring(fx, dust_spec(s, 1.2f, fx_next(fx) % 2 != 0), 1, center, radius, 3.0f + 5.0f * intensity, + 0.4f + 0.8f * intensity); + } + else if (fx_next(fx) % 3 == 0) { + fx_ring(fx, mist_spec, 1, center, radius, 3.0f + 6.0f * intensity, 1.0f + 2.0f * intensity, 1.3f); + } + else { + fx_ring(fx, spray_spec, 1, center, radius, 3.0f + 6.0f * intensity, 1.0f + 2.0f * intensity); + } + } + } + + // The sub: bubbles off the screws when submerged, bow spray when running on the surface. + void fx_sub(rf::Entity& ep, HullFx& fx, float lod, std::optional liquid_y) + { + const float speed = fx.vel.len(); + if (!liquid_y || speed < 1.0f) { + fx.bubble_accum = 0.0f; + fx.spray_accum = 0.0f; + return; + } + const float reach_up = vehicle_collision_reach_along(&ep, ep.orient.uvec); + if (ep.pos.y + reach_up < *liquid_y) { + const int n = fx_take(fx.bubble_accum, (6.0f + speed * 2.0f) * lod); + const rf::Vector3 stern = + ep.pos - ep.orient.fvec * vehicle_collision_reach_along(&ep, ep.orient.fvec * -1.0f); + for (int i = 0; i < n; ++i) { + fx_bubble(fx, stern + fx_rand_dir() * 0.3f, fx.vel * 0.2f + fx_rand_dir() * 0.6f, *liquid_y); + } + } + else if (ep.pos.y - reach_up < *liquid_y) { + const int n = fx_take(fx.spray_accum, std::clamp((speed - 1.5f) * 3.0f, 0.0f, 30.0f) * lod); + const rf::Vector3 bow = ep.pos + ep.orient.fvec * vehicle_collision_reach_along(&ep, ep.orient.fvec); + for (int i = 0; i < n; ++i) { + const rf::Vector3 side = ep.orient.rvec * ((fx_next(fx) % 2) ? -1.0f : 1.0f); + fx_spawn(fx, spray_spec, rf::Vector3{bow.x, *liquid_y, bow.z}, + side * fx_rand(1.0f, 2.5f) + rf::Vector3{0.0f, fx_rand(1.5f, 3.0f), 0.0f}); + } + } + } + + // A ground hull dropping into water hard enough to throw it. + void fx_plunge(rf::Entity& ep, HullFx& fx, float lod, std::optional liquid_y) + { + const float reach_down = vehicle_collision_reach_along(&ep, rf::Vector3{0.0f, -1.0f, 0.0f}); + const bool in = liquid_y && ep.pos.y - reach_down < *liquid_y; + if (in && !fx.bottom_in_water && fx.vel_slow.y < -3.0f) { + const float strength = std::clamp((-fx.vel_slow.y - 3.0f) / 12.0f, 0.2f, 1.0f); + const rf::Vector3 surface{ep.pos.x, *liquid_y, ep.pos.z}; + fx_ring(fx, spray_spec, static_cast((20.0f + 40.0f * strength) * lod), surface, 2.5f, + 3.0f + 4.0f * strength, 4.0f + 5.0f * strength, 1.2f); + fx_ring(fx, mist_spec, static_cast((6.0f + 10.0f * strength) * lod), surface, 2.5f, 1.5f, 1.5f, 1.6f); + } + fx.bottom_in_water = in; + } + + // Smoke from half health down, thick dark smoke and fire under a quarter; bubbles under water. + void fx_damage(rf::Entity& ep, HullFx& fx, float lod, std::optional liquid_y) + { + const float max_life = vehicle_hud_max_life(&ep); + const float frac = max_life > 0.0f ? vehicle_hud_life(&ep) / max_life : 1.0f; + const bool dying = rf::entity_is_dying(&ep); + if (frac >= 0.5f && !dying) { + fx.smoke_accum = 0.0f; + fx.fire_accum = 0.0f; + return; + } + // Aft of centre, so a first-person driver is not looking out through it. + const rf::Vector3 vent = ep.pos + ep.orient.uvec * (vehicle_collision_reach_along(&ep, ep.orient.uvec) * 0.7f) + - ep.orient.fvec * (vehicle_collision_reach_along(&ep, ep.orient.fvec * -1.0f) * 0.4f); + const float severity = dying ? 1.0f : std::clamp((0.5f - frac) / 0.5f, 0.0f, 1.0f); + // An APC's flames have to be read against a roof three times the jeep's. + const float hull_scale = std::clamp(ep.radius / 2.0f, 1.0f, 1.6f); + const bool burning = dying || frac < 0.25f; + const int n_smoke = fx_take(fx.smoke_accum, (3.0f + 5.0f * severity) * lod); + if (liquid_y && vent.y < *liquid_y) { + fx.fire_accum = 0.0f; + for (int i = 0; i < n_smoke; ++i) { + fx_bubble(fx, vent + fx_rand_dir() * 0.3f, fx_rand_dir() * 0.4f, *liquid_y); + } + return; + } + const ParticleSpec& smoke = burning ? dark_smoke_spec : smoke_spec; + for (int i = 0; i < n_smoke; ++i) { + fx_spawn(fx, smoke, vent + fx_rand_dir() * (0.3f * hull_scale), fx.vel * 0.5f + fx_rand_dir() * 0.4f, + (1.0f + 0.3f * severity) * hull_scale); + } + if (!burning) { + fx.fire_accum = 0.0f; + return; + } + const int n_fire = fx_take(fx.fire_accum, 10.0f * lod); + for (int i = 0; i < n_fire; ++i) { + fx_spawn(fx, fire_spec, vent + fx_rand_dir() * (0.35f * hull_scale), + fx.vel * 0.7f + rf::Vector3{0.0f, 0.5f, 0.0f}, hull_scale); + } + } + + int fx_count_particles(const rf::ParticleEmitter* emitter) + { + int n = 0; + for (const rf::Particle* p = emitter->particle_list.next; p != &emitter->particle_list; p = p->next) { + ++n; + } + return n; + } + + bool fx_active() + { + return rf::is_multi && !rf::is_dedicated_server && g_alpine_game_config.vehicle_effects + && vehicle_level_has_factories(); + } + + ConsoleCommand2 vehiclefx_cmd{ + "cl_vehiclefx", + [](std::optional enabled) { + g_alpine_game_config.vehicle_effects = enabled.value_or(!g_alpine_game_config.vehicle_effects); + if (!g_alpine_game_config.vehicle_effects) { + vehicle_fx_clear(); + } + rf::console::print("Vehicle effects are {}", g_alpine_game_config.vehicle_effects ? "enabled" : "disabled"); + }, + "Show dust, sparks, spray, smoke and fire effects for vehicles in multiplayer", + "cl_vehiclefx [bool]", + }; +} // namespace + +void vehicle_fx_clear() +{ + for (auto& [handle, fx] : g_hull_fx) { + fx_destroy_emitters(fx); + } + g_hull_fx.clear(); +} + +void vehicle_fx_level_init() +{ + // Emitter records outlive the level; the bitmap handles do not. + vehicle_fx_clear(); + std::fill(std::begin(g_fx_bitmaps), std::end(g_fx_bitmaps), -1); + g_fx_bitmaps_loaded = false; +} + +void vehicle_fx_do_frame() +{ + if (!fx_active()) { + if (!g_hull_fx.empty()) { + vehicle_fx_clear(); + } + return; + } + fx_load_bitmaps(); + + g_fx_dt = rf::frametime * demo_playback_sim_time_scale(); + const float dt = g_fx_dt; + g_fx_live_particles = 0; + for (auto it = g_hull_fx.begin(); it != g_hull_fx.end();) { + HullFx& fx = it->second; + rf::Entity* ep = rf::entity_from_handle(it->first); + if (!ep || vehicle_damage_class(ep) < 0) { + fx_destroy_emitters(fx); + it = g_hull_fx.erase(it); + continue; + } + for (int layer = 0; layer < fx_num_layers; ++layer) { + if (const rf::ParticleEmitter* emitter = fx.emitters[layer]) { + const int n = fx_count_particles(emitter); + fx.emitter_idle[layer] = n > 0 ? 0.0f : fx.emitter_idle[layer] + dt; + if (fx.emitter_idle[layer] > fx_emitter_idle_s) { + fx_destroy_emitter(fx, layer); + } + g_fx_live_particles += n; + } + } + ++it; + } + + const rf::Vector3 cam_pos = + rf::local_player && rf::local_player->cam ? rf::camera_get_pos(rf::local_player->cam) : rf::Vector3{}; + + for (rf::Entity& entity : DoublyLinkedList{rf::entity_list}) { + const int vdc = vehicle_damage_class(&entity); + if (vdc < 0 || (entity.obj_flags & rf::OF_HIDDEN)) { + continue; + } + HullFx& fx = g_hull_fx[entity.handle]; + + const rf::Vector3 delta = entity.pos - fx.last_pos; + const bool had_last = fx.has_last; + fx.last_pos = entity.pos; + fx.has_last = true; + if (entity.room) { + fx.room = entity.room; + } + for (rf::ParticleEmitter* emitter : fx.emitters) { + if (emitter) { + emitter->pos = entity.pos; + emitter->room = fx.room; + } + } + if (dt <= 0.0f) { + continue; + } + // From the position delta, not a velocity: a coasting driverless hull reports none on the wire. + const rf::Vector3 raw_vel = delta * (1.0f / dt); + if (!had_last || raw_vel.len_sq() > fx_max_speed * fx_max_speed) { + fx.vel = {}; + fx.vel_slow = {}; + fx.impact_cooldown = fx_impact_cooldown; + continue; + } + fx.vel += (raw_vel - fx.vel) * (1.0f - std::exp(-dt / 0.03f)); + fx.vel_slow += (fx.vel - fx.vel_slow) * (1.0f - std::exp(-dt / 0.2f)); + fx.airborne_age += dt; + + const float dist = (entity.pos - cam_pos).len(); + if (dist > fx_smoke_far_dist) { + continue; + } + const bool flyer = vdc == VDC_FIGHTER; + const bool in_range = dist <= fx_far_dist && vdc != VDC_TURRET; + fx.probe_timer -= dt; + if (in_range && fx.probe_timer <= 0.0f) { + fx.probe_timer = fx_probe_interval; + fx_probe_ground(entity, fx); + if (flyer) { + fx_probe_wash(entity, fx); + } + } + const std::optional liquid_y = fx_liquid_surface(entity, fx.ground); + + const float lod_smoke = std::clamp(1.0f - (dist - fx_far_dist) / (fx_smoke_far_dist - fx_far_dist), 0.0f, 1.0f); + fx_damage(entity, fx, lod_smoke, liquid_y); + if (!in_range) { + continue; + } + const float lod = std::clamp(1.0f - (dist - fx_near_dist) / (fx_far_dist - fx_near_dist), 0.3f, 1.0f); + if (vdc == VDC_SUB) { + fx_sub(entity, fx, lod, liquid_y); + continue; + } + if (!flyer) { + // Ahead of the impact test, which leaves a ground hull's water entry to it. + fx_plunge(entity, fx, lod, liquid_y); + } + fx_detect_impacts(entity, fx, flyer, lod, liquid_y); + fx_scrape(entity, fx, flyer, lod); + if (flyer) { + fx_downwash(entity, fx, lod); + } + else { + fx_ground_contact(entity, fx, lod, liquid_y); + } + } +} + +void vehicle_fx_install() +{ + vehiclefx_cmd.register_cmd(); +} diff --git a/game_patch/multi/vehicles/vehicle_fx.h b/game_patch/multi/vehicles/vehicle_fx.h new file mode 100644 index 000000000..3ea244d36 --- /dev/null +++ b/game_patch/multi/vehicles/vehicle_fx.h @@ -0,0 +1,11 @@ +#pragma once + +// Client-side cosmetic particles for multiplayer vehicles: wheel and tread dust, impacts, scrape +// sparks, fighter downwash, water spray and wakes, and damage smoke and fire. Every machine derives +// them from what it already sees of the hull, so nothing is sent on the wire. + +void vehicle_fx_level_init(); +// Drops every hull's effect state, e.g. when a demo seek jumps the hulls. +void vehicle_fx_clear(); +void vehicle_fx_do_frame(); +void vehicle_fx_install(); diff --git a/game_patch/multi/vehicles/vehicle_physics.h b/game_patch/multi/vehicles/vehicle_physics.h index a5fb815ac..210804c8f 100644 --- a/game_patch/multi/vehicles/vehicle_physics.h +++ b/game_patch/multi/vehicles/vehicle_physics.h @@ -10,9 +10,14 @@ namespace rf struct Entity; struct GeomodParams; struct GRoom; + struct PCollisionOut; struct Vector3; } +// collide_linesegment_world plus mover_brush_is_solid: a see-through mover the hulls pass through is +// skipped. `flags` are rf::CollideFlags. +bool vphys_collide_solid_segment(const rf::Vector3& a, const rf::Vector3& b, int flags, rf::PCollisionOut& out); + // True means the camera entity is already posed and camera_do_frame must not run (orbit view). bool vehicle_physics_camera_do_frame(rf::Camera* camera); diff --git a/game_patch/rf/particle_emitter.h b/game_patch/rf/particle_emitter.h index 4f2cf5fe0..ce3f8b108 100644 --- a/game_patch/rf/particle_emitter.h +++ b/game_patch/rf/particle_emitter.h @@ -1,5 +1,6 @@ #pragma once +#include #include "math/vector.h" #include "os/timestamp.h" #include "os/array.h" @@ -274,6 +275,23 @@ static auto& bolt_emitter_list = addr_as_ref>(0x0064608C); static auto& level_get_particle_emitter_from_uid = addr_as_ref(0x0045D630); static auto& level_get_bolt_emitter_from_uid = addr_as_ref(0x0045D680); +// particle_pools_init 0x00494E70: pool 0 holds 500, pool 1 (every emitter's particles) 1100. +// particle_create silently creates nothing once a pool's free list is empty. Only pool 0's used_list is +// simulated and drawn, so a pool 1 particle must be created into an emitter. +struct ParticlePool +{ + Particle* storage; // +0x00 + int capacity; // +0x04 + Particle free_list; // +0x08 sentinel + Particle used_list; // +0x80 sentinel of the particles no emitter owns + int num_live; // +0xF8 +}; +static_assert(offsetof(ParticlePool, free_list) == 0x08); +static_assert(offsetof(ParticlePool, used_list) == 0x80); +static_assert(offsetof(ParticlePool, num_live) == 0xF8); +static_assert(sizeof(ParticlePool) == 0xFC); +static auto& particle_pools = addr_as_ref(0x007A3B00); + static auto& particle_create = addr_as_ref(0x00496840); @@ -283,6 +301,11 @@ static auto& g_particle_emitter_types = addr_as_ref(0x static auto& g_num_particle_emitter_types = addr_as_ref(0x007BD99C); static auto& particle_emitter_type_lookup = addr_as_ref(0x00497550); +// Fixed records for every emitter (pool init 0x004973F0); particle_emitter_create returns null once +// all are in use. +constexpr int particle_emitter_capacity = 128; +static auto& particle_emitter_num_used = addr_as_ref(0x007BD998); + static auto& particle_emitter_create = addr_as_ref(0x00497CA0); diff --git a/game_patch/rf/physics.h b/game_patch/rf/physics.h index f0d81231b..6bac3f263 100644 --- a/game_patch/rf/physics.h +++ b/game_patch/rf/physics.h @@ -11,6 +11,22 @@ namespace rf struct Object; struct Entity; + // Surface material (foley.tbl $Material, parser 0x00434960); PCollisionOut::material holds it, + // resolved from the hit face's texture by 0x00468700. + enum SurfaceMaterial + { + MATERIAL_DEFAULT = 0, + MATERIAL_ROCK = 1, + MATERIAL_METAL = 2, + MATERIAL_FLESH = 3, + MATERIAL_WATER = 4, + MATERIAL_LAVA = 5, + MATERIAL_SOLID = 6, + MATERIAL_SAND = 7, + MATERIAL_ICE = 8, + MATERIAL_GLASS = 9, + }; + struct PCollisionOut { Vector3 hit_point; From 33c4249d16d4d3a499bf73560263f175b316b6ab Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 15 Oct 2026 00:12:30 -0230 Subject: [PATCH 2/8] init # Conflicts: # game_patch/graphics/d3d11/gr_d3d11.cpp # game_patch/graphics/d3d11/gr_d3d11.h # game_patch/graphics/d3d11/gr_d3d11_hooks.h # game_patch/main/main.cpp # game_patch/misc/alpine_settings.cpp # game_patch/misc/alpine_settings.h # game_patch/rf/particle_emitter.h --- docs/CHANGELOG.md | 12 + game_patch/CMakeLists.txt | 4 + game_patch/graphics/d3d11/gr_d3d11.cpp | 96 ++ game_patch/graphics/d3d11/gr_d3d11.h | 31 + .../d3d11/gr_d3d11_dynamic_geometry.cpp | 16 - game_patch/graphics/d3d11/gr_d3d11_hooks.cpp | 66 ++ game_patch/graphics/d3d11/gr_d3d11_hooks.h | 79 ++ .../graphics/d3d11/gr_d3d11_particle.cpp | 590 +++++++++++ game_patch/graphics/d3d11/gr_d3d11_particle.h | 140 +++ game_patch/graphics/d3d11/gr_d3d11_shader.h | 14 + game_patch/graphics/d3d11/gr_d3d11_vertex.h | 41 + game_patch/graphics/gr.cpp | 4 +- game_patch/graphics/gr.h | 2 + game_patch/graphics/gr_internal.h | 2 - game_patch/graphics/gr_light.cpp | 1 + game_patch/graphics/weather.cpp | 356 ++++++- game_patch/hud/hud.cpp | 2 + game_patch/hud/hud_internal.h | 30 + game_patch/hud/hud_status.cpp | 212 ++-- game_patch/hud/hud_weapons.cpp | 55 +- game_patch/hud/hud_world.cpp | 2 +- game_patch/hud/hud_world.h | 2 + game_patch/hud/multi_hud.cpp | 12 + game_patch/hud/multi_spectate.cpp | 43 +- game_patch/hud/multi_spectate.h | 3 + game_patch/input/mouse.cpp | 6 +- game_patch/main/main.cpp | 3 + game_patch/misc/alpine_settings.cpp | 35 + game_patch/misc/alpine_settings.h | 19 + game_patch/misc/player.cpp | 42 +- game_patch/multi/alpine_packets.h | 4 +- game_patch/multi/demo/demo_playback.cpp | 1 + game_patch/multi/jetpack.cpp | 63 +- .../multi/vehicles/physics/vphys_camera.cpp | 5 + game_patch/multi/vehicles/vehicle.cpp | 191 +++- game_patch/multi/vehicles/vehicle.h | 15 +- game_patch/multi/vehicles/vehicle_damage.cpp | 49 +- game_patch/multi/vehicles/vehicle_fx.cpp | 587 +++++++++-- game_patch/multi/vehicles/vehicle_fx.h | 8 +- game_patch/multi/vehicles/vehicle_internal.h | 36 +- game_patch/multi/vehicles/vehicle_physics.h | 2 + game_patch/multi/vehicles/vehicle_seats.cpp | 1 + game_patch/multi/vehicles/vehicle_spawn.cpp | 42 +- game_patch/multi/vehicles/vehicle_sync.cpp | 106 +- game_patch/multi/vehicles/vehicle_view.cpp | 100 +- game_patch/multi/vehicles/vehicle_view.h | 11 +- game_patch/object/object.cpp | 2 + game_patch/object/particle.cpp | 6 + game_patch/object/particle_sim.cpp | 951 ++++++++++++++++++ game_patch/object/particle_sim.h | 16 + game_patch/rf/geometry.h | 23 +- game_patch/rf/gr/gr.h | 3 + game_patch/rf/misc.h | 3 + game_patch/rf/particle_emitter.h | 83 +- game_patch/rf/sound/sound.h | 11 +- game_patch/rf/weapon.h | 2 + game_patch/sound/sound.cpp | 132 ++- game_patch/sound/sound.h | 6 +- game_patch/sound/sound_ds.cpp | 2 +- resources/CMakeLists.txt | 3 + resources/shaders/CMakeLists.txt | 3 + resources/shaders/particle_vs.hlsl | 304 ++++++ 62 files changed, 4196 insertions(+), 495 deletions(-) create mode 100644 game_patch/graphics/d3d11/gr_d3d11_particle.cpp create mode 100644 game_patch/graphics/d3d11/gr_d3d11_particle.h create mode 100644 game_patch/object/particle_sim.cpp create mode 100644 game_patch/object/particle_sim.h create mode 100644 resources/shaders/particle_vs.hlsl diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index edeefd340..353ee3f64 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -110,6 +110,16 @@ Version 1.5.0 (Trillium): Not yet released - Show `Your team has the bag` in Team Bagman - Add team-relative bag carrier icons and outline colors - Raise the limit on items per level from 200 to 2000 for v306+ levels +- Improve particle handling and performance: + - Stop simulating level-placed decorative particle emitters while they are out of view (`cl_particlecull`) + - Step particles at a fixed rate (`cl_particlefixedstep`, `cl_particlerate`) + - Test distant particles for world collision less often (`cl_particlecollidelod`) + - Raise the particle pool size to 8192 (`cl_particlepoolsize`) + - Raise the particle emitter limit from 128 to 8192 + - Render particles, plankton, rain and snow on the GPU with instancing, and simulate rain and snow motion on the GPU (Direct3D 11 renderer only) + - Add `dbg_particles` overlay and `dbg_particles_log` to measure particle performance, and `dbg_particlewarmup` to toggle replaying a hidden particle emitter when it comes back into view +- Raise the pitch of hit sounds as consecutive hits land, toggleable with `cl_hitsounds_streakpitch` +- Raise the limit of simultaneously playing sounds from 64 to 127 [@nickalreadyinuse](https://github.com/nickalreadyinuse) - Add `ui_color_console` console command to set the console background color @@ -233,6 +243,8 @@ Version 1.5.0 (Trillium): Not yet released - Fix new levels in the level editor defaulting to legacy `Cyclic_Timer` and mover behaviour - Fix Salvage flag and Bagman bag failing to spawn when a level's item count is at the engine limit - Fix crash reports leaving out memory regions and backtrace frames above 2 GB when the game or level editor runs with 4 GB of address space +- Fix spectate control hints overlapping the team scores in Wipeout +- Fix long key names in spectate control hints running off the left edge of the screen [@is-this-c](https://github.com/is-this-c) - Let `Caps Lock` capitalize diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index 1e97e923b..7f3b5e21f 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -80,6 +80,8 @@ set(SRCS graphics/d3d11/gr_d3d11_texture.h graphics/d3d11/gr_d3d11_dynamic_geometry.cpp graphics/d3d11/gr_d3d11_dynamic_geometry.h + graphics/d3d11/gr_d3d11_particle.cpp + graphics/d3d11/gr_d3d11_particle.h graphics/d3d11/gr_d3d11_context.cpp graphics/d3d11/gr_d3d11_context.h graphics/d3d11/gr_d3d11_shader.cpp @@ -346,6 +348,8 @@ set(SRCS object/mover.cpp object/monitor.cpp object/particle.cpp + object/particle_sim.cpp + object/particle_sim.h object/obj_light.cpp object/obj_collision.cpp object/obj_collision.h diff --git a/game_patch/graphics/d3d11/gr_d3d11.cpp b/game_patch/graphics/d3d11/gr_d3d11.cpp index 7df8f5318..0f83417d4 100644 --- a/game_patch/graphics/d3d11/gr_d3d11.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11.cpp @@ -7,6 +7,7 @@ #include "../../rf/v3d.h" #include "../../rf/gameseq.h" #include "../../rf/level.h" +#include "../../rf/particle_emitter.h" #include "../../rf/os/frametime.h" #include "../../rf/os/os.h" #include "../../bmpman/bmpman.h" @@ -29,6 +30,7 @@ #include "gr_d3d11_outline.h" #include "gr_d3d11_gamma.h" #include "gr_d3d11_scenefx.h" +#include "gr_d3d11_particle.h" extern void monitor_refresh_all(); @@ -80,6 +82,7 @@ namespace gr::d3d11 render_context_ = std::make_unique(device_, context_, *state_manager_, *shader_manager_, *texture_manager_); dyn_geo_renderer_ = std::make_unique(device_, *shader_manager_, *render_context_); af_lightmap_renderer_ = std::make_unique(device_, context_); + particle_renderer_ = std::make_unique(device_, *shader_manager_, *render_context_); solid_renderer_ = std::make_unique(device_, *shader_manager_, *state_manager_, *dyn_geo_renderer_, *render_context_, *af_lightmap_renderer_); mesh_renderer_ = std::make_unique(device_, *shader_manager_, *state_manager_, *render_context_); decoration_renderer_ = @@ -100,6 +103,13 @@ namespace gr::d3d11 outline_renderer_->flush(*mesh_renderer_); }); + // Anything queued elsewhere has to reach the framebuffer before the quads a batch is + // about to start collecting, the same pair render_particle_emitter drains by hand. + particle_renderer_->set_pre_batch_callback([this]() { + dyn_geo_renderer_->flush(); + outline_renderer_->flush(*mesh_renderer_); + }); + render_context_->set_render_target(default_render_target_view_, depth_stencil_view_); render_context_->set_cull_mode(D3D11_CULL_BACK); @@ -620,6 +630,7 @@ namespace gr::d3d11 // framebuffer first, and subsequent bitmaps (scope etc.) commit on top of them. // If outlines were already flushed in zbuffer_clear this is a no-op. flush_outlines_before_2d(); + particle_renderer_->flush_batch(); dyn_geo_renderer_->bitmap(bm_handle, static_cast(x), static_cast(y), static_cast(w), static_cast(h), static_cast(sx), static_cast(sy), static_cast(sw), static_cast(sh), @@ -629,12 +640,14 @@ namespace gr::d3d11 void Renderer::bitmap(int bm_handle, float x, float y, float w, float h, float sx, float sy, float sw, float sh, bool flip_x, bool flip_y, rf::gr::Mode mode) { flush_outlines_before_2d(); + particle_renderer_->flush_batch(); dyn_geo_renderer_->bitmap(bm_handle, x, y, w, h, sx, sy, sw, sh, flip_x, flip_y, mode); } void Renderer::poly_2d(int bm_handle, int nv, const rf::gr::Vertex* vertices, rf::gr::Mode mode) { flush_outlines_before_2d(); + particle_renderer_->flush_batch(); dyn_geo_renderer_->poly_2d(bm_handle, nv, vertices, mode); } @@ -720,6 +733,7 @@ namespace gr::d3d11 void Renderer::clear() { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->clear(); } @@ -750,12 +764,14 @@ namespace gr::d3d11 // later consumer found nothing left. The .vfx outlines are drawn from // flush_forced_xray() instead, which rebinds the scene camera explicitly. outline_renderer_->flush(*mesh_renderer_); + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->zbuffer_clear(); } void Renderer::set_clip() { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->set_clip(); } @@ -784,6 +800,9 @@ namespace gr::d3d11 frame_latency_stall_logged_ = false; } } + // Last stop for a batch nothing else drained this frame; the ring buffer stays mapped + // until it is submitted. + particle_renderer_->flush_batch(); outline_renderer_->flush_forced_xray(*mesh_renderer_); dyn_geo_renderer_->flush(); entity_shadow_renderer_->render_debug_overlay(context_); @@ -881,17 +900,22 @@ namespace gr::d3d11 void Renderer::tmapper(int nv, const rf::gr::Vertex **vertices, int vertex_attributes, rf::gr::Mode mode) { + // A pending batch was collected before this poly and has to be drawn before it. No-op + // unless a batched pass (particles, weather, plankton) is open. + particle_renderer_->flush_batch(); std::array tex_handles{rf::gr::screen.current_texture_1, rf::gr::screen.current_texture_2}; dyn_geo_renderer_->add_poly(nv, vertices, vertex_attributes, tex_handles, mode); } void Renderer::line_3d(const rf::gr::Vertex& v0, const rf::gr::Vertex& v1, rf::gr::Mode mode) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->line_3d(v0, v1, mode); } void Renderer::line_2d(float x1, float y1, float x2, float y2, rf::gr::Mode mode) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->line_2d(x1, y1, x2, y2, mode); } @@ -936,6 +960,7 @@ namespace gr::d3d11 bool Renderer::set_render_target(int bm_handle) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); if (bm_handle != -1) { ID3D11RenderTargetView* const render_target_view = @@ -996,6 +1021,7 @@ namespace gr::d3d11 // so the other gr_read_backbuffer callers (bitmap locks that can land in the // middle of a frame) keep their existing behaviour. static auto& screenshot_pending = addr_as_ref(0x00637085); + particle_renderer_->flush_batch(); if (screenshot_pending) { outline_renderer_->flush_forced_xray(*mesh_renderer_); } @@ -1047,6 +1073,7 @@ namespace gr::d3d11 void Renderer::render_solid(rf::GSolid* solid, rf::GRoom** rooms, int num_rooms) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); // Generate entity shadow map before rendering the scene @@ -1082,6 +1109,7 @@ namespace gr::d3d11 } const auto& chunks = solid_renderer_->room_decoration_chunks(solid, room); if (decoration_renderer_->has_soft_edges(chunks)) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); decoration_renderer_->render(solid, chunks, DecorationPass::edge); render_context_->set_draw_room_uid(object_room_uid_); @@ -1091,6 +1119,7 @@ namespace gr::d3d11 void Renderer::render_movable_solid(rf::GSolid* solid, const rf::Vector3& pos, const rf::Matrix3& orient, bool include_alpha) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); solid_renderer_->render_movable_solid(solid, pos, orient, include_alpha); } @@ -1107,6 +1136,7 @@ namespace gr::d3d11 // phase is rejected wherever they got there first. Draining the queue here instead of // leaving it to whichever sorted item happens to flush next keeps outlines in front. outline_renderer_->flush(*mesh_renderer_); + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); solid_renderer_->render_movable_solid_alpha(solid, pos, orient); } @@ -1114,12 +1144,69 @@ namespace gr::d3d11 void Renderer::render_alpha_detail_room(rf::GRoom *room, rf::GSolid *solid) { outline_renderer_->flush(*mesh_renderer_); + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); solid_renderer_->render_alpha_detail(room, solid); } + bool Renderer::render_particle_emitter(rf::ParticleEmitter* emitter, float interp_dt, int* out_drawn, + int* out_draws) + { + // Stock 0x00497BF0 draws nothing for an empty list, so nothing has to reach the framebuffer first + if (emitter->particle_list.next == &emitter->particle_list) { + if (out_drawn) { + *out_drawn = 0; + } + if (out_draws) { + *out_draws = 0; + } + return true; + } + // Draw where stock drew: anything the dyn_geo batcher is still holding, and the outlines + // queued to render behind it, have to reach the framebuffer before the emitter's quads. + dyn_geo_renderer_->flush(); + outline_renderer_->flush(*mesh_renderer_); + return particle_renderer_->render_emitter(emitter, interp_dt, out_drawn, out_draws); + } + + bool Renderer::batched_billboard(const rf::Vector3& pos, float angle, float radius, rf::gr::Mode mode) + { + return particle_renderer_->add_billboard(pos, angle, radius, mode); + } + + bool Renderer::batched_particle(const rf::Particle& particle) + { + return particle_renderer_->add_particle(particle); + } + + void Renderer::flush_batched_quads() + { + particle_renderer_->flush_batch(); + } + + bool Renderer::upload_weather_seeds(const std::vector& seeds) + { + return particle_renderer_->upload_weather_seeds(seeds); + } + + bool Renderer::update_weather_columns(int slot, int num_cells, int first, const std::vector& cells) + { + return particle_renderer_->update_weather_columns(slot, num_cells, first, cells); + } + + void Renderer::release_weather_columns() + { + particle_renderer_->release_weather_columns(); + } + + bool Renderer::draw_weather(const WeatherDraw& draw) + { + return particle_renderer_->draw_weather(draw); + } + void Renderer::render_sky_room(rf::GRoom *room, rf::Vector3& out_sky_transform_pos, rf::Matrix3& out_sky_transform_orient) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); // The projection ends at the camera far clip, which must not cut the sky; the far clip set after the sky // pass re-enables depth clipping @@ -1131,6 +1218,8 @@ namespace gr::d3d11 void Renderer::render_room_liquid_surface(rf::GSolid* solid, rf::GRoom* room) { + // The room's sorted particles drawn ahead of the surface are still batched + particle_renderer_->flush_batch(); // Flush outlines before liquid renders. The outline silhouette pass writes // depth (DepthWriteMask=ALL), so outline pixels claim depth ownership. // When the liquid subsequently depth-tests at those pixels, it fails @@ -1240,6 +1329,7 @@ namespace gr::d3d11 // Drains the batched HUD so the vignette composites on top of it, exactly where the // stock flash lands. + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->set_clip(); scene_post_pass_->render(context_, nullptr, default_render_target_view_, data); @@ -1327,6 +1417,7 @@ namespace gr::d3d11 data.flags = static_cast(flags); // Both modes draw over the finished scene, so drain pending 2D geometry first + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->set_clip(); @@ -1364,6 +1455,7 @@ namespace gr::d3d11 // Runs before the stock scope ring and reticle are drawn, so they stay sharp on top of the lens flush_outlines_before_2d(); + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->set_clip(); @@ -1414,6 +1506,7 @@ namespace gr::d3d11 void Renderer::render_v3d_vif(rf::VifLodMesh *lod_mesh, int lod_index, const rf::Vector3& pos, const rf::Matrix3& orient, const rf::MeshRenderParams& params, bool skip_ambient_cache) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->set_draw_room_uid(object_room_uid_); mesh_renderer_->render_v3d_vif(lod_mesh, lod_index, pos, orient, params, skip_ambient_cache); @@ -1451,6 +1544,7 @@ namespace gr::d3d11 void Renderer::render_vfx(rf::VfxSfxoRenderObj* obj, float frame) { // Keep ordering against gr_poly-drawn geometry (billboard vfx chunks, particles) + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->set_draw_room_uid(object_room_uid_); vfx_renderer_->render(obj, frame); @@ -1458,6 +1552,7 @@ namespace gr::d3d11 void Renderer::render_character_vif(rf::VifLodMesh *lod_mesh, int lod_index, const rf::Vector3& pos, const rf::Matrix3& orient, const rf::CharacterInstance *ci, const rf::MeshRenderParams& params, bool skip_ambient_cache) { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->set_draw_room_uid(object_room_uid_); mesh_renderer_->render_character_vif(lod_mesh, lod_index, pos, orient, ci, params, skip_ambient_cache); @@ -1493,6 +1588,7 @@ namespace gr::d3d11 void Renderer::fog_set() { + particle_renderer_->flush_batch(); dyn_geo_renderer_->flush(); render_context_->fog_set(); } diff --git a/game_patch/graphics/d3d11/gr_d3d11.h b/game_patch/graphics/d3d11/gr_d3d11.h index d00cdc80a..89ae21d71 100644 --- a/game_patch/graphics/d3d11/gr_d3d11.h +++ b/game_patch/graphics/d3d11/gr_d3d11.h @@ -25,6 +25,8 @@ namespace rf struct CharacterInstance; struct Player; struct VfxSfxoRenderObj; + struct Particle; + struct ParticleEmitter; } namespace alpine_lightmap @@ -38,6 +40,10 @@ namespace gr::d3d11 class ShaderManager; class TextureManager; class DynamicGeometryRenderer; + class ParticleRenderer; + struct WeatherSeed; + struct WeatherColumn; + struct WeatherDraw; class RenderContext; class AfLightmapRenderer; class SolidRenderer; @@ -91,6 +97,14 @@ namespace gr::d3d11 bool movable_solid_has_alpha(rf::GSolid* solid); void render_movable_solid_alpha(rf::GSolid* solid, const rf::Vector3& pos, const rf::Matrix3& orient); void render_alpha_detail_room(rf::GRoom *room, rf::GSolid *solid); + bool render_particle_emitter(rf::ParticleEmitter* emitter, float interp_dt, int* out_drawn, int* out_draws); + bool batched_billboard(const rf::Vector3& pos, float angle, float radius, rf::gr::Mode mode); + bool batched_particle(const rf::Particle& particle); + void flush_batched_quads(); + bool upload_weather_seeds(const std::vector& seeds); + bool update_weather_columns(int slot, int num_cells, int first, const std::vector& cells); + void release_weather_columns(); + bool draw_weather(const WeatherDraw& draw); void render_sky_room(rf::GRoom *room, rf::Vector3& out_sky_transform_pos, rf::Matrix3& out_sky_transform_orient); void render_room_liquid_surface(rf::GSolid* solid, rf::GRoom* room); void render_room_decoration_edges(rf::GSolid* solid, rf::GRoom* room); @@ -191,6 +205,7 @@ namespace gr::d3d11 std::unique_ptr shader_manager_; std::unique_ptr texture_manager_; std::unique_ptr dyn_geo_renderer_; + std::unique_ptr particle_renderer_; std::unique_ptr render_context_; std::unique_ptr af_lightmap_renderer_; std::unique_ptr solid_renderer_; @@ -269,6 +284,22 @@ namespace gr::d3d11 return (color.alpha << 24) | (color.blue << 16) | (color.green << 8) | color.red; } + static inline bool mode_uses_vertex_color(rf::gr::Mode mode) + { + if (mode.get_texture_source() == rf::gr::TEXTURE_SOURCE_NONE) { + return true; + } + return mode.get_color_source() != rf::gr::COLOR_SOURCE_TEXTURE; + } + + static inline bool mode_uses_vertex_alpha(rf::gr::Mode mode) + { + if (mode.get_texture_source() == rf::gr::TEXTURE_SOURCE_NONE) { + return true; + } + return mode.get_alpha_source() != rf::gr::ALPHA_SOURCE_TEXTURE; + } + static inline std::array normalize_texture_handles_for_mode(rf::gr::Mode mode, const std::array& textures) { switch (mode.get_texture_source()) { diff --git a/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp b/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp index e40be0fa0..a99ae4c3c 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp @@ -69,22 +69,6 @@ namespace gr::d3d11 render_context_.draw_indexed(num_index, start_index, start_vertex); } - static inline bool mode_uses_vertex_color(rf::gr::Mode mode) - { - if (mode.get_texture_source() == rf::gr::TEXTURE_SOURCE_NONE) { - return true; - } - return mode.get_color_source() != rf::gr::COLOR_SOURCE_TEXTURE; - } - - static inline bool mode_uses_vertex_alpha(rf::gr::Mode mode) - { - if (mode.get_texture_source() == rf::gr::TEXTURE_SOURCE_NONE) { - return true; - } - return mode.get_alpha_source() != rf::gr::ALPHA_SOURCE_TEXTURE; - } - static inline rf::Color get_vertex_color_from_screen(rf::gr::Mode mode) { rf::Color color{255, 255, 255, 255}; diff --git a/game_patch/graphics/d3d11/gr_d3d11_hooks.cpp b/game_patch/graphics/d3d11/gr_d3d11_hooks.cpp index e9aeef80e..045b551c9 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_hooks.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_hooks.cpp @@ -17,6 +17,7 @@ #include "../../rf/geometry.h" #include "../../rf/mover.h" #include "../../rf/object.h" +#include "../../rf/particle_emitter.h" #include "../../rf/player/player.h" #include "../../rf/vmesh.h" #include "../../rf/vfx.h" @@ -790,6 +791,57 @@ namespace gr::d3d11 } } + bool render_particle_emitter(rf::ParticleEmitter* emitter, float interp_dt, int* out_drawn, int* out_draws) + { + // Called from the emitter render callback, which also runs without a D3D11 renderer. + if (!renderer) { + return false; + } + return renderer->render_particle_emitter(emitter, interp_dt, out_drawn, out_draws); + } + + bool batched_billboard(const rf::Vector3& pos, float angle, float radius, rf::gr::Mode mode) + { + if (!renderer) { + return false; + } + return renderer->batched_billboard(pos, angle, radius, mode); + } + + bool batched_particle(const rf::Particle& particle) + { + return renderer && renderer->batched_particle(particle); + } + + void flush_batched_quads() + { + if (renderer) { + renderer->flush_batched_quads(); + } + } + + bool upload_weather_seeds(const std::vector& seeds) + { + return renderer && renderer->upload_weather_seeds(seeds); + } + + bool update_weather_columns(int slot, int num_cells, int first, const std::vector& cells) + { + return renderer && renderer->update_weather_columns(slot, num_cells, first, cells); + } + + void release_weather_columns() + { + if (renderer) { + renderer->release_weather_columns(); + } + } + + bool draw_weather(const WeatherDraw& draw) + { + return renderer && renderer->draw_weather(draw); + } + void clear_solid_render_cache() { if (renderer) { @@ -1006,11 +1058,24 @@ namespace gr::d3d11 } g_render_room_objects_hook.call_target(room, solid, num_objects, portal_objects); if (renderer) { + renderer->flush_batched_quads(); renderer->set_object_room_uid(-1); } }, }; + // Draws one item of the room's render list. Particles no emitter owns stay batched from one item to + // the next (batched_particle), so any other item has to find them in the framebuffer first. + static FunHook g_room_render_item_render_hook{ + 0x004D3460, + [](rf::GRoomRenderItem* item, int edx, void* user, rf::GSolid* solid) FASTCALL_LAMBDA { + if (renderer && item->render_fn != reinterpret_cast(&rf::particle_render_item)) { + renderer->flush_batched_quads(); + } + g_room_render_item_render_hook.call_target(item, edx, user, solid); + }, + }; + // obj_render_all queues every object of a room for the room pass. Add a second, sortable render item for // each mover brush with see-through faces, so those faces are drawn back to front after the // unsorted items instead of writing depth ahead of them. @@ -1330,6 +1395,7 @@ void gr_d3d11_apply_patch() sniper_scope_overlay_glass_injection.install(); precision_scope_overlay_glass_injection.install(); g_render_room_objects_hook.install(); + g_room_render_item_render_hook.install(); obj_render_all_hook.install(); g_render_room_objects_render_liquid_injection.install(); g_render_room_objects_decoration_edges_injection.install(); diff --git a/game_patch/graphics/d3d11/gr_d3d11_hooks.h b/game_patch/graphics/d3d11/gr_d3d11_hooks.h index 896e109a6..210c958e6 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_hooks.h +++ b/game_patch/graphics/d3d11/gr_d3d11_hooks.h @@ -7,6 +7,8 @@ namespace rf { struct GRoom; + struct Particle; + struct ParticleEmitter; } namespace alpine_lightmap @@ -15,6 +17,71 @@ namespace alpine_lightmap } namespace gr::d3d11 { + // GPU weather: one record of fixed randoms per particle, shared by every region's segment + struct WeatherSeed + { + // Position in the unit box + float x; + float y; + float z; + float phase; + float angle; + float spin; + float drift_x; + float drift_z; + }; + + // One ceiling cache cell as the GPU reads it: the world column the slot holds and the floor under it + struct WeatherColumn + { + std::int32_t cx; + std::int32_t cz; + float floor_y; + std::int32_t pad = 0; + }; + + // One weather region's particles for one frame. The GPU places each one from its seed, so the box + // carries the uniform motion folded in: box_shift is the box-relative offset the lattice has moved + // by, wrap_base the whole vertical laps it has made. + struct WeatherDraw + { + int count = 0; + bool snow = false; + rf::gr::Mode mode; + int bm_handle = -1; + rf::Color color{255, 255, 255, 255}; + rf::Vector3 box_min; + rf::Vector3 box_size; + rf::Vector3 box_shift; + std::uint32_t wrap_base = 0; + std::uint32_t salt = 0; + float time = 0.0f; + rf::Vector3 camera_pos; + rf::Matrix3 camera_orient; + // Rain streak half width, or snow sprite radius + float half_width = 0.0f; + rf::Vector3 fall_dir; + float streak_half_len = 0.0f; + float sway_amplitude = 0.0f; + float sway_speed = 0.0f; + float sway_phase = 0.0f; + bool sphere = false; + rf::Vector3 region_center; + rf::Matrix3 region_orient; + rf::Vector3 region_half_extents; + float region_radius = 0.0f; + bool has_surface = false; + float surface_y = 0.0f; + // Ceiling cache uploaded through update_weather_columns, -1 when the region has none + int columns_slot = -1; + float column_cell = 0.0f; + int cols_x = 0; + int cols_z = 0; + // A multiple of the grid size at or below every column the box covers + std::int32_t column_origin_x = 0; + std::int32_t column_origin_z = 0; + }; + bool set_render_target(int bm_handle); void invalidate_texture_cache(); int render_target_generation(); @@ -40,4 +107,16 @@ namespace gr::d3d11 { bool upload_af_lightmap_atlas(const alpine_lightmap::ReadResult& section, const std::vector& blocks); void release_af_lightmap_atlas(); bool af_lightmap_atlas_live(); + bool render_particle_emitter(rf::ParticleEmitter* emitter, float interp_dt, int* out_drawn, int* out_draws); + bool batched_billboard(const rf::Vector3& pos, float angle, float radius, rf::gr::Mode mode); + // Batches a particle drawn by its own room render item; false sends it down the stock path. + bool batched_particle(const rf::Particle& particle); + void flush_batched_quads(); + bool upload_weather_seeds(const std::vector& seeds); + // Writes cells [first, first + cells.size()) of a region's ceiling cache. A cache of a different + // size is only replaced by a full upload. False when the GPU copy could not be written. + bool update_weather_columns(int slot, int num_cells, int first, const std::vector& cells); + void release_weather_columns(); + // Draws one region's weather in place; false when it has to go through the CPU path instead. + bool draw_weather(const WeatherDraw& draw); } diff --git a/game_patch/graphics/d3d11/gr_d3d11_particle.cpp b/game_patch/graphics/d3d11/gr_d3d11_particle.cpp new file mode 100644 index 000000000..5f8ce8f67 --- /dev/null +++ b/game_patch/graphics/d3d11/gr_d3d11_particle.cpp @@ -0,0 +1,590 @@ +#include +#include +#include +#include +#include +#include +#include "../../rf/bmpman.h" +#include "../../rf/misc.h" +#include "../../rf/particle_emitter.h" +#include "gr_d3d11.h" +#include "gr_d3d11_context.h" +#include "gr_d3d11_particle.h" +#include "gr_d3d11_shader.h" + +namespace +{ + // Mirror of ParticleViewBuffer in particle_vs.hlsl (b5) + struct alignas(16) ParticleViewBufferData + { + std::array view_rvec; + float far_clip_z; + std::array view_uvec; + float far_clip_on; + std::array view_fvec; + float pad0; + std::array view_pos; + float pad1; + std::array matrix_scale; + float pad2; + std::array proj; + }; + static_assert(offsetof(ParticleViewBufferData, far_clip_z) == 12); + static_assert(offsetof(ParticleViewBufferData, view_uvec) == 16); + static_assert(offsetof(ParticleViewBufferData, far_clip_on) == 28); + static_assert(offsetof(ParticleViewBufferData, view_fvec) == 32); + static_assert(offsetof(ParticleViewBufferData, view_pos) == 48); + static_assert(offsetof(ParticleViewBufferData, matrix_scale) == 64); + static_assert(offsetof(ParticleViewBufferData, proj) == 80); + static_assert(sizeof(ParticleViewBufferData) == 96); + + // Mirror of WeatherBuffer in particle_vs.hlsl (b6) + struct alignas(16) WeatherBufferData + { + std::array box_min; + std::uint32_t wrap_base; + std::array box_size; + std::uint32_t salt; + std::array box_shift; + float sim_time; + std::array camera_pos; + float half_width; + std::array camera_rvec; + float streak_half_len; + std::array camera_uvec; + float sway_amplitude; + std::array fall_dir; + float sway_speed; + std::array region_center; + float sway_phase; + std::array region_rvec; + float region_radius; + std::array region_uvec; + float surface_y; + std::array region_fvec; + float column_cell; + std::array region_half_extents; + std::uint32_t flags; + std::array column_cols; + std::array column_origin; + std::array color; + }; + static_assert(offsetof(WeatherBufferData, wrap_base) == 12); + static_assert(offsetof(WeatherBufferData, box_size) == 16); + static_assert(offsetof(WeatherBufferData, box_shift) == 32); + static_assert(offsetof(WeatherBufferData, camera_pos) == 48); + static_assert(offsetof(WeatherBufferData, camera_rvec) == 64); + static_assert(offsetof(WeatherBufferData, camera_uvec) == 80); + static_assert(offsetof(WeatherBufferData, fall_dir) == 96); + static_assert(offsetof(WeatherBufferData, region_center) == 112); + static_assert(offsetof(WeatherBufferData, region_rvec) == 128); + static_assert(offsetof(WeatherBufferData, region_uvec) == 144); + static_assert(offsetof(WeatherBufferData, region_fvec) == 160); + static_assert(offsetof(WeatherBufferData, region_half_extents) == 176); + static_assert(offsetof(WeatherBufferData, flags) == 188); + static_assert(offsetof(WeatherBufferData, column_cols) == 192); + static_assert(offsetof(WeatherBufferData, column_origin) == 200); + static_assert(offsetof(WeatherBufferData, color) == 208); + static_assert(sizeof(WeatherBufferData) == 224); + + // weather_flags bits in particle_vs.hlsl + constexpr std::uint32_t weather_flag_snow = 1; + constexpr std::uint32_t weather_flag_sphere = 2; + constexpr std::uint32_t weather_flag_liquid_surface = 4; + constexpr std::uint32_t weather_flag_ceiling = 8; + + // VertexLayout::weather_seed reads a seed as two float4s, and weather_columns is a Buffer + static_assert(sizeof(gr::d3d11::WeatherSeed) == 32); + static_assert(sizeof(gr::d3d11::WeatherColumn) == 16); + + constexpr int batch_max_instance = 8192; + constexpr UINT instance_slot = 1; + constexpr UINT view_cbuffer_slot = 5; + constexpr UINT weather_cbuffer_slot = 6; + constexpr UINT weather_columns_srv_slot = 0; + // Same 0-1-2 / 0-2-3 fan Renderer::poly's add_poly builds on the stock quads, so the winding is unchanged + constexpr rf::ushort quad_indices[6] = {0, 1, 2, 0, 2, 3}; + // gr_d3d_3d_bitmap_stretched_square 0x00558E30 draws a shorter streak as an unrotated billboard + constexpr double min_stretch_len_sq = 0.001; +} + +namespace gr::d3d11 +{ + ParticleRenderer::ParticleRenderer(ComPtr device, ShaderManager& shader_manager, + RenderContext& render_context) : + device_{device}, render_context_(render_context), + instance_ring_buffer_{batch_max_instance, D3D11_BIND_VERTEX_BUFFER, device_, render_context.device_context()} + { + billboard_vertex_shader_ = shader_manager.get_vertex_shader(VertexShaderId::particle); + stretched_vertex_shader_ = shader_manager.get_vertex_shader(VertexShaderId::particle_stretched); + weather_vertex_shader_ = shader_manager.get_vertex_shader(VertexShaderId::weather); + std_pixel_shader_ = shader_manager.get_pixel_shader(PixelShaderId::standard); + std_pixel_shader_no_gas_ = shader_manager.get_pixel_shader(PixelShaderId::standard_no_gas); + + CD3D11_BUFFER_DESC index_desc{sizeof(quad_indices), D3D11_BIND_INDEX_BUFFER, D3D11_USAGE_IMMUTABLE}; + D3D11_SUBRESOURCE_DATA index_data{quad_indices, 0, 0}; + CD3D11_BUFFER_DESC cbuffer_desc{ + sizeof(ParticleViewBufferData), + D3D11_BIND_CONSTANT_BUFFER, + D3D11_USAGE_DYNAMIC, + D3D11_CPU_ACCESS_WRITE, + }; + instancing_ok_ = billboard_vertex_shader_.vertex_shader && billboard_vertex_shader_.input_layout + && stretched_vertex_shader_.vertex_shader && stretched_vertex_shader_.input_layout + && SUCCEEDED(device_->CreateBuffer(&index_desc, &index_data, &quad_index_buffer_)) + && SUCCEEDED(device_->CreateBuffer(&cbuffer_desc, nullptr, &view_cbuffer_)); + if (!instancing_ok_) { + xlog::warn("[Particles] Particles are drawn by the stock path: their shaders could not be loaded"); + } + + CD3D11_BUFFER_DESC weather_cbuffer_desc{ + sizeof(WeatherBufferData), + D3D11_BIND_CONSTANT_BUFFER, + D3D11_USAGE_DYNAMIC, + D3D11_CPU_ACCESS_WRITE, + }; + weather_ok_ = instancing_ok_ && weather_vertex_shader_.vertex_shader && weather_vertex_shader_.input_layout + && SUCCEEDED(device_->CreateBuffer(&weather_cbuffer_desc, nullptr, &weather_cbuffer_)); + if (instancing_ok_ && !weather_ok_) { + xlog::warn("[Particles] Weather is simulated on the CPU: its shader could not be loaded"); + } + } + + void ParticleRenderer::set_pre_batch_callback(std::function callback) + { + pre_batch_callback_ = std::move(callback); + } + + void ParticleRenderer::begin_run(const State& state, bool full) + { + if (!batch_pending_ && pre_batch_callback_) { + pre_batch_callback_(); + } + if (!batch_pending_ || state_ != state || full) { + flush(); + state_ = state; + capture_view(); + } + batch_pending_ = true; + } + + // The view instances are expanded with, as rotate_vertex would have read it + void ParticleRenderer::capture_view() + { + const Projection& proj = render_context_.projection(); + run_view_.matrix = rf::gr::view_matrix; + run_view_.pos = rf::gr::view_pos; + run_view_.scale = rf::gr::matrix_scale; + run_view_.far_clip_on = rf::gr_use_far_clip; + run_view_.far_clip_z = rf::gr_far_clip_dist_scaled; + run_view_.proj = {proj.scale_x(), proj.scale_y(), proj.scale_z(), proj.translate_z()}; + } + + GpuParticleInstance* ParticleRenderer::setup_instance(const State& state) + { + begin_run(state, instance_ring_buffer_.is_full(1)); + return instance_ring_buffer_.alloc(1); + } + + void ParticleRenderer::flush_batch() + { + if (!batch_pending_) { + return; + } + flush(); + cached_bm_handle_ = -1; // bitmap handles can be recycled between levels + } + + void ParticleRenderer::upload_view() + { + if (view_uploaded_ && run_view_ == uploaded_view_) { + return; + } + uploaded_view_ = run_view_; + view_uploaded_ = true; + + const View& v = run_view_; + ParticleViewBufferData data{}; + data.view_rvec = {v.matrix.rvec.x, v.matrix.rvec.y, v.matrix.rvec.z}; + data.view_uvec = {v.matrix.uvec.x, v.matrix.uvec.y, v.matrix.uvec.z}; + data.view_fvec = {v.matrix.fvec.x, v.matrix.fvec.y, v.matrix.fvec.z}; + data.view_pos = {v.pos.x, v.pos.y, v.pos.z}; + data.matrix_scale = {v.scale.x, v.scale.y, v.scale.z}; + data.far_clip_z = v.far_clip_z; + data.far_clip_on = v.far_clip_on ? 1.0f : 0.0f; + data.proj = v.proj; + D3D11_MAPPED_SUBRESOURCE mapped; + DF_GR_D3D11_CHECK_HR( + render_context_.device_context()->Map(view_cbuffer_, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped)); + std::memcpy(mapped.pData, &data, sizeof(data)); + render_context_.device_context()->Unmap(view_cbuffer_, 0); + } + + void ParticleRenderer::flush() + { + batch_pending_ = false; + auto [start_instance, num_instance] = instance_ring_buffer_.submit(); + if (num_instance == 0) { + return; + } + upload_view(); + render_context_.set_vertex_buffer(instance_ring_buffer_.get_buffer(), sizeof(GpuParticleInstance), + instance_slot); + render_context_.set_index_buffer(quad_index_buffer_); + render_context_.set_vertex_shader( + state_.primitive == Primitive::stretched ? stretched_vertex_shader_ : billboard_vertex_shader_); + render_context_.bind_vs_cbuffer(view_cbuffer_slot, view_cbuffer_); + render_context_.set_pixel_shader(state_.pixel_shader); + render_context_.set_primitive_topology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + render_context_.set_mode(state_.mode); + render_context_.set_textures(state_.textures[0], state_.textures[1]); + render_context_.set_cull_mode(D3D11_CULL_NONE); + render_context_.draw_indexed_instanced(static_cast(std::size(quad_indices)), num_instance, 0, 0, + start_instance); + ++draws_; + } + + bool ParticleRenderer::lookup_bitmap_size(int bm_handle, int& w, int& h) + { + if (bm_handle != cached_bm_handle_) { + rf::bm::get_dimensions(bm_handle, &cached_bm_w_, &cached_bm_h_); + cached_bm_handle_ = bm_handle; + } + w = cached_bm_w_; + h = cached_bm_h_; + return w != 0; + } + + bool ParticleRenderer::add_particle_instance(const rf::Particle& particle, const rf::Vector3& pos, + const rf::Vector3& last_pos) + { + // Frame selection, particle_render 0x00494B90. Stock rounds with floor(x + 0.5). + int bm_handle = particle.first_frame_bitmap; + const int num_frames = particle.num_frames; + if (num_frames >= 2) { + const float n = static_cast(num_frames); + int frame; + if (particle.flags & rf::PTF_LOOP) { + const float frac = particle.age - static_cast(static_cast(std::floor(particle.age))); + frame = static_cast(std::floor(frac * 15.0f / n + 0.5f)); + } + else if (particle.flags2 & rf::PTF2_HOLD_LAST_FRAME) { + frame = static_cast(std::floor( + particle.age / (particle.age_pct_to_finish_vbm * particle.max_life_seconds) * n + 0.5f)); + if (frame >= num_frames) { + frame = num_frames - 1; + } + } + else { + frame = static_cast(std::floor(particle.age / particle.max_life_seconds * n + 0.5f)); + } + bm_handle += std::clamp(frame, 0, num_frames - 1); + } + + rf::gr::Mode mode = (particle.flags & rf::PTF_GLOW) ? rf::gr::glow_3d_bitmap_mode : rf::gr::bitmap_3d_mode_no_z; + if (particle.flags & rf::PTF_NO_Z_CHECK) { + mode.set_zbuffer_type(rf::gr::ZBUFFER_TYPE_NONE); + } + + // Stock feeds the colour through gr_set_color, so the quad picks it up from the screen + // colour exactly where the dynamic geometry batcher would have. + rf::Color color{255, 255, 255, 255}; + if (mode_uses_vertex_color(mode)) { + color.red = particle.clr_current.red; + color.green = particle.clr_current.green; + color.blue = particle.clr_current.blue; + } + if (mode_uses_vertex_alpha(mode)) { + color.alpha = particle.clr_current.alpha; + } + + State state{ + Primitive::billboard, + normalize_texture_handles_for_mode(mode, {bm_handle, -1}), + mode, + render_context_.has_gas_regions() ? std_pixel_shader_ : std_pixel_shader_no_gas_, + }; + float angle = particle.bitmap_orient; + if (particle.flags & rf::PTF_VEL_STRETCH) { + // Stock squares the streak in x87 precision before comparing it with a double + const rf::Vector3 dir = last_pos - pos; + const double len_sq = static_cast(dir.x) * dir.x + static_cast(dir.y) * dir.y + + static_cast(dir.z) * dir.z; + if (len_sq >= min_stretch_len_sq) { + state.primitive = Primitive::stretched; + GpuParticleInstance& inst = *setup_instance(state); + inst.pos = {pos.x, pos.y, pos.z}; + inst.radius = particle.radius; + inst.axes = {last_pos.x, last_pos.y, last_pos.z, 0.0f}; + inst.color = pack_color(color); + return true; + } + angle = 0.0f; + } + return add_billboard_instance(pos, angle, particle.radius, bm_handle, color, state); + } + + bool ParticleRenderer::add_billboard_instance(const rf::Vector3& pos, float angle, float radius, int bm_handle, + rf::Color color, const State& state) + { + // Billboard build, gr_d3d_3d_bitmap_angle 0x00555AC0 -> 0x00555230: half extents stretched to the + // bitmap's aspect, rotated by the angle. particle_vs.hlsl places the corners around the centre. + float fx = radius; + float fy = radius; + if (bm_handle >= 0) { + int bm_w, bm_h; + if (!lookup_bitmap_size(bm_handle, bm_w, bm_h)) { + return false; // stock draws nothing for a zero width bitmap + } + if (bm_w < bm_h) { + fy = (static_cast(bm_h) * radius) / static_cast(bm_w); + } + else if (bm_h < bm_w) { + fx = (static_cast(bm_w) * radius) / static_cast(bm_h); + } + } + const float sin_a = std::sin(angle); + const float cos_a = std::cos(angle); + + GpuParticleInstance& inst = *setup_instance(state); + inst.pos = {pos.x, pos.y, pos.z}; + inst.radius = radius; + inst.axes = {fx * cos_a, fy * sin_a, fx * sin_a, fy * cos_a}; + inst.color = pack_color(color); + return true; + } + + bool ParticleRenderer::add_billboard(const rf::Vector3& pos, float angle, float radius, rf::gr::Mode mode) + { + if (!instancing_ok_ || !rf::gr::perspective_projection) { + return false; + } + rf::Color color{255, 255, 255, 255}; + if (mode_uses_vertex_color(mode)) { + color.red = rf::gr::screen.current_color.red; + color.green = rf::gr::screen.current_color.green; + color.blue = rf::gr::screen.current_color.blue; + } + if (mode_uses_vertex_alpha(mode)) { + color.alpha = rf::gr::screen.current_color.alpha; + } + const int bm_handle = rf::gr::screen.current_texture_1; + const State state{ + Primitive::billboard, + normalize_texture_handles_for_mode(mode, {bm_handle, -1}), + mode, + render_context_.has_gas_regions() ? std_pixel_shader_ : std_pixel_shader_no_gas_, + }; + add_billboard_instance(pos, angle, radius, bm_handle, color, state); + return true; // a zero width bitmap is still handled, stock draws nothing either + } + + bool ParticleRenderer::add_particle(const rf::Particle& particle) + { + // The debug draw adds a bounding box per particle that this path does not reproduce. + if (rf::particle_draw_bbox || !instancing_ok_ || !rf::gr::perspective_projection) { + return false; + } + ++rf::num_particles_rendered_this_frame; // stock counts every particle it visits + add_particle_instance(particle, particle.pos, particle.last_pos); + return true; + } + + bool ParticleRenderer::render_emitter(rf::ParticleEmitter* emitter, float interp_dt, int* out_drawn, int* out_draws) + { + if (rf::particle_draw_bbox || !instancing_ok_ || !rf::gr::perspective_projection) { + return false; + } + + drawn_ = 0; + draws_ = 0; + cached_bm_handle_ = -1; // bitmap handles can be recycled between levels + + for (rf::Particle* p = emitter->particle_list.next; p != &emitter->particle_list; p = p->next) { + rf::Vector3 pos = p->pos; + rf::Vector3 last_pos = p->last_pos; + if (interp_dt > 0.0f) { + // The streak is drawn last_pos to pos, so both move to keep its length + const rf::Vector3 delta = p->vel * interp_dt; + pos += delta; + last_pos += delta; + } + ++rf::num_particles_rendered_this_frame; // stock counts every particle it visits + if (add_particle_instance(*p, pos, last_pos)) { + ++drawn_; + } + } + + flush(); + if (out_drawn) { + *out_drawn = drawn_; + } + if (out_draws) { + *out_draws = draws_; + } + return true; + } + + bool ParticleRenderer::upload_weather_seeds(const std::vector& seeds) + { + weather_seed_buffer_.release(); + weather_seed_count_ = 0; + if (!weather_ok_ || seeds.empty()) { + return false; + } + CD3D11_BUFFER_DESC desc{ + static_cast(seeds.size() * sizeof(WeatherSeed)), + D3D11_BIND_VERTEX_BUFFER, + D3D11_USAGE_IMMUTABLE, + }; + D3D11_SUBRESOURCE_DATA data{seeds.data(), 0, 0}; + if (FAILED(device_->CreateBuffer(&desc, &data, &weather_seed_buffer_))) { + return false; + } + weather_seed_count_ = static_cast(seeds.size()); + return true; + } + + bool ParticleRenderer::update_weather_columns(int slot, int num_cells, int first, + const std::vector& cells) + { + const int count = static_cast(cells.size()); + if (!weather_ok_ || slot < 0 || num_cells <= 0 || first < 0 || count > num_cells - first) { + return false; + } + if (static_cast(slot) >= weather_columns_.size()) { + weather_columns_.resize(slot + 1); + } + WeatherColumnBuffer& columns = weather_columns_[slot]; + if (columns.num_cells != num_cells) { + columns = {}; + if (first != 0 || count != num_cells) { + return false; + } + CD3D11_BUFFER_DESC desc{ + static_cast(num_cells * sizeof(WeatherColumn)), + D3D11_BIND_SHADER_RESOURCE, + D3D11_USAGE_DEFAULT, + }; + D3D11_SUBRESOURCE_DATA data{cells.data(), 0, 0}; + D3D11_SHADER_RESOURCE_VIEW_DESC srv_desc{}; + srv_desc.Format = DXGI_FORMAT_R32G32B32A32_UINT; + srv_desc.ViewDimension = D3D11_SRV_DIMENSION_BUFFER; + srv_desc.Buffer.NumElements = static_cast(num_cells); + if (FAILED(device_->CreateBuffer(&desc, &data, &columns.buffer)) + || FAILED(device_->CreateShaderResourceView(columns.buffer, &srv_desc, &columns.srv))) { + columns = {}; + return false; + } + columns.num_cells = num_cells; + return true; + } + if (count > 0) { + const D3D11_BOX box{ + static_cast(first * sizeof(WeatherColumn)), 0, 0, + static_cast((first + count) * sizeof(WeatherColumn)), 1, 1, + }; + render_context_.device_context()->UpdateSubresource(columns.buffer, 0, &box, cells.data(), 0, 0); + } + return true; + } + + void ParticleRenderer::release_weather_columns() + { + weather_columns_.clear(); + } + + bool ParticleRenderer::draw_weather(const WeatherDraw& draw) + { + if (!weather_ok_ || !rf::gr::perspective_projection || draw.count > weather_seed_count_) { + return false; + } + ID3D11ShaderResourceView* columns_srv = nullptr; + if (draw.columns_slot >= 0) { + if (static_cast(draw.columns_slot) >= weather_columns_.size() + || weather_columns_[draw.columns_slot].num_cells != draw.cols_x * draw.cols_z) { + return false; + } + columns_srv = weather_columns_[draw.columns_slot].srv; + } + if (draw.count <= 0) { + return true; + } + + // Exactly where the CPU path's quads would have reached the framebuffer: after the open batch, + // or after whatever starting a batch drains. + if (batch_pending_) { + flush(); + } + else if (pre_batch_callback_) { + pre_batch_callback_(); + } + capture_view(); + upload_view(); + + // Colour from the mode, exactly as add_billboard takes it from the screen state + rf::Color color{255, 255, 255, 255}; + if (mode_uses_vertex_color(draw.mode)) { + color.red = draw.color.red; + color.green = draw.color.green; + color.blue = draw.color.blue; + } + if (mode_uses_vertex_alpha(draw.mode)) { + color.alpha = draw.color.alpha; + } + + auto vec = [](const rf::Vector3& v) { + return std::array{v.x, v.y, v.z}; + }; + WeatherBufferData data{}; + data.box_min = vec(draw.box_min); + data.wrap_base = draw.wrap_base; + data.box_size = vec(draw.box_size); + data.salt = draw.salt; + data.box_shift = vec(draw.box_shift); + data.sim_time = draw.time; + data.camera_pos = vec(draw.camera_pos); + data.half_width = draw.half_width; + data.camera_rvec = vec(draw.camera_orient.rvec); + data.streak_half_len = draw.streak_half_len; + data.camera_uvec = vec(draw.camera_orient.uvec); + data.sway_amplitude = draw.sway_amplitude; + data.fall_dir = vec(draw.fall_dir); + data.sway_speed = draw.sway_speed; + data.region_center = vec(draw.region_center); + data.sway_phase = draw.sway_phase; + data.region_rvec = vec(draw.region_orient.rvec); + data.region_radius = draw.region_radius; + data.region_uvec = vec(draw.region_orient.uvec); + data.surface_y = draw.surface_y; + data.region_fvec = vec(draw.region_orient.fvec); + data.column_cell = draw.column_cell; + data.region_half_extents = vec(draw.region_half_extents); + data.flags = (draw.snow ? weather_flag_snow : 0) | (draw.sphere ? weather_flag_sphere : 0) + | (draw.has_surface ? weather_flag_liquid_surface : 0) | (columns_srv ? weather_flag_ceiling : 0); + data.column_cols = {draw.cols_x, draw.cols_z}; + data.column_origin = {draw.column_origin_x, draw.column_origin_z}; + data.color = {color.red / 255.0f, color.green / 255.0f, color.blue / 255.0f, color.alpha / 255.0f}; + D3D11_MAPPED_SUBRESOURCE mapped; + DF_GR_D3D11_CHECK_HR( + render_context_.device_context()->Map(weather_cbuffer_, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped)); + std::memcpy(mapped.pData, &data, sizeof(data)); + render_context_.device_context()->Unmap(weather_cbuffer_, 0); + + render_context_.set_vertex_buffer(weather_seed_buffer_, sizeof(WeatherSeed), instance_slot); + render_context_.set_index_buffer(quad_index_buffer_); + render_context_.set_vertex_shader(weather_vertex_shader_); + render_context_.bind_vs_cbuffer(view_cbuffer_slot, view_cbuffer_); + render_context_.bind_vs_cbuffer(weather_cbuffer_slot, weather_cbuffer_); + render_context_.device_context()->VSSetShaderResources(weather_columns_srv_slot, 1, &columns_srv); + render_context_.set_pixel_shader( + render_context_.has_gas_regions() ? std_pixel_shader_ : std_pixel_shader_no_gas_); + render_context_.set_primitive_topology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + render_context_.set_mode(draw.mode); + const std::array textures = normalize_texture_handles_for_mode(draw.mode, {draw.bm_handle, -1}); + render_context_.set_textures(textures[0], textures[1]); + render_context_.set_cull_mode(D3D11_CULL_NONE); + render_context_.draw_indexed_instanced(static_cast(std::size(quad_indices)), draw.count, 0, 0, 0); + return true; + } +} diff --git a/game_patch/graphics/d3d11/gr_d3d11_particle.h b/game_patch/graphics/d3d11/gr_d3d11_particle.h new file mode 100644 index 000000000..a0f005943 --- /dev/null +++ b/game_patch/graphics/d3d11/gr_d3d11_particle.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "../../rf/gr/gr.h" +#include "gr_d3d11_shader.h" +#include "gr_d3d11_buffer.h" +#include "gr_d3d11_hooks.h" + +namespace rf +{ + struct Particle; + struct ParticleEmitter; +} + +namespace gr::d3d11 +{ + struct GpuParticleInstance; + class RenderContext; + + // Batched stand-in for the stock per particle render path (particle_render 0x00494B90): every + // particle becomes one instance record, which particle_vs.hlsl expands to the stock quad, and one + // instanced draw is issued per texture+mode run. Emitters are drawn from inside their render + // callback, so the sort order against other transparent geometry is the one stock produced. + class ParticleRenderer + { + public: + ParticleRenderer(ComPtr device, ShaderManager& shader_manager, RenderContext& render_context); + // Returns false when the emitter has to go through the stock path instead. + bool render_emitter(rf::ParticleEmitter* emitter, float interp_dt, int* out_drawn, int* out_draws); + // Adds one particle no emitter owns to the open batch, which the caller flushes before + // anything else is drawn. Returns false when it has to go through the stock path instead. + bool add_particle(const rf::Particle& particle); + // Batched stand-in for gr_3d_bitmap_angle. Takes colour and texture from the gr screen state, + // exactly like the dynamic geometry batcher. + bool add_billboard(const rf::Vector3& pos, float angle, float radius, rf::gr::Mode mode); + // Ends a batched pass. A no-op when nothing is pending. + void flush_batch(); + // GPU weather: the shared seeds, each region's ceiling cache, and one instanced draw per region + // issued in place, after whatever was batched before it. + bool upload_weather_seeds(const std::vector& seeds); + bool update_weather_columns(int slot, int num_cells, int first, const std::vector& cells); + void release_weather_columns(); + bool draw_weather(const WeatherDraw& draw); + // Runs when the batch goes from empty to pending, so geometry queued elsewhere reaches + // the framebuffer before the quads that follow it. + void set_pre_batch_callback(std::function callback); + + private: + // How particle_vs.hlsl expands the run's instances: a billboard or a velocity stretched square + enum class Primitive + { + billboard, + stretched, + }; + + struct State + { + Primitive primitive = Primitive::billboard; + std::array textures = { -1, -1 }; + rf::gr::Mode mode{ + rf::gr::TEXTURE_SOURCE_NONE, + rf::gr::COLOR_SOURCE_VERTEX, + rf::gr::ALPHA_SOURCE_VERTEX, + rf::gr::ALPHA_BLEND_NONE, + rf::gr::ZBUFFER_TYPE_NONE, + rf::gr::FOG_ALLOWED, + }; + ID3D11PixelShader* pixel_shader = nullptr; + + bool operator==(const State& other) const = default; + }; + + // The engine view the instanced quads are expanded with, as rotate_vertex and + // compute_vertex_codes read it + struct View + { + rf::Matrix3 matrix; + rf::Vector3 pos; + rf::Vector3 scale; + float far_clip_z = 0.0f; + bool far_clip_on = false; + std::array proj{}; + + bool operator==(const View& other) const = default; + }; + + struct WeatherColumnBuffer + { + ComPtr buffer; + ComPtr srv; + int num_cells = 0; + }; + + void begin_run(const State& state, bool full); + void capture_view(); + GpuParticleInstance* setup_instance(const State& state); + void flush(); + void upload_view(); + bool lookup_bitmap_size(int bm_handle, int& w, int& h); + // particle_render's frame select, mode and colour, then the billboard or stretched record. + // pos and last_pos are where the particle is drawn, which interpolation may have moved. + bool add_particle_instance(const rf::Particle& particle, const rf::Vector3& pos, const rf::Vector3& last_pos); + // Shared billboard record for every gr_3d_bitmap_angle source. Returns false when stock would + // have drawn nothing. + bool add_billboard_instance(const rf::Vector3& pos, float angle, float radius, int bm_handle, + rf::Color color, const State& state); + + ComPtr device_; + RenderContext& render_context_; + RingBuffer instance_ring_buffer_; + ComPtr quad_index_buffer_; + ComPtr view_cbuffer_; + VertexShaderAndLayout billboard_vertex_shader_; + VertexShaderAndLayout stretched_vertex_shader_; + VertexShaderAndLayout weather_vertex_shader_; + ComPtr weather_cbuffer_; + ComPtr weather_seed_buffer_; + int weather_seed_count_ = 0; + std::vector weather_columns_; + ComPtr std_pixel_shader_; + ComPtr std_pixel_shader_no_gas_; + bool instancing_ok_ = false; + bool weather_ok_ = false; + State state_; + View run_view_; + View uploaded_view_; + bool view_uploaded_ = false; + std::function pre_batch_callback_; + bool batch_pending_ = false; + int drawn_ = 0; + int draws_ = 0; + int cached_bm_handle_ = -1; + int cached_bm_w_ = 0; + int cached_bm_h_ = 0; + }; +} diff --git a/game_patch/graphics/d3d11/gr_d3d11_shader.h b/game_patch/graphics/d3d11/gr_d3d11_shader.h index d7dc1b90e..1901dcabe 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_shader.h +++ b/game_patch/graphics/d3d11/gr_d3d11_shader.h @@ -27,6 +27,9 @@ namespace gr::d3d11 outline_standard, gamma, decoration, + particle, + particle_stretched, + weather, }; enum class PixelShaderId @@ -66,6 +69,12 @@ namespace gr::d3d11 return "gamma_vs.bin"; case VertexShaderId::decoration: return "decoration_vs.bin"; + case VertexShaderId::particle: + return "particle_vs.bin"; + case VertexShaderId::particle_stretched: + return "particle_stretched_vs.bin"; + case VertexShaderId::weather: + return "weather_vs.bin"; default: return nullptr; } @@ -90,6 +99,11 @@ namespace gr::d3d11 return VertexLayout::standard; // unused — gamma VS uses SV_VertexID, no input layout case VertexShaderId::decoration: return VertexLayout::standard_instanced; + case VertexShaderId::particle: + case VertexShaderId::particle_stretched: + return VertexLayout::particle_instance; + case VertexShaderId::weather: + return VertexLayout::weather_seed; default: return VertexLayout::standard; } diff --git a/game_patch/graphics/d3d11/gr_d3d11_vertex.h b/game_patch/graphics/d3d11/gr_d3d11_vertex.h index 38e73bf4f..4f605da84 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_vertex.h +++ b/game_patch/graphics/d3d11/gr_d3d11_vertex.h @@ -13,6 +13,8 @@ namespace gr::d3d11 character, transformed, standard_instanced, + particle_instance, + weather_seed, }; using float3 = std::array; @@ -128,6 +130,41 @@ namespace gr::d3d11 return desc; } + // One particle quad per instance, in slot 1: no per vertex data, the corner comes from SV_VertexID. + // Billboards carry their rotated half extents in axes, velocity stretched quads the streak tail. + struct GpuParticleInstance + { + float3 pos; + float radius; + std::array axes; + int color; + }; + static_assert(sizeof(GpuParticleInstance) == 36); + + template<> + inline + std::vector + VertexLayoutTrait::get_desc() + { + return { + { "INSTANCE", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 0, D3D11_INPUT_PER_INSTANCE_DATA, 1 }, + { "INSTANCE", 1, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 16, D3D11_INPUT_PER_INSTANCE_DATA, 1 }, + { "INSTANCE_COLOR", 0, DXGI_FORMAT_R8G8B8A8_UNORM, 1, 32, D3D11_INPUT_PER_INSTANCE_DATA, 1 }, + }; + } + + // One gr::d3d11::WeatherSeed per instance, in slot 1: the corner comes from SV_VertexID like the particles' + template<> + inline + std::vector + VertexLayoutTrait::get_desc() + { + return { + { "INSTANCE", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 0, D3D11_INPUT_PER_INSTANCE_DATA, 1 }, + { "INSTANCE", 1, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 16, D3D11_INPUT_PER_INSTANCE_DATA, 1 }, + }; + } + inline std::vector get_vertex_layout_desc(VertexLayout vertex_layout) { switch (vertex_layout) { @@ -139,6 +176,10 @@ namespace gr::d3d11 return VertexLayoutTrait::get_desc(); case VertexLayout::standard_instanced: return VertexLayoutTrait::get_desc(); + case VertexLayout::particle_instance: + return VertexLayoutTrait::get_desc(); + case VertexLayout::weather_seed: + return VertexLayoutTrait::get_desc(); default: assert(false); return {}; diff --git a/game_patch/graphics/gr.cpp b/game_patch/graphics/gr.cpp index f39291f7a..555d348fa 100644 --- a/game_patch/graphics/gr.cpp +++ b/game_patch/graphics/gr.cpp @@ -210,9 +210,9 @@ CodeInjection gameplay_render_frame_fov_injection{ []() { // Scale world FOV rf::gr::gameplay_fov = gr_scale_world_fov(rf::gr::gameplay_fov); - // Free-look spectate stepped zoom and the turret zoom narrow the FOV (1.0 when not zoomed) + // Free-look spectate stepped zoom and the vehicle gun zoom narrow the FOV (1.0 when not zoomed) rf::gr::gameplay_fov /= multi_spectate_get_view_fov_scale(); - rf::gr::gameplay_fov /= vehicle_turret_zoom_fov_scale(); + rf::gr::gameplay_fov /= vehicle_gun_zoom_fov_scale(); }, }; diff --git a/game_patch/graphics/gr.h b/game_patch/graphics/gr.h index 476c9fd7e..24eac96fa 100644 --- a/game_patch/graphics/gr.h +++ b/game_patch/graphics/gr.h @@ -23,6 +23,8 @@ bool gr_3d_bitmap_oriented_wh(const rf::Vector3* pnt, const rf::Matrix3* M, floa // Current view's screen position of a world point; false when behind the camera or not projected. bool gr_project_world_to_screen(const rf::Vector3& world_pos, float& out_sx, float& out_sy); int gr_fit_string(std::string& text, int max_width, int font_id = -1, std::string_view suffix = "-"); +void explosion_flash_light_create( + const rf::Vector3& pos, float radius, float intensity, float r, float g, float b, int duration_ms, bool fade_only); void explosion_flash_lights_level_init(); void explosion_flash_lights_destroy_all(); diff --git a/game_patch/graphics/gr_internal.h b/game_patch/graphics/gr_internal.h index a749cbc18..f6321a6e6 100644 --- a/game_patch/graphics/gr_internal.h +++ b/game_patch/graphics/gr_internal.h @@ -68,8 +68,6 @@ constexpr std::array k_explosion_flash_vclip_specs_ void explosion_flash_lights_init(); bool vclip_should_do_explosion_flash(bool from_weapon, int vclip_id, float* radius_scale, float* intensity, float* r, float* g, float* b, int* duration_ms); -void explosion_flash_light_create( - const rf::Vector3& pos, float radius, float intensity, float r, float g, float b, int duration_ms, bool fade_only); void explosion_flash_light_create_offset( const rf::Vector3& pos, const rf::Vector3& dir, float offset_meters, float radius, float intensity, float r, float g, float b, int duration_ms); void explosion_flash_lights_do_frame(); diff --git a/game_patch/graphics/gr_light.cpp b/game_patch/graphics/gr_light.cpp index 46bc40a57..f42be65b4 100644 --- a/game_patch/graphics/gr_light.cpp +++ b/game_patch/graphics/gr_light.cpp @@ -7,6 +7,7 @@ #include "../rf/gr/gr.h" #include "../rf/os/frametime.h" #include "../rf/vmesh.h" +#include "gr.h" #include "gr_internal.h" #include "../misc/alpine_settings.h" diff --git a/game_patch/graphics/weather.cpp b/game_patch/graphics/weather.cpp index d0404136d..3ac4ca6f9 100644 --- a/game_patch/graphics/weather.cpp +++ b/game_patch/graphics/weather.cpp @@ -8,11 +8,13 @@ #include #include #include +#include #include #include #include #include "gr.h" #include "weather.h" +#include "d3d11/gr_d3d11_hooks.h" #include "../main/main.h" #include "../misc/alpine_settings.h" #include "../misc/level.h" @@ -30,6 +32,7 @@ #include "../rf/player/camera.h" #include "../rf/player/player.h" #include "../object/object.h" +#include "../object/particle_sim.h" namespace { @@ -63,6 +66,11 @@ constexpr float weather_column_width_max = 8.0f; constexpr int weather_column_max_cells_axis = 512; constexpr int weather_column_trace_budget = 256; constexpr int weather_column_geomod_delay_frames = 30; +// Cells the GPU path's proactive fill may examine per region per frame: a large visible distance makes a +// 512x512 grid, which must not be rescanned whole every frame the camera crosses a cell. +constexpr int weather_column_fill_checks = 4096; +// Dirty cells closer together than this go up to the GPU as one write +constexpr int weather_column_upload_gap = 32; constexpr int rain_count = 4096; constexpr float rain_box_half_x = 26.0f; @@ -77,6 +85,8 @@ constexpr float snow_box_half_z = 22.0f; constexpr float snow_drift_speed = 0.25f; constexpr float snow_spin_min = 0.5f; constexpr float snow_spin_max = 1.5f; +// A common period of the sway's sin(phase) and cos(0.7 * phase) +constexpr double snow_sway_period = 20.0 * 3.14159265358979323846; // Every active region draws out of one shared arena instead of owning a fixed pool, so the number of // regions a level can run at once is bounded by a particle budget rather than by a slot count. @@ -194,12 +204,42 @@ struct WeatherRegionState float cell = 0.0f; float region_top = 0.0f; float region_bottom = 0.0f; + // GPU path: the cells its copy of the grid is missing, and the cell range under the box it last + // found fully traced + std::vector column_dirty; + int gpu_column_cells = 0; + bool columns_filled = false; + int32_t filled_min_cx = 0; + int32_t filled_max_cx = 0; + int32_t filled_min_cz = 0; + int32_t filled_max_cz = 0; + // The pass in progress, resumed next frame where the check limit or the trace budget stopped it + bool fill_in_pass = false; + bool fill_pass_clean = true; + int32_t fill_min_cx = 0; + int32_t fill_max_cx = 0; + int32_t fill_min_cz = 0; + int32_t fill_max_cz = 0; + int32_t fill_center_cz = 0; + int32_t fill_step = 0; + int32_t fill_cx = 0; +}; + +enum class WeatherGpuSeeds +{ + pending, + ready, + failed, }; std::vector g_weather_regions; std::vector g_weather_states; WeatherParticle g_weather_arena[weather_arena_count]; float g_weather_share_scale = 1.0f; +// Weather time since the regions were last reset; the GPU path places every particle from it +double g_weather_clock = 0.0; +WeatherGpuSeeds g_weather_gpu_seeds = WeatherGpuSeeds::pending; +std::vector g_weather_column_upload; rf::Camera* weather_get_camera() { @@ -257,11 +297,24 @@ void plankton_update_state() plankton_reseed(camera_pos, weather_find_room(camera_pos)); } +// The only gr_3d_bitmap_angle call site in plankton_update_render: one mote per iteration, with +// its colour already pushed through gr_set_color and the texture set once before the loop. +CallHook plankton_bitmap_3d_angle_hook{ + 0x0049868A, + [](rf::Vector3* pos, float angle, float radius, rf::gr::Mode mode) { + if (particle_sim_gpu_enabled() && gr::d3d11::batched_billboard(*pos, angle, radius, mode)) { + return; + } + plankton_bitmap_3d_angle_hook.call_target(pos, angle, radius, mode); + }, +}; + FunHook plankton_update_render_hook{ 0x004980F0, []() { plankton_update_state(); plankton_update_render_hook.call_target(); + gr::d3d11::flush_batched_quads(); }, }; @@ -332,6 +385,7 @@ void weather_reset_states() { g_weather_states.assign(g_weather_regions.size(), WeatherRegionState{}); g_weather_share_scale = 1.0f; + g_weather_clock = 0.0; } bool weather_make_box(const rf::Vector3& camera_pos, float half_x, float half_y, float half_z, @@ -631,17 +685,45 @@ void weather_column_grid_prepare(WeatherRegionState& state, const WeatherRegion& state.cols_z = cols; state.cell = cell; state.columns.assign(static_cast(cols) * cols, WeatherColumnCell{}); + state.column_dirty.clear(); + state.gpu_column_cells = 0; + state.columns_filled = false; + state.fill_in_pass = false; } } -// The ceiling above one world column: one trace straight down the region's own height, cached. -bool weather_column_floor(WeatherRegionState& state, float x, float z, int& budget, float& floor_y) +int32_t weather_column_coord(float value, float cell) +{ + return static_cast(std::floor(value / cell)); +} + +int weather_column_slot(const WeatherRegionState& state, int32_t cx, int32_t cz) { - const int32_t cx = static_cast(std::floor(x / state.cell)); - const int32_t cz = static_cast(std::floor(z / state.cell)); const int mx = ((cx % state.cols_x) + state.cols_x) % state.cols_x; const int mz = ((cz % state.cols_z) + state.cols_z) % state.cols_z; - WeatherColumnCell& slot = state.columns[mx + mz * state.cols_x]; + return mx + mz * state.cols_x; +} + +// One trace straight down the region's own height through the middle of the column +void weather_column_trace(const WeatherRegionState& state, WeatherColumnCell& slot, int32_t cx, int32_t cz) +{ + rf::Vector3 p0{(cx + 0.5f) * state.cell, state.region_top, (cz + 0.5f) * state.cell}; + rf::Vector3 p1{p0.x, state.region_bottom, p0.z}; + rf::GCollisionOutput out{}; + slot.cx = cx; + slot.cz = cz; + slot.retrace_frame = 0; + slot.floor_y = rf::collide_linesegment_level_solid(p0, p1, 0, &out) + ? out.hit_point.y + : -std::numeric_limits::max(); +} + +// The ceiling above one world column, cached. +bool weather_column_floor(WeatherRegionState& state, float x, float z, int& budget, float& floor_y) +{ + const int32_t cx = weather_column_coord(x, state.cell); + const int32_t cz = weather_column_coord(z, state.cell); + WeatherColumnCell& slot = state.columns[weather_column_slot(state, cx, cz)]; const bool hit = slot.cx == cx && slot.cz == cz; const bool needs_trace = !hit || (slot.retrace_frame != 0 && rf::frame_count >= slot.retrace_frame); @@ -658,19 +740,132 @@ bool weather_column_floor(WeatherRegionState& state, float x, float z, int& budg } --budget; - rf::Vector3 p0{(cx + 0.5f) * state.cell, state.region_top, (cz + 0.5f) * state.cell}; - rf::Vector3 p1{p0.x, state.region_bottom, p0.z}; - rf::GCollisionOutput out{}; - slot.cx = cx; - slot.cz = cz; - slot.retrace_frame = 0; - slot.floor_y = rf::collide_linesegment_level_solid(p0, p1, 0, &out) - ? out.hit_point.y - : -std::numeric_limits::max(); + weather_column_trace(state, slot, cx, cz); floor_y = slot.floor_y; return true; } +// The GPU cannot ask for a column on demand, so the GPU path traces every column under the sim box +// ahead of time, nearest row to the camera first, out of the same shared budget. +void weather_column_fill_box(WeatherRegionState& state, const rf::Vector3& camera_pos, int& budget) +{ + const WeatherBox& box = state.box; + const int32_t min_cx = weather_column_coord(box.center.x - box.half_x, state.cell); + const int32_t max_cx = weather_column_coord(box.center.x + box.half_x, state.cell); + const int32_t min_cz = weather_column_coord(box.center.z - box.half_z, state.cell); + const int32_t max_cz = weather_column_coord(box.center.z + box.half_z, state.cell); + if (state.columns_filled && min_cx == state.filled_min_cx && max_cx == state.filled_max_cx + && min_cz == state.filled_min_cz && max_cz == state.filled_max_cz) { + return; + } + state.columns_filled = false; + + // A pass starts at the camera's row so the nearest rows come first. The box follows the camera, so a + // range change while a pass is running keeps its cursor: restarting would re-check the near rows on + // every cell crossing and never reach the far ones while the camera moves. Only a box that has left + // the pass's starting row behind (a teleport, or travel faster than the trace budget) restarts it. + if (!state.fill_in_pass || state.fill_center_cz < min_cz || state.fill_center_cz > max_cz) { + state.fill_in_pass = true; + state.fill_pass_clean = true; + state.fill_center_cz = std::clamp(weather_column_coord(camera_pos.z, state.cell), min_cz, max_cz); + state.fill_step = 0; + state.fill_cx = min_cx; + } + else if (min_cx != state.fill_min_cx || max_cx != state.fill_max_cx || min_cz != state.fill_min_cz + || max_cz != state.fill_max_cz) { + // Cells the shift exposed are covered by this pass or the next, so only a pass that never saw the + // range change can end clean. + state.fill_pass_clean = false; + } + state.fill_min_cx = min_cx; + state.fill_max_cx = max_cx; + state.fill_min_cz = min_cz; + state.fill_max_cz = max_cz; + + const int32_t center_cz = state.fill_center_cz; + const int32_t reach = std::max(center_cz - min_cz, max_cz - center_cz); + int checks = weather_column_fill_checks; + for (int32_t step = state.fill_step; step <= reach * 2; ++step) { + const int32_t cz = (step & 1) ? center_cz + (step + 1) / 2 : center_cz - step / 2; + const int32_t first_cx = step == state.fill_step ? std::max(state.fill_cx, min_cx) : min_cx; + if (cz < min_cz || cz > max_cz) { + continue; + } + for (int32_t cx = first_cx; cx <= max_cx; ++cx) { + if (checks <= 0) { + state.fill_step = step; + state.fill_cx = cx; + return; + } + --checks; + const int index = weather_column_slot(state, cx, cz); + WeatherColumnCell& slot = state.columns[index]; + if (slot.cx == cx && slot.cz == cz && slot.retrace_frame == 0) { + continue; + } + if (slot.cx == cx && slot.cz == cz && rf::frame_count < slot.retrace_frame) { + state.fill_pass_clean = false; + continue; + } + if (budget <= 0) { + state.fill_step = step; + state.fill_cx = cx; + return; + } + --budget; + weather_column_trace(state, slot, cx, cz); + state.column_dirty.push_back(index); + } + } + + state.fill_in_pass = false; + if (state.fill_pass_clean) { + state.columns_filled = true; + state.filled_min_cx = min_cx; + state.filled_max_cx = max_cx; + state.filled_min_cz = min_cz; + state.filled_max_cz = max_cz; + } +} + +bool weather_column_upload_range(int slot, const WeatherRegionState& state, int first, int end) +{ + g_weather_column_upload.clear(); + for (int i = first; i < end; ++i) { + const WeatherColumnCell& cell = state.columns[i]; + g_weather_column_upload.push_back({cell.cx, cell.cz, cell.floor_y}); + } + return gr::d3d11::update_weather_columns(slot, static_cast(state.columns.size()), first, + g_weather_column_upload); +} + +// Brings the GPU copy of the grid up to date: the whole grid after anything the GPU did not see, else +// only what the fill traced, merged into runs. +bool weather_column_upload(int slot, WeatherRegionState& state) +{ + const int num_cells = static_cast(state.columns.size()); + auto& dirty = state.column_dirty; + bool ok = true; + if (state.gpu_column_cells != num_cells) { + ok = weather_column_upload_range(slot, state, 0, num_cells); + } + else if (!dirty.empty()) { + std::sort(dirty.begin(), dirty.end()); + std::size_t i = 0; + while (ok && i < dirty.size()) { + const int first = dirty[i]; + int end = first + 1; + while (++i < dirty.size() && dirty[i] <= end + weather_column_upload_gap) { + end = dirty[i] + 1; + } + ok = weather_column_upload_range(slot, state, first, end); + } + } + dirty.clear(); + state.gpu_column_cells = ok ? num_cells : 0; + return ok; +} + int weather_region_bitmap(const std::string& name, int& handle) { if (handle == -1) { @@ -679,6 +874,119 @@ int weather_region_bitmap(const std::string& name, int& handle) return handle; } +// Seeded like the CPU arena, in the unit box: the GPU path reads them for every region. +bool weather_gpu_ready() +{ + if (!particle_sim_gpu_enabled()) { + return false; + } + if (g_weather_gpu_seeds == WeatherGpuSeeds::pending) { + std::vector particles(weather_arena_count); + weather_seed(particles.data(), weather_arena_count, WeatherBox{{0.5f, 0.5f, 0.5f}, 0.5f, 0.5f, 0.5f}, + WeatherRegionType::snow); + std::vector seeds; + seeds.reserve(particles.size()); + for (const WeatherParticle& p : particles) { + seeds.push_back({p.pos.x, p.pos.y, p.pos.z, p.phase, p.angle, p.spin, p.drift_x, p.drift_z}); + } + g_weather_gpu_seeds = gr::d3d11::upload_weather_seeds(seeds) ? WeatherGpuSeeds::ready : WeatherGpuSeeds::failed; + if (g_weather_gpu_seeds == WeatherGpuSeeds::failed) { + xlog::warn("[Weather] Weather is simulated on the CPU: the GPU seed buffer could not be created"); + } + } + return g_weather_gpu_seeds == WeatherGpuSeeds::ready; +} + +// Where the box lattice has moved to by now along one axis: the box-relative offset and the whole laps. +// Double precision, so the uniform motion stays exact however long the level runs. +float weather_lattice_shift(float velocity, float box_min, float box_size, double& laps) +{ + const double travelled = static_cast(velocity) * g_weather_clock - box_min; + laps = std::floor(travelled / box_size); + return static_cast(travelled - laps * box_size); +} + +// The GPU path: weather_vs places each particle from its seed and the clock, the same motion the CPU +// path integrates. The CPU folds the uniform part into the box and keeps the ceiling cache filled. +bool weather_draw_gpu(int index, WeatherRegion& region, WeatherRegionState& state, int& trace_budget, + const rf::Vector3& camera_pos, const rf::Matrix3& camera_orient, bool has_surface, float surface_y) +{ + gr::d3d11::WeatherDraw draw; + rf::Vector3 velocity; + switch (region.type) { + case WeatherRegionType::rain: { + velocity = rf::Vector3{region.rain_wind_x, -region.rain_fall_speed, region.rain_wind_z}; + const float speed = velocity.len(); + if (speed < 0.001f) { + return true; + } + draw.mode = rain_streak_mode; + draw.color = region.rain_color; + draw.fall_dir = velocity / speed; + draw.streak_half_len = std::max(speed * region.rain_streak_seconds * 0.5f, 0.001f); + draw.half_width = rain_streak_half_width; + break; + } + case WeatherRegionType::snow: + // gr_3d_bitmap_oriented_wh draws nothing this small + if (region.snow_sprite_radius <= 1e-5f) { + return true; + } + velocity = rf::Vector3{0.0f, -region.snow_fall_speed, 0.0f}; + draw.snow = true; + draw.mode = rf::gr::glow_3d_bitmap_mode; + draw.bm_handle = weather_region_bitmap(region.snow_bitmap, region.snow_bm_handle); + draw.color = region.snow_color; + draw.half_width = region.snow_sprite_radius; + draw.sway_amplitude = region.snow_sway_amplitude; + draw.sway_speed = region.snow_sway_speed; + draw.sway_phase = static_cast( + std::fmod(static_cast(region.snow_sway_speed) * g_weather_clock, snow_sway_period)); + break; + } + + const WeatherBox& box = state.box; + draw.count = state.length; + draw.box_min = rf::Vector3{box.center.x - box.half_x, box.center.y - box.half_y, box.center.z - box.half_z}; + draw.box_size = rf::Vector3{box.half_x * 2.0f, box.half_y * 2.0f, box.half_z * 2.0f}; + double lateral_laps = 0.0; + double vertical_laps = 0.0; + draw.box_shift.x = weather_lattice_shift(velocity.x, draw.box_min.x, draw.box_size.x, lateral_laps); + draw.box_shift.y = weather_lattice_shift(velocity.y, draw.box_min.y, draw.box_size.y, vertical_laps); + draw.box_shift.z = weather_lattice_shift(velocity.z, draw.box_min.z, draw.box_size.z, lateral_laps); + // Only ever hashed, so the lap count wrapping round 32 bits is harmless + draw.wrap_base = static_cast(static_cast(std::fmod(vertical_laps, 4294967296.0))); + draw.salt = static_cast(index); + draw.time = static_cast(g_weather_clock); + draw.camera_pos = camera_pos; + draw.camera_orient = camera_orient; + draw.sphere = region.shape == WeatherRegionShape::sphere; + draw.region_center = region.center; + draw.region_orient = region.orient; + draw.region_half_extents = region.half_extents; + draw.region_radius = region.radius; + draw.has_surface = has_surface; + draw.surface_y = surface_y; + + if (region.block_by_geometry) { + weather_column_grid_prepare(state, region); + weather_column_fill_box(state, camera_pos, trace_budget); + if (!weather_column_upload(index, state)) { + return false; + } + draw.columns_slot = index; + draw.column_cell = state.cell; + draw.cols_x = state.cols_x; + draw.cols_z = state.cols_z; + // One column of slack under the box, for a position rounding into the column below it + const int32_t below_cx = weather_column_coord(draw.box_min.x, state.cell) - 1; + const int32_t below_cz = weather_column_coord(draw.box_min.z, state.cell) - 1; + draw.column_origin_x = below_cx - ((below_cx % state.cols_x) + state.cols_x) % state.cols_x; + draw.column_origin_z = below_cz - ((below_cz % state.cols_z) + state.cols_z) % state.cols_z; + } + return gr::d3d11::draw_weather(draw); +} + void rain_do_frame(WeatherParticle* particles, int count, const WeatherBox& box, const WeatherRegion& region, WeatherRegionState& state, int& trace_budget, const rf::Vector3& camera_pos, bool has_surface, float surface_y, float dt) @@ -986,6 +1294,10 @@ static void weather_drop_columns(std::size_t i) state.cols_x = 0; state.cols_z = 0; state.cell = 0.0f; + state.column_dirty.clear(); + state.gpu_column_cells = 0; + state.columns_filled = false; + state.fill_in_pass = false; } // Called by the Anchor_Marker events for each of their link UIDs, same as the other region types. @@ -1035,6 +1347,8 @@ void weather_notify_geomod(const rf::Vector3& pos, float radius) const float dz = (slot.cz + 0.5f) * state.cell - pos.z; if (dx * dx + dz * dz <= reach_sq) { slot.retrace_frame = rf::frame_count + weather_column_geomod_delay_frames; + state.columns_filled = false; + state.fill_in_pass = false; } } } @@ -1047,6 +1361,7 @@ void weather_clear_regions() { g_weather_regions.clear(); weather_reset_states(); + gr::d3d11::release_weather_columns(); } void weather_render() @@ -1094,7 +1409,9 @@ void weather_render() const rf::Matrix3 camera_orient = rf::camera_get_orient(camera); const float dt = std::clamp(rf::frametime, 0.0f, max_frame_delta) * demo_playback_sim_time_scale(); + g_weather_clock += dt; int trace_budget = weather_column_trace_budget; + const bool gpu = weather_gpu_ready(); for (std::size_t i = 0; i < g_weather_regions.size(); ++i) { WeatherRegionState& state = g_weather_states[i]; @@ -1102,11 +1419,16 @@ void weather_render() continue; } WeatherRegion& region = g_weather_regions[i]; + if (gpu && weather_draw_gpu(static_cast(i), region, state, trace_budget, camera_pos, camera_orient, + has_surface, surface_y)) { + continue; + } WeatherParticle* particles = g_weather_arena + state.offset; if (region.block_by_geometry) { weather_column_grid_prepare(state, region); } + const int budget_before = trace_budget; switch (region.type) { case WeatherRegionType::rain: @@ -1118,17 +1440,23 @@ void weather_render() camera_orient, has_surface, surface_y, dt); break; } + // The GPU copy did not see what these traces wrote + if (trace_budget != budget_before) { + state.gpu_column_cells = 0; + } } // The tint and the snow texture are global gr state, and everything drawn after this hook would // inherit them. rf::gr::set_color(255, 255, 255, 255); rf::gr::set_texture(-1, -1); + gr::d3d11::flush_batched_quads(); } void weather_apply_patch() { weather_reset_states(); plankton_update_render_hook.install(); + plankton_bitmap_3d_angle_hook.install(); r_weather_cmd.register_cmd(); } diff --git a/game_patch/hud/hud.cpp b/game_patch/hud/hud.cpp index a11cbb030..469c3302d 100644 --- a/game_patch/hud/hud.cpp +++ b/game_patch/hud/hud.cpp @@ -8,6 +8,7 @@ #include "../misc/vote_panel.h" #include "../multi/demo/demo.h" #include "../multi/jetpack.h" +#include "../multi/vehicles/vehicle_view.h" #include "minimap.h" #include "multi_scoreboard.h" #include "multi_spectate.h" @@ -424,6 +425,7 @@ void hud_render_00437BC0() // Render spectate mode UI under scoreboard multi_spectate_render(); + vehicle_render_control_hints(); if (rf::gameseq_get_state() != rf::GS_MULTI_LIMBO) { multi_hud_render_killfeed(); diff --git a/game_patch/hud/hud_internal.h b/game_patch/hud/hud_internal.h index e13b69d9b..ef7da897b 100644 --- a/game_patch/hud/hud_internal.h +++ b/game_patch/hud/hud_internal.h @@ -71,6 +71,36 @@ void hud_status_set_big(bool is_big); // How long a vehicle bar takes to rise over its previous alpha, and to fade once full. inline constexpr int64_t hud_vehicle_bar_rise_ms = 150; inline constexpr int64_t hud_vehicle_bar_fade_ms = 1000; +// The vertical gauge the jetpack fuel and the vehicle hull share, at mid-height on the left or right edge. +struct HudSideGaugeRect +{ + int x; + int y; + int w; + int h; +}; +HudSideGaugeRect hud_side_gauge_rect(bool left); +int hud_side_gauge_label_y(const HudSideGaugeRect& rect); +// Its label above and an optional value below; alpha scales the whole gauge. +void hud_draw_side_gauge(bool left, float frac, const rf::Color& fill, const char* label, const char* value, + float alpha); +// Centred text over a shadow pass, so it keeps its edges over bright geometry. +void hud_draw_shadowed_text(int x, int y, const char* text, int font, const rf::Color& color, rf::ubyte shadow_alpha, + int shadow_offset); +// A vehicle HUD bar's alpha: rising while it has something to show, fading once full. One per bar; +// a bar not drawn last frame, or now drawn for another hull, starts afresh. +struct HudVehicleBarFade +{ + int hull_handle = -1; + int last_frame = -1; + bool active = false; + int64_t rise_since_ms = 0; + float rise_from = 0.0f; + int64_t full_since_ms = -1; // -1 while not full + + // This frame's alpha, 0 once the bar is hidden. + float update(int hull, bool showing); +}; // Fill colour of a vehicle health bar at this fraction of max life; a is the HUD bar's alpha. void hud_vehicle_bar_fill_color(float frac, rf::ubyte& r, rf::ubyte& g, rf::ubyte& b, rf::ubyte& a); void hud_personas_apply_patches(); diff --git a/game_patch/hud/hud_status.cpp b/game_patch/hud/hud_status.cpp index 9c291c383..7c174ece5 100644 --- a/game_patch/hud/hud_status.cpp +++ b/game_patch/hud/hud_status.cpp @@ -1,5 +1,5 @@ #include "hud_internal.h" -#include "../rf/bmpman.h" +#include "hud.h" #include "../rf/hud.h" #include "../rf/player/player.h" #include "../rf/entity.h" @@ -8,7 +8,9 @@ #include "../rf/gr/gr_font.h" #include "../rf/localize.h" #include "../rf/multi.h" +#include "../rf/os/frametime.h" #include "../misc/alpine_settings.h" +#include "../os/os.h" #include "../multi/vehicles/vehicle.h" #include #include @@ -16,6 +18,7 @@ #include #include #include +#include bool g_big_health_armor_hud = false; @@ -47,96 +50,151 @@ void hud_vehicle_bar_fill_color(float frac, rf::ubyte& r, rf::ubyte& g, rf::ubyt namespace { - // unscaled hud.tbl units - constexpr int hud_vehicle_panel_gap = 6; - constexpr int hud_vehicle_bar_height = 24; + constexpr int hud_side_gauge_text_gap = 4; - int hud_bitmap_height(int bmh) + float hud_vehicle_bar_alpha(const HudVehicleBarFade& bar, int64_t now) { - int w = 0; - int h = 0; - if (bmh >= 0) { - rf::bm::get_dimensions(bmh, &w, &h); - } - return h; + const float fade = bar.full_since_ms < 0 + ? 1.0f + : std::clamp(1.0f - static_cast(now - bar.full_since_ms) / hud_vehicle_bar_fade_ms, 0.0f, 1.0f); + const float t = std::clamp(static_cast(now - bar.rise_since_ms) / hud_vehicle_bar_rise_ms, 0.0f, 1.0f); + return std::min(bar.rise_from + (1.0f - bar.rise_from) * t, fade); } +} // namespace - // Where the health/armor cluster ends, in unscaled hud.tbl units. - int hud_status_cluster_bottom(rf::Entity* entity) - { - const int health_idx = std::clamp(static_cast(entity->life * 0.1f), 0, 10); - const float max_armor = entity->info ? entity->info->max_armor : 0.0f; - const int enviro_idx = max_armor > 0.0f - ? std::clamp(static_cast(entity->armor / max_armor * 10.0f), 0, 10) - : 0; - int bottom = rf::hud_coords[rf::hud_health].y + hud_bitmap_height(rf::hud_health_bitmaps[health_idx]); - bottom = std::max(bottom, - rf::hud_coords[rf::hud_envirosuit].y + hud_bitmap_height(rf::hud_enviro_bitmaps[enviro_idx])); - bottom = std::max(bottom, rf::hud_coords[rf::hud_health_value_ul_corner].y - + rf::hud_coords[rf::hud_health_value_width_and_height].y); - bottom = std::max(bottom, rf::hud_coords[rf::hud_envirosuit_value_ul_corner].y - + rf::hud_coords[rf::hud_envirosuit_value_width_and_height].y); - return bottom; +float HudVehicleBarFade::update(int hull, bool showing) +{ + if (hull_handle != hull || last_frame != rf::frame_count - 1) { + *this = HudVehicleBarFade{hull}; } - - // Drawn under the rider's own health/armor cluster, not instead of it. - void hud_draw_vehicle_status(rf::Entity* entity, float scale) - { - rf::Entity* vehicle = vehicle_ridden_hull(entity); - if (!vehicle) { - return; + last_frame = rf::frame_count; + + const int64_t now = timer::get_i64(1000); + if (showing) { + if (!active || full_since_ms >= 0) { + rise_from = active ? hud_vehicle_bar_alpha(*this, now) : 0.0f; + rise_since_ms = now; + full_since_ms = -1; + active = true; + } + } + else { + if (!active) { + return 0.0f; } + if (full_since_ms < 0) { + full_since_ms = now; + } + if (now - full_since_ms >= hud_vehicle_bar_fade_ms) { + active = false; + return 0.0f; + } + } + return hud_vehicle_bar_alpha(*this, now); +} - const int bar_x_unscaled = rf::hud_coords[rf::hud_envirosuit].x; - const int bar_y_unscaled = hud_status_cluster_bottom(entity) + hud_vehicle_panel_gap; - const int bar_w_unscaled = rf::hud_coords[rf::hud_envirosuit_value_ul_corner].x - + rf::hud_coords[rf::hud_envirosuit_value_width_and_height].x - bar_x_unscaled; +void hud_draw_shadowed_text(int x, int y, const char* text, int font, const rf::Color& color, rf::ubyte shadow_alpha, + int shadow_offset) +{ + rf::gr::set_color(0, 0, 0, shadow_alpha); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, x + shadow_offset, y + shadow_offset, text, font); + rf::gr::set_color(color); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, x, y, text, font); +} - rf::HudPoint pos = hud_scale_coords(rf::HudPoint{bar_x_unscaled, bar_y_unscaled}, scale); - const int w = std::max(8, static_cast(bar_w_unscaled * scale)); - const int h = std::max(6, static_cast(hud_vehicle_bar_height * scale)); +HudSideGaugeRect hud_side_gauge_rect(bool left) +{ + const bool big = g_alpine_game_config.big_hud; + const int w = big ? 16 : 10; + const int h = big ? 180 : 120; + const int margin = big ? 60 : 40; + return {left ? margin : rf::gr::clip_width() - margin - w, (rf::gr::clip_height() - h) / 2, w, h}; +} - // Keep the whole bar on screen whatever hud.tbl and the scale produce. - pos.x -= std::max(0, pos.x + w - rf::gr::clip_width()); - pos.y -= std::max(0, pos.y + h - rf::gr::clip_height()); +int hud_side_gauge_label_y(const HudSideGaugeRect& rect) +{ + return rect.y - rf::gr::get_font_height(hud_get_small_font()) - hud_side_gauge_text_gap; +} +void hud_draw_side_gauge(bool left, float frac, const rf::Color& fill, const char* label, const char* value, + float alpha) +{ + const auto scaled = [alpha](int a) { return static_cast(a * alpha); }; + const HudSideGaugeRect rect = hud_side_gauge_rect(left); + const auto [x, y, w, h] = rect; + const int border = g_alpine_game_config.big_hud ? 2 : 1; + + rf::gr::set_color(0, 0, 0, scaled(128)); + rf::gr::rect(x, y, w, h); + rf::gr::set_color(255, 255, 255, scaled(170)); + hud_rect_border(x, y, w, h, border); + + const int inner_h = h - 2 * border; + const int fill_h = static_cast(inner_h * std::clamp(frac, 0.0f, 1.0f)); + if (fill_h > 0) { + rf::gr::set_color(fill.red, fill.green, fill.blue, scaled(220)); + rf::gr::rect(x + border, y + border + (inner_h - fill_h), w - 2 * border, fill_h); + } + + const int font = hud_get_small_font(); + const rf::Color text_color{255, 255, 255, scaled(200)}; + hud_draw_shadowed_text(x + w / 2, hud_side_gauge_label_y(rect), label, font, text_color, scaled(160), 1); + if (value) { + hud_draw_shadowed_text(x + w / 2, y + h + hud_side_gauge_text_gap, value, font, text_color, scaled(160), 1); + } +} + +namespace +{ + // Below the reticle, clear of the notification slots above it: amber through the countdown, then a red pulse + // while the hull drains. + void hud_draw_vehicle_hazard_warning(const VehicleHazardView& hazard) + { + static float pulse_phase = 0.0f; + const bool draining = hazard.warn_left <= 0.0f; + pulse_phase = draining ? pulse_phase + rf::frametime * 12.0f : 0.0f; + const auto alpha = static_cast(draining ? 150.0f + 105.0f * std::cos(pulse_phase) : 255.0f); + const rf::Color color = draining ? rf::Color{255, 60, 40, alpha} : rf::Color{255, 190, 0, alpha}; + + const int font_id = hud_get_large_font(); + const int x = rf::gr::clip_width() / 2; + const int y = rf::gr::clip_height() * 5 / 8; + const std::string detail = draining ? "BAIL OUT!" : std::format("BAIL OUT {:.1f}", hazard.warn_left); + hud_draw_shadowed_text(x, y, hazard.is_sub ? "OUT OF WATER" : "FLOODING", font_id, color, alpha, 2); + hud_draw_shadowed_text(x, y + rf::gr::get_font_height(font_id), detail.c_str(), font_id, color, alpha, 2); + } + + // The jetpack fuel gauge's twin on the left, with the hull's life below; only while the hull is below full. + void hud_draw_vehicle_hull_gauge(rf::Entity* vehicle) + { + static HudVehicleBarFade fade; const float max_life = vehicle_hud_max_life(vehicle); const float life = vehicle_hud_life(vehicle); const float frac = max_life > 0.0f ? std::clamp(life / max_life, 0.0f, 1.0f) : 1.0f; + const float alpha = fade.update(vehicle->handle, frac < 1.0f); + if (alpha <= 0.0f) { + return; + } + rf::ubyte r, g, b, a; + hud_vehicle_bar_fill_color(frac, r, g, b, a); + // Rounded up, so a hull still alive never reads 0. + const auto life_str = std::to_string(std::max(static_cast(std::ceil(life)), 0)); + hud_draw_side_gauge(true, frac, {r, g, b}, "HULL", life_str.c_str(), alpha); + } - const int border = std::max(1, static_cast(std::lround(scale))); - const int inner_x = pos.x + border; - const int inner_y = pos.y + border; - const int inner_w = std::max(1, w - 2 * border); - const int inner_h = std::max(1, h - 2 * border); - - rf::gr::set_color(0, 0, 0, 150); - rf::gr::rect(pos.x, pos.y, w, h); - - if (frac > 0.0f) { - rf::ubyte r, g, b, a; - hud_vehicle_bar_fill_color(frac, r, g, b, a); - rf::gr::set_color(r, g, b, a); - // Rounded rather than truncated, so a full hull reaches the far edge. - rf::gr::rect(inner_x, inner_y, std::clamp(static_cast(std::lround(inner_w * frac)), 1, inner_w), - inner_h); + // The ridden hull's gauge, and its drown or strand warning. + void hud_draw_vehicle_status(rf::Entity* entity) + { + rf::Entity* vehicle = vehicle_ridden_hull(entity); + if (!vehicle) { + return; } + hud_draw_vehicle_hull_gauge(vehicle); - rf::gr::set_color(150, 150, 150, 170); - hud_rect_border(pos.x, pos.y, w, h, border); - - // No ascent metric exists - both engine helpers return the whole LINE height - so th/6 - // approximates the empty descender space and pulls the digits off the visual high side. - const int font_id = rf::hud_status_font; - auto life_str = std::to_string(std::max(static_cast(life), 0)); - const int th = rf::gr::get_string_size(life_str, font_id).second; - const int text_x = inner_x + inner_w / 2; - const int text_y = inner_y + (inner_h - th + 1) / 2 + th / 6; - - rf::gr::set_color(0, 0, 0, 220); - rf::gr::string_aligned(rf::gr::ALIGN_CENTER, text_x + border, text_y + border, life_str.c_str(), font_id); - rf::gr::set_color(255, 255, 255, 255); - rf::gr::string_aligned(rf::gr::ALIGN_CENTER, text_x, text_y, life_str.c_str(), font_id); + VehicleHazardView hazard; + if (vehicle_hazard_view(vehicle, hazard)) { + hud_draw_vehicle_hazard_warning(hazard); + } } // Retargets hud_status_render's entity_in_vehicle call (0x00439DA0). Answering false lands on @@ -169,7 +227,7 @@ FunHook hud_status_render_hook{ // The retarget has already sent this man down the stock on-foot path. hud_status_render_hook.call_target(player); if (widget_rider) { - hud_draw_vehicle_status(widget_rider, 1.0f); + hud_draw_vehicle_status(widget_rider); } return; } @@ -262,7 +320,7 @@ FunHook hud_status_render_hook{ } if (widget_rider) { - hud_draw_vehicle_status(widget_rider, scale); + hud_draw_vehicle_status(widget_rider); } } }, diff --git a/game_patch/hud/hud_weapons.cpp b/game_patch/hud/hud_weapons.cpp index 1b623b6da..ff9c8453a 100644 --- a/game_patch/hud/hud_weapons.cpp +++ b/game_patch/hud/hud_weapons.cpp @@ -40,16 +40,7 @@ constexpr float hud_refill_bar_fill_alpha = 0.75f; namespace { // Per weapon slot; only the viewed hull's bars are ever drawn. - struct HudRefillBar - { - int hull_handle = -1; - int last_frame = -1; - bool active = false; - int64_t rise_since_ms = 0; - float rise_from = 0.0f; - int64_t full_since_ms = -1; // -1 while charging - }; - HudRefillBar g_hud_refill_bars[2]; + HudVehicleBarFade g_hud_refill_bars[2]; } // namespace static void hud_ammo_bitmap_size(int bmh, int& w, int& h); @@ -78,52 +69,18 @@ static bool hud_ammo_hull_has_secondary(const rf::Entity* hull) return vehicle_weapon_ammo(hull, hull->ai.current_secondary_weapon) >= 0; } -static float hud_refill_bar_alpha(const HudRefillBar& bar, int64_t now) -{ - const float fade = bar.full_since_ms < 0 - ? 1.0f - : std::clamp(1.0f - static_cast(now - bar.full_since_ms) / hud_vehicle_bar_fade_ms, 0.0f, 1.0f); - const float t = std::clamp(static_cast(now - bar.rise_since_ms) / hud_vehicle_bar_rise_ms, 0.0f, 1.0f); - return std::min(bar.rise_from + (1.0f - bar.rise_from) * t, fade); -} - // Left of the no-clip row, centred on its signal light; dy is the row's offset. static void hud_render_vehicle_refill_bar(const rf::Entity* hull, int slot, int dy) { - HudRefillBar& bar = g_hud_refill_bars[slot]; - // A bar not drawn last frame starts afresh rather than finishing a fade nobody saw. - if (bar.hull_handle != hull->handle || bar.last_frame != rf::frame_count - 1) { - bar = HudRefillBar{hull->handle}; - } - bar.last_frame = rf::frame_count; - - const int64_t now = timer::get_i64(1000); float progress = 1.0f; - if (vehicle_hud_ammo_refill_progress(hull, slot, progress)) { - if (!bar.active || bar.full_since_ms >= 0) { - bar.rise_from = bar.active ? hud_refill_bar_alpha(bar, now) : 0.0f; - bar.rise_since_ms = now; - bar.full_since_ms = -1; - bar.active = true; - } - } - else { - if (!bar.active) { - return; - } - if (bar.full_since_ms < 0) { - bar.full_since_ms = now; - } - if (now - bar.full_since_ms >= hud_vehicle_bar_fade_ms) { - bar.active = false; - return; - } - progress = 1.0f; - } - const float alpha = hud_refill_bar_alpha(bar, now); + const bool charging = vehicle_hud_ammo_refill_progress(hull, slot, progress); + const float alpha = g_hud_refill_bars[slot].update(hull->handle, charging); if (alpha <= 0.0f) { return; } + if (!charging) { + progress = 1.0f; + } int signal_w = 0; int signal_h = 0; diff --git a/game_patch/hud/hud_world.cpp b/game_patch/hud/hud_world.cpp index fa17cdb2f..3ccc6d2a7 100644 --- a/game_patch/hud/hud_world.cpp +++ b/game_patch/hud/hud_world.cpp @@ -1622,7 +1622,7 @@ void add_damage_notify_world_hud_string(rf::Vector3 pos, uint8_t damaged_player_ es.player_id = damaged_player_id; es.hull_handle = hull_handle; es.damage = damage; - es.timestamp.set_ms(1000); + es.timestamp.set_ms(damage_number_merge_ms); es.float_away = true; es.wind_phase_offset = wind_offset_dist(g_rng); es.crit = crit; diff --git a/game_patch/hud/hud_world.h b/game_patch/hud/hud_world.h index 118662466..6a5abbef1 100644 --- a/game_patch/hud/hud_world.h +++ b/game_patch/hud/hud_world.h @@ -125,6 +125,8 @@ void populate_fullscreen_overlay_events(); void fullscreen_overlay_do_frame(); void hud_world_level_unload(); void add_location_ping_world_hud_sprite(rf::Vector3 pos, std::string player_name, int player_id); +// Hits on one victim within this window add up into one damage number. +inline constexpr int damage_number_merge_ms = 1000; void add_damage_notify_world_hud_string(rf::Vector3 pos, uint8_t damaged_player_id, int hull_handle, uint16_t damage, bool died, bool crit = false); void do_render_world_hud_sprite(rf::Vector3 pos, float base_scale, int bitmap_handle, WorldHUDRenderMode render_mode, diff --git a/game_patch/hud/multi_hud.cpp b/game_patch/hud/multi_hud.cpp index 531966111..83e8fd91d 100644 --- a/game_patch/hud/multi_hud.cpp +++ b/game_patch/hud/multi_hud.cpp @@ -2635,6 +2635,17 @@ ConsoleCommand2 cl_vehiclehealthbars_cmd{ "cl_vehiclehealthbars [bool]", }; +ConsoleCommand2 cl_vehiclehints_cmd{ + "cl_vehiclehints", + [](std::optional enabled) { + g_alpine_game_config.vehicle_control_hints = enabled.value_or(!g_alpine_game_config.vehicle_control_hints); + rf::console::print("Vehicle control hints are {}", + g_alpine_game_config.vehicle_control_hints ? "enabled" : "disabled"); + }, + "Toggle the list of vehicle controls shown while riding a vehicle or turret", + "cl_vehiclehints [bool]", +}; + ConsoleCommand2 ui_gametype_help_cmd{ "ui_gametype_help", [] { @@ -2727,6 +2738,7 @@ void multi_hud_apply_patches() ui_gametype_help_cmd.register_cmd(); cl_vehiclemarkers_cmd.register_cmd(); cl_vehiclehealthbars_cmd.register_cmd(); + cl_vehiclehints_cmd.register_cmd(); ui_miniscoreboard_cmd.register_cmd(); ui_always_show_specators_cmd.register_cmd(); ui_simple_server_chat_messages_cmd.register_cmd(); diff --git a/game_patch/hud/multi_spectate.cpp b/game_patch/hud/multi_spectate.cpp index a131d378b..0e8f2a169 100644 --- a/game_patch/hud/multi_spectate.cpp +++ b/game_patch/hud/multi_spectate.cpp @@ -2316,6 +2316,7 @@ static int spectate_bottom_left_hud_top() case rf::NG_TYPE_CTF: case rf::NG_TYPE_TEAMDM: case rf::NG_TYPE_TBAG: + case rf::NG_TYPE_WO: case rf::NG_TYPE_SAL: return clip_h - (big ? 80 : 55) - 10; default: @@ -2376,6 +2377,23 @@ static void draw_spectate_hints(const std::pair* hints } } +void hud_draw_control_hints(const std::pair* hints, int count) +{ + const int font = hud_get_default_font(); + const int font_h = rf::gr::get_font_height(font); + const bool big = g_alpine_game_config.big_hud; + // The binds are right-aligned on left_x: pushed right as far as a long one (e.g. "Caps Lock") needs. + int widest = 0; + for (int i = 0; i < count; ++i) { + widest = std::max(widest, rf::gr::get_string_size(hints[i].first, font).first); + } + const int left_x = std::max(big ? 120 : 70, widest + (big ? 16 : 8)); + const int right_x = left_x + (big ? 20 : 10); + int y = rf::gr::screen_height() - (big ? 200 : 120) + font_h * 2; + y = spectate_raise_hints_above_hud(y, count, font_h); + draw_spectate_hints(hints, count, left_x, right_x, y, font, font_h); +} + // Top y of the "Spectating: " plaque at the bottom of the screen (same // math as the plaque draw below, using font heights for the two text lines). // The mode-label block anchors here: at this position in non-follow modes @@ -2405,10 +2423,8 @@ void multi_spectate_render() { if (multi_spectate_is_static()) { if (!g_alpine_game_config.spectate_mode_minimal_ui && !g_remote_server_cfg_popup.is_active() && !vote_panel_is_gameplay_overlay_active() && !demo_controls_ui_is_open()) { int medium_font = hud_get_default_font(); - int medium_font_h = rf::gr::get_font_height(medium_font); int large_font = hud_get_large_font(); int scr_w = rf::gr::screen_width(); - int scr_h = rf::gr::screen_height(); rf::Color white_clr{255, 255, 255, 255}; rf::Color shadow_clr{0, 0, 0, 128}; @@ -2434,10 +2450,6 @@ void multi_spectate_render() { rf::gr::string_aligned(rf::gr::ALIGN_CENTER, title_x, title_y + large_font_h, cam_subtitle.c_str(), medium_font); - int hints_y = scr_h - (g_alpine_game_config.big_hud ? 200 : 120) + medium_font_h * 2; - int hints_left_x = g_alpine_game_config.big_hud ? 120 : 70; - int hints_right_x = g_alpine_game_config.big_hud ? 140 : 80; - std::string attach_text = get_action_bind_name( get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_ATTACH)); std::string change_text = get_action_bind_name( @@ -2466,19 +2478,15 @@ void multi_spectate_render() { if (!demo_playback_active()) hints[nh++] = {exit_spec_text.c_str(), "Exit Spectate Mode"}; nh = append_demo_playback_hints(hints, nh, demo_binds); - hints_y = spectate_raise_hints_above_hud(hints_y, nh, medium_font_h); - draw_spectate_hints(hints, nh, hints_left_x, hints_right_x, hints_y, medium_font, medium_font_h); + hud_draw_control_hints(hints, nh); } return; } if (multi_spectate_is_freelook()) { if (!g_alpine_game_config.spectate_mode_minimal_ui && !g_remote_server_cfg_popup.is_active() && !vote_panel_is_gameplay_overlay_active() && !demo_controls_ui_is_open()) { - int medium_font = hud_get_default_font(); - int medium_font_h = rf::gr::get_font_height(medium_font); int large_font = hud_get_large_font(); int scr_w = rf::gr::screen_width(); - int scr_h = rf::gr::screen_height(); rf::Color white_clr{255, 255, 255, 255}; rf::Color shadow_clr{0, 0, 0, 128}; @@ -2494,9 +2502,6 @@ void multi_spectate_render() { } ); - int hints_left_x = g_alpine_game_config.big_hud ? 120 : 70; - int hints_right_x = g_alpine_game_config.big_hud ? 140 : 80; - std::string attach_text = get_action_bind_name( get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_ATTACH) ); @@ -2526,9 +2531,7 @@ void multi_spectate_render() { if (!demo_playback_active()) hints[nh++] = {exit_spec_text.c_str(), "Exit Spectate Mode"}; nh = append_demo_playback_hints(hints, nh, demo_binds); - int hints_y = scr_h - (g_alpine_game_config.big_hud ? 200 : 120) + medium_font_h * 2; - hints_y = spectate_raise_hints_above_hud(hints_y, nh, medium_font_h); - draw_spectate_hints(hints, nh, hints_left_x, hints_right_x, hints_y, medium_font, medium_font_h); + hud_draw_control_hints(hints, nh); } return; } @@ -2639,8 +2642,6 @@ void multi_spectate_render() { view_subtitle.c_str(), medium_font); if (!g_remote_server_cfg_popup.is_active() && !vote_panel_is_gameplay_overlay_active() && !demo_controls_ui_is_open()) { - int hints_left_x = g_alpine_game_config.big_hud ? 120 : 70; - int hints_right_x = g_alpine_game_config.big_hud ? 140 : 80; std::string attach_text = get_action_bind_name( get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_ATTACH) ); @@ -2670,9 +2671,7 @@ void multi_spectate_render() { if (!demo_playback_active()) hints[nh++] = {exit_spec_text.c_str(), "Exit Spectate Mode"}; nh = append_demo_playback_hints(hints, nh, demo_binds); - int hints_y = scr_h - (g_alpine_game_config.big_hud ? 200 : 120) + medium_font_h * 2; - hints_y = spectate_raise_hints_above_hud(hints_y, nh, medium_font_h); - draw_spectate_hints(hints, nh, hints_left_x, hints_right_x, hints_y, medium_font, medium_font_h); + hud_draw_control_hints(hints, nh); } } diff --git a/game_patch/hud/multi_spectate.h b/game_patch/hud/multi_spectate.h index 181d86f46..a35e096b7 100644 --- a/game_patch/hud/multi_spectate.h +++ b/game_patch/hud/multi_spectate.h @@ -1,5 +1,6 @@ #pragma once +#include #include "../rf/player/control_config.h" namespace rf @@ -27,6 +28,8 @@ void multi_spectate_appy_patch(); void multi_spectate_after_full_game_init(); void multi_spectate_level_init(); void multi_spectate_render(); +// A column of bind / label rows at the spectate hints' place, raised clear of the bottom-left HUD. +void hud_draw_control_hints(const std::pair* hints, int count); void multi_spectate_on_player_kill(rf::Player* player, rf::Player* killer); void multi_spectate_on_destroy_player(rf::Player* player); void multi_spectate_player_create_entity_post(rf::Player* player, rf::Entity* entity); diff --git a/game_patch/input/mouse.cpp b/game_patch/input/mouse.cpp index 0e32a3a47..69f9da74e 100644 --- a/game_patch/input/mouse.cpp +++ b/game_patch/input/mouse.cpp @@ -126,7 +126,7 @@ void consume_raw_mouse_deltas(float& out_pitch, float& out_yaw, bool apply_scope if (rf::local_player->fpgun_data.scanning_for_target) { sens *= scanner_sensitivity_value; } else { - float zoom = std::max(rf::local_player->fpgun_data.zoom_factor, vehicle_turret_zoom_fov_scale()); + float zoom = std::max(rf::local_player->fpgun_data.zoom_factor, vehicle_gun_zoom_fov_scale()); if (zoom > 1.0f) { if (g_alpine_game_config.scope_static_sensitivity) { // Static: flat multiplier regardless of zoom level @@ -470,9 +470,9 @@ CodeInjection static_zoom_sensitivity_patch2 { regs.al = static_cast(1); // make cmp at 0x004309DA test true } } - else if (player && player == rf::local_player && vehicle_turret_zoom_fov_scale() > 1.0f) { + else if (player && player == rf::local_player && vehicle_gun_zoom_fov_scale() > 1.0f) { // No fpgun zoom for the stock dynamic branch to read, so both scope rules are applied here. - const float divisor = dynamic_scope_sensitivity_divisor(vehicle_turret_zoom_fov_scale()); + const float divisor = dynamic_scope_sensitivity_divisor(vehicle_gun_zoom_fov_scale()); applied_static_sensitivity_value = g_alpine_game_config.scope_static_sensitivity ? scope_sensitivity_value : (divisor > 1.0f ? 1.0f / divisor : 1.0f); diff --git a/game_patch/main/main.cpp b/game_patch/main/main.cpp index b1f69e0f0..c661e0820 100644 --- a/game_patch/main/main.cpp +++ b/game_patch/main/main.cpp @@ -31,6 +31,7 @@ #include "../hud/multi_scoreboard.h" #include "../hud/multi_spectate.h" #include "../object/object.h" +#include "../object/particle_sim.h" #include "../multi/multi.h" #include "../multi/demo/demo.h" #include "../multi/gametype.h" @@ -248,6 +249,7 @@ CodeInjection after_frame_render_hook{ experimental_render(); #endif debug_render_ui(); + particle_sim_render_ui(); } // To dim our screen, and display "LOADING...", exit limbo here instead. @@ -266,6 +268,7 @@ FunHook level_load_hook{ waypoints_level_reset(); projector_clear_all(); atx_level_reset(); + particle_sim_level_reset(); alpine_camera_clear_static_mode(); if (!save_filename.empty()) xlog::info("Restoring game from save file: {}", save_filename); diff --git a/game_patch/misc/alpine_settings.cpp b/game_patch/misc/alpine_settings.cpp index e742bf22a..2e2ab1c91 100644 --- a/game_patch/misc/alpine_settings.cpp +++ b/game_patch/misc/alpine_settings.cpp @@ -596,6 +596,26 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.set_scope_glass(std::stoi(settings["ScopeGlass"])); processed_keys.insert("ScopeGlass"); } + if (settings.count("ParticleSimCull")) { + g_alpine_game_config.particle_sim_cull = std::stoi(settings["ParticleSimCull"]); + processed_keys.insert("ParticleSimCull"); + } + if (settings.count("ParticleSimFixedStep")) { + g_alpine_game_config.particle_sim_fixed_step = std::stoi(settings["ParticleSimFixedStep"]); + processed_keys.insert("ParticleSimFixedStep"); + } + if (settings.count("ParticleSimRate")) { + g_alpine_game_config.set_particle_sim_rate(std::stoi(settings["ParticleSimRate"])); + processed_keys.insert("ParticleSimRate"); + } + if (settings.count("ParticleSimCollideLod")) { + g_alpine_game_config.particle_sim_collide_lod = std::stoi(settings["ParticleSimCollideLod"]); + processed_keys.insert("ParticleSimCollideLod"); + } + if (settings.count("ParticlePoolSize")) { + g_alpine_game_config.set_particle_pool_size(std::stoi(settings["ParticlePoolSize"])); + processed_keys.insert("ParticlePoolSize"); + } if (settings.count("MeshLightingMode")) { g_alpine_game_config.mesh_lighting_mode = std::clamp(std::stoi(settings["MeshLightingMode"]), 0, 2); recalc_mesh_static_lighting(); @@ -1016,6 +1036,10 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.vehicle_gunner_third_person = std::stoi(settings["VehicleGunnerThirdPerson"]); processed_keys.insert("VehicleGunnerThirdPerson"); } + if (settings.count("VehicleControlHints")) { + g_alpine_game_config.vehicle_control_hints = std::stoi(settings["VehicleControlHints"]); + processed_keys.insert("VehicleControlHints"); + } if (settings.count("VehicleCamRecenter")) { g_alpine_game_config.vehicle_cam_recenter = std::stoi(settings["VehicleCamRecenter"]); processed_keys.insert("VehicleCamRecenter"); @@ -1143,6 +1167,10 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.set_hit_sound_min_interval_ms(std::stoi(settings["HitSoundIntervalMs"])); processed_keys.insert("HitSoundIntervalMs"); } + if (settings.count("HitSoundStreakPitch")) { + g_alpine_game_config.hit_sound_streak_pitch = std::stoi(settings["HitSoundStreakPitch"]); + processed_keys.insert("HitSoundStreakPitch"); + } if (settings.count("PlayTaunts")) { g_alpine_game_config.play_taunt_sounds = std::stoi(settings["PlayTaunts"]); processed_keys.insert("PlayTaunts"); @@ -1609,6 +1637,11 @@ void alpine_player_settings_save(rf::Player* player) file << "Weather=" << g_alpine_game_config.weather << "\n"; file << "UnderwaterFx=" << g_alpine_game_config.underwater_fx << "\n"; file << "ScopeGlass=" << g_alpine_game_config.scope_glass << "\n"; + file << "ParticleSimCull=" << g_alpine_game_config.particle_sim_cull << "\n"; + file << "ParticleSimFixedStep=" << g_alpine_game_config.particle_sim_fixed_step << "\n"; + file << "ParticleSimRate=" << g_alpine_game_config.particle_sim_rate << "\n"; + file << "ParticleSimCollideLod=" << g_alpine_game_config.particle_sim_collide_lod << "\n"; + file << "ParticlePoolSize=" << g_alpine_game_config.particle_pool_size << "\n"; file << "MeshLightingMode=" << g_alpine_game_config.mesh_lighting_mode << "\n"; file << "DynamicLightNdotL=" << g_alpine_game_config.dynamic_light_ndotl << "\n"; file << "PixelLightOverbright=" << g_alpine_game_config.pixel_light_overbright << "\n"; @@ -1730,6 +1763,7 @@ void alpine_player_settings_save(rf::Player* player) file << "VehicleHealthBars=" << g_alpine_game_config.vehicle_health_bars << "\n"; file << "VehicleDriverThirdPerson=" << g_alpine_game_config.vehicle_driver_third_person << "\n"; file << "VehicleGunnerThirdPerson=" << g_alpine_game_config.vehicle_gunner_third_person << "\n"; + file << "VehicleControlHints=" << g_alpine_game_config.vehicle_control_hints << "\n"; file << "VehicleCamRecenter=" << g_alpine_game_config.vehicle_cam_recenter << "\n"; file << "VehicleCamFollow=" << g_alpine_game_config.vehicle_cam_follow << "\n"; file << "VehicleCamVelocity=" << g_alpine_game_config.vehicle_cam_velocity << "\n"; @@ -1761,6 +1795,7 @@ void alpine_player_settings_save(rf::Player* player) file << "ShowAssistNames=" << g_alpine_game_config.show_assist_names << "\n"; file << "HighlightAssistedKills=" << g_alpine_game_config.highlight_assisted_kills << "\n"; file << "HitSoundIntervalMs=" << g_alpine_game_config.hit_sound_min_interval_ms << "\n"; + file << "HitSoundStreakPitch=" << g_alpine_game_config.hit_sound_streak_pitch << "\n"; file << "PlayTaunts=" << g_alpine_game_config.play_taunt_sounds << "\n"; file << "ShowRunTimer=" << g_alpine_game_config.show_run_timer << "\n"; file << "ShowGametypeHelp=" << g_alpine_game_config.show_gametype_help << "\n"; diff --git a/game_patch/misc/alpine_settings.h b/game_patch/misc/alpine_settings.h index 2ade13c04..681dab346 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -102,11 +102,13 @@ struct AlpineGameSettings bool world_hud_team_player_labels = false; bool show_location_pings = true; bool play_hit_sounds = true; + bool hit_sound_streak_pitch = true; bool show_awards = true; bool vehicle_respawn_markers = true; bool vehicle_health_bars = true; bool vehicle_driver_third_person = false; bool vehicle_gunner_third_person = false; + bool vehicle_control_hints = true; // Third-person vehicle camera. Times are smoothing times in seconds (0 = rigid). bool vehicle_cam_recenter = true; // idle drift back behind the vehicle @@ -284,6 +286,23 @@ struct AlpineGameSettings { scope_glass = std::clamp(value, 0, 2); } + bool particle_sim_cull = true; // skip simulating level particle emitters that are not being rendered + bool particle_sim_fixed_step = true; // step cosmetic particle lists at a fixed rate instead of once per frame + bool particle_sim_collide_lod = true; // test cosmetic particle world collision less often with distance + static constexpr int min_particle_pool_size = 1100; + static constexpr int max_particle_pool_size = 16384; + int particle_pool_size = 8192; // 0 keeps the stock capacities (500 and 1100) + void set_particle_pool_size(int size) + { + particle_pool_size = size <= 0 ? 0 : std::clamp(size, min_particle_pool_size, max_particle_pool_size); + } + static constexpr int min_particle_sim_rate = 30; + static constexpr int max_particle_sim_rate = 240; + int particle_sim_rate = 60; + void set_particle_sim_rate(int hz) + { + particle_sim_rate = std::clamp(hz, min_particle_sim_rate, max_particle_sim_rate); + } bool show_enemy_bullets = true; bool fps_counter = true; static constexpr int min_fps_counter_average_ms = 0; diff --git a/game_patch/misc/player.cpp b/game_patch/misc/player.cpp index 06df8dcb6..53f08c859 100644 --- a/game_patch/misc/player.cpp +++ b/game_patch/misc/player.cpp @@ -570,12 +570,13 @@ FunHook player_execute_action_ if (multi_spectate_execute_action(action, was_pressed)) { return; } - // A turret operator's alt is his zoom, read from the raw control state. Stock would run his - // hand weapon's alt route, which can fire the turret locally where the server never hears it. - // A dying operator still needs alt: it is his respawn request. + // A turret operator's or jeep gunner's alt is his zoom, read from the raw control state. Stock + // would run his hand weapon's alt route, which can fire the gun locally where the server never + // hears it. A dying gunner still needs alt: it is his respawn request. if (action == rf::CC_ACTION_SECONDARY_ATTACK && rf::is_multi) { rf::Entity* ep = rf::entity_from_handle(player->entity_handle); - if (ep && !rf::entity_is_dying(ep) && vehicle_hull_is_turret(vehicle_ridden_hull(ep))) { + rf::Entity* hull = ep && !rf::entity_is_dying(ep) ? vehicle_ridden_hull(ep) : nullptr; + if (hull && vehicle_alt_fire_zooms(hull) && vehicle_firing_seat_occupant(hull) == ep) { return; } } @@ -744,8 +745,8 @@ void play_local_sound_3d(uint16_t sound_id, rf::Vector3 pos, int group, float vo rf::snd_play_3d(sound_id, pos, volume, rf::Vector3{}, group); } -void play_local_sound_2d(uint16_t sound_id, int group, float volume) { - rf::snd_play(sound_id, group, 0.0f, volume); +int play_local_sound_2d(uint16_t sound_id, int group, float volume) { + return rf::snd_play(sound_id, group, 0.0f, volume); } void play_local_hit_sound(bool died) { @@ -767,7 +768,22 @@ void play_local_hit_sound(bool died) { hit_sound_timestamp.invalidate(); } - play_local_sound_2d(get_custom_sound_id(died ? 3 : 2), 0, 1.0f); + // Each hit within the damage number merge window of the last climbs a step in pitch; a kill tops the streak + // and ends it. + constexpr float streak_pitch_step = 0.06f; + constexpr int streak_max_steps = 6; + static rf::TimestampRealtime streak_timestamp; + static int streak = 0; + streak = streak_timestamp.valid() && !streak_timestamp.elapsed() ? std::min(streak + 1, streak_max_steps) : 0; + streak_timestamp.set(damage_number_merge_ms); + if (died) { + streak_timestamp.invalidate(); + } + + const int instance = play_local_sound_2d(get_custom_sound_id(died ? 3 : 2), 0, 1.0f); + if (g_alpine_game_config.hit_sound_streak_pitch && streak > 0) { + snd_instance_set_pitch(instance, 1.0f + streak_pitch_step * streak); + } last_hit_sound_was_died = died; } @@ -802,6 +818,17 @@ ConsoleCommand2 hit_sound_interval_cmd{ "Set a minimum interval between damage notification hit sounds in milliseconds", }; +ConsoleCommand2 hit_sound_streak_pitch_cmd{ + "cl_hitsounds_streakpitch", + []() { + g_alpine_game_config.hit_sound_streak_pitch = !g_alpine_game_config.hit_sound_streak_pitch; + rf::console::print("Hit sound pitch rising with consecutive hits is {}", + g_alpine_game_config.hit_sound_streak_pitch ? "enabled" : "disabled"); + }, + "Toggle whether hit sounds rise in pitch as consecutive hits land", + "cl_hitsounds_streakpitch", +}; + ConsoleCommand2 location_ping_display_cmd{ "cl_locationpings", []() { @@ -1158,6 +1185,7 @@ void player_do_patch() mp_set_character_cmd.register_cmd(); localhitsound_cmd.register_cmd(); hit_sound_interval_cmd.register_cmd(); + hit_sound_streak_pitch_cmd.register_cmd(); tauntsound_cmd.register_cmd(); set_autoswitch_fire_wait_cmd.register_cmd(); location_ping_display_cmd.register_cmd(); diff --git a/game_patch/multi/alpine_packets.h b/game_patch/multi/alpine_packets.h index de82dad3e..15e76b545 100644 --- a/game_patch/multi/alpine_packets.h +++ b/game_patch/multi/alpine_packets.h @@ -667,7 +667,8 @@ static_assert(sizeof(af_vehicle_state_packet) == sizeof(RF_GamePacketHeader) + 4 // Vehicle weapon control. Client -> server: trigger edges, and the jeep driver's horn edges (alt_fire 0). // Server -> client: a discrete shot, or a continuous weapon's actual on/off edge (STOP's alt_fire is 0 -// and ignored); the horn reaches clients as AF_VEHICLE_STATE_HORN. +// and ignored), or the sub setting off its torpedoes in flight (DETONATE's alt_fire is how many it left +// flying); the horn reaches clients as AF_VEHICLE_STATE_HORN. enum af_vehicle_fire_action : uint8_t { AF_VEHICLE_FIRE_STOP = 0, @@ -675,6 +676,7 @@ enum af_vehicle_fire_action : uint8_t AF_VEHICLE_FIRE_SHOT = 2, AF_VEHICLE_HORN_STOP = 3, AF_VEHICLE_HORN_START = 4, + AF_VEHICLE_FIRE_DETONATE = 5, }; struct af_vehicle_fire_packet diff --git a/game_patch/multi/demo/demo_playback.cpp b/game_patch/multi/demo/demo_playback.cpp index c80fe90dd..336481125 100644 --- a/game_patch/multi/demo/demo_playback.cpp +++ b/game_patch/multi/demo/demo_playback.cpp @@ -756,6 +756,7 @@ namespace explosion_flash_lights_destroy_all(); vehicle_tracers_clear(); vehicle_fx_clear(); + vehicle_hazard_clear_client_clocks(); } // A burst that ends between a fire-ON and fire-OFF obj_update leaves the entity's diff --git a/game_patch/multi/jetpack.cpp b/game_patch/multi/jetpack.cpp index bdb9a568c..5614b05da 100644 --- a/game_patch/multi/jetpack.cpp +++ b/game_patch/multi/jetpack.cpp @@ -62,7 +62,6 @@ constexpr int JETPACK_STATE_SYNC_INTERVAL_MS = 100; // a keybind hint offers itself under the reticle until the pack is first used. constexpr int JETPACK_REVEAL_MS = 2500; constexpr const char* JETPACK_GAUGE_LABEL = "JETPACK"; -constexpr int JETPACK_LABEL_GAP_PX = 4; // between label and bar top constexpr int JETPACK_TEXT_ALPHA = 200; constexpr int JETPACK_TEXT_SHADOW_ALPHA = 160; constexpr int JETPACK_TEXT_SHADOW_OFFSET_PX = 1; @@ -786,62 +785,9 @@ int jetpack_remote_gauge_alpha(JetpackRemoteFuel& state) return 255 - (255 * elapsed) / JETPACK_GAUGE_FADE_MS; } -// Shadow pass then main pass, so HUD text keeps its edges over bright geometry -// (the technique multi_hud's big notification uses). `x` is the text center. -void jetpack_draw_shadowed_string(int x, int y, const char* text, int font, int alpha_scale) -{ - rf::gr::set_color(0, 0, 0, static_cast((JETPACK_TEXT_SHADOW_ALPHA * alpha_scale) / 255)); - rf::gr::string_aligned(rf::gr::ALIGN_CENTER, x + JETPACK_TEXT_SHADOW_OFFSET_PX, - y + JETPACK_TEXT_SHADOW_OFFSET_PX, text, font); - rf::gr::set_color(255, 255, 255, static_cast((JETPACK_TEXT_ALPHA * alpha_scale) / 255)); - rf::gr::string_aligned(rf::gr::ALIGN_CENTER, x, y, text, font); -} - -struct JetpackGaugeRect -{ - int x; - int y; - int w; - int h; -}; - -JetpackGaugeRect jetpack_fuel_gauge_rect() -{ - const bool big = g_alpine_game_config.big_hud; - const int bar_w = big ? 16 : 10; - const int bar_h = big ? 180 : 120; - const int margin = big ? 60 : 40; - return {rf::gr::clip_width() - margin - bar_w, (rf::gr::clip_height() - bar_h) / 2, bar_w, bar_h}; -} - -int jetpack_fuel_gauge_label_y(int bar_y, int font) -{ - return bar_y - rf::gr::get_font_height(font) - JETPACK_LABEL_GAP_PX; -} - void jetpack_draw_fuel_gauge(float fuel, int alpha_scale) { - const bool big = g_alpine_game_config.big_hud; - const auto [x, y, bar_w, bar_h] = jetpack_fuel_gauge_rect(); - const int border = big ? 2 : 1; - - rf::gr::set_color(0, 0, 0, static_cast((128 * alpha_scale) / 255)); - rf::gr::rect(x, y, bar_w, bar_h); - - rf::gr::set_color(255, 255, 255, static_cast((170 * alpha_scale) / 255)); - hud_rect_border(x, y, bar_w, bar_h, border); - - const int inner_h = bar_h - 2 * border; - const int fill_h = static_cast(inner_h * std::clamp(fuel, 0.0f, 1.0f)); - if (fill_h > 0) { - rf::gr::set_color(255, 165, 0, static_cast((220 * alpha_scale) / 255)); - rf::gr::rect(x + border, y + border + (inner_h - fill_h), bar_w - 2 * border, fill_h); - } - - // Names the bar, sitting directly above it and centered on it. - const int font = hud_get_small_font(); - const int label_y = jetpack_fuel_gauge_label_y(y, font); - jetpack_draw_shadowed_string(x + bar_w / 2, label_y, JETPACK_GAUGE_LABEL, font, alpha_scale); + hud_draw_side_gauge(false, fuel, {255, 165, 0}, JETPACK_GAUGE_LABEL, nullptr, alpha_scale / 255.0f); } void jetpack_draw_thrust_hint(int alpha_scale) @@ -857,14 +803,17 @@ void jetpack_draw_thrust_hint(int alpha_scale) const int offset = big ? JETPACK_HINT_OFFSET_PX_BIG : JETPACK_HINT_OFFSET_PX; const int x = rf::gr::clip_width() / 2; const int y = rf::gr::clip_height() / 2 + offset; - jetpack_draw_shadowed_string(x, y, g_jetpack_hint_text.c_str(), hud_get_default_font(), alpha_scale); + const rf::Color color{255, 255, 255, static_cast((JETPACK_TEXT_ALPHA * alpha_scale) / 255)}; + hud_draw_shadowed_text(x, y, g_jetpack_hint_text.c_str(), hud_get_default_font(), color, + static_cast((JETPACK_TEXT_SHADOW_ALPHA * alpha_scale) / 255), + JETPACK_TEXT_SHADOW_OFFSET_PX); } } // namespace int jetpack_fuel_gauge_label_top_y() { - return jetpack_fuel_gauge_label_y(jetpack_fuel_gauge_rect().y, hud_get_small_font()); + return hud_side_gauge_label_y(hud_side_gauge_rect(false)); } bool jetpacks_are_active() diff --git a/game_patch/multi/vehicles/physics/vphys_camera.cpp b/game_patch/multi/vehicles/physics/vphys_camera.cpp index d75039d48..5ba9a37ad 100644 --- a/game_patch/multi/vehicles/physics/vphys_camera.cpp +++ b/game_patch/multi/vehicles/physics/vphys_camera.cpp @@ -938,6 +938,11 @@ bool vehicle_physics_camera_toggle_view() } } +bool vehicle_physics_camera_local_can_toggle_view() +{ + return vcam_local_choice_seat(false) != VehicleOrbitSeat::none; +} + bool vehicle_physics_camera_local_seat_is_orbit() { rf::Entity* rider = rf::local_player_entity; diff --git a/game_patch/multi/vehicles/vehicle.cpp b/game_patch/multi/vehicles/vehicle.cpp index 972282de2..454b2bedd 100644 --- a/game_patch/multi/vehicles/vehicle.cpp +++ b/game_patch/multi/vehicles/vehicle.cpp @@ -18,6 +18,7 @@ #include "vehicle_tracers.h" #include "vehicle_fx.h" #include "../alpine_packets.h" +#include "../demo/demo.h" #include "../multi.h" #include "../server_internal.h" #include "../../hud/hud.h" @@ -82,8 +83,12 @@ namespace { // Far above any vehicle's max_life (5000), so it kills through the non-player damage scale. constexpr float vehicle_drown_damage = 1000000.0f; - constexpr int64_t vehicle_drown_time_ms = 2000; - constexpr int64_t vehicle_strand_time_ms = 3000; // a sub out of the water this long is scuttled + // In its lethal medium a hull first warns its riders, then drains to the scuttle; getting clear stops it. + constexpr float vehicle_drown_warn_s = 1.5f; + constexpr float vehicle_strand_warn_s = 2.0f; // a sub out of the water + constexpr float vehicle_hazard_drain_s = 3.0f; // empties a full hull in this + constexpr float vehicle_hazard_recover_rate = 2.0f; // the clock runs back this much faster once clear + constexpr float vehicle_hazard_credit_s = 5.0f; // credit reach past the time spent in the medium constexpr float vehicle_void_probe = 300.0f; // world units of clear air below = out of the level constexpr float vehicle_void_time = 2.0f; // held this long before the kill @@ -379,6 +384,11 @@ bool vehicle_hull_is_turret(const rf::Entity* ep) return ep && ep->info && ep->info->use_function == rf::ENTITY_USE_TURRET; } +bool vehicle_alt_fire_zooms(rf::Entity* hull) +{ + return vehicle_hull_is_turret(hull) || (hull && rf::entity_is_jeep(hull)); +} + bool vehicle_is_flyer(rf::Entity* ep) { return ep && ep->info && ep->info->use_function == rf::ENTITY_USE_VEHICLE @@ -430,6 +440,7 @@ void vehicle_client_do_frame() vehicle_update_jeep_wheels(); vehicle_update_treads(); vehicle_fx_do_frame(); + vehicle_hazard_client_do_frame(); // Ahead of the engine frame the hook wraps (0x004B2D90), so the cockpit is stepped first. vehicle_process_spectated_cockpit(); } @@ -506,9 +517,9 @@ void vehicle_client_do_frame() primary_held = rf::control_is_control_down(controls, rf::CC_ACTION_PRIMARY_ATTACK); alt_held = rf::control_is_control_down(controls, rf::CC_ACTION_SECONDARY_ATTACK); } - // A turret's alt trigger is its operator's zoom and is never reported. - vehicle_turret_zoom_do_frame(); - alt_held = alt_held && !vehicle_hull_is_turret(vehicle); + // A turret's or jeep's alt trigger is its gunner's zoom and is never reported. + vehicle_gun_zoom_do_frame(); + alt_held = alt_held && !vehicle_alt_fire_zooms(vehicle); auto report = [](rf::Entity* target, bool fire_held, bool fire_alt) { if (!target) { @@ -546,6 +557,7 @@ void vehicle_client_do_frame() g_vehicle_state.reported_fire_vehicle = handle; g_vehicle_state.reported_primary_held = false; g_vehicle_state.reported_alt_held = false; + g_vehicle_state.torpedo_press_hull = -1; } if (primary_held != g_vehicle_state.reported_primary_held) { @@ -553,6 +565,12 @@ void vehicle_client_do_frame() g_vehicle_state.reported_primary_held = primary_held; } if (alt_held != g_vehicle_state.reported_alt_held) { + // The pilot's own armed torpedoes go off at the press; the server's DETONATE covers the rest. + if (alt_held && vehicle && !rf::is_server && rf::entity_is_sub(vehicle)) { + vehicle_detonate_armed_torpedoes(vehicle, vehicle_torpedo_arm_pilot_s); + g_vehicle_state.torpedo_press_hull = vehicle->handle; + g_vehicle_state.torpedo_press.set(0); + } report(vehicle, alt_held, true); g_vehicle_state.reported_alt_held = alt_held; } @@ -590,12 +608,56 @@ namespace return std::max(vehicle_drown_damage, ep->life + ep->armor + vehicle_drown_damage); } - // The Bullet body is released first so nothing fights the death animation; nobody is credited. + // A non-sub with its ORIGIN below its room's liquid plane, the threshold the sub's board gate uses, + // or a sub with it above. A turret never moves, so it neither drowns nor strands. + bool vehicle_in_hazard(rf::Entity* ep) + { + if (vehicle_hull_is_turret(ep)) { + return false; + } + const bool submerged = ep->room && ep->room->contains_liquid + && ep->pos.y < ep->room->bbox_min.y + ep->room->liquid_depth; + return submerged != rf::entity_is_sub(ep); + } + + float vehicle_hazard_warn_s(rf::Entity* ep) + { + return rf::entity_is_sub(ep) ? vehicle_strand_warn_s : vehicle_drown_warn_s; + } + + // Counts up in the medium and back down faster out of it, so a splash or a bob at the waterline + // neither restarts the warning nor escapes it. + float vehicle_hazard_step(int handle, bool in_hazard, float dt) + { + if (in_hazard) { + return g_vehicle_state.hazard_time[handle] += dt; + } + auto it = g_vehicle_state.hazard_time.find(handle); + if (it == g_vehicle_state.hazard_time.end()) { + return 0.0f; + } + it->second -= dt * vehicle_hazard_recover_rate; + if (it->second > 0.0f) { + return it->second; + } + g_vehicle_state.hazard_time.erase(it); + return 0.0f; + } + + // Life per second, slow at first and quickening, so a full hull empties in vehicle_hazard_drain_s. + float vehicle_hazard_drain_rate(float max_life, float drained_s) + { + const float progress = std::min(drained_s / vehicle_hazard_drain_s, 1.0f); + return max_life / vehicle_hazard_drain_s * (0.4f + 1.2f * progress); + } + + // The Bullet body is released first so nothing fights the death animation. The blow itself credits nobody; + // a drown or strand scuttle may have named the destroyer first. void vehicle_server_scuttle(rf::Entity* ep, float damage) { const int handle = ep->handle; g_vehicle_state.kinematics.erase(handle); - g_vehicle_state.hazard_since.erase(handle); + g_vehicle_state.hazard_time.erase(handle); g_vehicle_state.void_timer.erase(handle); g_vehicle_state.coast_memory.erase(handle); vehicle_physics_server_release(handle); @@ -603,6 +665,76 @@ namespace } } // namespace +bool vehicle_hazard_view(rf::Entity* vehicle, VehicleHazardView& out) +{ + if (!vehicle || rf::entity_is_dying(vehicle) || !vehicle_in_hazard(vehicle)) { + return false; + } + auto it = g_vehicle_state.hazard_time.find(vehicle->handle); + if (it == g_vehicle_state.hazard_time.end()) { + return false; + } + const float warn_s = vehicle_hazard_warn_s(vehicle); + out.is_sub = rf::entity_is_sub(vehicle); + out.warn_left = std::max(warn_s - it->second, 0.0f); + out.drain_frac = std::clamp((it->second - warn_s) / vehicle_hazard_drain_s, 0.0f, 1.0f); + return true; +} + +bool vehicle_hazard_on_clock(int vehicle_handle) +{ + return g_vehicle_state.hazard_time.count(vehicle_handle) != 0; +} + +void vehicle_hazard_clear_client_clocks() +{ + if (!rf::is_server) { + g_vehicle_state.hazard_time.clear(); + } +} + +// A client runs the server's clock on its own view of the hulls, for the riders' warning; on a +// listen host the server pass keeps it. The alarm is the local rider's, or the watched one's. +void vehicle_hazard_client_do_frame() +{ + // On demo time, so a paused or slowed replay holds or slows the countdown with its hulls. + const float dt = rf::frametime * demo_playback_sim_time_scale(); + if (!rf::is_server && dt > 0.0f) { + for (auto it = g_vehicle_state.hazard_time.begin(); it != g_vehicle_state.hazard_time.end();) { + it = rf::entity_from_handle(it->first) ? std::next(it) : g_vehicle_state.hazard_time.erase(it); + } + for (rf::Entity& entity : DoublyLinkedList{rf::entity_list}) { + if (vehicle_is_synced_entity_type(&entity) && !rf::entity_is_dying(&entity)) { + vehicle_hazard_step(entity.handle, vehicle_in_hazard(&entity), dt); + } + } + } + + rf::Entity* hull = vehicle_ridden_hull(rf::local_player_entity); + rf::Player* watched = multi_spectate_get_target_player(); + if (!hull && watched && watched != rf::local_player && !multi_spectate_is_freelook()) { + hull = vehicle_ridden_hull(rf::entity_from_handle(watched->entity_handle)); + } + int& alarm = g_vehicle_state.hazard_alarm_snd; + VehicleHazardView view; + if (!hull || !vehicle_hazard_view(hull, view)) { + if (alarm >= 0) { + rf::snd_stop(alarm); + alarm = -1; + } + return; + } + if (!snd_instance_is_playing(alarm)) { + static const int foley = rf::foley_lookup_by_name("Alarm 3"); + const int sound = foley >= 0 ? rf::foley_get_sound_handle(foley) : -1; + alarm = sound >= 0 ? rf::snd_play(sound, rf::SOUND_GROUP_EFFECTS, 0.0f, 0.8f) : -1; + } + // Rising as the hull drains. + if (view.drain_frac > 0.0f) { + snd_instance_set_pitch(alarm, 1.0f + 0.25f * view.drain_frac); + } +} + void vehicle_apply_factory_state_from_packet(uint16_t factory_index, uint8_t state, uint16_t s_remaining, uint8_t team) { @@ -690,16 +822,19 @@ float vehicle_factory_ui_progress(int i, int64_t now) return std::clamp(1.0f - left_ms / total_ms, 0.0f, 1.0f); } -void vehicle_level_init() +void vehicle_level_init(bool load_assets) { vehicle_horn_level_init(); + if (g_vehicle_state.hazard_alarm_snd >= 0) { + rf::snd_stop(g_vehicle_state.hazard_alarm_snd); + } g_vehicle_state = VehicleModuleState{}; vehicle_clear_local_enclosed_flag(); vehicle_drop_jeep_tire_mesh(); vehicle_view_level_init(); vehicle_markers_level_init(); vehicle_tracers_level_init(); - vehicle_fx_level_init(); + vehicle_fx_level_init(load_assets); // The bm cache is rebuilt per level, so a handle resolved for the last one means nothing here. vehicle_tread_runtime_reset(); vehicle_ammo_regen_runtime_reset(); @@ -724,7 +859,7 @@ void vehicle_do_frame() if (!ep) { vehicle_drop_combat_state(handle); g_vehicle_state.void_timer.erase(handle); - g_vehicle_state.hazard_since.erase(handle); + g_vehicle_state.hazard_time.erase(handle); dropped.push_back(handle); continue; } @@ -745,7 +880,7 @@ void vehicle_do_frame() if (rf::entity_is_dying(ep)) { g_vehicle_state.kinematics.erase(handle); // a dying vehicle neither coasts nor sinks - g_vehicle_state.hazard_since.erase(handle); + g_vehicle_state.hazard_time.erase(handle); g_vehicle_state.coast_memory.erase(handle); // ...nor runs anybody over // A Bullet body still writing pos/orient into the wreck would fight the death animation. @@ -756,24 +891,20 @@ void vehicle_do_frame() // A turret never moves, so it neither drowns nor falls into the void. const bool is_turret = vehicle_hull_is_turret(ep); - // Submerged = the hull ORIGIN below this room's liquid plane, the threshold the sub's board - // gate uses. A non-sub drowns under water, a sub strands out of it; the dwell absorbs a splash, - // a landing bounce or a breach. Killer -1 credits nobody. - if (!is_turret) { - const bool is_sub = rf::entity_is_sub(ep); - const bool submerged = ep->room && ep->room->contains_liquid - && ep->pos.y < ep->room->bbox_min.y + ep->room->liquid_depth; - if (submerged == is_sub) { - g_vehicle_state.hazard_since.erase(handle); - } - else { - const int64_t now = timer::get_i64(1000); - const int64_t since = - g_vehicle_state.hazard_since.try_emplace(handle, now).first->second; - if (now - since >= (is_sub ? vehicle_strand_time_ms : vehicle_drown_time_ms)) { - vehicle_server_scuttle(ep, vehicle_scuttle_lethal_damage(ep)); - continue; - } + // A non-sub drowns under water, a sub strands out of it. Past the warning the hull drains, writing + // life the way regen does so the health stream carries it, down to the scuttle; whoever hit it + // shortly before or during is named its destroyer, so its riders die to him, not by their own hand. + const bool in_hazard = vehicle_in_hazard(ep); + const float hazard_s = vehicle_hazard_step(handle, in_hazard, rf::frametime); + const float drained_s = hazard_s - vehicle_hazard_warn_s(ep); + if (in_hazard && drained_s > 0.0f) { + constexpr float last_life = 1.0f; // the scuttle's own blow takes the last of it + const float drain = vehicle_hazard_drain_rate(vehicle_hud_max_life(ep), drained_s) * rf::frametime; + ep->life = std::max(ep->life - drain, last_life); + if (ep->life <= last_life || drained_s >= 2.0f * vehicle_hazard_drain_s) { + vehicle_note_hazard_destroyer(ep, hazard_s + vehicle_hazard_credit_s); + vehicle_server_scuttle(ep, vehicle_scuttle_lethal_damage(ep)); + continue; } } @@ -897,7 +1028,7 @@ void vehicle_do_frame() void vehicle_on_multi_shutdown() { vehicle_tbl_overrides_revert(); // put weapons.tbl/entity.tbl back before the session ends - vehicle_level_init(); + vehicle_level_init(false); } namespace diff --git a/game_patch/multi/vehicles/vehicle.h b/game_patch/multi/vehicles/vehicle.h index f842af973..727f27063 100644 --- a/game_patch/multi/vehicles/vehicle.h +++ b/game_patch/multi/vehicles/vehicle.h @@ -102,6 +102,8 @@ float vehicle_collision_reach_along(rf::Entity* ep, const rf::Vector3& dir); bool vehicle_is_flyer(rf::Entity* ep); bool vehicle_hull_is_turret(const rf::Entity* ep); +// A turret's or jeep's alt trigger toggles its gunner's zoom and drives no weapon. +bool vehicle_alt_fire_zooms(rf::Entity* hull); // Rebuild the fire frame from the AUTHORITATIVE hull matrix, never the interpolated angles. void vehicle_rebuild_eye_orient(rf::Entity* ep, const rf::Matrix3& hull); @@ -259,6 +261,16 @@ void vehicle_store_health_from_packet(int vehicle_handle, float life, float max_ int vehicle_weapon_ammo(const rf::Entity* vehicle, int weapon_type); float vehicle_hud_life(const rf::Entity* vehicle); float vehicle_hud_max_life(const rf::Entity* vehicle); +// A hull's drown or strand clock as its riders see it; false while it is clear of its lethal medium. +struct VehicleHazardView +{ + bool is_sub = false; + float warn_left = 0.0f; // seconds of warning left, 0 once the drain has begun + float drain_frac = 0.0f; // 0..1 through the drain +}; +bool vehicle_hazard_view(rf::Entity* vehicle, VehicleHazardView& out); +// A demo seek leaves the clocks a client ran for the frames it skipped. +void vehicle_hazard_clear_client_clocks(); // False when weapon slot 0 (primary) or 1 (secondary) has no refill pending; else its 0..1 progress. bool vehicle_hud_ammo_refill_progress(const rf::Entity* vehicle, int slot_index, float& progress); @@ -343,7 +355,8 @@ bool __cdecl vehicle_rider_keeps_own_orient(rf::Entity* ep); // with the HULL frame the view was composed from. False elsewhere; before the widescreen stretch. bool vehicle_cockpit_view_pose(rf::Vector3* out_pos, rf::Matrix3* out_orient); -void vehicle_level_init(); +// load_assets is false when leaving multiplayer: nothing will draw what it would load. +void vehicle_level_init(bool load_assets = true); void vehicle_level_init_post(); void vehicle_do_frame(); void vehicle_on_multi_shutdown(); diff --git a/game_patch/multi/vehicles/vehicle_damage.cpp b/game_patch/multi/vehicles/vehicle_damage.cpp index ddb5e65d6..bacfa082a 100644 --- a/game_patch/multi/vehicles/vehicle_damage.cpp +++ b/game_patch/multi/vehicles/vehicle_damage.cpp @@ -1480,8 +1480,8 @@ namespace if (vehicle_blast_damage_is_mint(victim->handle)) { rf::Entity* vehicle = vehicle_synced_entity(g_vehicle_blast_mint.vehicle_handle); if (vehicle) { - // An unattributed lethal blow (drowned, stranded, returned, void, own ram share) is the - // rider's own death, whoever softened the hull first; they are paid in assists instead. + // An unattributed lethal blow (drowned or stranded with no recent hit to blame, returned, + // void, own ram share) is the rider's own death; whoever softened the hull is paid in assists. auto lethal = g_vehicle_state.lethal_killer.find(vehicle->handle); if (lethal != g_vehicle_state.lethal_killer.end() && lethal->second.entity_handle == -1) { return victim->handle; @@ -1540,7 +1540,7 @@ namespace && wi.ammo_type < static_cast(std::extent_v); } - // Slot 0 is the primary weapon, slot 1 the secondary; a turret's alt trigger is never stored. + // Slot 0 is the primary weapon, slot 1 the secondary; a zooming alt trigger is never stored. bool vehicle_ammo_slot_trigger_held(const rf::Entity* ep, int slot_index) { auto it = g_vehicle_state.fire.find(ep->handle); @@ -1671,6 +1671,34 @@ int vehicle_occupant_death_blast_hull(int victim_handle, int damage_type) ? g_vehicle_blast_mint.vehicle_handle : -1; } +// Ahead of a drown or strand scuttle, which is unattributed: the last player blow that hurt the hull +// within window_s names its destroyer, so the riders it takes die to him rather than by their own hand. +void vehicle_note_hazard_destroyer(rf::Entity* vehicle, float window_s) +{ + auto it = g_vehicle_state.last_hurt.find(vehicle->handle); + if (it == g_vehicle_state.last_hurt.end() + || timer::get_i64(1000) - it->second.ms > static_cast(window_s * 1000.0f)) { + return; + } + // A team-mate's graze, friendly fire or not, never makes the riders' drowning his team kills. + const VehicleLethalKiller& blow = it->second.blow; + const rf::Player* attacker = blow.player_id != 0xFF ? rf::multi_find_player_by_id(blow.player_id) + : rf::player_from_entity_handle(blow.entity_handle); + if (!attacker) { + return; + } + if (multi_is_team_game_type()) { + for (int i = 0; i < vehicle_seat_count(vehicle); ++i) { + const int leech = vehicle_seat_leech(vehicle, i); + const rf::Player* rider = leech != -1 ? rf::player_from_entity_handle(leech) : nullptr; + if (rider && rider != attacker && rider->team == attacker->team) { + return; + } + } + } + g_vehicle_state.lethal_killer.try_emplace(vehicle->handle, blow); +} + bool vehicle_lethal_blow_attribution(int vehicle_handle, const rf::Player* killer_player, int& weapon_type, bool& splash, int& vehicle_class) { @@ -1692,7 +1720,8 @@ void vehicle_note_hull_damage(rf::Entity* vehicle, float life_before, int killer || life_before <= 0.0f) { return; // a blow on a wreck neither kills it again nor softens it for anybody } - if (vehicle->life <= 0.0f) { + // This blow as the hull's destroyer. + const auto blow = [&] { VehicleLethalKiller lethal; rf::Entity* killer = killer_handle != -1 ? rf::entity_from_handle(killer_handle) : nullptr; // A rider's own shot is his own death, as the own ram share is: suicides and assists. @@ -1713,11 +1742,19 @@ void vehicle_note_hull_damage(rf::Entity* vehicle, float life_before, int killer lethal.splash = splash; } } - g_vehicle_state.lethal_killer.try_emplace(vehicle->handle, lethal); + return lethal; + }; + if (vehicle->life <= 0.0f) { + g_vehicle_state.lethal_killer.try_emplace(vehicle->handle, blow()); } else if (real_damage > 0.0f) { const bool stock_indicated = splash || (kill_attribution_in_projectile_impact() && weapon_type >= 0); vehicle_note_hull_hit(vehicle, killer_handle, stock_indicated); + if (killer_handle != -1 && rf::player_from_entity_handle(killer_handle)) { + if (VehicleLethalKiller hurt = blow(); hurt.entity_handle != -1) { + g_vehicle_state.last_hurt[vehicle->handle] = {hurt, timer::get_i64(1000)}; + } + } } if (killer_handle == -1 || real_damage <= 0.0f) { return; // player_from_entity_handle does not validate -1 @@ -2077,7 +2114,7 @@ void vehicle_regen_do_frame(rf::Entity* ep) return; } // A hull on the drown or strand clock is not on the repair clock. - if (g_vehicle_state.hazard_since.count(ep->handle) != 0) { + if (g_vehicle_state.hazard_time.count(ep->handle) != 0) { return; } diff --git a/game_patch/multi/vehicles/vehicle_fx.cpp b/game_patch/multi/vehicles/vehicle_fx.cpp index b10493a6f..4c81ade3f 100644 --- a/game_patch/multi/vehicles/vehicle_fx.cpp +++ b/game_patch/multi/vehicles/vehicle_fx.cpp @@ -13,10 +13,12 @@ #include "vehicle_internal.h" #include "vehicle_physics.h" #include "../demo/demo.h" +#include "../../graphics/gr.h" #include "../../graphics/weather.h" #include "../../main/main.h" #include "../../misc/alpine_settings.h" #include "../../misc/level.h" +#include "../../object/particle_sim.h" #include "../../os/console.h" #include "../../rf/bmpman.h" #include "../../rf/collide.h" @@ -30,6 +32,8 @@ #include "../../rf/physics.h" #include "../../rf/player/camera.h" #include "../../rf/player/player.h" +#include "../../rf/sound/sound.h" +#include "../../sound/sound.h" namespace { @@ -37,9 +41,11 @@ namespace constexpr int fx_particle_pool = 1; constexpr int fx_max_live_particles = 400; constexpr float fx_pool_headroom = 0.8f; - // The engine's emitter records serve everything, entity fire (four per burning entity) and the - // flamethrower included, and those silently go without when none is free. - constexpr int fx_max_emitters = 40; + // Engine records are plentiful, but each emitter is a room render item and is walked every frame, so this + // covers the busy layers of every hull in range and no more. At the cap, a hull near the camera takes one + // from the farthest hull rather than going without. + constexpr int fx_max_emitters = 96; + // Kept free for everything else that shares the records: entity fire, the flamethrower, explosions. constexpr int fx_min_free_engine_emitters = 32; // An emitter with nothing left to draw is handed back after this long. constexpr float fx_emitter_idle_s = 1.0f; @@ -57,6 +63,8 @@ namespace constexpr float fx_impact_full_dv = 20.0f; constexpr float fx_impact_cooldown = 0.4f; constexpr float fx_impact_miss_cooldown = 0.1f; + // How fast the remembered peak speeds fall away (u/s per second), so they cover the ~0.3 s an impact takes. + constexpr float fx_peak_decay = 12.0f; // A landing needs the hull to have been off the ground this recently; the foot of a slope is not one. constexpr float fx_landing_airborne_window = 0.3f; constexpr float fx_ground_slack = 0.5f; @@ -65,6 +73,45 @@ namespace // particle_create applies gravity as gravity_scale * 9.8 (0x00589858). constexpr float fx_gravity = 9.8f; + constexpr float fx_heavy_impact = 0.5f; + // Rock chips come from the engine's shared pool of 80 geomod rocks, and each burst also relaunches the + // rocks around it and recycles the oldest once the pool is full, so bursts are rationed across all hulls. + constexpr float fx_debris_min_strength = 0.4f; + constexpr float fx_debris_cooldown = 1.0f; + constexpr float fx_debris_global_cooldown = 0.5f; + + enum FxSound + { + FXS_IMPACT_METAL, + FXS_IMPACT_ROCK, + FXS_IMPACT_GLASS, + FXS_SPLASH_SMALL, + FXS_SPLASH_MEDIUM, + FXS_SPLASH_LARGE, + FXS_SPLASH_HUGE, + FXS_SPARK, + FXS_BURNING, + FXS_COUNT, + }; + + constexpr const char* fx_foley_names[FXS_COUNT] = { + "Riot Impact Metal", + "Riot Impact Rock", + "Glass Land", + "Small Water Splash", + "Medium Water Splash", + "Large Water Splash", + "Huge Water Splash", + "Riot Spark", + "Burning", + }; + + // Stock ambient loops for the fighter's downwash: a rush of wind over ground, spray over water. + constexpr const char* fx_wash_sound_files[] = { + "Amb_Wind_02.wav", + "Amb_Water_Fall_02.wav", + }; + enum FxBitmap { FXB_SMOKE, @@ -100,7 +147,7 @@ namespace }; constexpr ParticleSpec spark_spec{ - FXB_SPARK, {255, 255, 255, 255}, {128, 128, 128, 0}, 0.05f, 0.08f, 0.0f, 0.15f, 0.35f, 2.0f, 0.0f, + FXB_SPARK, {255, 255, 255, 255}, {128, 128, 128, 0}, 0.08f, 0.13f, 0.0f, 0.25f, 0.5f, 2.0f, 0.0f, rf::PTF_GLOW | rf::PTF_VEL_STRETCH, }; // The bubble bitmap's white specks read as droplets; the stock corona has an orange core. @@ -117,11 +164,11 @@ namespace rf::PTF_RANDOM_ORIENT, }; constexpr ParticleSpec dark_smoke_spec{ - FXB_DARK_SMOKE, {255, 255, 255, 120}, {255, 255, 255, 0}, 0.4f, 0.6f, 0.5f, 1.4f, 2.2f, -0.15f, -1.0f, + FXB_DARK_SMOKE, {255, 255, 255, 150}, {255, 255, 255, 0}, 0.4f, 0.6f, 0.9f, 2.4f, 3.4f, -0.3f, -1.0f, rf::PTF_RANDOM_ORIENT, }; constexpr ParticleSpec fire_spec{ - FXB_FIRE, {255, 255, 255, 170}, {255, 255, 255, 0}, 0.25f, 0.45f, -0.3f, 0.3f, 0.7f, -0.6f, -3.0f, + FXB_FIRE, {255, 255, 255, 190}, {255, 255, 255, 0}, 0.35f, 0.6f, -0.3f, 0.45f, 0.8f, -0.6f, -3.0f, rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT, }; constexpr ParticleSpec bubble_spec{ @@ -178,6 +225,17 @@ namespace return {FXB_SMOKE, clr, dest, 0.25f * size, 0.45f * size, 0.8f, 0.6f, 1.1f, -0.05f, -2.0f, flags}; } + // A landing or crash throws a heavier, longer-lived cloud than a rolling wheel. + ParticleSpec impact_dust_spec(const SurfaceFx& s, float size, bool haze) + { + ParticleSpec spec = dust_spec(s, size, haze); + spec.growth_rate = 1.4f; + spec.min_life = 1.2f; + spec.max_life = 2.2f; + spec.acceleration = -2.5f; + return spec; + } + struct GroundProbe { bool hit = false; @@ -203,12 +261,18 @@ namespace std::array emitter_idle{}; rf::Timestamp emitter_retry; rf::GRoom* room = nullptr; + float cam_dist = 0.0f; + // Alternates spawn sites across frames: at these rates one frame rarely spawns more than one. uint32_t spawn_seq = 0; rf::Vector3 last_pos{}; bool has_last = false; rf::Vector3 vel{}; // fast, what the hull is doing now rf::Vector3 vel_slow{}; // what it was doing a moment ago, for impacts + // Recent peak fall and horizontal speeds: an impact is detected as soon as the hull starts to slow, + // before the smoothed speeds show how hard it hit. + float peak_fall = 0.0f; + float peak_h = 0.0f; float probe_timer = 0.0f; GroundProbe ground; @@ -216,8 +280,25 @@ namespace float airborne_age = fx_landing_airborne_window; // since the probe last saw the hull off the ground float scrape_timer = 0.0f; float impact_cooldown = 0.0f; + float debris_cooldown = 0.0f; + float scrape_snd_timer = 0.0f; + float scrape_flash_timer = 0.0f; + float splash_snd_timer = 0.0f; + float fire_light_timer = 0.0f; bool bottom_in_water = false; + // Wanted afresh every frame: a hull can go out of range or hidden without anything ending them. + float wash_level = 0.0f; + bool burning = false; + rf::Vector3 vent{}; + int wash_snd = -1; + bool wash_snd_water = false; + float wash_snd_pitch = 0.0f; // last ratio set on wash_snd; 0 for none yet + int burn_snd = -1; + float wash_snd_retry = 0.0f; + float burn_snd_retry = 0.0f; + bool hold_loops = false; // a skipped frame decided nothing about them + float dust_accum = 0.0f; float spray_accum = 0.0f; float wash_accum = 0.0f; @@ -226,11 +307,25 @@ namespace float bubble_accum = 0.0f; }; + template + constexpr std::array fx_unloaded() + { + std::array handles{}; + handles.fill(-1); + return handles; + } + std::unordered_map g_hull_fx; - int g_fx_bitmaps[FXB_COUNT] = {-1, -1, -1, -1, -1}; + std::array g_fx_bitmaps = fx_unloaded(); bool g_fx_bitmaps_loaded = false; int g_fx_live_particles = 0; int g_fx_num_emitters = 0; + std::array g_fx_foley = fx_unloaded(); + std::array g_fx_wash_sounds = fx_unloaded(); + rf::ImpactSoundSet* g_fx_big_debris_iss = nullptr; + rf::ImpactSoundSet* g_fx_small_debris_iss = nullptr; + bool g_fx_sounds_loaded = false; + float g_fx_debris_cooldown = 0.0f; // This frame's simulated time: a paused demo stops the particles, and a demo timescale moves the hulls. float g_fx_dt = 0.0f; @@ -266,12 +361,6 @@ namespace return n; } - // Alternates spawn sites across frames: at these rates one frame rarely spawns more than one. - uint32_t fx_next(HullFx& fx) - { - return fx.spawn_seq++; - } - void fx_load_bitmaps() { if (g_fx_bitmaps_loaded) { @@ -286,11 +375,169 @@ namespace } } + rf::ImpactSoundSet* fx_find_iss(const char* name) + { + rf::ImpactSoundSet* iss = rf::material_find_impact_sound_set(name); + if (!iss) { + xlog::warn("[vehicle_fx] no impact sound set '{}'", name); + } + return iss; + } + + void fx_load_sounds() + { + if (g_fx_sounds_loaded) { + return; + } + g_fx_sounds_loaded = true; + for (int i = 0; i < FXS_COUNT; ++i) { + g_fx_foley[i] = rf::foley_lookup_by_name(fx_foley_names[i]); + if (g_fx_foley[i] < 0) { + xlog::warn("[vehicle_fx] no foley '{}'", fx_foley_names[i]); + } + } + for (std::size_t i = 0; i < std::size(fx_wash_sound_files); ++i) { + g_fx_wash_sounds[i] = rf::snd_get_handle(fx_wash_sound_files[i], 8.0f, 0.9f, 1.0f); + // With sound off the engine registers nothing, and that is no fault of the file. + if (g_fx_wash_sounds[i] < 0 && rf::sound_enabled) { + xlog::warn("[vehicle_fx] failed to load sound '{}'", fx_wash_sound_files[i]); + } + } + g_fx_big_debris_iss = fx_find_iss("big debris bounce"); + g_fx_small_debris_iss = fx_find_iss("small debris bounce"); + } + + // A fresh pick each time, so a foley's variants alternate. + int fx_sound_handle(FxSound sound) + { + return g_fx_foley[sound] >= 0 ? rf::foley_get_sound_handle(g_fx_foley[sound]) : -1; + } + + int fx_play_handle(int handle, const rf::Vector3& pos, float volume) + { + return handle >= 0 ? rf::snd_play_3d(handle, pos, std::min(volume, 1.0f), rf::zero_vector, + rf::SOUND_GROUP_EFFECTS) + : -1; + } + + void fx_play(FxSound sound, const rf::Vector3& pos, float volume) + { + fx_play_handle(fx_sound_handle(sound), pos, volume); + } + + // Picks as a bouncing debris piece does. + void fx_play_iss(const rf::ImpactSoundSet* iss, int material, const rf::Vector3& pos, float volume) + { + if (!iss) { + return; + } + const int* sounds = nullptr; + int count = 0; + iss->get_material_sounds(material, sounds, count); + if (!sounds) { + return; + } + // The engine does not clamp the count. + count = std::min(count, static_cast(std::end(iss->sounds) - sounds)); + if (count <= 0) { + return; + } + const int pick = std::uniform_int_distribution{0, count - 1}(g_rng); + fx_play_handle(sounds[pick], pos, volume); + } + + // Kept going while wanted: `start` plays it afresh when it is not playing, as a one-shot foley ends. + void fx_update_loop(int& instance, float& retry, const rf::Vector3& pos, const rf::Vector3& vel, float volume, + auto&& start) + { + if (snd_instance_is_playing(instance)) { + rf::snd_change_3d(instance, pos, vel, std::min(volume, 1.0f)); + return; + } + retry = std::max(retry - g_fx_dt, 0.0f); + if (retry > 0.0f) { + return; + } + instance = start(); + if (instance < 0) { + retry = 0.5f; + } + } + + void fx_stop_loop(int& instance) + { + if (instance >= 0) { + rf::snd_stop(instance); + instance = -1; + } + } + + // Only the D3D11 renderer draws (and retires) these lights. + void fx_light(const rf::Vector3& pos, float radius, float intensity, int duration_ms) + { + if (is_d3d11()) { + explosion_flash_light_create(pos, radius, intensity, 1.0f, 0.7f, 0.35f, duration_ms, false); + } + } + + // Sparks and impacts. + void fx_flash(const rf::Vector3& pos, float radius, float intensity, int duration_ms) + { + if (g_alpine_game_config.explosion_env_flash_lights) { + fx_light(pos, radius, intensity, duration_ms); + } + } + + void fx_tick(float& timer) + { + timer = std::max(timer - g_fx_dt, 0.0f); + } + bool fx_budget_left() { const rf::ParticlePool& pool = rf::particle_pools[fx_particle_pool]; return g_fx_live_particles < fx_max_live_particles - && pool.num_live < static_cast(static_cast(pool.capacity) * fx_pool_headroom); + && pool.num_used < static_cast(static_cast(pool.capacity) * fx_pool_headroom); + } + + // Destroying hands the live particles to the engine's orphan list, which still simulates and + // draws them, so nothing on screen is cut short. + void fx_destroy_emitter(HullFx& fx, int layer) + { + if (rf::ParticleEmitter*& emitter = fx.emitters[layer]) { + emitter->destroy(); + emitter = nullptr; + --g_fx_num_emitters; + } + } + + // Emitters go first come in entity order, so far hulls can hold them all while the one at the camera + // goes without: a near hull takes the idlest layer of the farthest hull beyond fx_near_dist. + bool fx_release_far_emitter(const HullFx& fx) + { + if (fx.cam_dist > fx_near_dist) { + return false; + } + HullFx* farthest = nullptr; + for (auto& [handle, other] : g_hull_fx) { + const bool holds = + std::ranges::any_of(other.emitters, [](const rf::ParticleEmitter* e) { return e != nullptr; }); + if (holds && other.cam_dist > fx_near_dist && (!farthest || other.cam_dist > farthest->cam_dist)) { + farthest = &other; + } + } + if (!farthest) { + return false; + } + int idlest = -1; + for (int layer = 0; layer < fx_num_layers; ++layer) { + if (farthest->emitters[layer] + && (idlest < 0 || farthest->emitter_idle[layer] > farthest->emitter_idle[idlest])) { + idlest = layer; + } + } + fx_destroy_emitter(*farthest, idlest); + return true; } // Created on the first particle a layer actually spawns, so a quiet hull holds no engine record. @@ -300,43 +547,32 @@ namespace if (emitter) { return emitter; } - if (!fx.room || g_fx_num_emitters >= fx_max_emitters - || rf::particle_emitter_num_used >= rf::particle_emitter_capacity - fx_min_free_engine_emitters - || (fx.emitter_retry.valid() && !fx.emitter_retry.elapsed())) { + if (!fx.room + || rf::particle_emitter_num_used >= particle_sim_emitter_capacity() - fx_min_free_engine_emitters + || (fx.emitter_retry.valid() && !fx.emitter_retry.elapsed()) + || (g_fx_num_emitters >= fx_max_emitters && !fx_release_far_emitter(fx))) { return nullptr; } // A container only: never switched on and never updated, so it spawns nothing itself. Its // particles come from particle_create, and the engine simulates, culls and draws them as its own. rf::ParticleEmitterType type{}; + type.pos = fx.last_pos; type.min_spawn_delay = 1.0f; type.max_spawn_delay = 1.0f; type.bitmap = g_fx_bitmaps[FXB_SMOKE]; type.num_frames = 1; - rf::Vector3 pos = fx.last_pos; - // Unparented and carried by hand: particle_emitter_create never links an emitter for ticking. - emitter = rf::particle_emitter_create(0, type, fx.room, pos, false); + // Parent 0 is no object (handle signatures start at 1) but counts as parented, which stock needs to fit + // the cull radius to the particles (0x00497E0A). Never ticked: particle_emitter_create does not link it. + emitter = rf::particle_emitter_create(0, type, fx.room, fx.last_pos, false); if (!emitter) { fx.emitter_retry.set(1000); return nullptr; } - emitter->pos = fx.last_pos; - emitter->room = fx.room; fx.emitter_idle[layer] = 0.0f; ++g_fx_num_emitters; return emitter; } - // Destroying hands the live particles to the engine's orphan list, which still simulates and - // draws them, so nothing on screen is cut short. - void fx_destroy_emitter(HullFx& fx, int layer) - { - if (rf::ParticleEmitter*& emitter = fx.emitters[layer]) { - emitter->destroy(); - emitter = nullptr; - --g_fx_num_emitters; - } - } - void fx_destroy_emitters(HullFx& fx) { for (int layer = 0; layer < fx_num_layers; ++layer) { @@ -344,6 +580,13 @@ namespace } } + void fx_release(HullFx& fx) + { + fx_destroy_emitters(fx); + fx_stop_loop(fx.wash_snd); + fx_stop_loop(fx.burn_snd); + } + void fx_spawn(HullFx& fx, const ParticleSpec& s, const rf::Vector3& pos, const rf::Vector3& vel, float size = 1.0f, float max_life = 0.0f) { @@ -378,8 +621,9 @@ namespace } // Decoration only: never a damaging particle. pci.flags2 = 0; - // Linked into the hull's emitter, so it is drawn whenever that emitter's room is. - rf::particle_create(fx_particle_pool, pci, emitter->room, &pci.pos, 0, nullptr, emitter); + // Linked into the hull's emitter, so it is drawn whenever that emitter's room is. No parent object: a + // particle's parent only names a damage source and ties it to a level emitter. + rf::particle_create(fx_particle_pool, pci, emitter->room, &pci.pos, -1, nullptr, emitter); ++g_fx_live_particles; } @@ -408,7 +652,7 @@ namespace float out_speed, float up_speed, float size = 1.0f) { for (int i = 0; i < count; ++i) { - const float a = fx_rand(0.0f, 6.2831853f); + const float a = fx_rand(0.0f, vehicle_two_pi); const rf::Vector3 out{std::cos(a), 0.0f, std::sin(a)}; const rf::Vector3 pos = center + out * (radius * fx_rand(0.3f, 1.0f)) + rf::Vector3{0.0f, 0.1f, 0.0f}; const rf::Vector3 vel = @@ -417,6 +661,15 @@ namespace } } + // Water thrown off a hull cutting the surface at `pos`: to its right on even sequences, else its left. + void fx_side_spray(HullFx& fx, const rf::Entity& ep, uint32_t seq, const rf::Vector3& pos, + const rf::Vector3& carry, float up) + { + const rf::Vector3 side = ep.orient.rvec * ((seq % 2) ? -1.0f : 1.0f); + fx_spawn(fx, spray_spec, pos, + side * fx_rand(1.0f, 2.5f) + carry + rf::Vector3{0.0f, up + fx_rand(0.0f, 1.5f), 0.0f}); + } + bool fx_cast(const rf::Vector3& from, const rf::Vector3& to, rf::PCollisionOut& out) { return vphys_collide_solid_segment(from, to, rf::CF_PROCESS_INVISIBLE_FACES, out); @@ -486,28 +739,87 @@ namespace return rf::local_player_entity && rf::local_player_entity->host_handle == ep.handle; } + FxSound fx_impact_sound(int material) + { + switch (material) { + case rf::MATERIAL_METAL: + return FXS_IMPACT_METAL; + case rf::MATERIAL_GLASS: + return FXS_IMPACT_GLASS; + default: + return FXS_IMPACT_ROCK; + } + } + + // Chips knocked off a hard surface, borrowed from the geomod rock pool. + void fx_rock_debris(HullFx& fx, const rf::PCollisionOut& hit, float strength, float lod) + { + switch (hit.material) { + case rf::MATERIAL_DEFAULT: + case rf::MATERIAL_ROCK: + case rf::MATERIAL_SOLID: + case rf::MATERIAL_SAND: + case rf::MATERIAL_ICE: + break; + default: + return; + } + if (strength < fx_debris_min_strength || fx.debris_cooldown > 0.0f || g_fx_debris_cooldown > 0.0f + || !hit.hit_face || !hit.hit_face->which_room || hit.hit_face->attributes.bitmap_id < 0) { + return; + } + fx.debris_cooldown = fx_debris_cooldown; + g_fx_debris_cooldown = fx_debris_global_cooldown; + rf::Vector3 pos = hit.hit_point; + rf::Vector3 dir = hit.hit_normal; + // Off open ground a radius r throws about 8r rocks. + rf::geomod_create_rock_debris(&pos, (0.6f + 1.2f * strength) * lod, &dir, hit.hit_face->attributes.bitmap_id, + reinterpret_cast(hit.hit_face->which_room)); + } + + // Spray thrown up and mist rolling out from `surface`. A bigger body (size > 1) throws a wider ring, more of + // it, and is heard a splash size up. + void fx_splash(HullFx& fx, const rf::Vector3& surface, float strength, float lod, float size) + { + const float amount = size * size * lod; + fx_ring(fx, spray_spec, static_cast((12.0f + 30.0f * strength) * amount), surface, 2.0f * size, + 3.0f + 4.0f * strength, (3.0f + 4.0f * strength) * size, size); + fx_ring(fx, mist_spec, static_cast((4.0f + 8.0f * strength) * amount), surface, 2.0f * size, 1.5f, + 1.0f * size, 1.5f * size); + constexpr FxSound splashes[] = {FXS_SPLASH_SMALL, FXS_SPLASH_MEDIUM, FXS_SPLASH_LARGE, FXS_SPLASH_HUGE}; + const int tier = (strength < 0.35f ? 0 : strength < 0.7f ? 1 : 2) + (size > 1.0f ? 1 : 0); + fx_play(splashes[tier], surface, 0.5f + 0.5f * strength); + } + void fx_impact(rf::Entity& ep, HullFx& fx, const rf::PCollisionOut& hit, float strength, float lod, std::optional water_y) { if (water_y) { - const rf::Vector3 surface{hit.hit_point.x, *water_y, hit.hit_point.z}; - fx_ring(fx, spray_spec, static_cast((12.0f + 30.0f * strength) * lod), surface, 2.0f, - 3.0f + 4.0f * strength, 3.0f + 4.0f * strength); - fx_ring(fx, mist_spec, static_cast((4.0f + 8.0f * strength) * lod), surface, 2.0f, 1.5f, 1.0f, 1.5f); + fx_splash(fx, rf::Vector3{hit.hit_point.x, *water_y, hit.hit_point.z}, strength, lod, 1.0f); } else { const SurfaceFx s = surface_fx(hit.material); if (s.dust > 0.0f) { - const int count = static_cast((3.0f + 9.0f * strength) * s.dust * lod); + // Out past the hull's footprint, or the hull hides most of its own cloud. + const float footprint = std::max(vehicle_collision_reach_along(&ep, ep.orient.rvec), + vehicle_collision_reach_along(&ep, ep.orient.fvec)); + const int count = static_cast((6.0f + 22.0f * strength) * s.dust * lod); for (const bool haze : {false, true}) { - fx_ring(fx, dust_spec(s, 1.0f + strength, haze), count, hit.hit_point, 2.0f, - 2.0f + 4.0f * strength, 0.5f + 1.0f * strength); + fx_ring(fx, impact_dust_spec(s, 1.5f + 1.5f * strength, haze), count, hit.hit_point, + footprint + 1.0f, 3.0f + 6.0f * strength, 1.0f + 2.5f * strength); } } if (s.sparks) { fx_burst_off(fx, spark_spec, static_cast((6.0f + 18.0f * strength) * lod), hit.hit_point, hit.hit_normal, 6.0f + 8.0f * strength); + fx_flash(hit.hit_point + hit.hit_normal * 0.3f, 2.0f + 2.0f * strength, 0.6f + 0.6f * strength, + static_cast(80.0f + 70.0f * strength)); + } + fx_play(fx_impact_sound(hit.material), hit.hit_point, 0.4f + 0.6f * strength); + if (strength >= fx_heavy_impact) { + fx_play_iss(g_fx_big_debris_iss, hit.material, hit.hit_point, strength); } + fx_rock_debris(fx, hit, strength, lod); } if (fx_local_player_rides(ep) && rf::local_player && rf::local_player->cam) { rf::camera_shake(rf::local_player->cam, 0.002f + 0.006f * strength, 0.35f); @@ -517,7 +829,8 @@ namespace // A sudden loss of the speed the hull had a moment ago: a landing or a wall. void fx_detect_impacts(rf::Entity& ep, HullFx& fx, bool flyer, float lod, std::optional liquid_y) { - fx.impact_cooldown = std::max(fx.impact_cooldown - g_fx_dt, 0.0f); + fx_tick(fx.impact_cooldown); + fx_tick(fx.debris_cooldown); if (fx.impact_cooldown > 0.0f) { return; } @@ -526,27 +839,34 @@ namespace const rf::Vector3 slow_h{fx.vel_slow.x, 0.0f, fx.vel_slow.z}; const float slow_h_speed = slow_h.len(); float wall_dv = 0.0f; + float wall_kept = 0.0f; rf::Vector3 wall_dir{}; if (slow_h_speed > fx_impact_min_dv) { wall_dir = slow_h * (1.0f / slow_h_speed); - wall_dv = slow_h_speed - std::max(rf::Vector3{fx.vel.x, 0.0f, fx.vel.z}.dot_prod(wall_dir), 0.0f); + wall_kept = std::max(rf::Vector3{fx.vel.x, 0.0f, fx.vel.z}.dot_prod(wall_dir), 0.0f); + wall_dv = slow_h_speed - wall_kept; } const float dv = std::max(land_dv, wall_dv); if (dv < fx_impact_min_dv) { return; } + // A wall only takes the speed it stopped. + const float impact_speed = land_dv >= wall_dv ? fx.peak_fall : fx.peak_h - wall_kept; const float strength = - std::clamp((dv - fx_impact_min_dv) / (fx_impact_full_dv - fx_impact_min_dv), 0.15f, 1.0f); + std::clamp((impact_speed - fx_impact_min_dv) / (fx_impact_full_dv - fx_impact_min_dv), 0.15f, 1.0f); const rf::Vector3 dir = land_dv >= wall_dv ? rf::Vector3{0.0f, -1.0f, 0.0f} : wall_dir; rf::PCollisionOut o{}; - if (!fx_cast(ep.pos, ep.pos + dir * (vehicle_collision_reach_along(&ep, dir) + 1.5f), o)) { + // Generous reach: suspension and wheels start slowing a landing hull before its body is near the ground. + if (!fx_cast(ep.pos, ep.pos + dir * (vehicle_collision_reach_along(&ep, dir) + 2.5f), o)) { fx.impact_cooldown = fx_impact_miss_cooldown; return; } const bool water = liquid_y && o.hit_point.y <= *liquid_y + 0.3f; fx.impact_cooldown = fx_impact_cooldown; fx.vel_slow = fx.vel; + fx.peak_fall = std::max(-fx.vel.y, 0.0f); + fx.peak_h = rf::Vector3{fx.vel.x, 0.0f, fx.vel.z}.len(); // A ground hull's water entry is fx_plunge's. if (water && !flyer) { return; @@ -557,6 +877,8 @@ namespace // Body contact while moving along a wall (and, for flyers, a ceiling, or a floor while airborne). void fx_scrape(rf::Entity& ep, HullFx& fx, bool flyer, float lod) { + fx_tick(fx.scrape_snd_timer); + fx_tick(fx.scrape_flash_timer); fx.scrape_timer -= g_fx_dt; if (fx.scrape_timer > 0.0f) { return; @@ -591,10 +913,23 @@ namespace + fx_rand_dir_off(o.hit_normal) * 1.5f; fx_spawn(fx, spark_spec, o.hit_point, vel); } + if (fx.scrape_snd_timer <= 0.0f) { + fx.scrape_snd_timer = fx_rand(0.12f, 0.25f); + fx_play(FXS_SPARK, o.hit_point, std::clamp(along_speed / 15.0f, 0.3f, 1.0f)); + } + if (fx.scrape_flash_timer <= 0.0f) { + fx.scrape_flash_timer = fx_rand(0.25f, 0.4f); + fx_flash(o.hit_point + o.hit_normal * 0.3f, 2.5f, 0.5f, 90); + } } else if (s.dust > 0.0f) { - fx_spawn(fx, dust_spec(s, 0.8f, fx_next(fx) % 2 != 0), o.hit_point, + fx_spawn(fx, dust_spec(s, 0.8f, fx.spawn_seq++ % 2 != 0), o.hit_point, o.hit_normal * 0.8f + along * -0.1f); + if (fx.scrape_snd_timer <= 0.0f) { + fx.scrape_snd_timer = fx_rand(0.4f, 0.7f); + fx_play_iss(g_fx_small_debris_iss, o.material, o.hit_point, + std::clamp(along_speed / 20.0f, 0.2f, 0.6f)); + } } } } @@ -602,6 +937,7 @@ namespace // Wheels and tracks: dust behind them on dry ground, spray where they run through water. void fx_ground_contact(rf::Entity& ep, HullFx& fx, float lod, std::optional liquid_y) { + fx_tick(fx.splash_snd_timer); const float reach_down = vehicle_collision_reach_along(&ep, ep.orient.uvec * -1.0f); const rf::Vector3 vel_h{fx.vel.x, 0.0f, fx.vel.z}; const float speed = vel_h.len(); @@ -629,12 +965,15 @@ namespace } const int n = fx_take(fx.spray_accum, std::clamp((speed - 1.5f) * 4.0f, 0.0f, 50.0f) * lod); for (int i = 0; i < n; ++i) { - const uint32_t seq = fx_next(fx); + // Even sequences take the right-hand wheels, so each side throws outward. + const uint32_t seq = fx.spawn_seq++; const rf::Vector3& c = contacts[seq % 4]; - const rf::Vector3 side = ep.orient.rvec * ((seq % 2) ? -1.0f : 1.0f); - fx_spawn(fx, spray_spec, rf::Vector3{c.x, *liquid_y, c.z}, - side * fx_rand(1.0f, 2.5f) + vel_h * 0.3f - + rf::Vector3{0.0f, fx_rand(2.0f, 3.5f) + speed * 0.1f, 0.0f}); + fx_side_spray(fx, ep, seq, rf::Vector3{c.x, *liquid_y, c.z}, vel_h * 0.3f, 2.0f + speed * 0.1f); + } + if (speed > 1.5f && fx.splash_snd_timer <= 0.0f) { + const float pace = std::clamp((speed - 1.5f) / 12.0f, 0.0f, 1.0f); + fx.splash_snd_timer = 0.6f - 0.4f * pace; + fx_play(FXS_SPLASH_SMALL, rf::Vector3{ep.pos.x, *liquid_y, ep.pos.z}, 0.3f + 0.4f * pace); } return; } @@ -653,7 +992,7 @@ namespace const float size = 1.0f + std::min(speed / 40.0f, 0.4f); for (int i = 0; i < n; ++i) { // Both layers at every wheel; rear wheels three times as often as the front, left and right alike. - const uint32_t seq = fx_next(fx); + const uint32_t seq = fx.spawn_seq++; const uint32_t k = seq >> 1; const int w = static_cast(k % 8 < 6 ? k % 2 : 2 + k % 2); const rf::Vector3 vel = vel_h * -0.12f + rf::Vector3{0.0f, fx_rand(0.4f, 1.4f), 0.0f} @@ -674,20 +1013,22 @@ namespace const float h = ep.pos.y - reach_down - fx.wash.surface_y; const float closeness = std::clamp(1.0f - h / fx_downwash_height, 0.0f, 1.0f); const SurfaceFx s = fx.wash.water ? SurfaceFx{1.0f, {}, false} : surface_fx(fx.wash.material); + const float intensity = closeness * closeness; + // Heard over any surface, seen only over one that throws something up. + fx.wash_level = intensity; if (closeness <= 0.0f || s.dust <= 0.0f) { fx.wash_accum = 0.0f; return; } - const float intensity = closeness * closeness; const rf::Vector3 center{ep.pos.x, fx.wash.surface_y, ep.pos.z}; const float radius = 1.5f + 1.5f * intensity; const int n = fx_take(fx.wash_accum, 45.0f * intensity * lod * s.dust); for (int i = 0; i < n; ++i) { if (!fx.wash.water) { - fx_ring(fx, dust_spec(s, 1.2f, fx_next(fx) % 2 != 0), 1, center, radius, 3.0f + 5.0f * intensity, - 0.4f + 0.8f * intensity); + fx_ring(fx, dust_spec(s, 1.2f, fx.spawn_seq++ % 2 != 0), 1, center, radius, + 3.0f + 5.0f * intensity, 0.4f + 0.8f * intensity); } - else if (fx_next(fx) % 3 == 0) { + else if (fx.spawn_seq++ % 3 == 0) { fx_ring(fx, mist_spec, 1, center, radius, 3.0f + 6.0f * intensity, 1.0f + 2.0f * intensity, 1.3f); } else { @@ -699,6 +1040,7 @@ namespace // The sub: bubbles off the screws when submerged, bow spray when running on the surface. void fx_sub(rf::Entity& ep, HullFx& fx, float lod, std::optional liquid_y) { + fx_tick(fx.splash_snd_timer); const float speed = fx.vel.len(); if (!liquid_y || speed < 1.0f) { fx.bubble_accum = 0.0f; @@ -718,9 +1060,11 @@ namespace const int n = fx_take(fx.spray_accum, std::clamp((speed - 1.5f) * 3.0f, 0.0f, 30.0f) * lod); const rf::Vector3 bow = ep.pos + ep.orient.fvec * vehicle_collision_reach_along(&ep, ep.orient.fvec); for (int i = 0; i < n; ++i) { - const rf::Vector3 side = ep.orient.rvec * ((fx_next(fx) % 2) ? -1.0f : 1.0f); - fx_spawn(fx, spray_spec, rf::Vector3{bow.x, *liquid_y, bow.z}, - side * fx_rand(1.0f, 2.5f) + rf::Vector3{0.0f, fx_rand(1.5f, 3.0f), 0.0f}); + fx_side_spray(fx, ep, fx.spawn_seq++, rf::Vector3{bow.x, *liquid_y, bow.z}, rf::Vector3{}, 1.5f); + } + if (speed > 3.0f && fx.splash_snd_timer <= 0.0f) { + fx.splash_snd_timer = fx_rand(0.8f, 1.6f); + fx_play(FXS_SPLASH_SMALL, rf::Vector3{bow.x, *liquid_y, bow.z}, 0.3f); } } } @@ -732,10 +1076,7 @@ namespace const bool in = liquid_y && ep.pos.y - reach_down < *liquid_y; if (in && !fx.bottom_in_water && fx.vel_slow.y < -3.0f) { const float strength = std::clamp((-fx.vel_slow.y - 3.0f) / 12.0f, 0.2f, 1.0f); - const rf::Vector3 surface{ep.pos.x, *liquid_y, ep.pos.z}; - fx_ring(fx, spray_spec, static_cast((20.0f + 40.0f * strength) * lod), surface, 2.5f, - 3.0f + 4.0f * strength, 4.0f + 5.0f * strength, 1.2f); - fx_ring(fx, mist_spec, static_cast((6.0f + 10.0f * strength) * lod), surface, 2.5f, 1.5f, 1.5f, 1.6f); + fx_splash(fx, rf::Vector3{ep.pos.x, *liquid_y, ep.pos.z}, strength, lod, 1.25f); } fx.bottom_in_water = in; } @@ -751,14 +1092,18 @@ namespace fx.fire_accum = 0.0f; return; } - // Aft of centre, so a first-person driver is not looking out through it. - const rf::Vector3 vent = ep.pos + ep.orient.uvec * (vehicle_collision_reach_along(&ep, ep.orient.uvec) * 0.7f) - - ep.orient.fvec * (vehicle_collision_reach_along(&ep, ep.orient.fvec * -1.0f) * 0.4f); + // A jeep burns under its hood; elsewhere aft of centre, so a first-person driver is not looking out through it. + const float up = vehicle_collision_reach_along(&ep, ep.orient.uvec); + const rf::Vector3 vent = vehicle_damage_class(&ep) == VDC_JEEP + ? ep.pos + ep.orient.uvec * (up * 0.4f) + + ep.orient.fvec * (vehicle_collision_reach_along(&ep, ep.orient.fvec) * 0.6f) + : ep.pos + ep.orient.uvec * (up * 0.7f) + - ep.orient.fvec * (vehicle_collision_reach_along(&ep, ep.orient.fvec * -1.0f) * 0.4f); const float severity = dying ? 1.0f : std::clamp((0.5f - frac) / 0.5f, 0.0f, 1.0f); // An APC's flames have to be read against a roof three times the jeep's. const float hull_scale = std::clamp(ep.radius / 2.0f, 1.0f, 1.6f); const bool burning = dying || frac < 0.25f; - const int n_smoke = fx_take(fx.smoke_accum, (3.0f + 5.0f * severity) * lod); + const int n_smoke = fx_take(fx.smoke_accum, (3.0f + 7.0f * severity) * lod); if (liquid_y && vent.y < *liquid_y) { fx.fire_accum = 0.0f; for (int i = 0; i < n_smoke; ++i) { @@ -775,11 +1120,61 @@ namespace fx.fire_accum = 0.0f; return; } - const int n_fire = fx_take(fx.fire_accum, 10.0f * lod); + const int n_fire = fx_take(fx.fire_accum, 20.0f * lod); for (int i = 0; i < n_fire; ++i) { - fx_spawn(fx, fire_spec, vent + fx_rand_dir() * (0.35f * hull_scale), + fx_spawn(fx, fire_spec, vent + fx_rand_dir() * (0.25f * hull_scale), fx.vel * 0.7f + rf::Vector3{0.0f, 0.5f, 0.0f}, hull_scale); } + fx.burning = true; + fx.vent = vent; + // Overlapping short flashes of random strength flicker; thinned-out distant smoke gets none. + fx_tick(fx.fire_light_timer); + if (g_alpine_game_config.burning_entity_lights && lod >= 1.0f && fx.fire_light_timer <= 0.0f) { + fx.fire_light_timer = fx_rand(0.12f, 0.18f); + const float life = fx.fire_light_timer + 0.16f; + // A light stays where it was made: lead it by half its life so a moving hull carries it. + fx_light(vent + fx.vel * (life * 0.5f), 3.0f * hull_scale, fx_rand(0.8f, 1.0f), + static_cast(life * 1000.0f)); + } + } + + void fx_update_loops(HullFx& fx) + { + if (fx.hold_loops) { + fx.hold_loops = false; + return; + } + if (fx.wash_level > 0.0f) { + // Swapped when the fighter crosses between ground and water; swelling as it closes on either. + if (fx.wash_snd_water != fx.wash.water) { + fx_stop_loop(fx.wash_snd); + fx.wash_snd_water = fx.wash.water; + } + // Linear in closeness from silence at the edge, so a fighter hovering there does not cut it in and out. + const float volume = std::sqrt(fx.wash_level); + fx_update_loop(fx.wash_snd, fx.wash_snd_retry, fx.last_pos, fx.vel, volume, [&] { + fx.wash_snd_pitch = 0.0f; + const int sound = g_fx_wash_sounds[fx.wash.water ? 1 : 0]; + return sound >= 0 ? snd_play_3d_looping(sound, fx.last_pos, volume, rf::SOUND_GROUP_EFFECTS) : -1; + }); + const float pitch = 0.85f + 0.25f * fx.wash_level; + if (pitch != fx.wash_snd_pitch) { + snd_instance_set_pitch(fx.wash_snd, pitch); + fx.wash_snd_pitch = pitch; + } + } + else { + fx_stop_loop(fx.wash_snd); + fx.wash_snd_retry = 0.0f; + } + if (fx.burning) { + fx_update_loop(fx.burn_snd, fx.burn_snd_retry, fx.vent, fx.vel, 1.0f, + [&] { return fx_play_handle(fx_sound_handle(FXS_BURNING), fx.vent, 1.0f); }); + } + else { + fx_stop_loop(fx.burn_snd); + fx.burn_snd_retry = 0.0f; + } } int fx_count_particles(const rf::ParticleEmitter* emitter) @@ -806,7 +1201,7 @@ namespace } rf::console::print("Vehicle effects are {}", g_alpine_game_config.vehicle_effects ? "enabled" : "disabled"); }, - "Show dust, sparks, spray, smoke and fire effects for vehicles in multiplayer", + "Show dust, sparks, spray, smoke and fire effects, with their sounds and lights, for vehicles in multiplayer", "cl_vehiclefx [bool]", }; } // namespace @@ -814,17 +1209,25 @@ namespace void vehicle_fx_clear() { for (auto& [handle, fx] : g_hull_fx) { - fx_destroy_emitters(fx); + fx_release(fx); } g_hull_fx.clear(); } -void vehicle_fx_level_init() +void vehicle_fx_level_init(bool load_assets) { - // Emitter records outlive the level; the bitmap handles do not. + // Emitter records outlive the level; the bitmap and sound handles do not. vehicle_fx_clear(); - std::fill(std::begin(g_fx_bitmaps), std::end(g_fx_bitmaps), -1); + g_fx_bitmaps.fill(-1); g_fx_bitmaps_loaded = false; + g_fx_sounds_loaded = false; + g_fx_debris_cooldown = 0.0f; + // Runs after the last level's unload frees its bitmaps and sounds, and ahead of this one's first frame. + // The factories are not parsed yet, so every multiplayer level loads them. + if (load_assets && rf::is_multi && !rf::is_dedicated_server && g_alpine_game_config.vehicle_effects) { + fx_load_bitmaps(); + fx_load_sounds(); + } } void vehicle_fx_do_frame() @@ -835,19 +1238,24 @@ void vehicle_fx_do_frame() } return; } + // Level init has loaded them unless cl_vehiclefx was switched on since. fx_load_bitmaps(); + fx_load_sounds(); g_fx_dt = rf::frametime * demo_playback_sim_time_scale(); const float dt = g_fx_dt; + fx_tick(g_fx_debris_cooldown); g_fx_live_particles = 0; for (auto it = g_hull_fx.begin(); it != g_hull_fx.end();) { HullFx& fx = it->second; rf::Entity* ep = rf::entity_from_handle(it->first); if (!ep || vehicle_damage_class(ep) < 0) { - fx_destroy_emitters(fx); + fx_release(fx); it = g_hull_fx.erase(it); continue; } + fx.wash_level = 0.0f; + fx.burning = false; for (int layer = 0; layer < fx_num_layers; ++layer) { if (const rf::ParticleEmitter* emitter = fx.emitters[layer]) { const int n = fx_count_particles(emitter); @@ -875,12 +1283,17 @@ void vehicle_fx_do_frame() const bool had_last = fx.has_last; fx.last_pos = entity.pos; fx.has_last = true; + fx.cam_dist = (entity.pos - cam_pos).len(); if (entity.room) { fx.room = entity.room; } - for (rf::ParticleEmitter* emitter : fx.emitters) { - if (emitter) { - emitter->pos = entity.pos; + // The layers would tie in the back-to-front sort and swap from frame to frame; additive ones sort just nearer, + // so fire always lands on its smoke. + rf::Vector3 to_cam = cam_pos - entity.pos; + to_cam.normalize_safe(); + for (int layer = 0; layer < fx_num_layers; ++layer) { + if (rf::ParticleEmitter* emitter = fx.emitters[layer]) { + emitter->pos = (layer & 1) ? entity.pos + to_cam * 0.1f : entity.pos; emitter->room = fx.room; } } @@ -892,14 +1305,19 @@ void vehicle_fx_do_frame() if (!had_last || raw_vel.len_sq() > fx_max_speed * fx_max_speed) { fx.vel = {}; fx.vel_slow = {}; + fx.peak_fall = 0.0f; + fx.peak_h = 0.0f; fx.impact_cooldown = fx_impact_cooldown; + fx.hold_loops = true; continue; } fx.vel += (raw_vel - fx.vel) * (1.0f - std::exp(-dt / 0.03f)); fx.vel_slow += (fx.vel - fx.vel_slow) * (1.0f - std::exp(-dt / 0.2f)); + fx.peak_fall = std::max(-fx.vel.y, fx.peak_fall - fx_peak_decay * dt); + fx.peak_h = std::max(rf::Vector3{fx.vel.x, 0.0f, fx.vel.z}.len(), fx.peak_h - fx_peak_decay * dt); fx.airborne_age += dt; - const float dist = (entity.pos - cam_pos).len(); + const float dist = fx.cam_dist; if (dist > fx_smoke_far_dist) { continue; } @@ -938,6 +1356,13 @@ void vehicle_fx_do_frame() fx_ground_contact(entity, fx, lod, liquid_y); } } + + // A paused demo has paused the loops with it. + if (dt > 0.0f) { + for (auto& [handle, fx] : g_hull_fx) { + fx_update_loops(fx); + } + } } void vehicle_fx_install() diff --git a/game_patch/multi/vehicles/vehicle_fx.h b/game_patch/multi/vehicles/vehicle_fx.h index 3ea244d36..ea4cc6433 100644 --- a/game_patch/multi/vehicles/vehicle_fx.h +++ b/game_patch/multi/vehicles/vehicle_fx.h @@ -1,10 +1,10 @@ #pragma once -// Client-side cosmetic particles for multiplayer vehicles: wheel and tread dust, impacts, scrape -// sparks, fighter downwash, water spray and wakes, and damage smoke and fire. Every machine derives -// them from what it already sees of the hull, so nothing is sent on the wire. +// Client-side cosmetic particles, sounds and lights for multiplayer vehicles: wheel and tread dust, +// impacts, scrape sparks, fighter downwash, water spray and wakes, and damage smoke and fire. Every +// machine derives them from what it already sees of the hull, so nothing is sent on the wire. -void vehicle_fx_level_init(); +void vehicle_fx_level_init(bool load_assets); // Drops every hull's effect state, e.g. when a demo seek jumps the hulls. void vehicle_fx_clear(); void vehicle_fx_do_frame(); diff --git a/game_patch/multi/vehicles/vehicle_internal.h b/game_patch/multi/vehicles/vehicle_internal.h index 91f54f88b..7cff95f2d 100644 --- a/game_patch/multi/vehicles/vehicle_internal.h +++ b/game_patch/multi/vehicles/vehicle_internal.h @@ -297,6 +297,13 @@ struct VehicleTreadScroll int config = -1; // which vehicle_tread_configs row this hull is }; +// Server: the last player blow that took life off a hull, recorded as its destroyer would be, and when. +struct VehicleLastHurt +{ + VehicleLethalKiller blow; + int64_t ms = 0; +}; + // Every map here is keyed by the vehicle's local object handle and dropped wholesale on level init. struct VehicleModuleState { @@ -306,6 +313,8 @@ struct VehicleModuleState std::unordered_map fire_rearm; std::unordered_map health_sync; std::unordered_map last_damager; // vehicle handle -> attacker entity handle + // What a drown or strand scuttle credits, if the hull went in soon after. + std::unordered_map last_hurt; std::unordered_map lethal_killer; // What bounds a crash report and gates a run-over; fed by received rows and local bodies. std::unordered_map observed_speed; @@ -320,9 +329,15 @@ struct VehicleModuleState std::unordered_map regen; // Server: how long each vehicle has had nothing beneath it, for the void-fall death. std::unordered_map void_timer; - // Server: an entry exists only while the hull is continuously in its lethal medium - under water - // for a non-sub, out of it for a sub. - std::unordered_map hazard_since; + // Every machine: seconds each hull has spent in its lethal medium - under water for a non-sub, out + // of it for a sub - counting back down once clear. An entry exists only while that is above zero. + std::unordered_map hazard_time; + // Client: the cockpit alarm of the hull the local player rides or watches. + int hazard_alarm_snd = -1; + // Client: the sub this machine's pilot last pressed alt in, and when, for the server's DETONATE that answers it. + // On game time, the clock a torpedo's lifeleft runs on. + int torpedo_press_hull = -1; + rf::Timestamp torpedo_press; // Factory-spawned hull attributes; team is NOT obj->team, which feeds the engine's friendliness // rules. Maintained on the server and mirrored on clients, both keyed by local object handle. std::unordered_map hull_state; @@ -349,9 +364,9 @@ struct VehicleModuleState int reported_fire_vehicle = -1; bool reported_primary_held = false; bool reported_alt_held = false; - // Client: the turret the local operator has zoomed in, -1 for none, and last frame's alt trigger. - int turret_zoom_handle = -1; - bool turret_zoom_alt_down = false; + // Client: the turret or jeep the local gunner has zoomed in, -1 for none, and last frame's alt trigger. + int gun_zoom_handle = -1; + bool gun_zoom_alt_down = false; // Server: an entry exists only while the jeep's driver holds the horn. std::unordered_map horn; std::unordered_map horn_cooldown; // by player id @@ -438,6 +453,15 @@ rf::Player* vehicle_seat_occupant_player(const rf::Entity* vehicle, int seat); VehicleSpawnSlot* vehicle_slot_for_hull(int vehicle_handle); // Any seat the occupancy packet can state holds a rider. bool vehicle_hull_occupied(const rf::Entity* vehicle); +bool vehicle_hazard_on_clock(int vehicle_handle); +void vehicle_hazard_client_do_frame(); +// A torpedo arms after vehicle_torpedo_arm_s of flight on the server. The server's copy can be a little younger than +// the pilot's (it fires in a later pass, and the press arrives with jitter), so he predicts only well past it. +inline constexpr float vehicle_torpedo_arm_s = 0.2f; +inline constexpr float vehicle_torpedo_arm_pilot_s = 0.3f; +// Sets off the hull's armed torpedoes; false when there were none. still_flying gets how many are left in flight. +bool vehicle_detonate_armed_torpedoes(rf::Entity* hull, float arm_s, int* still_flying = nullptr); +void vehicle_note_hazard_destroyer(rf::Entity* vehicle, float window_s); // Server: re-state a turret factory whose hull just changed occupancy; other slots are unaffected. void vehicle_slot_announce_for_hull(int vehicle_handle); // Server: every boarding - the hull takes the boarder's team, clears the auto-return, and the first diff --git a/game_patch/multi/vehicles/vehicle_physics.h b/game_patch/multi/vehicles/vehicle_physics.h index 210804c8f..11b14c773 100644 --- a/game_patch/multi/vehicles/vehicle_physics.h +++ b/game_patch/multi/vehicles/vehicle_physics.h @@ -27,6 +27,8 @@ bool vehicle_physics_camera_toggle_view(); // Whether the local player's current seat is viewed through the orbit camera. bool vehicle_physics_camera_local_seat_is_orbit(); +// Whether the local player's seat offers a third-person view to toggle into. +bool vehicle_physics_camera_local_can_toggle_view(); // A third-person driver's mouse and look keys turn the orbit, not the hull. bool vehicle_physics_camera_owns_driver_look(); diff --git a/game_patch/multi/vehicles/vehicle_seats.cpp b/game_patch/multi/vehicles/vehicle_seats.cpp index 2eb95c6d3..f569fa843 100644 --- a/game_patch/multi/vehicles/vehicle_seats.cpp +++ b/game_patch/multi/vehicles/vehicle_seats.cpp @@ -343,6 +343,7 @@ namespace // entity_die credits the last damager with every occupant it kills, and whoever softened // this hull while it stood empty is not responsible for the people who just got in. g_vehicle_state.last_damager.erase(vehicle->handle); + g_vehicle_state.last_hurt.erase(vehicle->handle); // A new occupant in ANY seat takes the vehicle over. g_vehicle_state.kinematics.erase(vehicle->handle); g_vehicle_state.coast_memory.erase(vehicle->handle); diff --git a/game_patch/multi/vehicles/vehicle_spawn.cpp b/game_patch/multi/vehicles/vehicle_spawn.cpp index 7ddfea507..427036825 100644 --- a/game_patch/multi/vehicles/vehicle_spawn.cpp +++ b/game_patch/multi/vehicles/vehicle_spawn.cpp @@ -542,17 +542,20 @@ namespace std::optional damage_radius; // $Damage Radius std::optional crater_radius; // +Crater Radius bool clear_homing; // $Homing: false + std::optional lifetime; // $Lifetime + std::optional thrust_lifetime; // $Thrust Lifetime }; - // The mods/gg diff, with the APC and fighter minigun pools doubled. Anything not listed keeps its stock value. + // The mods/gg diff, with the APC and fighter minigun pools doubled and the torpedo's and fighter rocket's + // lifetimes our own. Anything not listed keeps its stock value. constexpr VehicleWeaponOverride vehicle_weapon_overrides[] = { - {"Torpedo", 17.0f, 1.25f, 275.0f, 5, 12.0f, 8.5f, true}, - {"Fighter Rocket", {}, 2.0f, 275.0f, 5, {}, 10.0f, true}, - {"APC Rocket", {}, {}, 275.0f, 5, {}, {}, false}, - {"APC Minigun", {}, {}, 45.0f, 200, {}, {}, false}, - {"Jeep Gun", {}, {}, 45.0f, 150, {}, {}, false}, - {"Fighter Minigun", {}, {}, 45.0f, 200, {}, {}, false}, - {"Vauss", {}, {}, 45.0f, 150, {}, {}, false}, + {"Torpedo", 17.0f, 1.25f, 275.0f, 5, 12.0f, 8.5f, true, 5.0f, {}}, + {"Fighter Rocket", {}, 2.0f, 275.0f, 5, {}, 10.0f, true, 15.0f, 5.0f}, + {"APC Rocket", {}, {}, 275.0f, 5, {}, {}, false, {}, {}}, + {"APC Minigun", {}, {}, 45.0f, 200, {}, {}, false, {}, {}}, + {"Jeep Gun", {}, {}, 45.0f, 150, {}, {}, false, {}, {}}, + {"Fighter Minigun", {}, {}, 45.0f, 200, {}, {}, false, {}, {}}, + {"Vauss", {}, {}, 45.0f, 150, {}, {}, false, {}, {}}, }; struct VehicleEntityOverride @@ -574,6 +577,7 @@ namespace { int weapon_type; float max_speed, max_speed_multi, max_speed_single, lifetime_mul_vel; + float lifetime_seconds, lifetime_seconds_single, lifetime_seconds_multi, thrust_lifetime_seconds; float fire_wait, damage_multi; float damage_radius, damage_radius_single, damage_radius_multi; float crater_radius; @@ -761,6 +765,10 @@ void vehicle_tbl_overrides_revert() wi.max_speed_multi = s.max_speed_multi; wi.max_speed_single = s.max_speed_single; wi.lifetime_mul_vel = s.lifetime_mul_vel; + wi.lifetime_seconds = s.lifetime_seconds; + wi.lifetime_seconds_single = s.lifetime_seconds_single; + wi.lifetime_seconds_multi = s.lifetime_seconds_multi; + wi.thrust_lifetime_seconds = s.thrust_lifetime_seconds; wi.fire_wait = s.fire_wait; wi.damage_multi = s.damage_multi; wi.damage_radius = s.damage_radius; @@ -815,7 +823,9 @@ void vehicle_tbl_overrides_level_init_post() } rf::WeaponInfo& wi = rf::weapon_types[wt]; g_vehicle_tbl_weapon_saved.push_back({wt, wi.max_speed, wi.max_speed_multi, - wi.max_speed_single, wi.lifetime_mul_vel, wi.fire_wait, + wi.max_speed_single, wi.lifetime_mul_vel, + wi.lifetime_seconds, wi.lifetime_seconds_single, + wi.lifetime_seconds_multi, wi.thrust_lifetime_seconds, wi.fire_wait, wi.damage_multi, wi.damage_radius, wi.damage_radius_single, wi.damage_radius_multi, wi.crater_radius, wi.max_ammo, wi.max_ammo_multi, @@ -826,10 +836,20 @@ void vehicle_tbl_overrides_level_init_post() wi.max_speed = *o.velocity; wi.max_speed_multi = *o.velocity; wi.max_speed_single = *o.velocity; - // Precomputed at startup and NOT re-derived by the resolver, so a velocity change must - // carry it or the round keeps its old maximum range. + } + if (o.lifetime) { + wi.lifetime_seconds = *o.lifetime; + wi.lifetime_seconds_single = *o.lifetime; + wi.lifetime_seconds_multi = *o.lifetime; + } + if (o.velocity || o.lifetime) { + // The AI's firing range, precomputed at startup and NOT re-derived by the resolver. wi.lifetime_mul_vel = wi.max_speed * wi.lifetime_seconds; } + if (o.thrust_lifetime) { + // Read only at weapon creation; nothing re-derives it. Past it the round falls under gravity. + wi.thrust_lifetime_seconds = *o.thrust_lifetime; + } if (o.fire_wait) { wi.fire_wait = *o.fire_wait; } diff --git a/game_patch/multi/vehicles/vehicle_sync.cpp b/game_patch/multi/vehicles/vehicle_sync.cpp index eee65be8e..4a115830e 100644 --- a/game_patch/multi/vehicles/vehicle_sync.cpp +++ b/game_patch/multi/vehicles/vehicle_sync.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -790,8 +791,8 @@ namespace // next_fire_secondary), so holding both is not a conflict. void vehicle_server_apply_trigger(rf::Entity* vehicle, bool alt, bool held, uint8_t requester_id) { - // A turret's alt trigger is its operator's local zoom and drives no weapon. - if (alt && vehicle_hull_is_turret(vehicle)) { + // A turret's or jeep's alt trigger is its gunner's local zoom and drives no weapon. + if (alt && vehicle_alt_fire_zooms(vehicle)) { return; } const int weapon = alt ? vehicle->ai.current_secondary_weapon : vehicle->ai.current_primary_weapon; @@ -944,6 +945,7 @@ void vehicle_drop_combat_state(int vehicle_handle) g_vehicle_state.horn.erase(vehicle_handle); g_vehicle_state.health_sync.erase(vehicle_handle); g_vehicle_state.last_damager.erase(vehicle_handle); + g_vehicle_state.last_hurt.erase(vehicle_handle); g_vehicle_state.lethal_killer.erase(vehicle_handle); g_vehicle_state.observed_speed.erase(vehicle_handle); g_vehicle_state.crash_cooldown.erase(vehicle_handle); @@ -954,7 +956,7 @@ void vehicle_drop_combat_state(int vehicle_handle) g_vehicle_state.orient_sent.erase(vehicle_handle); g_vehicle_state.kinematics.erase(vehicle_handle); g_vehicle_state.void_timer.erase(vehicle_handle); - g_vehicle_state.hazard_since.erase(vehicle_handle); + g_vehicle_state.hazard_time.erase(vehicle_handle); g_vehicle_state.coast_memory.erase(vehicle_handle); g_vehicle_state.regen.erase(vehicle_handle); g_vehicle_state.pending_seats.erase(vehicle_handle); @@ -1670,7 +1672,17 @@ void vehicle_server_handle_fire_request(rf::Player* pp, int vehicle_handle, uint const bool held = action == AF_VEHICLE_FIRE_START; const bool alt = alt_fire != 0; // Never stored, so it can neither fire nor hold off the ammo refill. - if (alt && vehicle_hull_is_turret(vehicle)) { + if (alt && vehicle_alt_fire_zooms(vehicle)) { + return; + } + // The sub's alt trigger sets off its armed torpedoes in flight, here and on every machine, the pilot's + // included: his prediction may have spared one this side of the arming time. + if (alt && rf::entity_is_sub(vehicle)) { + int still_flying = 0; + if (held && vehicle_detonate_armed_torpedoes(vehicle, vehicle_torpedo_arm_s, &still_flying)) { + af_send_vehicle_fire_packet_to_all(nullptr, vehicle->handle, AF_VEHICLE_FIRE_DETONATE, + static_cast(std::min(still_flying, 255))); + } return; } @@ -1704,6 +1716,75 @@ void vehicle_server_announce_weapon_edge(int entity_handle, int weapon_type, boo on && alt_fire ? 1 : 0); } +// The sub's torpedoes are set off as entity_detonate_remote_charges (0x00429C70) does: an expired torpedo +// detonates on its next move, damaging only on the server. Each machine sets off its own copies. +namespace +{ + // This machine's copies of the hull's own rounds still in flight, never one a rider fired by hand. + void vehicle_flying_torpedoes(rf::Entity* hull, std::vector& flying) + { + flying.clear(); + for (rf::Weapon& weapon : DoublyLinkedList{rf::weapon_list}) { + if (weapon.info && (weapon.info->flags & rf::WTF_TORPEDO) && weapon.lifeleft_seconds > 0.0f + && weapon.parent_handle == hull->handle) { + flying.push_back(&weapon); + } + } + } + + float vehicle_torpedo_age(const rf::Weapon* weapon) + { + return weapon->info->lifetime_seconds - weapon->lifeleft_seconds; + } + + // A watcher's DETONATE: the server's count rather than this machine's clock, its copies being a trip off the + // server's; all but the `keep` youngest go. A shot the server fires after it is sent reaches him after it. + void vehicle_detonate_all_but_youngest_torpedoes(rf::Entity* hull, std::size_t keep) + { + std::vector flying; + vehicle_flying_torpedoes(hull, flying); + std::sort(flying.begin(), flying.end(), [](const rf::Weapon* a, const rf::Weapon* b) { + return vehicle_torpedo_age(a) < vehicle_torpedo_age(b); + }); + for (std::size_t i = keep; i < flying.size(); ++i) { + flying[i]->lifeleft_seconds = -1.0f; + } + } + + // The pilot's DETONATE: the server's rule applied as of his press. The server's count cannot serve him: a shot + // he fires around the press exists here before the server fires its copy, so it was never counted. + void vehicle_detonate_torpedoes_armed_at_press(rf::Entity* hull, const rf::Timestamp& press) + { + const float since_press_s = static_cast(press.time_since()) / 1000.0f; + std::vector flying; + vehicle_flying_torpedoes(hull, flying); + for (rf::Weapon* weapon : flying) { + if (vehicle_torpedo_age(weapon) - since_press_s >= vehicle_torpedo_arm_s) { + weapon->lifeleft_seconds = -1.0f; + } + } + } +} // namespace + +bool vehicle_detonate_armed_torpedoes(rf::Entity* hull, float arm_s, int* still_flying) +{ + std::vector flying; + vehicle_flying_torpedoes(hull, flying); + int kept = 0; + for (rf::Weapon* weapon : flying) { + if (vehicle_torpedo_age(weapon) >= arm_s) { + weapon->lifeleft_seconds = -1.0f; + } + else { + ++kept; + } + } + if (still_flying) { + *still_flying = kept; + } + return kept < static_cast(flying.size()); +} + void vehicle_apply_fire_from_packet(int vehicle_handle, uint8_t action, uint8_t alt_fire) { rf::Object* obj = rf::obj_from_remote_handle(vehicle_handle); @@ -1711,6 +1792,18 @@ void vehicle_apply_fire_from_packet(int vehicle_handle, uint8_t action, uint8_t return; } const bool alt = alt_fire != 0; + // A dying sub's torpedoes are still its own. + if (action == AF_VEHICLE_FIRE_DETONATE) { + if (rf::Entity* vehicle = vehicle_synced_entity(obj->handle)) { + if (vehicle_local_firing_vehicle() == vehicle && g_vehicle_state.torpedo_press_hull == vehicle->handle) { + vehicle_detonate_torpedoes_armed_at_press(vehicle, g_vehicle_state.torpedo_press); + } + else { + vehicle_detonate_all_but_youngest_torpedoes(vehicle, alt_fire); + } + } + return; + } if (action == AF_VEHICLE_FIRE_STOP) { // Unconditional, dying hull included: a spare turn-off is a no-op, a missed one fires forever. if (rf::Entity* vehicle = vehicle_synced_entity(obj->handle)) { @@ -1795,6 +1888,11 @@ void vehicle_apply_orient_from_packet(int vehicle_handle, uint16_t tick, int16_t // rides; a spectator gets the flash alone, as for the man he watches. void vehicle_rider_damage_feedback(int vehicle_handle, const rf::Vector3* hit_dir) { + // A draining hull loses life on every health update; its alarm and warning say so instead. The whole + // clock, not just the drain: this machine's runs a little behind the server's. + if (!hit_dir && !rf::is_server && vehicle_hazard_on_clock(vehicle_handle)) { + return; + } rf::Entity* local_hull = vehicle_ridden_hull(rf::local_player_entity); if (rf::local_player && local_hull && local_hull->handle == vehicle_handle) { player_damage_feedback(); diff --git a/game_patch/multi/vehicles/vehicle_view.cpp b/game_patch/multi/vehicles/vehicle_view.cpp index ca0bd5a38..d49190052 100644 --- a/game_patch/multi/vehicles/vehicle_view.cpp +++ b/game_patch/multi/vehicles/vehicle_view.cpp @@ -22,6 +22,10 @@ #include "../server_internal.h" #include "../../hud/hud.h" #include "../../hud/multi_spectate.h" +#include "../../hud/remote_server_cfg_ui.h" +#include "../../misc/vote_panel.h" +#include "../../input/input.h" +#include "../../misc/alpine_settings.h" #include "../../misc/level.h" #include "../../misc/player.h" #include "../../os/console.h" @@ -749,15 +753,16 @@ namespace } // The first step of the freelook spectate zoom. - constexpr float vehicle_turret_zoom_fov_divisor = 2.0f; + constexpr float vehicle_gun_zoom_fov_divisor = 2.0f; - rf::Entity* vehicle_local_operated_turret() + rf::Entity* vehicle_local_zoom_gun() { if (!rf::is_multi || rf::is_dedicated_server || multi_spectate_is_spectating()) { return nullptr; } rf::Entity* hull = vehicle_local_firing_vehicle(); - return vehicle_hull_is_turret(hull) ? hull : nullptr; + // A third-person jeep gunner's camera orbits the hull, not along his aim. + return vehicle_alt_fire_zooms(hull) && !vehicle_physics_camera_local_seat_is_orbit() ? hull : nullptr; } } // namespace @@ -779,30 +784,91 @@ void vehicle_view_level_init() g_apc_minigun_cache.clear(); } -float vehicle_turret_zoom_fov_scale() +void vehicle_render_control_hints() { - const rf::Entity* turret = vehicle_local_operated_turret(); - return turret && turret->handle == g_vehicle_state.turret_zoom_handle ? vehicle_turret_zoom_fov_divisor - : 1.0f; + if (!g_alpine_game_config.vehicle_control_hints || !rf::is_multi || rf::gameseq_get_state() != rf::GS_GAMEPLAY + || multi_spectate_is_spectating() || is_hud_effectively_hidden() || g_remote_server_cfg_popup.is_active() + || vote_panel_is_gameplay_overlay_active()) { + return; + } + rf::Entity* rider = rf::local_player_entity; + rf::Entity* hull = rider && !rf::entity_is_dying(rider) ? vehicle_ridden_live_hull(rider) : nullptr; + if (!hull) { + return; + } + + // Each label names what the trigger does in THIS seat. + const auto weapon_label = [](int weapon) { + return weapon >= 0 && weapon < rf::num_weapon_types + ? "Fire " + std::string{rf::weapon_types[weapon].display_name.c_str()} + : std::string{}; + }; + std::string primary_label; + std::string alt_label; + const bool driller = rf::entity_is_driller(hull); + if (vehicle_firing_seat_occupant(hull) == rider) { + primary_label = driller ? "Use Drill" : weapon_label(hull->ai.current_primary_weapon); + // A third-person jeep gunner's alt does nothing: his zoom is off in the orbit view. The driller's alt + // is the stock cockpit camera cycle (0x004A8630), which only the first-person view shows. + const bool orbit = vehicle_physics_camera_local_seat_is_orbit(); + alt_label = vehicle_alt_fire_zooms(hull) ? (vehicle_local_zoom_gun() ? "Zoom" : "") + : rf::entity_is_sub(hull) ? "Detonate Torpedoes" + : driller ? (orbit ? "" : "Cycle Camera View") + : weapon_label(hull->ai.current_secondary_weapon); + } + else if (rf::entity_is_jeep(hull) && vehicle_seat_leech(hull, 0) == rider->handle) { + primary_label = "Horn"; + } + + const std::string primary_bind = get_action_bind_name(rf::CC_ACTION_PRIMARY_ATTACK).c_str(); + const std::string alt_bind = get_action_bind_name(rf::CC_ACTION_SECONDARY_ATTACK).c_str(); + const std::string view_bind = get_action_bind_name(get_af_control(rf::AF_ACTION_SPECTATE_CHANGE_VIEW)).c_str(); + const std::string use_bind = get_action_bind_name(rf::CC_ACTION_USE).c_str(); + // The seat keys are fixed: vehicle_poll_seat_hotkeys reads 1-6 directly. + const int seats = std::min(vehicle_seat_count(hull), 6); + const std::string seat_keys = "1-" + std::to_string(seats); + + std::pair hints[5]; + int n = 0; + if (!primary_label.empty()) { + hints[n++] = {primary_bind.c_str(), primary_label.c_str()}; + } + if (!alt_label.empty()) { + hints[n++] = {alt_bind.c_str(), alt_label.c_str()}; + } + if (vehicle_physics_camera_local_can_toggle_view()) { + hints[n++] = {view_bind.c_str(), "First / Third Person View"}; + } + if (seats > 1) { + hints[n++] = {seat_keys.c_str(), "Change Seat"}; + } + hints[n++] = {use_bind.c_str(), vehicle_hull_is_turret(hull) ? "Exit Turret" : "Exit Vehicle"}; + hud_draw_control_hints(hints, n); +} + +float vehicle_gun_zoom_fov_scale() +{ + const rf::Entity* gun = vehicle_local_zoom_gun(); + return gun && gun->handle == g_vehicle_state.gun_zoom_handle ? vehicle_gun_zoom_fov_divisor : 1.0f; } -void vehicle_turret_zoom_do_frame() +void vehicle_gun_zoom_do_frame() { - // Any frame without the zoomed turret manned drops the zoom, whatever ended the operator's turn. - const rf::Entity* turret = vehicle_local_operated_turret(); - if (!turret || turret->handle != g_vehicle_state.turret_zoom_handle) { - g_vehicle_state.turret_zoom_handle = -1; + // Any frame without the zoomed gun manned drops the zoom, whatever ended the gunner's turn. + const rf::Entity* gun = vehicle_local_zoom_gun(); + if (!gun || gun->handle != g_vehicle_state.gun_zoom_handle) { + g_vehicle_state.gun_zoom_handle = -1; } - // Tracked off the turret and through pauses too, so a press held from before is never an edge. + // Tracked off the gun and through pauses too, so a press held from before is never an edge. const bool alt_down = rf::local_player && rf::control_is_control_down(&rf::local_player->settings.controls, rf::CC_ACTION_SECONDARY_ATTACK); - const bool pressed = alt_down && !g_vehicle_state.turret_zoom_alt_down; - g_vehicle_state.turret_zoom_alt_down = alt_down; - if (!pressed || !turret || rf::game_paused || rf::gameseq_get_state() != rf::GS_GAMEPLAY + const bool pressed = alt_down && !g_vehicle_state.gun_zoom_alt_down; + g_vehicle_state.gun_zoom_alt_down = alt_down; + if (!pressed || !gun || rf::game_paused || rf::gameseq_get_state() != rf::GS_GAMEPLAY || rf::console::console_is_visible() || rf::multi_chat_is_say_visible()) { return; } - g_vehicle_state.turret_zoom_handle = g_vehicle_state.turret_zoom_handle == -1 ? turret->handle : -1; + g_vehicle_state.gun_zoom_handle = g_vehicle_state.gun_zoom_handle == -1 ? gun->handle : -1; } VehicleFpShotStart vehicle_fp_own_shot_start(rf::Entity* hull, rf::Entity* shooter, const rf::Vector3& fire_pos, diff --git a/game_patch/multi/vehicles/vehicle_view.h b/game_patch/multi/vehicles/vehicle_view.h index cc3dbda8d..eb2ac9aaf 100644 --- a/game_patch/multi/vehicles/vehicle_view.h +++ b/game_patch/multi/vehicles/vehicle_view.h @@ -28,9 +28,12 @@ bool vehicle_apc_minigun_muzzle_pos(rf::Entity* hull, rf::Vector3* out_pos); // The memoized prop indices must not outlive the level's meshes. void vehicle_view_level_init(); -// Client, once per frame: the local turret operator's alt trigger toggles his zoom. -void vehicle_turret_zoom_do_frame(); -// The local operator's turret zoom as a FOV divisor: 1 unless he has toggled it on in the turret he mans. -float vehicle_turret_zoom_fov_scale(); +// Client HUD: the controls of the seat the local player rides, in the spectate hints' place. +void vehicle_render_control_hints(); + +// Client, once per frame: the local turret operator's or jeep gunner's alt trigger toggles his zoom. +void vehicle_gun_zoom_do_frame(); +// The local gunner's zoom as a FOV divisor: 1 unless he has toggled it on in the turret or jeep he mans. +float vehicle_gun_zoom_fov_scale(); void vehicle_view_apply_patch(); diff --git a/game_patch/object/object.cpp b/game_patch/object/object.cpp index 21d9c716e..6daafa024 100644 --- a/game_patch/object/object.cpp +++ b/game_patch/object/object.cpp @@ -43,6 +43,7 @@ #include "obj_collision.h" #include "object.h" #include "object_private.h" +#include "particle_sim.h" #include "../misc/level.h" std::string get_object_type_string(int type) { @@ -1352,6 +1353,7 @@ void object_do_patch() monitor_do_patch(); mover_do_patch(); particle_do_patch(); + particle_sim_do_patch(); obj_light_apply_patch(); obj_collision_apply_patch(); clock_do_patch(); diff --git a/game_patch/object/particle.cpp b/game_patch/object/particle.cpp index 8c19e298d..1807708d5 100644 --- a/game_patch/object/particle.cpp +++ b/game_patch/object/particle.cpp @@ -15,6 +15,7 @@ #include "../rf/level.h" #include "../rf/player/player.h" #include "../rf/player/camera.h" +#include "particle_sim.h" FunHook particle_emitter_create_hook{ 0x00497CA0, @@ -61,6 +62,10 @@ CodeInjection particle_update_accel_patch{ CallHook particle_create_level_emitter_hook{ 0x00496DBC, [](int pool_id, rf::ParticleCreateInfo& pci, rf::GRoom* room, rf::Vector3 *a4, int parent_obj, rf::Particle** result, rf::ParticleEmitter* emitter) { + // A frozen emitter must not grow its particle list while it is unseen + if (particle_sim_emitter_culled(emitter)) { + return; + } // On AF levels, create particles only within the active distance // Applies to particle emitters placed in level file if (!rf::is_dedicated_server && af_rfl_version(rf::level.version) && parent_obj == 0 && emitter->uid > 0) { @@ -86,6 +91,7 @@ FunHook +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../graphics/d3d11/gr_d3d11_hooks.h" +#include "../hud/hud.h" +#include "../misc/alpine_settings.h" +#include "../multi/multi.h" +#include "../os/console.h" +#include "../os/os.h" +#include "../rf/gameseq.h" +#include "../rf/geometry.h" +#include "../rf/gr/gr.h" +#include "../rf/gr/gr_font.h" +#include "../rf/multi.h" +#include "../rf/particle_emitter.h" +#include "../rf/player/camera.h" +#include "../rf/player/player.h" +#include "particle_sim.h" + +namespace +{ + +// An emitter is culled once its room has gone unrendered for more than this many frames. +constexpr int visible_frame_slack = 2; +// A wake-up only ramps back an emitter that was frozen for at least this long. +constexpr int64_t warmup_min_culled_ms = 1000; +constexpr float warmup_step = 1.0f / 30.0f; +constexpr int warmup_max_steps_per_emitter = 90; +constexpr int warmup_max_steps_per_frame = 300; +constexpr int warmup_max_spawns_per_step = 8; +constexpr int fixed_step_max_steps_per_frame = 2; +// Camera distance bands for the collision test interval k. +constexpr float collide_lod_near_dist = 15.0f; +constexpr float collide_lod_mid_dist = 40.0f; +// Stock capacities of the two engine pools, used when the configured pool size is 0. +constexpr int stock_pool_capacity[2] = {500, 1100}; + +// One frame's counters. A capture sums and maxes the same set, hence one width for all. +struct FrameCounters +{ + int64_t sim_us = 0; + int64_t render_us = 0; + int64_t simulated = 0; + int64_t rendered = 0; + int64_t emitters_rendered = 0; + int64_t emitters_culled = 0; + int64_t warmup_steps = 0; + int64_t warmup_emitters = 0; + int64_t eligible_steps = 0; + int64_t collide_tested = 0; + int64_t collide_skipped = 0; + int64_t spawn_attempts = 0; + int64_t spawn_failures = 0; + int64_t gpu_drawn = 0; + int64_t gpu_draws = 0; +}; + +constexpr int64_t FrameCounters::* frame_counter_fields[] = { + &FrameCounters::sim_us, &FrameCounters::render_us, &FrameCounters::simulated, &FrameCounters::rendered, + &FrameCounters::emitters_rendered, &FrameCounters::emitters_culled, &FrameCounters::warmup_steps, + &FrameCounters::warmup_emitters, &FrameCounters::eligible_steps, &FrameCounters::collide_tested, + &FrameCounters::collide_skipped, &FrameCounters::spawn_attempts, &FrameCounters::spawn_failures, + &FrameCounters::gpu_drawn, &FrameCounters::gpu_draws, +}; +static_assert(std::size(frame_counter_fields) * sizeof(int64_t) == sizeof(FrameCounters)); + +struct ParticleSimState +{ + bool overlay = false; + bool warmup = true; // session only, not persisted + + FrameCounters cur; // frame being built + FrameCounters last; // last complete frame + int64_t total_spawn_failures = 0; + + // one second rolling window + int64_t window_start_ms = 0; + int window_frames = 0; + int64_t window_sim_sum = 0; + int64_t window_sim_max = 0; + int64_t window_render_sum = 0; + int64_t window_render_max = 0; + int64_t shown_sim_avg = 0; + int64_t shown_sim_max = 0; + int64_t shown_render_avg = 0; + int64_t shown_render_max = 0; + + // dbg_particles_log capture + bool capturing = false; + int capture_secs = 0; + int64_t capture_end_ms = 0; + int capture_frames = 0; + FrameCounters capture_sum; + FrameCounters capture_max; + int capture_pool_high_water[2] = {}; +}; + +ParticleSimState g_ps; + +// Only the overlay and a capture read the timings, so normal play skips the timer reads. +bool perf_timing_active() +{ + return g_ps.overlay || g_ps.capturing; +} + +// last_seen_frame 0 means the emitter has not been stamped since the map was last cleared. +struct EmitterRecord +{ + int last_seen_frame = 0; + bool culled = false; + int64_t culled_since_ms = 0; +}; + +// Side state for one particle pool slot. last_age 0 marks a slot that has not been used yet. +struct CollideState +{ + rf::Vector3 last_tested; + float acc_dt = 0.0f; + float last_age = 0.0f; + int frames = 0; +}; + +int g_frame = 0; +int g_map_cleared_frame = 0; +std::unordered_map g_emitters; + +float g_sim_acc = 0.0f; +float g_interp_dt = 0.0f; +std::vector> g_interp_scratch; + +std::vector g_collide_state[2]; +rf::Vector3 g_camera_pos; +bool g_camera_pos_valid = false; + +std::unique_ptr g_pool_storage[2]; +int g_pool_storage_capacity[2] = {}; + +bool cull_sim_enabled() +{ + return g_alpine_game_config.particle_sim_cull && !rf::is_dedicated_server && !is_headless_mode(); +} + +bool fixed_step_enabled() +{ + return g_alpine_game_config.particle_sim_fixed_step && !rf::is_dedicated_server; +} + +bool collide_lod_enabled() +{ + return g_alpine_game_config.particle_sim_collide_lod && !rf::is_dedicated_server; +} + +// Off only to debug against the stock drawing path, which every renderer but D3D11 always uses. +bool g_gpu_particles = true; + +bool gpu_particles_enabled() +{ + return g_gpu_particles && is_d3d11(); +} + +// Level emitters that cannot affect gameplay are the only ones that may be frozen. +bool emitter_is_cullable(const rf::ParticleEmitter* emitter) +{ + return emitter + && emitter->parent_handle == 0 && emitter->uid > 0 + && !(emitter->pci.flags2 & rf::PTF2_DAMAGES) + && !(emitter->pci.flags & rf::PTF_HIT_CALLBACK); +} + +// Attached emitters may be fixed stepped too, gameplay relevant ones never are. +bool emitter_is_fixed_step_eligible(const rf::ParticleEmitter* emitter) +{ + return emitter + && !(emitter->pci.flags2 & rf::PTF2_DAMAGES) + && !(emitter->pci.flags & rf::PTF_HIT_CALLBACK); +} + +CollideState* collide_state_for(const rf::Particle* particle) +{ + const int pool_id = particle->pool_id; + if (pool_id < 0 || pool_id > 1) { + return nullptr; + } + const rf::ParticlePool& pool = rf::particle_pools[pool_id]; + if (!pool.particles || pool.capacity <= 0) { + return nullptr; + } + std::vector& states = g_collide_state[pool_id]; + if (states.size() != static_cast(pool.capacity)) { + states.assign(static_cast(pool.capacity), CollideState{}); + } + const std::ptrdiff_t index = particle - pool.particles; + if (index < 0 || index >= pool.capacity) { + return nullptr; + } + return &states[static_cast(index)]; +} + +// Points the engine pool records at Alpine owned arrays and rebuilds their free lists the way +// the stock rebuild does. Only ever called from the two wholesale rebuilds, which free every +// particle first, so no live particle is ever stranded by the swap. There is no eviction: a +// spawn on hit can call particle_create from inside the particle_list_move walk, and unlinking +// the node that walk saved as next would corrupt the iteration. +void relink_pools() +{ + const int configured = g_alpine_game_config.particle_pool_size; + for (int i = 0; i < 2; ++i) { + const int desired_cap = configured == 0 ? stock_pool_capacity[i] : configured; + if (!g_pool_storage[i] || g_pool_storage_capacity[i] != desired_cap) { + g_pool_storage[i] = std::make_unique(static_cast(desired_cap)); + g_pool_storage_capacity[i] = desired_cap; + } + + rf::ParticlePool& pool = rf::particle_pools[i]; + pool.particles = g_pool_storage[i].get(); + pool.capacity = desired_cap; + pool.num_used = 0; + pool.free_list.next = &pool.free_list; + pool.free_list.prev = &pool.free_list; + for (int j = 0; j < desired_cap; ++j) { + rf::Particle& entry = pool.particles[j]; + entry.flags = 0; + entry.flags2 = 0; + entry.next = &pool.free_list; + entry.prev = pool.free_list.prev; + pool.free_list.prev->next = &entry; + pool.free_list.prev = &entry; + } + } +} + +struct ParticleCounts +{ + int collide = 0; + int damaging = 0; + int fade = 0; +}; + +void count_particle_list(const rf::Particle& head, ParticleCounts& counts) +{ + for (const rf::Particle* p = head.next; p && p != &head; p = p->next) { + if (p->flags & rf::PTF_COLLIDE) { + ++counts.collide; + } + if (p->flags2 & rf::PTF2_DAMAGES) { + ++counts.damaging; + } + if (p->flags & rf::PTF_CLR_CHANGE) { + ++counts.fade; + } + } +} + +void report_line(const std::string& text) +{ + rf::console::print("{}", text); + xlog::info("{}", text); +} + +void finish_capture() +{ + g_ps.capturing = false; + const int frames = std::max(g_ps.capture_frames, 1); + const FrameCounters& sum = g_ps.capture_sum; + const FrameCounters& peak = g_ps.capture_max; + report_line(std::format("particle perf capture: {} s, {} frames", g_ps.capture_secs, g_ps.capture_frames)); + report_line(std::format(" sim us avg {} max {}", sum.sim_us / frames, peak.sim_us)); + report_line(std::format(" render us avg {} max {}", sum.render_us / frames, peak.render_us)); + report_line(std::format(" simulated avg {} max {} rendered avg {} max {}", + sum.simulated / frames, peak.simulated, sum.rendered / frames, peak.rendered)); + report_line(std::format(" emitters culled avg {} max {} cull sim {}", + sum.emitters_culled / frames, peak.emitters_culled, cull_sim_enabled() ? "on" : "off")); + report_line(std::format(" warmup steps avg {} max {} emitters warmed {} warmup {}", + sum.warmup_steps / frames, peak.warmup_steps, sum.warmup_emitters, g_ps.warmup ? "on" : "off")); + report_line(std::format(" eligible steps avg {} max {} fixed step {} rate {}", + sum.eligible_steps / frames, peak.eligible_steps, + fixed_step_enabled() ? "on" : "off", g_alpine_game_config.particle_sim_rate)); + report_line(std::format(" collide tested avg {} max {} skipped avg {} max {} collide lod {}", + sum.collide_tested / frames, peak.collide_tested, sum.collide_skipped / frames, peak.collide_skipped, + collide_lod_enabled() ? "on" : "off")); + report_line(std::format(" gpu particles {} drawn avg {} max {} draws avg {} max {}", + gpu_particles_enabled() ? "on" : "off", sum.gpu_drawn / frames, peak.gpu_drawn, + sum.gpu_draws / frames, peak.gpu_draws)); + report_line(std::format(" spawns {} failures {}", sum.spawn_attempts, sum.spawn_failures)); + report_line(std::format(" pool high water {}/{} {}/{}", + g_ps.capture_pool_high_water[0], rf::particle_pools[0].capacity, + g_ps.capture_pool_high_water[1], rf::particle_pools[1].capacity)); +} + +// Runs before the engine simulate pass: the previous frame is complete at this point because +// the emitter render callbacks for it already ran. +void frame_boundary() +{ + g_ps.cur.rendered = rf::num_particles_rendered_this_frame; // zeroed by the call that follows + g_ps.last = g_ps.cur; + g_ps.cur = {}; + + if (!perf_timing_active()) { + return; + } + const int64_t now_ms = timer::get_i64(1000); + + if (g_ps.window_start_ms == 0) { + g_ps.window_start_ms = now_ms; + } + ++g_ps.window_frames; + g_ps.window_sim_sum += g_ps.last.sim_us; + g_ps.window_sim_max = std::max(g_ps.window_sim_max, g_ps.last.sim_us); + g_ps.window_render_sum += g_ps.last.render_us; + g_ps.window_render_max = std::max(g_ps.window_render_max, g_ps.last.render_us); + + if (now_ms - g_ps.window_start_ms >= 1000) { + g_ps.shown_sim_avg = g_ps.window_sim_sum / g_ps.window_frames; + g_ps.shown_sim_max = g_ps.window_sim_max; + g_ps.shown_render_avg = g_ps.window_render_sum / g_ps.window_frames; + g_ps.shown_render_max = g_ps.window_render_max; + g_ps.window_start_ms = now_ms; + g_ps.window_frames = 0; + g_ps.window_sim_sum = 0; + g_ps.window_sim_max = 0; + g_ps.window_render_sum = 0; + g_ps.window_render_max = 0; + } + + if (g_ps.capturing) { + ++g_ps.capture_frames; + for (const auto field : frame_counter_fields) { + g_ps.capture_sum.*field += g_ps.last.*field; + g_ps.capture_max.*field = std::max(g_ps.capture_max.*field, g_ps.last.*field); + } + for (int i = 0; i < 2; ++i) { + g_ps.capture_pool_high_water[i] = std::max(g_ps.capture_pool_high_water[i], rf::particle_pools[i].num_used); + } + if (now_ms >= g_ps.capture_end_ms) { + finish_capture(); + } + } +} + +// Replays the spawns and ageing missed while an emitter was frozen, so a stream that needs time +// to reach the player is already established when its room comes back into view. +void warm_up_emitter(rf::ParticleEmitter* emitter, int64_t culled_ms, int& budget) +{ + if (!g_ps.warmup || budget <= 0 || !emitter->active || (emitter->emitter_flags & rf::PEF_ALTERNATE_STATES)) { + return; + } + const float culled_secs = static_cast(culled_ms) / 1000.0f; + const float replay_secs = std::min(culled_secs, emitter->max_life_secs); + int num_steps = std::min(static_cast(replay_secs / warmup_step), warmup_max_steps_per_emitter); + num_steps = std::min(num_steps, budget); + if (num_steps <= 0) { + return; + } + budget -= num_steps; + + const float mean_delay = std::max((emitter->min_spawn_delay + emitter->max_spawn_delay) * 0.5f, 1.0f / 60.0f); + float acc = mean_delay; // so the first step spawns + for (int i = 0; i < num_steps; ++i) { + acc += warmup_step; + int spawned = 0; + while (acc >= mean_delay && spawned < warmup_max_spawns_per_step) { + emitter->spawn_first_particle(); + acc -= mean_delay; + ++spawned; + } + rf::particle_list_move(&emitter->particle_list, warmup_step); + } + + g_ps.cur.warmup_steps += num_steps; + ++g_ps.cur.warmup_emitters; +} + +// Same sequence as the stock particle_move_all: the global list, every emitter list and the +// secondary list. Culled emitter lists are skipped and eligible ones run at the fixed rate. +void move_all_visible(float frametime) +{ + const int64_t now_ms = timer::get_i64(1000); + int warmup_budget = warmup_max_steps_per_frame; + + const float step = 1.0f / static_cast(g_alpine_game_config.particle_sim_rate); + float eligible_dt = frametime; + int eligible_steps = 1; + if (!fixed_step_enabled() || frametime >= step) { + // At or below the sim rate this matches stock exactly and slow frames never cost more + g_sim_acc = 0.0f; + g_interp_dt = 0.0f; + } + else { + g_sim_acc += frametime; + eligible_steps = std::min(static_cast(g_sim_acc / step), fixed_step_max_steps_per_frame); + g_sim_acc -= eligible_steps * step; + eligible_dt = step; + g_interp_dt = g_sim_acc; + } + g_ps.cur.eligible_steps = eligible_steps; + + rf::num_particles_simulated_this_frame = 0; + rf::num_particles_rendered_this_frame = 0; + rf::particle_list_move(&rf::particle_pools[0].active_list, frametime); + rf::ParticleEmitter* emitter = rf::particle_emitter_list_head.next; + while (emitter != &rf::particle_emitter_list_head) { + const bool culled = particle_sim_emitter_culled(emitter); + if (emitter_is_cullable(emitter)) { + EmitterRecord& rec = g_emitters[emitter]; + if (culled != rec.culled) { + rec.culled = culled; + if (culled) { + rec.culled_since_ms = now_ms; + } + else if (now_ms - rec.culled_since_ms >= warmup_min_culled_ms) { + warm_up_emitter(emitter, now_ms - rec.culled_since_ms, warmup_budget); + } + } + } + if (culled) { + ++g_ps.cur.emitters_culled; + } + else if (emitter_is_fixed_step_eligible(emitter)) { + for (int i = 0; i < eligible_steps; ++i) { + rf::particle_list_move(&emitter->particle_list, eligible_dt); + } + } + else { + rf::particle_list_move(&emitter->particle_list, frametime); + } + emitter = emitter->next; // the stock loop also reads next after the move call + } + rf::particle_list_move(&rf::particle_secondary_list, frametime); +} + +FunHook particle_move_all_hook{ + 0x00496480, + [](float frametime) { + frame_boundary(); + ++g_frame; + + g_camera_pos_valid = rf::local_player && rf::local_player->cam; + if (g_camera_pos_valid) { + g_camera_pos = rf::camera_get_pos(rf::local_player->cam); + } + + const bool timed = perf_timing_active(); + const int64_t start_us = timed ? timer::get_i64(1000000) : 0; + if (cull_sim_enabled() || fixed_step_enabled()) { + move_all_visible(frametime); + } + else { + particle_move_all_hook.call_target(frametime); + } + if (timed) { + g_ps.cur.sim_us = timer::get_i64(1000000) - start_us; + } + g_ps.cur.simulated = rf::num_particles_simulated_this_frame; + }, +}; + +// Runs once per rendered room, before its emitters are handed to the portal renderer. Stamping +// here makes the visibility signal the room, not the particle derived portal sphere. +FunHook particle_emitter_add_to_render_list_hook{ + 0x00497C20, + [](rf::GRoom* room) { + for (rf::ParticleEmitter* e = rf::particle_emitter_list_head.next; + e != &rf::particle_emitter_list_head; e = e->next) { + if (e->room == room && emitter_is_cullable(e)) { + g_emitters[e].last_seen_frame = g_frame; + } + } + particle_emitter_add_to_render_list_hook.call_target(room); + }, +}; + +FunHook particle_emitter_render_hook{ + 0x00497BF0, + [](rf::ParticleEmitter* emitter, rf::GSolid* solid) { + // The fixed step remainder is extrapolated forward for the draw only + const float interp_dt = g_interp_dt > 0.0f && fixed_step_enabled() + && emitter_is_fixed_step_eligible(emitter) ? g_interp_dt : 0.0f; + + const bool timed = perf_timing_active(); + const int64_t start_us = timed ? timer::get_i64(1000000) : 0; + + int gpu_drawn = 0; + int gpu_draws = 0; + const bool on_gpu = gpu_particles_enabled() + && gr::d3d11::render_particle_emitter(emitter, interp_dt, &gpu_drawn, &gpu_draws); + if (on_gpu) { + // The batched path applies the extrapolation itself, without touching the particles + g_ps.cur.gpu_drawn += gpu_drawn; + g_ps.cur.gpu_draws += gpu_draws; + } + else { + if (interp_dt > 0.0f) { + g_interp_scratch.clear(); + for (rf::Particle* p = emitter->particle_list.next; p != &emitter->particle_list; p = p->next) { + g_interp_scratch.emplace_back(p->pos, p->last_pos); + const rf::Vector3 delta = p->vel * interp_dt; + p->pos += delta; + p->last_pos += delta; // the stretched quad draws last_pos to pos, keep the streak length + } + } + + particle_emitter_render_hook.call_target(emitter, solid); + + if (interp_dt > 0.0f) { + std::size_t i = 0; + for (rf::Particle* p = emitter->particle_list.next; + p != &emitter->particle_list && i < g_interp_scratch.size(); p = p->next, ++i) { + p->pos = g_interp_scratch[i].first; + p->last_pos = g_interp_scratch[i].second; + } + } + } + + if (timed) { + g_ps.cur.render_us += timer::get_i64(1000000) - start_us; + } + ++g_ps.cur.emitters_rendered; + }, +}; + +// Room render item of a particle no emitter owns, one item per particle. Consecutive items share one batch, +// which the D3D11 room render item hook flushes before any other item draws. +FunHook particle_render_item_hook{ + 0x00496830, + [](rf::Particle* particle) { + if (gpu_particles_enabled() && gr::d3d11::batched_particle(*particle)) { + ++g_ps.cur.gpu_drawn; + return; + } + particle_render_item_hook.call_target(particle); + }, +}; + +// Reached only by fall through when the collide world flag is set. Skipping the raycast for a +// distant cosmetic particle costs a delayed hit reaction, never a tunnel: the deferred test +// rewinds to where the last test ended so the segment still covers the whole untested path. +CodeInjection particle_collide_lod_injection{ + 0x004951E6, + [](auto& regs) { + rf::Particle* particle = regs.esi; + if (!collide_lod_enabled() || (particle->flags2 & rf::PTF2_DAMAGES) + || (particle->flags & rf::PTF_HIT_CALLBACK)) { + return; + } + CollideState* state = collide_state_for(particle); + if (!state) { + return; + } + + const float dt = addr_as_ref(regs.esp + 0x90); + // age was advanced by dt earlier in this step, so a first step particle has age == dt + if (particle->age <= dt || state->last_age == 0.0f || particle->age < state->last_age) { + // a fresh particle took over this pool slot + state->last_tested = particle->last_pos; + state->acc_dt = 0.0f; + state->frames = 0; + } + state->last_age = particle->age; + + int k = 1; + if (g_camera_pos_valid) { + const float dist = (particle->pos - g_camera_pos).len(); + k = dist < collide_lod_near_dist ? 1 : (dist < collide_lod_mid_dist ? 2 : 4); + } + + state->acc_dt += dt; + ++state->frames; + if (state->frames < k) { + ++g_ps.cur.collide_skipped; + regs.eip = 0x004951F9; // the simple move, which falls into the shared tail + return; + } + + particle->pos = state->last_tested; + rf::particle_move_collide(particle, state->acc_dt); + state->last_tested = particle->pos; + state->acc_dt = 0.0f; + state->frames = 0; + ++g_ps.cur.collide_tested; + regs.eip = 0x00495216; // the shared tail, skipping the stock collide call + }, +}; + +// The stock 128 emitter records are shared by every emitter in the game (level, entity, fire, flamethrower, +// weapon trail, explosion, Alpine effects) and silently run out, which also stops fire and flamethrower +// damage. Extra records join the free list once at game init and are kept until exit, so no emitter pointer can +// outlive its record. Free records cost nothing per frame: the engine only walks emitters in use, and the +// particle pools still bound what live emitters can spawn. +constexpr int emitter_capacity = 8192; +std::unique_ptr g_extra_emitter_storage; +int g_emitter_capacity = rf::particle_emitter_stock_capacity; + +// Called once, from game init (0x004B22B5). Its prologue is a 10 byte non relative MOV. +FunHook particle_emitter_pool_init_hook{ + 0x004973F0, + []() { + particle_emitter_pool_init_hook.call_target(); + const int num_extra = emitter_capacity - rf::particle_emitter_stock_capacity; + g_extra_emitter_storage = std::make_unique(num_extra * sizeof(rf::ParticleEmitter)); + auto* extra = reinterpret_cast(g_extra_emitter_storage.get()); + rf::ParticleEmitter& head = rf::particle_emitter_free_list_head; + for (int i = 0; i < num_extra; ++i) { + rf::ParticleEmitter* emitter = rf::particle_emitter_construct(&extra[i]); + emitter->next = &head; + emitter->prev = head.prev; + head.prev->next = emitter; + head.prev = emitter; + } + g_emitter_capacity = emitter_capacity; + }, +}; + +// Game init pool build. Its prologue is 8 non relative bytes, so a FunHook is safe here. +FunHook particle_pools_init_hook{ + 0x00494E70, + []() { + particle_pools_init_hook.call_target(); + relink_pools(); + }, +}; + +// Level init pool build. Its own prologue holds a CALL at byte 3, so the call site is hooked instead. +CallHook particle_pools_level_init_hook{ + 0x00460C71, + [](int enable_collision) { + particle_pools_level_init_hook.call_target(enable_collision); + relink_pools(); + }, +}; + +// The room pass render list (g_room_render_item_add 0x004D3560, drained by g_render_room_objects 0x004D3C40) +// holds 2048 items per room and refuses the rest. Every particle no emitter owns takes an item of its own, so with +// the larger pools they alone could fill it and stop the room's emitters from drawing. The list and the pointer +// lists its sortable items pass through are sized for the objects plus every particle and emitter. Alpha +// grouping, whose per group member arrays stay at 2048, is disabled in g_solid.cpp, so it never sees them. +constexpr int stock_room_render_items = 2048; +constexpr int room_render_items = + stock_room_render_items + 2 * AlpineGameSettings::max_particle_pool_size + emitter_capacity; + +StaticBufferResizePatch room_render_items_resize_patch{ + 0x0088FD20, + stock_room_render_items, + room_render_items, + { + {0x004D31BC}, // static init (trivial constructor) + {0x004D363D}, // g_room_render_item_add + {0x004D3CA8}, // g_render_room_objects, at +0x10 + }, +}; + +// Sortable items in queue order +StaticBufferResizePatch room_render_sortable_resize_patch{ + 0x00879B00, + stock_room_render_items, + room_render_items, + { + {0x004D3CA3}, + {0x004D3D24}, + }, +}; + +// Sortable items 0x004D43E0 leaves ungrouped (all of them), then sorted back to front +StaticBufferResizePatch room_render_ungrouped_resize_patch{ + 0x009B9550, + stock_room_render_items, + room_render_items, + { + {0x004D3D64}, + {0x004D3D6B}, + {0x004D3F24}, + {0x004D4035}, + {0x004D44B9}, + }, +}; + +// Sorted items drawn before the room's liquid surface (all of them in a room without one) +StaticBufferResizePatch room_render_before_liquid_resize_patch{ + 0x0088BB10, + stock_room_render_items, + room_render_items, + { + {0x004D3F1F}, + {0x004D403A}, + {0x004D40CC}, + {0x004D42C3}, + }, +}; + +// Sorted items drawn after the room's liquid surface +StaticBufferResizePatch room_render_after_liquid_resize_patch{ + 0x0088DB10, + stock_room_render_items, + room_render_items, + { + {0x004D3F1A}, + {0x004D41D6}, + {0x004D43B1}, + }, +}; + +ConsoleCommand2 dbg_particles_cmd{ + "dbg_particles", + []() { + g_ps.overlay = !g_ps.overlay; + rf::console::print("Particle performance overlay is {}", g_ps.overlay ? "enabled" : "disabled"); + }, + "Toggle the particle performance overlay", + "dbg_particles", +}; + +ConsoleCommand2 dbg_particles_log_cmd{ + "dbg_particles_log", + [](std::optional seconds) { + const int secs = std::clamp(seconds.value_or(10), 1, 600); + g_ps.capturing = true; + g_ps.capture_secs = secs; + g_ps.capture_end_ms = timer::get_i64(1000) + secs * 1000; + g_ps.capture_frames = 0; + g_ps.capture_sum = {}; + g_ps.capture_max = {}; + g_ps.capture_pool_high_water[0] = 0; + g_ps.capture_pool_high_water[1] = 0; + rf::console::print("Capturing particle performance for {} seconds...", secs); + }, + "Log particle performance counters for a number of seconds", + "dbg_particles_log [seconds]", +}; + +ConsoleCommand2 particle_cull_sim_cmd{ + "cl_particlecull", + []() { + g_alpine_game_config.particle_sim_cull = !g_alpine_game_config.particle_sim_cull; + rf::console::print("Particle simulation culling is {}", + g_alpine_game_config.particle_sim_cull ? "enabled" : "disabled"); + }, + "Toggle skipping simulation of level particle emitters that are not visible", + "cl_particlecull", +}; + +ConsoleCommand2 particle_sim_warmup_cmd{ + "dbg_particlewarmup", + []() { + g_ps.warmup = !g_ps.warmup; + rf::console::print("Particle emitter warm-up on wake is {}", g_ps.warmup ? "enabled" : "disabled"); + }, + "Toggle ramping a frozen level particle emitter back up when it becomes visible again", + "dbg_particlewarmup", +}; + +ConsoleCommand2 particle_fixed_step_cmd{ + "cl_particlefixedstep", + []() { + g_alpine_game_config.particle_sim_fixed_step = !g_alpine_game_config.particle_sim_fixed_step; + rf::console::print("Fixed timestep particle simulation is {}", + g_alpine_game_config.particle_sim_fixed_step ? "enabled" : "disabled"); + }, + "Toggle stepping cosmetic particles at a fixed rate with render interpolation", + "cl_particlefixedstep", +}; + +ConsoleCommand2 particle_sim_rate_cmd{ + "cl_particlerate", + [](std::optional hz) { + if (hz) { + g_alpine_game_config.set_particle_sim_rate(hz.value()); + } + rf::console::print("Particle simulation rate is {} Hz (valid range {}-{})", + g_alpine_game_config.particle_sim_rate, AlpineGameSettings::min_particle_sim_rate, + AlpineGameSettings::max_particle_sim_rate); + }, + "Set the fixed particle simulation rate in Hz", + "cl_particlerate [hz]", +}; + +ConsoleCommand2 gpu_particles_cmd{ + "dbg_particlegpu", + []() { + g_gpu_particles = !g_gpu_particles; + rf::console::print("GPU particle rendering: {}{}", g_gpu_particles ? "on" : "off", + is_d3d11() ? "" : " (Direct3D 11 only, currently inactive)"); + }, + "Toggles GPU rendering of particles, plankton and weather (off = stock CPU path)", +}; + +ConsoleCommand2 particle_collide_lod_cmd{ + "cl_particlecollidelod", + []() { + g_alpine_game_config.particle_sim_collide_lod = !g_alpine_game_config.particle_sim_collide_lod; + rf::console::print("Amortised particle world collision is {}", + g_alpine_game_config.particle_sim_collide_lod ? "enabled" : "disabled"); + }, + "Toggle testing cosmetic particle world collision less often with camera distance", + "cl_particlecollidelod", +}; + +ConsoleCommand2 particle_pool_size_cmd{ + "cl_particlepoolsize", + [](std::optional size) { + if (size) { + g_alpine_game_config.set_particle_pool_size(size.value()); + rf::console::print("Applies at the next level load."); + } + if (g_alpine_game_config.particle_pool_size == 0) { + rf::console::print("Particle pool size is stock ({} and {})", stock_pool_capacity[0], + stock_pool_capacity[1]); + } + else { + rf::console::print("Particle pool size is {} per pool (0 for stock, otherwise {}-{})", + g_alpine_game_config.particle_pool_size, AlpineGameSettings::min_particle_pool_size, + AlpineGameSettings::max_particle_pool_size); + } + }, + "Set the capacity of each particle pool, 0 for the stock capacities", + "cl_particlepoolsize [n]", +}; + +} // namespace + +int particle_sim_emitter_capacity() +{ + return g_emitter_capacity; +} + +bool particle_sim_gpu_enabled() +{ + return gpu_particles_enabled(); +} + +bool particle_sim_emitter_culled(const rf::ParticleEmitter* emitter) +{ + if (!cull_sim_enabled() || !emitter_is_cullable(emitter)) { + return false; + } + const auto it = g_emitters.find(emitter); + const int stamp = it != g_emitters.end() && it->second.last_seen_frame != 0 + ? it->second.last_seen_frame + : g_map_cleared_frame; + return g_frame - stamp > visible_frame_slack; +} + +void particle_sim_level_reset() +{ + g_emitters.clear(); + g_map_cleared_frame = g_frame; + g_sim_acc = 0.0f; + g_interp_dt = 0.0f; + g_collide_state[0].clear(); + g_collide_state[1].clear(); + g_camera_pos_valid = false; +} + +void particle_sim_note_create(int pool_id) +{ + if (pool_id < 0 || pool_id > 1) { + return; + } + ++g_ps.cur.spawn_attempts; + const rf::ParticlePool& pool = rf::particle_pools[pool_id]; + if (pool.free_list.next == &pool.free_list) { + ++g_ps.cur.spawn_failures; + ++g_ps.total_spawn_failures; + } +} + +void particle_sim_render_ui() +{ + if (!g_ps.overlay || !rf::gameseq_in_gameplay()) { + return; + } + + ParticleCounts counts; + int emitters_total = 0; + int emitters_active = 0; + count_particle_list(rf::particle_pools[0].active_list, counts); + count_particle_list(rf::particle_secondary_list, counts); + for (const rf::ParticleEmitter* e = rf::particle_emitter_list_head.next; + e && e != &rf::particle_emitter_list_head; e = e->next) { + ++emitters_total; + if (e->active) { + ++emitters_active; + } + count_particle_list(e->particle_list, counts); + } + + rf::gr::set_color(255, 255, 255, 255); + const int font_id = hud_get_default_font(); + const int x = rf::gr::screen_width() - 340; + int y = 120; + auto line = [&](const std::string& text) { + rf::gr::string(x, y, text.c_str(), font_id); + y += 12; + }; + + line(std::format("particles sim {} rend {} (stock counters)", g_ps.last.simulated, g_ps.last.rendered)); + line(std::format("pool0 {}/{} pool1 {}/{}", rf::particle_pools[0].num_used, rf::particle_pools[0].capacity, + rf::particle_pools[1].num_used, rf::particle_pools[1].capacity)); + line(std::format("spawn fail {}/frame total {}", g_ps.last.spawn_failures, g_ps.total_spawn_failures)); + line(std::format("emitters total {} rendered {} active {} culled {}", emitters_total, + g_ps.last.emitters_rendered, emitters_active, g_ps.last.emitters_culled)); + line(std::format("collide {} damaging {} fade {}", counts.collide, counts.damaging, counts.fade)); + line(std::format("cull sim {} warmup {} steps {} warmed {}", cull_sim_enabled() ? "on" : "off", + g_ps.warmup ? "on" : "off", g_ps.last.warmup_steps, g_ps.last.warmup_emitters)); + line(std::format("fixed step {} rate {} steps {} interp ms {:.1f}", fixed_step_enabled() ? "on" : "off", + g_alpine_game_config.particle_sim_rate, g_ps.cur.eligible_steps, g_interp_dt * 1000.0f)); + line(std::format("collide lod {} tested {} skipped {}", collide_lod_enabled() ? "on" : "off", + g_ps.last.collide_tested, g_ps.last.collide_skipped)); + line(std::format("gpu particles {} drawn {} draws {}", gpu_particles_enabled() ? "on" : "off", + g_ps.last.gpu_drawn, g_ps.last.gpu_draws)); + line(std::format("sim avg/max us {}/{} render avg/max us {}/{}", g_ps.shown_sim_avg, g_ps.shown_sim_max, + g_ps.shown_render_avg, g_ps.shown_render_max)); +} + +void particle_sim_do_patch() +{ + particle_move_all_hook.install(); + particle_emitter_add_to_render_list_hook.install(); + particle_emitter_render_hook.install(); + particle_render_item_hook.install(); + particle_collide_lod_injection.install(); + particle_pools_init_hook.install(); + particle_emitter_pool_init_hook.install(); + particle_pools_level_init_hook.install(); + + room_render_items_resize_patch.install(); + room_render_sortable_resize_patch.install(); + room_render_ungrouped_resize_patch.install(); + room_render_before_liquid_resize_patch.install(); + room_render_after_liquid_resize_patch.install(); + write_mem(0x004D3621 + 1, room_render_items); // g_room_render_item_add: cmp eax, 0x800 + + dbg_particles_cmd.register_cmd(); + dbg_particles_log_cmd.register_cmd(); + particle_cull_sim_cmd.register_cmd(); + particle_sim_warmup_cmd.register_cmd(); + particle_fixed_step_cmd.register_cmd(); + particle_sim_rate_cmd.register_cmd(); + particle_collide_lod_cmd.register_cmd(); + gpu_particles_cmd.register_cmd(); + particle_pool_size_cmd.register_cmd(); +} diff --git a/game_patch/object/particle_sim.h b/game_patch/object/particle_sim.h new file mode 100644 index 000000000..748ff13d6 --- /dev/null +++ b/game_patch/object/particle_sim.h @@ -0,0 +1,16 @@ +#pragma once + +namespace rf +{ + struct ParticleEmitter; +} + +void particle_sim_do_patch(); +void particle_sim_render_ui(); +void particle_sim_note_create(int pool_id); +void particle_sim_level_reset(); +bool particle_sim_emitter_culled(const rf::ParticleEmitter* emitter); +// Engine emitter records available in total, the stock 128 plus the ones Alpine adds. +int particle_sim_emitter_capacity(); +// Particles, plankton and weather draw on the GPU: Direct3D 11 unless dbg_particlegpu turned it off. +bool particle_sim_gpu_enabled(); diff --git a/game_patch/rf/geometry.h b/game_patch/rf/geometry.h index a64041993..9b2bc7731 100644 --- a/game_patch/rf/geometry.h +++ b/game_patch/rf/geometry.h @@ -779,7 +779,10 @@ namespace rf { AddrCaller{0x004D7F70}.this_call(&g_decal_pass_list_b, normal); } - static auto& geomod_create_rock_debris = addr_as_ref(0x0048FE30); // Recycles every live glass shard back into the pool and re-resolves foley sounds/bitmaps // (cached lookups). Does not touch broken-pane state (that lives in room/face data); @@ -804,8 +807,24 @@ namespace rf static auto& g_get_room_render_list = addr_as_ref(0x004D3330); using GRoomRenderItemFn = void(*)(void* user, GSolid* solid); + + // One entry of the room pass render list (stock 0x0088FD20, resized in particle_sim.cpp) that + // g_room_render_item_add fills + struct GRoomRenderItem + { + void* user; // +0x00 + Vector3 pos; // +0x04 + float radius; // +0x10 + bool sortable; // +0x14 + char padding[0x2C - 0x15]; + GRoomRenderItemFn render_fn; // +0x2C + }; + static_assert(offsetof(GRoomRenderItem, radius) == 0x10); + static_assert(offsetof(GRoomRenderItem, sortable) == 0x14); + static_assert(offsetof(GRoomRenderItem, render_fn) == 0x2C); + static_assert(sizeof(GRoomRenderItem) == 0x30); // Queues a render item for the current room pass. Returns false when the sphere (cull_pos, radius) - // is frustum culled or the 2048 entry list is full; a null render_fn queues nothing. + // is frustum culled or the list is full; a null render_fn queues nothing. static auto& g_room_render_item_add = addr_as_ref(0x01818A28); static auto& matrix_scale = addr_as_ref(0x01818B48); static auto& one_over_matrix_scale_z = addr_as_ref(0x01818A60); + // Cleared by gr_setup_3d (0x00547150) for an orthographic view, in which rotate_vertex gives every + // vertex the same depth and compute_vertex_codes skips the near and far planes. + static auto& perspective_projection = addr_as_ref(0x005A4D19); static auto& projection_xadd = addr_as_ref(0x01818B54); static auto& projection_xmul = addr_as_ref(0x01818B58); static auto& projection_yadd = addr_as_ref(0x01818B5C); diff --git a/game_patch/rf/misc.h b/game_patch/rf/misc.h index 30b0d95ea..5fe64a940 100644 --- a/game_patch/rf/misc.h +++ b/game_patch/rf/misc.h @@ -20,6 +20,9 @@ namespace rf static auto& gr_use_far_clip = addr_as_ref(0x01818B65); static auto& gr_far_clip_dist = addr_as_ref(0x01818B68); + // gr_far_clip_dist times matrix_scale.z (gr_d3d_setup_frustum 0x00546A40), the scaled view depth + // compute_vertex_codes clips at + static auto& gr_far_clip_dist_scaled = addr_as_ref(0x01818B6C); static auto& gr_set_far_clip = addr_as_ref(0x00518060); static auto& gr_setup_frustum = addr_as_ref(0x00517E70); diff --git a/game_patch/rf/particle_emitter.h b/game_patch/rf/particle_emitter.h index ce3f8b108..6251be1a2 100644 --- a/game_patch/rf/particle_emitter.h +++ b/game_patch/rf/particle_emitter.h @@ -30,9 +30,10 @@ enum ParticleFlags : unsigned PTF_CLR_CHANGE = 0x4, PTF_GRAVITY = 0x8, PTF_COLLIDE = 0x10, + PTF_HIT_CALLBACK = 0x20, PTF_ACCELERATE = 0x40, PTF_EXPLODE = 0x80, - PTF_LOOP = 0x100, + PTF_LOOP = 0x100, // cycle the frame set at 15 fps instead of once over the lifetime PTF_RANDOM_ORIENT = 0x200, PTF_COLLIDE_LIQUID = 0x400, PTF_COLLIDE_AND_DIE = 0x800, @@ -47,7 +48,7 @@ enum ParticleFlags : unsigned enum ParticleFlags2 { PTF2_DAMAGES = 0x1, - PTF2_HOLD_LAST_FRAME = 0x4, + PTF2_HOLD_LAST_FRAME = 0x4, // the frame set finishes at age_pct_to_finish_vbm of the lifetime PTF2_FIRE_DAMAGE = 0x8, PTF2_DAMAGE_FACTOR_MASK = 0xF000, // $damage_factor 0-15 << 12 }; @@ -101,7 +102,7 @@ struct ParticleCreateInfo Color clr_dest; int flags; int flags2; - int age_pct_to_finish_vbm; + float age_pct_to_finish_vbm; int hit_callback; }; static_assert(sizeof(ParticleCreateInfo) == 0x4C); @@ -128,7 +129,7 @@ struct Particle char pool_id; float bitmap_orient; int flags; - int age_pct_to_finish_vbm; + float age_pct_to_finish_vbm; void (*hit_callback)(Vector3 *hit_pos, const Vector3 *vel, Vector3 *normal, float *, int *is_liquid, int *hit_obj_uid); GRoom *room; ParticleEmitter *emitter; @@ -136,6 +137,23 @@ struct Particle }; static_assert(sizeof(Particle) == 0x78); +// Fixed size particle pool (particle_pools_init 0x00494E70). free_list and active_list are sentinel +// nodes: the free list is empty when free_list.next points back at free_list, and particle_create +// silently fails then. Only pool 0's active_list is simulated and drawn, so a pool 1 particle must be +// created into an emitter. +struct ParticlePool +{ + Particle* particles; // +0x00 + int capacity; // +0x04 + Particle free_list; // +0x08 + Particle active_list; // +0x80 the particles no emitter owns + int num_used; // +0xF8 +}; +static_assert(offsetof(ParticlePool, free_list) == 0x08); +static_assert(offsetof(ParticlePool, active_list) == 0x80); +static_assert(offsetof(ParticlePool, num_used) == 0xF8); +static_assert(sizeof(ParticlePool) == 0xFC); + // Underwater plankton mote. Not emitter driven. struct PlanktonParticle { @@ -215,6 +233,9 @@ struct ParticleEmitter AddrCaller{0x00497D80}.c_call(this); } }; +static_assert(offsetof(ParticleEmitter, particle_list) == 0xB0); +static_assert(offsetof(ParticleEmitter, next) == 0x148); +static_assert(offsetof(ParticleEmitter, prev) == 0x14C); static_assert(sizeof(ParticleEmitter) == 0x158); struct BoltInfo @@ -275,36 +296,50 @@ static auto& bolt_emitter_list = addr_as_ref>(0x0064608C); static auto& level_get_particle_emitter_from_uid = addr_as_ref(0x0045D630); static auto& level_get_bolt_emitter_from_uid = addr_as_ref(0x0045D680); -// particle_pools_init 0x00494E70: pool 0 holds 500, pool 1 (every emitter's particles) 1100. -// particle_create silently creates nothing once a pool's free list is empty. Only pool 0's used_list is -// simulated and drawn, so a pool 1 particle must be created into an emitter. -struct ParticlePool -{ - Particle* storage; // +0x00 - int capacity; // +0x04 - Particle free_list; // +0x08 sentinel - Particle used_list; // +0x80 sentinel of the particles no emitter owns - int num_live; // +0xF8 -}; -static_assert(offsetof(ParticlePool, free_list) == 0x08); -static_assert(offsetof(ParticlePool, used_list) == 0x80); -static_assert(offsetof(ParticlePool, num_live) == 0xF8); -static_assert(sizeof(ParticlePool) == 0xFC); -static auto& particle_pools = addr_as_ref(0x007A3B00); - static auto& particle_create = addr_as_ref(0x00496840); +// Pool 0 holds 500 particles, pool 1 holds 1100. Particle::pool_id selects one. +static auto& particle_pools = addr_as_ref(0x007A3B00); + +// List sentinel: only next/prev are meaningful, the rest of the record is not an emitter. +static auto& particle_emitter_list_head = addr_as_ref(0x007BD6E8); + +// Sentinel of the list destroyed emitters hand their live particles to; particle_move_all walks it +// after the pool 0 active list and the emitter lists. +static auto& particle_secondary_list = addr_as_ref(0x007BD670); + +// Both are zeroed by particle_move_all and filled during the simulate and render passes. +static auto& num_particles_simulated_this_frame = addr_as_ref(0x007B2768); +static auto& num_particles_rendered_this_frame = addr_as_ref(0x007B276C); + +// Room pass render item callback of a particle no emitter owns (queued by 0x004967A0): calls particle_render. +static auto& particle_render_item = addr_as_ref(0x00496830); + +// Debug switch: particle_render also draws a bounding box per particle while this is set. +static auto& particle_draw_bbox = addr_as_ref(0x007B2759); + +// Simulates one particle list (a sentinel node): the global list, an emitter list or the secondary list. +static auto& particle_list_move = addr_as_ref(0x00495120); + +// Moves one particle by dt with world collision, leaving pos at the post move position. +static auto& particle_move_collide = addr_as_ref(0x00495880); + // Array of particle emitter type template pointers loaded from emitters.tbl (64 slots) static auto& g_particle_emitter_types = addr_as_ref(0x007B2770); static auto& g_num_particle_emitter_types = addr_as_ref(0x007BD99C); static auto& particle_emitter_type_lookup = addr_as_ref(0x00497550); -// Fixed records for every emitter (pool init 0x004973F0); particle_emitter_create returns null once -// all are in use. -constexpr int particle_emitter_capacity = 128; +// The stock emitter records: a static array of 128 (pool init 0x004973F0) on a circular free list whose +// sentinel is a full record. particle_emitter_create pops its head and returns null when it is empty; +// particle_emitter_destroy appends at its tail. Nothing indexes the array, so records may live anywhere. +constexpr int particle_emitter_stock_capacity = 128; +static auto& particle_emitter_free_list_head = addr_as_ref(0x007BD840); static auto& particle_emitter_num_used = addr_as_ref(0x007BD998); +// The constructor the stock array's static initialiser runs on every record. +static auto& particle_emitter_construct = + addr_as_ref(0x00496FD0); static auto& particle_emitter_create = addr_as_ref(0x00497CA0); diff --git a/game_patch/rf/sound/sound.h b/game_patch/rf/sound/sound.h index d84a2b783..5c4b05ef4 100644 --- a/game_patch/rf/sound/sound.h +++ b/game_patch/rf/sound/sound.h @@ -67,6 +67,13 @@ namespace rf int num_material_sounds[iss_num_material_types]; // +0xA0 int is_all_sounds; // +0xC8 String name; // +0xCC + + // A material's sound list and its count, unclamped; every material gets list 0 when is_all_sounds + // (read as a byte) is set, and an out-of-range material gets null and 0. + void get_material_sounds(int material, const int*& sounds_out, int& count_out) const + { + AddrCaller{0x00413660}.this_call(this, material, &sounds_out, &count_out); + } }; static_assert(sizeof(ImpactSoundSet) == 0xD4); @@ -80,7 +87,9 @@ namespace rf static auto& ds3d_enabled = addr_as_ref(0x01AED340); #ifdef ALPINE_FACTION // In DF sound channels limit has been raised - constexpr int num_sound_channels = 64; + constexpr int num_sound_channels = 127; + // The count is patched into signed 8-bit instruction operands + static_assert(num_sound_channels <= 127); extern SoundInstance sound_instances[num_sound_channels]; #else constexpr int num_sound_channels = 30; diff --git a/game_patch/rf/weapon.h b/game_patch/rf/weapon.h index 5101bf8a9..0e3f5cb61 100644 --- a/game_patch/rf/weapon.h +++ b/game_patch/rf/weapon.h @@ -295,6 +295,8 @@ namespace rf constexpr int max_weapon_types = 64; static auto& weapon_types = addr_as_ref(0x0085CD08); + // Sentinel of the live weapon list, linked through Weapon::next/prev. + static auto& weapon_list = addr_as_ref(0x00872128); static auto& remote_charge_det_weapon_type = addr_as_ref(0x0085CCE0); static auto& machine_pistol_special_weapon_type = addr_as_ref(0x0085CD00); static auto& machine_pistol_weapon_type = addr_as_ref(0x0085CCD8); diff --git a/game_patch/sound/sound.cpp b/game_patch/sound/sound.cpp index 9f21fd314..366f17767 100644 --- a/game_patch/sound/sound.cpp +++ b/game_patch/sound/sound.cpp @@ -250,68 +250,78 @@ int snd_pc_play_3d_new(int handle, const rf::Vector3& pos, float vol_scale, bool } -FunHook snd_play_3d_hook{ - 0x005056A0, - [](int handle, const rf::Vector3& pos, float volume, const rf::Vector3&, int group) { - xlog::trace("snd_play_3d {} {:.2f} {}", handle, volume, group); - - // Upper-bound guard - if (!rf::sound_enabled || handle < 0 || handle >= rf::g_num_sounds) { - return -1; - } - if (rf::snd_load_hint(handle) != 0) { - xlog::warn("Failed to load sound {}", handle); - return -1; - } +static int snd_play_3d_instance(int handle, const rf::Vector3& pos, float volume, int group, bool looping) +{ + xlog::trace("snd_play_3d {} {:.2f} {}", handle, volume, group); + // Upper-bound guard + if (!rf::sound_enabled || handle < 0 || handle >= rf::g_num_sounds) { + return -1; + } + if (rf::snd_load_hint(handle) != 0) { + xlog::warn("Failed to load sound {}", handle); + return -1; + } - bool looping = rf::sounds[handle].is_looping; - int instance_index = snd_get_free_instance(); - if (instance_index < 0) { - return -1; - } + int instance_index = snd_get_free_instance(); + if (instance_index < 0) { + return -1; + } - float base_volume_2d; - float pan; - rf::snd_calculate_2d_from_3d_info(handle, pos, &pan, &base_volume_2d, volume); - // xlog::warn("snd_play_3d vol {:.2f}", vol_scale); - auto& instance = rf::sound_instances[instance_index]; - int sig; - if (rf::ds3d_enabled) { - // Note that snd_pc_play_3d_new calls snd_calculate_2d_from_3d_info on its own - // so use volume here instead of base_volume_2d - float vol_scale = volume * rf::snd_group_volume[group]; - sig = snd_pc_play_3d_new(handle, pos, vol_scale, looping); + float base_volume_2d; + float pan; + rf::snd_calculate_2d_from_3d_info(handle, pos, &pan, &base_volume_2d, volume); + // xlog::warn("snd_play_3d vol {:.2f}", vol_scale); + auto& instance = rf::sound_instances[instance_index]; + int sig; + if (rf::ds3d_enabled) { + // Note that snd_pc_play_3d_new calls snd_calculate_2d_from_3d_info on its own + // so use volume here instead of base_volume_2d + float vol_scale = volume * rf::snd_group_volume[group]; + sig = snd_pc_play_3d_new(handle, pos, vol_scale, looping); + } + else { + float vol_scale = base_volume_2d * rf::snd_group_volume[group]; + if (looping) { + sig = rf::snd_pc_play_looping(handle, vol_scale, pan, 0.0f, true); } else { - float vol_scale = base_volume_2d * rf::snd_group_volume[group]; - if (looping) { - sig = rf::snd_pc_play_looping(handle, vol_scale, pan, 0.0f, true); - } - else { - sig = rf::snd_pc_play(handle, vol_scale, pan, 0.0f, true); - } - } - xlog::trace("snd_play_3d handle {} pos <{:.2f} {:.2f} {:.2f}> vol {:.2f} sig {}", handle, pos.x, pos.y, pos.z, volume, sig); - if (sig < 0) { - return -1; + sig = rf::snd_pc_play(handle, vol_scale, pan, 0.0f, true); } + } + xlog::trace("snd_play_3d handle {} pos <{:.2f} {:.2f} {:.2f}> vol {:.2f} sig {}", handle, pos.x, pos.y, pos.z, + volume, sig); + if (sig < 0) { + return -1; + } - instance.sig = sig; - instance.handle = handle; - instance.group = group; - instance.base_volume = base_volume_2d; - instance.pan = pan; - instance.is_3d_sound = true; - instance.pos = pos; - instance.base_volume_3d = volume; - instance.use_count++; + instance.sig = sig; + instance.handle = handle; + instance.group = group; + instance.base_volume = base_volume_2d; + instance.pan = pan; + instance.is_3d_sound = true; + instance.pos = pos; + instance.base_volume_3d = volume; + instance.use_count++; + + int instance_handle = instance_index | (instance.use_count << 8); + return instance_handle; +} - int instance_handle = instance_index | (instance.use_count << 8); - return instance_handle; +FunHook snd_play_3d_hook{ + 0x005056A0, + [](int handle, const rf::Vector3& pos, float volume, const rf::Vector3&, int group) { + const bool looping = handle >= 0 && handle < rf::g_num_sounds && rf::sounds[handle].is_looping; + return snd_play_3d_instance(handle, pos, volume, group, looping); }, }; +int snd_play_3d_looping(int handle, const rf::Vector3& pos, float volume, int group) +{ + return snd_play_3d_instance(handle, pos, volume, group, true); +} + FunHook snd_change_3d_hook{ 0x005058C0, [](int instance_handle, const rf::Vector3& pos, const rf::Vector3& vel, float volume) { @@ -590,6 +600,26 @@ bool snd_instance_is_playing(int instance_handle) && rf::snd_pc_is_playing(instance.sig); } +void snd_instance_set_pitch(int instance_handle, float ratio) +{ + if (rf::sound_backend != rf::SOUND_BACKEND_DS || !snd_instance_is_playing(instance_handle)) { + return; + } + const int chnl = rf::snd_ds_get_channel(rf::sound_instances[static_cast(instance_handle)].sig); + if (chnl < 0) { + return; + } + IDirectSoundBuffer* buffer = rf::ds_channels[chnl].pdsb; + // Room for a WAVEFORMATEXTENSIBLE: GetFormat fails rather than truncate. + alignas(WAVEFORMATEX) BYTE format[64]{}; + if (!buffer || FAILED(buffer->GetFormat(reinterpret_cast(format), sizeof(format), nullptr))) { + return; + } + const float freq = reinterpret_cast(format)->nSamplesPerSec * ratio; + buffer->SetFrequency(static_cast(std::clamp(freq, static_cast(DSBFREQUENCY_MIN), + static_cast(DSBFREQUENCY_MAX)))); +} + void gamesound_parse_custom_sounds() { // Record first custom sound ID diff --git a/game_patch/sound/sound.h b/game_patch/sound/sound.h index e692ff76d..7a492b700 100644 --- a/game_patch/sound/sound.h +++ b/game_patch/sound/sound.h @@ -25,7 +25,11 @@ int get_jeep_horn_sound_id(); // A snd_play/snd_play_3d instance that is still playing its own sound; stock snd_is_playing answers for // whatever has since reused the slot. bool snd_instance_is_playing(int instance_handle); -void play_local_sound_2d(uint16_t sound_id, int group, float volume); +// snd_play_3d looping whatever the sound's record says: a stock file a level may also play once. +int snd_play_3d_looping(int handle, const rf::Vector3& pos, float volume, int group); +// Plays a playing instance at ratio times its recorded rate (DirectSound only). +void snd_instance_set_pitch(int instance_handle, float ratio); +int play_local_sound_2d(uint16_t sound_id, int group, float volume); void play_local_sound_3d(uint16_t sound_id, rf::Vector3 pos, int group, float volume); void play_chat_sound(std::string_view msg, bool is_taunt); diff --git a/game_patch/sound/sound_ds.cpp b/game_patch/sound/sound_ds.cpp index d0014b59d..d466026a0 100644 --- a/game_patch/sound/sound_ds.cpp +++ b/game_patch/sound/sound_ds.cpp @@ -219,7 +219,7 @@ static FunHook snd_ds_get_channel_hook{ return -1; } int index = static_cast(sig); - if (rf::ds_channels[index].sig != sig) { + if (index >= static_cast(std::size(rf::ds_channels)) || rf::ds_channels[index].sig != sig) { return -1; } return index; diff --git a/resources/CMakeLists.txt b/resources/CMakeLists.txt index 4e1ee6d6b..dbe756262 100644 --- a/resources/CMakeLists.txt +++ b/resources/CMakeLists.txt @@ -388,6 +388,9 @@ add_packfile(alpinefaction.vpp standard_vs:${CMAKE_BINARY_DIR}/shaders/standard_vs.bin character_vs:${CMAKE_BINARY_DIR}/shaders/character_vs.bin transformed_vs:${CMAKE_BINARY_DIR}/shaders/transformed_vs.bin + particle_vs:${CMAKE_BINARY_DIR}/shaders/particle_vs.bin + particle_stretched_vs:${CMAKE_BINARY_DIR}/shaders/particle_stretched_vs.bin + weather_vs:${CMAKE_BINARY_DIR}/shaders/weather_vs.bin shadow_standard_vs:${CMAKE_BINARY_DIR}/shaders/shadow_standard_vs.bin shadow_character_vs:${CMAKE_BINARY_DIR}/shaders/shadow_character_vs.bin outline_character_vs:${CMAKE_BINARY_DIR}/shaders/outline_character_vs.bin diff --git a/resources/shaders/CMakeLists.txt b/resources/shaders/CMakeLists.txt index 2328be465..8d7f83440 100644 --- a/resources/shaders/CMakeLists.txt +++ b/resources/shaders/CMakeLists.txt @@ -23,6 +23,9 @@ add_shader(standard_vs standard_vs.hlsl vs_4_0) add_shader(decoration_vs standard_vs.hlsl vs_4_0 -DINSTANCED) add_shader(character_vs character_vs.hlsl vs_4_0) add_shader(transformed_vs transformed_vs.hlsl vs_4_0) +add_shader(particle_vs particle_vs.hlsl vs_4_0) +add_shader(particle_stretched_vs particle_vs.hlsl vs_4_0 -DSTRETCHED) +add_shader(weather_vs particle_vs.hlsl vs_4_0 -DWEATHER) add_shader(shadow_standard_vs shadow_standard_vs.hlsl vs_4_0) add_shader(shadow_character_vs shadow_character_vs.hlsl vs_4_0) diff --git a/resources/shaders/particle_vs.hlsl b/resources/shaders/particle_vs.hlsl new file mode 100644 index 000000000..8896d8f2e --- /dev/null +++ b/resources/shaders/particle_vs.hlsl @@ -0,0 +1,304 @@ +// Particle quads expanded from one instance record each, with the exact math of the stock CPU paths the +// D3D11 renderer runs them through: gr_d3d_3d_bitmap_angle (billboard) or, with STRETCHED, +// gr_d3d_3d_bitmap_stretched_square (velocity stretched), each followed by Renderer::poly. +// With WEATHER, rain and snow placed from a fixed seed per particle and drawn as the quad +// gr_3d_bitmap_oriented_wh builds. + +// Layout owned by gr_d3d11_particle.cpp (ParticleViewBufferData) +cbuffer ParticleViewBuffer : register(b5) +{ + // gr view matrix rows as rotate_vertex applies them, already scaled by matrix_scale + float3 view_rvec; + // Scaled view depth beyond which compute_vertex_codes clips, when far_clip_on is set + float far_clip_z; + float3 view_uvec; + float far_clip_on; + float3 view_fvec; + float view_pad0; + float3 view_pos; + float view_pad1; + float3 matrix_scale; + float view_pad2; + // Projection: scale_x, scale_y, scale_z, translate_z + float4 proj; +}; + +struct VsOutput +{ + float4 pos : SV_POSITION; + float3 norm : NORMAL; + float4 color : COLOR; + float2 uv0 : TEXCOORD0; + float3 uv1 : TEXCOORD1; + float4 world_pos_and_depth : TEXCOORD2; +}; + +// rotate_vertex 0x00518BF0 +float3 rotate(float3 world_pos) +{ + float3 rel = world_pos - view_pos; + return float3(dot(view_rvec, rel), dot(view_uvec, rel), dot(view_fvec, rel)); +} + +// compute_vertex_codes 0x005475D0 on a rotated vertex: x/y planes, near (z <= 0) and far +uint vertex_codes(float3 v) +{ + return (v.x > v.z ? 0x08u : 0u) | (v.y > v.z ? 0x20u : 0u) | (-v.z > v.x ? 0x04u : 0u) + | (-v.z > v.y ? 0x10u : 0u) | (v.z <= 0.0f ? 0x80u : 0u) + | (far_clip_on > 0.5f && v.z > far_clip_z ? 0x02u : 0u); +} + +#ifdef WEATHER + +struct VsInput +{ + uint vertex_id : SV_VertexID; + uint instance_id : SV_InstanceID; + // xyz: position in the unit box, w: sway phase + float4 seed_pos : INSTANCE0; + // x: angle, y: spin, z: drift x, w: drift z + float4 seed_motion : INSTANCE1; +}; + +// Layout owned by gr_d3d11_particle.cpp (WeatherBufferData) +cbuffer WeatherBuffer : register(b6) +{ + float3 box_min; + // Whole vertical laps the box lattice has made + uint wrap_base; + float3 box_size; + uint salt; + // Box-relative offset the lattice has moved by, in [0, box_size) + float3 box_shift; + float sim_time; + float3 camera_pos; + // Rain streak half width, or snow sprite radius + float half_width; + float3 camera_rvec; + float streak_half_len; + float3 camera_uvec; + float sway_amplitude; + float3 fall_dir; + float sway_speed; + float3 region_center; + // Sway phase advance, reduced to a common period of sin and cos(0.7) + float sway_phase; + float3 region_rvec; + float region_radius; + float3 region_uvec; + float surface_y; + float3 region_fvec; + float column_cell; + float3 region_half_extents; + uint weather_flags; + int2 column_cols; + // A whole number of grids below every column the box covers, so the slot needs no signed modulus + int2 column_origin; + float4 weather_color; +}; + +// Ceiling cache, one slot per world column modulo the grid: x, y the column it holds, z the floor height +Buffer weather_columns : register(t0); + +static const uint weather_snow = 1; +static const uint weather_sphere = 2; +static const uint weather_liquid_surface = 4; +static const uint weather_ceiling = 8; + +// gr_3d_bitmap_oriented_wh corners in poly order: (-w,-h), (+w,-h), (+w,+h), (-w,+h) +static const float2 weather_corner[4] = {float2(-1, -1), float2(1, -1), float2(1, 1), float2(-1, 1)}; +static const float2 weather_corner_uv[4] = {float2(1, 1), float2(0, 1), float2(0, 0), float2(1, 0)}; + +uint pcg_hash(uint v) +{ + uint state = v * 747796405u + 2891336453u; + uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; + return (word >> 22u) ^ word; +} + +float hash01(uint h) +{ + return float(h >> 8) * (1.0f / 16777216.0f); +} + +VsOutput main(VsInput input) +{ + uint key = pcg_hash(input.instance_id + pcg_hash(salt)); + + // Height: the seed carried down by the fall and folded into the box. Every pass through the box is + // a new lap, and a new lap rerolls the lateral position, as a vertical wrap does on the CPU path. + float fall = frac(input.seed_pos.y + hash01(key)) * box_size.y + box_shift.y; + uint lap = fall >= box_size.y ? 1u : 0u; + float local_y = fall - box_size.y * lap; + uint lap_key = pcg_hash(key ^ pcg_hash(wrap_base + lap)); + float2 lateral = frac(input.seed_pos.xz + float2(hash01(lap_key), hash01(pcg_hash(lap_key)))); + lateral = lateral * box_size.xz + box_shift.xz; + + bool snow = (weather_flags & weather_snow) != 0; + if (snow) { + // Closed form of the CPU path's per frame sway: x' = sin(phase) * a, z' = cos(0.7 * phase) * a + float phase = input.seed_pos.w + sway_phase; + float2 sway = sway_speed > 0.0001f + ? float2(-cos(phase), sin(0.7f * phase) / 0.7f) * (sway_amplitude / sway_speed) + : float2(sin(input.seed_pos.w), cos(0.7f * input.seed_pos.w)) * (sway_amplitude * sim_time); + lateral += input.seed_motion.zw * sim_time + sway; + } + lateral -= box_size.xz * floor(lateral / box_size.xz); + float3 pos = box_min + float3(lateral.x, local_y, lateral.y); + + // weather_particle_drawn + bool hidden = (weather_flags & weather_liquid_surface) != 0 && pos.y < surface_y; + float3 rel = pos - region_center; + if (weather_flags & weather_sphere) { + hidden = hidden || dot(rel, rel) > region_radius * region_radius; + } else { + float3 local = abs(float3(dot(rel, region_rvec), dot(rel, region_uvec), dot(rel, region_fvec))); + hidden = hidden || any(local > region_half_extents); + } + // A column not traced yet hides the particle, like the CPU path's lookup without an answer + if (weather_flags & weather_ceiling) { + int2 cell = int2(floor(pos.xz / column_cell)); + uint2 slot = uint2(cell - column_origin) % uint2(column_cols); + uint4 column = weather_columns.Load(slot.x + slot.y * column_cols.x); + hidden = hidden || any(int2(column.xy) != cell) || pos.y < asfloat(column.z); + } + + float3 right; + float3 up; + float half_height; + float3 centre; + if (snow) { + // The camera plane spun by the flake's own angle + float s; + float c; + sincos(input.seed_motion.x + input.seed_motion.y * sim_time, s, c); + right = camera_rvec * c + camera_uvec * s; + up = camera_uvec * c - camera_rvec * s; + half_height = half_width; + centre = pos; + } else { + // A streak that faces the camera while staying aligned with the fall vector + right = cross(fall_dir, camera_pos - pos); + float right_len = length(right); + hidden = hidden || right_len < 0.0001f; + right = right_len > 0.0f ? right / right_len : right; + up = fall_dir; + half_height = streak_half_len; + centre = pos - fall_dir * streak_half_len; + } + + float3 rotated[4]; + uint and_codes = 0xFF; + [unroll] for (int i = 0; i < 4; i++) { + rotated[i] = rotate(centre + right * (weather_corner[i].x * half_width) + + up * (weather_corner[i].y * half_height)); + and_codes &= vertex_codes(rotated[i]); + } + + // Renderer::poly without constant sw: each corner keeps its own depth + uint corner = input.vertex_id & 3; + float3 unscaled = rotated[corner] / matrix_scale; + float w = unscaled.z; + VsOutput output; + // A hidden or rejected quad collapses to one point, which rasterises nothing + output.pos = (hidden || and_codes != 0) ? float4(0, 0, -1, 1) + : float4(unscaled.xy * proj.xy, w * proj.z + proj.w, w); + output.norm = float3(0, 0, 0); // pre-transformed, like transformed_vs + output.color = weather_color; + output.uv0 = weather_corner_uv[corner]; + output.uv1 = float3(0, 0, -1); + output.world_pos_and_depth = float4(0, 0, 0, w); + return output; +} + +#else + +struct VsInput +{ + uint vertex_id : SV_VertexID; + // xyz: world position, w: radius + float4 inst_pos : INSTANCE0; +#ifdef STRETCHED + // xyz: world position the streak trails back to + float4 inst_tail : INSTANCE1; +#else + // Rotated half extents: x = fx * cos, y = fy * sin, z = fx * sin, w = fy * cos + float4 inst_axes : INSTANCE1; +#endif + float4 inst_color : INSTANCE_COLOR; +}; + +static const float2 corner_uv[4] = {float2(0, 0), float2(1, 0), float2(1, 1), float2(0, 1)}; + +#ifdef STRETCHED +// Vector3::normalize 0x004FAB70: a zero vector becomes the x axis +float3 normalize_rf(float3 v) +{ + float len = sqrt(dot(v, v)); + return len <= 0.0f ? float3(1, 0, 0) : v * (1.0f / len); +} +#endif + +VsOutput main(VsInput input) +{ + VsOutput output; + uint corner = input.vertex_id & 3; + float3 rotated[4]; +#ifdef STRETCHED + // A world space square from pos along the streak, turned to face the view direction + float3 pos = input.inst_pos.xyz; + float3 dir = input.inst_tail.xyz - pos; + float3 back = -view_fvec; + float3 right = normalize_rf(cross(back, normalize_rf(dir))); + float3 up = normalize_rf(cross(right, back)); + float half_radius = input.inst_pos.w * 0.5f; + rotated[0] = rotate(pos + up * (dot(up, dir) + half_radius)); + rotated[1] = rotate(pos + right * half_radius); + rotated[2] = rotate(pos - up * half_radius); + rotated[3] = rotate(pos - right * half_radius); +#else + // Camera facing corners around the rotated centre, offset in unscaled view units + float3 centre = rotate(input.inst_pos.xyz); + float dc = input.inst_axes.x; + float e0 = input.inst_axes.y; + float e4 = input.inst_axes.z; + float f2 = input.inst_axes.w; + float2 offsets[4] = {float2(-dc - e0, f2 - e4), float2(dc - e0, f2 + e4), float2(dc + e0, e4 - f2), + float2(e0 - dc, -e4 - f2)}; + [unroll] for (int i = 0; i < 4; i++) { + rotated[i] = float3(offsets[i] * matrix_scale.xy + centre.xy, centre.z); + } +#endif + + // Renderer::poly drops a quad wholly outside one clip plane + uint and_codes = 0xFF; + [unroll] for (int k = 0; k < 4; k++) { + and_codes &= vertex_codes(rotated[k]); + } + + float3 unscaled = rotated[corner] / matrix_scale; +#ifdef STRETCHED + // Each corner keeps its own depth (Renderer::poly without constant sw), so x/z * z needs no divide + float w = unscaled.z; + float2 clip_xy = unscaled.xy * proj.xy; +#else + // One depth for the whole quad, pulled towards the camera by the radius (constant sw) + float depth = centre.z; + float depth_bias = matrix_scale.z * input.inst_pos.w; + if (depth_bias < depth) { + depth -= depth_bias; + } + float w = max(depth / matrix_scale.z, 0.1f); + float2 clip_xy = unscaled.xy * proj.xy / unscaled.z * w; +#endif + // A rejected quad collapses to one point, which rasterises nothing + output.pos = and_codes != 0 ? float4(0, 0, -1, 1) : float4(clip_xy, w * proj.z + proj.w, w); + output.norm = float3(0, 0, 0); // pre-transformed, like transformed_vs + output.color = input.inst_color; + output.uv0 = corner_uv[corner]; + output.uv1 = float3(0, 0, -1); + output.world_pos_and_depth = float4(0, 0, 0, w); + return output; +} + +#endif From 6c606fd8444a4bd94946900074e5a08e20ec34ae Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 15 Oct 2026 01:04:41 -0230 Subject: [PATCH 3/8] fix gnu build --- resources/shaders/particle_vs.hlsl | 68 ++++++++++++++++++------------ 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/resources/shaders/particle_vs.hlsl b/resources/shaders/particle_vs.hlsl index 8896d8f2e..a271ef269 100644 --- a/resources/shaders/particle_vs.hlsl +++ b/resources/shaders/particle_vs.hlsl @@ -48,6 +48,24 @@ uint vertex_codes(float3 v) | (far_clip_on > 0.5f && v.z > far_clip_z ? 0x02u : 0u); } +// One of a quad's four corners by vertex index. A select, not an index into a local array: vkd3d-shader (the GNU +// build's compiler, under Wine) crashes unrolling and propagating through the indexed form. +float3 pick_corner3(uint corner, float3 c0, float3 c1, float3 c2, float3 c3) +{ + return corner == 0 ? c0 : corner == 1 ? c1 : corner == 2 ? c2 : c3; +} + +float2 pick_corner2(uint corner, float2 c0, float2 c1, float2 c2, float2 c3) +{ + return corner == 0 ? c0 : corner == 1 ? c1 : corner == 2 ? c2 : c3; +} + +// Renderer::poly drops a quad wholly outside one clip plane +uint quad_and_codes(float3 c0, float3 c1, float3 c2, float3 c3) +{ + return 0xFF & vertex_codes(c0) & vertex_codes(c1) & vertex_codes(c2) & vertex_codes(c3); +} + #ifdef WEATHER struct VsInput @@ -187,17 +205,15 @@ VsOutput main(VsInput input) centre = pos - fall_dir * streak_half_len; } - float3 rotated[4]; - uint and_codes = 0xFF; - [unroll] for (int i = 0; i < 4; i++) { - rotated[i] = rotate(centre + right * (weather_corner[i].x * half_width) - + up * (weather_corner[i].y * half_height)); - and_codes &= vertex_codes(rotated[i]); - } + float3 r0 = rotate(centre + right * (weather_corner[0].x * half_width) + up * (weather_corner[0].y * half_height)); + float3 r1 = rotate(centre + right * (weather_corner[1].x * half_width) + up * (weather_corner[1].y * half_height)); + float3 r2 = rotate(centre + right * (weather_corner[2].x * half_width) + up * (weather_corner[2].y * half_height)); + float3 r3 = rotate(centre + right * (weather_corner[3].x * half_width) + up * (weather_corner[3].y * half_height)); + uint and_codes = quad_and_codes(r0, r1, r2, r3); // Renderer::poly without constant sw: each corner keeps its own depth uint corner = input.vertex_id & 3; - float3 unscaled = rotated[corner] / matrix_scale; + float3 unscaled = pick_corner3(corner, r0, r1, r2, r3) / matrix_scale; float w = unscaled.z; VsOutput output; // A hidden or rejected quad collapses to one point, which rasterises nothing @@ -205,7 +221,8 @@ VsOutput main(VsInput input) : float4(unscaled.xy * proj.xy, w * proj.z + proj.w, w); output.norm = float3(0, 0, 0); // pre-transformed, like transformed_vs output.color = weather_color; - output.uv0 = weather_corner_uv[corner]; + output.uv0 = pick_corner2(corner, weather_corner_uv[0], weather_corner_uv[1], weather_corner_uv[2], + weather_corner_uv[3]); output.uv1 = float3(0, 0, -1); output.world_pos_and_depth = float4(0, 0, 0, w); return output; @@ -243,7 +260,10 @@ VsOutput main(VsInput input) { VsOutput output; uint corner = input.vertex_id & 3; - float3 rotated[4]; + float3 r0; + float3 r1; + float3 r2; + float3 r3; #ifdef STRETCHED // A world space square from pos along the streak, turned to face the view direction float3 pos = input.inst_pos.xyz; @@ -252,10 +272,10 @@ VsOutput main(VsInput input) float3 right = normalize_rf(cross(back, normalize_rf(dir))); float3 up = normalize_rf(cross(right, back)); float half_radius = input.inst_pos.w * 0.5f; - rotated[0] = rotate(pos + up * (dot(up, dir) + half_radius)); - rotated[1] = rotate(pos + right * half_radius); - rotated[2] = rotate(pos - up * half_radius); - rotated[3] = rotate(pos - right * half_radius); + r0 = rotate(pos + up * (dot(up, dir) + half_radius)); + r1 = rotate(pos + right * half_radius); + r2 = rotate(pos - up * half_radius); + r3 = rotate(pos - right * half_radius); #else // Camera facing corners around the rotated centre, offset in unscaled view units float3 centre = rotate(input.inst_pos.xyz); @@ -263,20 +283,14 @@ VsOutput main(VsInput input) float e0 = input.inst_axes.y; float e4 = input.inst_axes.z; float f2 = input.inst_axes.w; - float2 offsets[4] = {float2(-dc - e0, f2 - e4), float2(dc - e0, f2 + e4), float2(dc + e0, e4 - f2), - float2(e0 - dc, -e4 - f2)}; - [unroll] for (int i = 0; i < 4; i++) { - rotated[i] = float3(offsets[i] * matrix_scale.xy + centre.xy, centre.z); - } + r0 = float3(float2(-dc - e0, f2 - e4) * matrix_scale.xy + centre.xy, centre.z); + r1 = float3(float2(dc - e0, f2 + e4) * matrix_scale.xy + centre.xy, centre.z); + r2 = float3(float2(dc + e0, e4 - f2) * matrix_scale.xy + centre.xy, centre.z); + r3 = float3(float2(e0 - dc, -e4 - f2) * matrix_scale.xy + centre.xy, centre.z); #endif - // Renderer::poly drops a quad wholly outside one clip plane - uint and_codes = 0xFF; - [unroll] for (int k = 0; k < 4; k++) { - and_codes &= vertex_codes(rotated[k]); - } - - float3 unscaled = rotated[corner] / matrix_scale; + uint and_codes = quad_and_codes(r0, r1, r2, r3); + float3 unscaled = pick_corner3(corner, r0, r1, r2, r3) / matrix_scale; #ifdef STRETCHED // Each corner keeps its own depth (Renderer::poly without constant sw), so x/z * z needs no divide float w = unscaled.z; @@ -295,7 +309,7 @@ VsOutput main(VsInput input) output.pos = and_codes != 0 ? float4(0, 0, -1, 1) : float4(clip_xy, w * proj.z + proj.w, w); output.norm = float3(0, 0, 0); // pre-transformed, like transformed_vs output.color = input.inst_color; - output.uv0 = corner_uv[corner]; + output.uv0 = pick_corner2(corner, corner_uv[0], corner_uv[1], corner_uv[2], corner_uv[3]); output.uv1 = float3(0, 0, -1); output.world_pos_and_depth = float4(0, 0, 0, w); return output; From 2a2d493498eb6716ded090d177decab7276e36ef Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 15 Oct 2026 23:31:08 -0230 Subject: [PATCH 4/8] fix kill sound pitch --- game_patch/misc/player.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/game_patch/misc/player.cpp b/game_patch/misc/player.cpp index 53f08c859..66ceac486 100644 --- a/game_patch/misc/player.cpp +++ b/game_patch/misc/player.cpp @@ -768,8 +768,8 @@ void play_local_hit_sound(bool died) { hit_sound_timestamp.invalidate(); } - // Each hit within the damage number merge window of the last climbs a step in pitch; a kill tops the streak - // and ends it. + // Each hit within the damage number merge window of the last climbs a step in pitch; a kill ends the streak and + // always plays at its own pitch. constexpr float streak_pitch_step = 0.06f; constexpr int streak_max_steps = 6; static rf::TimestampRealtime streak_timestamp; @@ -781,7 +781,7 @@ void play_local_hit_sound(bool died) { } const int instance = play_local_sound_2d(get_custom_sound_id(died ? 3 : 2), 0, 1.0f); - if (g_alpine_game_config.hit_sound_streak_pitch && streak > 0) { + if (g_alpine_game_config.hit_sound_streak_pitch && !died && streak > 0) { snd_instance_set_pitch(instance, 1.0f + streak_pitch_step * streak); } last_hit_sound_was_died = died; From 121cbecc030bb9ab03defc4090ec943ac91ffdac Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 16 Oct 2026 01:30:32 -0230 Subject: [PATCH 5/8] do not build shaders or vpp on gnu ci builds --- .github/workflows/ci.yml | 12 +++++------- CMakeLists.txt | 2 ++ docs/BUILDING.md | 3 +++ resources/CMakeLists.txt | 7 ++++++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad29422f5..1f7891e4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,19 +28,20 @@ jobs: - name: Install packages run: | - dpkg --add-architecture i386 apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - g++-mingw-w64-i686-posix ninja-build wine32 wine cmake ca-certificates + g++-mingw-w64-i686-posix ninja-build cmake ca-certificates - name: Create a build environment run: cmake -E make_directory ./build + # Shaders would need Wine + vkd3d, which miscompiles them; the packfile ships from the MSVC job - name: Configure CMake working-directory: ./build run: | cmake .. \ -G Ninja \ + -D ALPINE_BUILD_PACKFILE=OFF \ -D CMAKE_C_COMPILER_LAUNCHER=sccache \ -D CMAKE_CXX_COMPILER_LAUNCHER=sccache \ -D CMAKE_BUILD_TYPE=$BUILD_TYPE \ @@ -48,10 +49,7 @@ jobs: - name: Build working-directory: ./build - run: | - export WINEPREFIX=$GITHUB_WORKSPACE/.wine - mkdir -p $WINEPREFIX - cmake --build . -j $(nproc) + run: cmake --build . -j $(nproc) - name: Stage artifacts shell: bash @@ -60,7 +58,7 @@ jobs: out="$GITHUB_WORKSPACE/dist/gnu" mkdir -p "$out" - want=(AlpineFaction.dll AlpineEditor.dll AlpineFactionLauncher.exe CrashHandler.exe d3d8to9.dll alpinefaction.vpp) + want=(AlpineFaction.dll AlpineEditor.dll AlpineFactionLauncher.exe CrashHandler.exe d3d8to9.dll) for f in "${want[@]}"; do src="$(find "$GITHUB_WORKSPACE/build" -type f -name "$f" -print -quit || true)" diff --git a/CMakeLists.txt b/CMakeLists.txt index 980e9d722..cd0f8eca5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,6 +10,8 @@ set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +option(ALPINE_BUILD_PACKFILE "Compile the shaders and build alpinefaction.vpp" ON) + # Set our target version to Windows 7. add_compile_definitions( _WIN32_WINNT=0x0601 diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 40748f40f..51c07e285 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -44,6 +44,9 @@ cd build cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/mingw-ubuntu.cmake -DCMAKE_BUILD_TYPE=Release ``` +Compiling the shaders and `alpinefaction.vpp` runs the shader compiler under Wine (vkd3d-shader). Add +`-DALPINE_BUILD_PACKFILE=OFF` to skip them and build only the binaries; take `alpinefaction.vpp` from a Windows build. + Start build: ``` diff --git a/resources/CMakeLists.txt b/resources/CMakeLists.txt index dbe756262..47d90ed90 100644 --- a/resources/CMakeLists.txt +++ b/resources/CMakeLists.txt @@ -5,6 +5,9 @@ else() endif() function(add_packfile packfile_name) + if(NOT ALPINE_BUILD_PACKFILE) + return() + endif() list(TRANSFORM ARGN REPLACE "([^:]*):.*" "\\1" OUTPUT_VARIABLE input_deps) list(TRANSFORM ARGN REPLACE "[^:]*:(.*)" "\\1" OUTPUT_VARIABLE input_paths) @@ -427,4 +430,6 @@ add_custom_command( ) add_custom_target(TermsTxt ALL DEPENDS ${CMAKE_BINARY_DIR}/bin/terms.txt) -add_subdirectory(shaders) +if(ALPINE_BUILD_PACKFILE) + add_subdirectory(shaders) +endif() From 095773984ee98393d12509ba5e88245916b17b2c Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 18 Oct 2026 09:53:43 -0230 Subject: [PATCH 6/8] add shell casings controls --- docs/CHANGELOG.md | 2 + game_patch/main/main.cpp | 1 + game_patch/misc/alpine_settings.cpp | 20 +++++ game_patch/misc/alpine_settings.h | 10 +++ game_patch/object/entity.cpp | 131 ++++++++++++++++++++++++++++ game_patch/object/object.h | 1 + 6 files changed, 165 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 30cd99cc8..8000361cc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -130,6 +130,8 @@ Version 1.5.0 (Trillium): Not yet released - Add `dbg_particles` overlay and `dbg_particles_log` to measure particle performance, and `dbg_particlewarmup` to toggle replaying a hidden particle emitter when it comes back into view - Raise the pitch of hit sounds as consecutive hits land, toggleable with `cl_hitsounds_streakpitch` - Raise the limit of simultaneously playing sounds from 64 to 127 +- Ejected shell casings now bounce off the level and play their impact sounds instead of falling through it, toggleable with `cl_shellcollide`, and last 2 seconds by default, adjustable with `cl_shelllifetime` +- Add `cl_shellcasings` and `cl_magazines` to toggle ejected shell casings and magazines [@nickalreadyinuse](https://github.com/nickalreadyinuse) - Add `ui_color_console` console command to set the console background color diff --git a/game_patch/main/main.cpp b/game_patch/main/main.cpp index c661e0820..2d25ca3a3 100644 --- a/game_patch/main/main.cpp +++ b/game_patch/main/main.cpp @@ -376,6 +376,7 @@ FunHook level_init_post_hook{ // After gametype init so the demo snapshot includes koth/bagman/salvage/pit state demo_server_on_level_init_post(); gib_flames_level_init(); + shell_casings_level_init(); // Multiplayer resets the jetpack from its own level-init injection. if (!rf::is_multi) { jetpack_level_init(); diff --git a/game_patch/misc/alpine_settings.cpp b/game_patch/misc/alpine_settings.cpp index e674da3a2..6ca69a869 100644 --- a/game_patch/misc/alpine_settings.cpp +++ b/game_patch/misc/alpine_settings.cpp @@ -348,6 +348,22 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.gib_flames = std::stoi(settings["GibFlames"]); processed_keys.insert("GibFlames"); } + if (settings.count("ShellCasings")) { + g_alpine_game_config.shell_casings = std::stoi(settings["ShellCasings"]); + processed_keys.insert("ShellCasings"); + } + if (settings.count("ShellCasingsCollide")) { + g_alpine_game_config.shell_casings_collide = std::stoi(settings["ShellCasingsCollide"]); + processed_keys.insert("ShellCasingsCollide"); + } + if (settings.count("ShellCasingLifetimeMs")) { + g_alpine_game_config.set_shell_casing_lifetime_ms(std::stoi(settings["ShellCasingLifetimeMs"])); + processed_keys.insert("ShellCasingLifetimeMs"); + } + if (settings.count("EjectedMagazines")) { + g_alpine_game_config.ejected_magazines = std::stoi(settings["EjectedMagazines"]); + processed_keys.insert("EjectedMagazines"); + } if (settings.count("ShowFPGun")) { player->settings.render_fpgun = std::stoi(settings["ShowFPGun"]); @@ -1574,6 +1590,10 @@ void alpine_player_settings_save(rf::Player* player) file << "GibLifetimeMs=" << g_alpine_game_config.gib_lifetime_ms << "\n"; file << "FreelookCamSlide=" << g_alpine_game_config.freelook_cam_slide << "\n"; file << "GibFlames=" << g_alpine_game_config.gib_flames << "\n"; + file << "ShellCasings=" << g_alpine_game_config.shell_casings << "\n"; + file << "ShellCasingsCollide=" << g_alpine_game_config.shell_casings_collide << "\n"; + file << "ShellCasingLifetimeMs=" << g_alpine_game_config.shell_casing_lifetime_ms << "\n"; + file << "EjectedMagazines=" << g_alpine_game_config.ejected_magazines << "\n"; file << "ShowFPGun=" << player->settings.render_fpgun << "\n"; file << "AutoswitchWeapons=" << player->settings.autoswitch_weapons << "\n"; file << "NeverAutoswitchExplosives=" << player->settings.dont_autoswitch_to_explosives << "\n"; diff --git a/game_patch/misc/alpine_settings.h b/game_patch/misc/alpine_settings.h index 3a876b0d4..c0597284b 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -404,6 +404,16 @@ struct AlpineGameSettings freelook_cam_slide = std::clamp(value, min_freelook_cam_slide, max_freelook_cam_slide); } bool gib_flames = true; + bool shell_casings = true; + bool shell_casings_collide = true; + static constexpr int min_shell_casing_lifetime_ms = 1000; + static constexpr int max_shell_casing_lifetime_ms = 10000; + int shell_casing_lifetime_ms = 2000; + void set_shell_casing_lifetime_ms(int lifetime_ms) + { + shell_casing_lifetime_ms = std::clamp(lifetime_ms, min_shell_casing_lifetime_ms, max_shell_casing_lifetime_ms); + } + bool ejected_magazines = true; bool real_armor_values = false; bool always_show_spectators = false; RemoteServerCfgPopup::DisplayMode remote_server_cfg_display_mode = diff --git a/game_patch/object/entity.cpp b/game_patch/object/entity.cpp index 366881c05..7c0157a59 100644 --- a/game_patch/object/entity.cpp +++ b/game_patch/object/entity.cpp @@ -3,8 +3,11 @@ #include #include #include +#include #include #include +#include +#include #include #include #include @@ -700,6 +703,124 @@ ConsoleCommand2 cl_gibflames_cmd{ "Toggle flames on gib chunks", }; +// The live shell casings, oldest first: past the cap the oldest still alive goes. +struct ShellCasingRecord +{ + int handle; + int64_t spawn_ms; +}; +static std::deque g_shell_casings; +static constexpr std::size_t max_live_shell_casings = 500; + +void shell_casings_level_init() +{ + g_shell_casings.clear(); +} + +// Spawns one ejected shell casing as cosmetic local debris. +FunHook entity_eject_shell_hook{ + 0x0042A1D0, + [](rf::Entity* ep) { + if (rf::is_dedicated_server || !g_alpine_game_config.shell_casings) { + return; + } + // debris_create appends to the debris list, so a casing made here is its new tail. + const rf::Debris* const tail_before = rf::debris_list.prev; + entity_eject_shell_hook.call_target(ep); + const rf::Debris* const tail = rf::debris_list.prev; + if (tail == tail_before || tail == &rf::debris_list) { + return; + } + const int64_t now = timer::get_i64(1000); + g_shell_casings.push_back({tail->handle, now}); + while (g_shell_casings.size() > max_live_shell_casings) { + const ShellCasingRecord oldest = g_shell_casings.front(); + g_shell_casings.pop_front(); + // Older than any casing lives: already gone, and its handle may since name another object. + if (now - oldest.spawn_ms > AlpineGameSettings::max_shell_casing_lifetime_ms) { + continue; + } + rf::Object* casing = rf::obj_from_handle(oldest.handle); + if (casing && casing->type == rf::OT_DEBRIS) { + rf::obj_flag_dead(casing); + } + } + }, +}; + +// Just past the stock lifetime (0.8 s, 1.2 s for a player's) going into the casing's DebrisCreateStruct. +CodeInjection entity_eject_shell_lifetime_injection{ + 0x0042A4F0, + [](auto& regs) { + addr_as_ref(regs.esp + 0x64) = g_alpine_game_config.shell_casing_lifetime_ms; + }, +}; + +// Keep the PF_COLLIDE_WORLD flag instead of removing it like the stock function does. +CodeInjection entity_eject_shell_collide_injection{ + 0x0042A565, + [](auto& regs) { + if (g_alpine_game_config.shell_casings_collide) { + regs.edx = static_cast(regs.edx) | rf::PF_COLLIDE_WORLD; + } + }, +}; + +// Spawns the local player's ejected magazine as debris, from the reload's eject timer. +FunHook entity_eject_magazine_hook{ + 0x00419DF0, + [](rf::Entity* ep, rf::WeaponInfo* info) { + if (g_alpine_game_config.ejected_magazines) { + entity_eject_magazine_hook.call_target(ep, info); + } + }, +}; + +ConsoleCommand2 cl_shellcasings_cmd{ + "cl_shellcasings", + [](std::optional enabled) { + g_alpine_game_config.shell_casings = enabled.value_or(!g_alpine_game_config.shell_casings); + rf::console::print("Ejected shell casings are {}", + g_alpine_game_config.shell_casings ? "enabled" : "disabled"); + }, + "Toggle shell casings ejected by firing weapons", + "cl_shellcasings [bool]", +}; + +ConsoleCommand2 cl_shellcollide_cmd{ + "cl_shellcollide", + [](std::optional enabled) { + g_alpine_game_config.shell_casings_collide = enabled.value_or(!g_alpine_game_config.shell_casings_collide); + rf::console::print("Shell casings colliding with the level is {}", + g_alpine_game_config.shell_casings_collide ? "enabled" : "disabled"); + }, + "Toggle ejected shell casings bouncing off the level instead of falling through it", + "cl_shellcollide [bool]", +}; + +ConsoleCommand2 cl_shelllifetime_cmd{ + "cl_shelllifetime", + [](std::optional lifetime_s) { + if (lifetime_s && std::isfinite(*lifetime_s)) { + g_alpine_game_config.set_shell_casing_lifetime_ms(static_cast(std::lround(*lifetime_s * 1000.0f))); + } + rf::console::print("Shell casing lifetime is {:.1f} s", + g_alpine_game_config.shell_casing_lifetime_ms / 1000.0f); + }, + "Set ejected shell casing lifetime in seconds (1-10)", + "cl_shelllifetime [seconds]", +}; + +ConsoleCommand2 cl_magazines_cmd{ + "cl_magazines", + [](std::optional enabled) { + g_alpine_game_config.ejected_magazines = enabled.value_or(!g_alpine_game_config.ejected_magazines); + rf::console::print("Ejected magazines are {}", g_alpine_game_config.ejected_magazines ? "enabled" : "disabled"); + }, + "Toggle magazines ejected when you reload", + "cl_magazines [bool]", +}; + // makes some entities red - unfinished CodeInjection player_create_entity_patch { 0x004A4234, @@ -966,6 +1087,12 @@ void entity_do_patch() // in entity_eject_shell. Fixed debris pool being exhausted when local player is dead. AsmWriter(0x0042A223, 0x0042A232).mov(asm_regs::ecx, {&rf::local_player}); + // Toggle ejected shell casings and magazines, and let casings bounce off the level + entity_eject_shell_hook.install(); + entity_eject_shell_lifetime_injection.install(); + entity_eject_shell_collide_injection.install(); + entity_eject_magazine_hook.install(); + // Fix move sound not being muted if entity is created hidden (example: jeep in L18S3) entity_process_post_hidden_injection.install(); @@ -1003,6 +1130,10 @@ void entity_do_patch() cl_gibvelocityscale_cmd.register_cmd(); cl_giblifetimems_cmd.register_cmd(); cl_gibflames_cmd.register_cmd(); + cl_shellcasings_cmd.register_cmd(); + cl_shellcollide_cmd.register_cmd(); + cl_shelllifetime_cmd.register_cmd(); + cl_magazines_cmd.register_cmd(); cl_painsounds_cmd.register_cmd(); cl_footsteps_cmd.register_cmd(); } diff --git a/game_patch/object/object.h b/game_patch/object/object.h index 137a109b1..d202da529 100644 --- a/game_patch/object/object.h +++ b/game_patch/object/object.h @@ -38,6 +38,7 @@ void set_alpine_respawn_point_teams(rf::AlpineRespawnPoint* point, bool red, boo std::vector get_alpine_respawn_points(); void entity_set_gib_flag(rf::Entity* ep); void gib_flames_level_init(); +void shell_casings_level_init(); void riot_shield_apply_remote_state(rf::Entity* ep, float life, const rf::Vector3& impact_pos); void riot_shield_do_frame(); void riot_shield_on_player_spawn(rf::Player* player); From 97c5247fb4d28e15e3853ef25185315198261892 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 18 Oct 2026 11:42:00 -0230 Subject: [PATCH 7/8] fix scraping damage --- game_patch/multi/alpine_packets.cpp | 40 +-- game_patch/multi/alpine_packets.h | 18 +- game_patch/multi/server_internal.h | 3 +- .../multi/vehicles/physics/vphys_internal.h | 11 +- .../multi/vehicles/physics/vphys_step.cpp | 125 ++++++++- .../multi/vehicles/physics/vphys_world.cpp | 26 +- game_patch/multi/vehicles/vehicle.cpp | 52 ++++ game_patch/multi/vehicles/vehicle.h | 38 ++- .../multi/vehicles/vehicle_crash_tuning.h | 81 ++++-- game_patch/multi/vehicles/vehicle_damage.cpp | 248 +++++++++++++++--- game_patch/multi/vehicles/vehicle_fx.cpp | 130 ++++++--- game_patch/multi/vehicles/vehicle_internal.h | 33 ++- game_patch/multi/vehicles/vehicle_physics.h | 21 ++ game_patch/multi/vehicles/vehicle_seats.cpp | 1 - game_patch/multi/vehicles/vehicle_sync.cpp | 17 +- resources/CMakeLists.txt | 1 + resources/licensing-info.txt | 3 + resources/sounds/af_fighter_scrape_loop.wav | Bin 0 -> 106766 bytes 18 files changed, 706 insertions(+), 142 deletions(-) create mode 100644 resources/sounds/af_fighter_scrape_loop.wav diff --git a/game_patch/multi/alpine_packets.cpp b/game_patch/multi/alpine_packets.cpp index 8cd132b1a..32bc4a4c2 100644 --- a/game_patch/multi/alpine_packets.cpp +++ b/game_patch/multi/alpine_packets.cpp @@ -1981,6 +1981,9 @@ static void af_process_client_req_packet(const void* data, size_t len, const rf: vehicle_server_handle_crash_report(player, req.vehicle_handle, req.kind == AF_VEHICLE_CRUSH_GROUND, req.impact_dv); } + else if (req.kind == AF_VEHICLE_CRUSH_SCRAPE) { + vehicle_server_handle_scrape_report(player, req.vehicle_handle, req.impact_dv); + } break; } case af_client_req_type::af_req_stats_pssk: { @@ -3988,10 +3991,12 @@ void af_process_vehicle_orient_packet(const void* data, size_t len, const rf::Ne static void build_af_vehicle_health_packet(af_vehicle_health_packet& pkt, int vehicle_handle, float life, float max_life, int primary_ammo, int secondary_ammo, - const uint16_t refill_ms[2], const rf::Vector3* hit_dir = nullptr) + const uint16_t refill_ms[2], const rf::Vector3* hit_dir = nullptr, + uint8_t flags = 0) { pkt.header.type = static_cast(af_packet_type::af_vehicle_health); pkt.header.size = static_cast(sizeof(pkt) - sizeof(RF_GamePacketHeader)); + pkt.flags = flags; pkt.vehicle_handle = vehicle_handle; pkt.life = life; pkt.max_life = max_life; @@ -4027,7 +4032,7 @@ void af_send_vehicle_health_packet(rf::Player* player, int vehicle_handle, float // server -> everyone, not just occupants: spectator HUDs and the demo recorder need it too void af_send_vehicle_health_packet_to_all(int vehicle_handle, float life, float max_life, int primary_ammo, int secondary_ammo, const uint16_t refill_ms[2], - bool is_reliable, const rf::Vector3* hit_dir) + bool is_reliable, const rf::Vector3* hit_dir, uint8_t flags) { if (!rf::is_server) { return; @@ -4035,7 +4040,7 @@ void af_send_vehicle_health_packet_to_all(int vehicle_handle, float life, float af_vehicle_health_packet pkt{}; build_af_vehicle_health_packet(pkt, vehicle_handle, life, max_life, primary_ammo, secondary_ammo, refill_ms, - hit_dir); + hit_dir, flags); af_broadcast_to_af_clients(&pkt, sizeof(pkt), is_reliable); } @@ -4056,7 +4061,8 @@ void af_process_vehicle_health_packet(const void* data, size_t len, const rf::Ne const bool has_hit_dir = pkt.hit_dir[0] != 0 || pkt.hit_dir[1] != 0 || pkt.hit_dir[2] != 0; vehicle_store_health_from_packet(pkt.vehicle_handle, pkt.life, pkt.max_life, pkt.primary_ammo, pkt.secondary_ammo, pkt.refill_ms[0], pkt.refill_ms[1], - has_hit_dir ? &hit_dir : nullptr); + has_hit_dir ? &hit_dir : nullptr, + (pkt.flags & AF_VEHICLE_HEALTH_SCRAPE_ONLY) != 0); } static void build_af_vehicle_factory_state_packet(af_vehicle_factory_state_packet& pkt, @@ -4138,7 +4144,7 @@ void af_send_vehicle_use_request(int vehicle_handle, uint8_t seat_index) af_send_client_req_packet(packet, true); // reliable } -void af_send_vehicle_crush_report(int vehicle_handle, int victim_handle) +static void af_send_vehicle_crush_req(const VehicleCrushReqPayload& payload) { if (!rf::is_multi || rf::is_server) { return; @@ -4148,25 +4154,25 @@ void af_send_vehicle_crush_report(int vehicle_handle, int victim_handle) packet.header.type = static_cast(af_packet_type::af_client_req); packet.header.size = sizeof(uint8_t) + sizeof(VehicleCrushReqPayload); packet.req_type = af_client_req_type::af_req_vehicle_crush; - packet.payload = VehicleCrushReqPayload{vehicle_handle, victim_handle, AF_VEHICLE_CRUSH_RUNOVER, 0.0f}; + packet.payload = payload; af_send_client_req_packet(packet, true); // reliable } -void af_send_vehicle_crash_report(int vehicle_handle, bool ground, float impact_dv) +void af_send_vehicle_crush_report(int vehicle_handle, int victim_handle) { - if (!rf::is_multi || rf::is_server) { - return; - } + af_send_vehicle_crush_req({vehicle_handle, victim_handle, AF_VEHICLE_CRUSH_RUNOVER, 0.0f}); +} - af_client_req_packet packet{}; - packet.header.type = static_cast(af_packet_type::af_client_req); - packet.header.size = sizeof(uint8_t) + sizeof(VehicleCrushReqPayload); - packet.req_type = af_client_req_type::af_req_vehicle_crush; - packet.payload = VehicleCrushReqPayload{ - vehicle_handle, -1, ground ? AF_VEHICLE_CRUSH_GROUND : AF_VEHICLE_CRUSH_WALL, impact_dv}; +void af_send_vehicle_crash_report(int vehicle_handle, bool ground, float impact_dv) +{ + af_send_vehicle_crush_req( + {vehicle_handle, -1, ground ? AF_VEHICLE_CRUSH_GROUND : AF_VEHICLE_CRUSH_WALL, impact_dv}); +} - af_send_client_req_packet(packet, true); // reliable +void af_send_vehicle_scrape_report(int vehicle_handle, float slide_excess) +{ + af_send_vehicle_crush_req({vehicle_handle, -1, AF_VEHICLE_CRUSH_SCRAPE, slide_excess}); } void af_send_koth_hill_captured_packet(rf::Player* player, uint8_t hill_uid, HillOwner owner, const std::vector& new_owner_player_ids) diff --git a/game_patch/multi/alpine_packets.h b/game_patch/multi/alpine_packets.h index 15e76b545..577e64749 100644 --- a/game_patch/multi/alpine_packets.h +++ b/game_patch/multi/alpine_packets.h @@ -344,8 +344,9 @@ static_assert(sizeof(VehicleUseReqPayload) == 5); enum af_vehicle_crush_kind : uint8_t { AF_VEHICLE_CRUSH_RUNOVER = 0, // victim_handle names who the hull ran over - AF_VEHICLE_CRUSH_WALL = 1, // impact_dv: the hull hit the level or a mover + AF_VEHICLE_CRUSH_WALL = 1, // impact_dv: the hull hit the level, a mover or a prop AF_VEHICLE_CRUSH_GROUND = 2, // impact_dv: the hull landed + AF_VEHICLE_CRUSH_SCRAPE = 3, // impact_dv: units of slide past the class's free speed, in wall terms }; // Roadkill or hull impact report from the hull's driver; handles are server handles, and the server @@ -355,7 +356,7 @@ struct VehicleCrushReqPayload int32_t vehicle_handle = -1; int32_t victim_handle = -1; // -1 unless kind is AF_VEHICLE_CRUSH_RUNOVER uint8_t kind = AF_VEHICLE_CRUSH_RUNOVER; - float impact_dv = 0.0f; // u/s along the contact normal; 0 for a run-over + float impact_dv = 0.0f; // u/s along the contact normal, or a scrape's slide; 0 for a run-over }; static_assert(sizeof(VehicleCrushReqPayload) == 13); @@ -699,8 +700,15 @@ struct af_vehicle_health_packet int32_t secondary_ammo; int8_t hit_dir[3]; // world travel direction of the latest attributed hit, x127; all zero = none uint16_t refill_ms[2]; // per weapon slot, ms until its ammo refills; 0 = none pending + uint8_t flags; // af_vehicle_health_flags +}; +static_assert(sizeof(af_vehicle_health_packet) == sizeof(RF_GamePacketHeader) + 28); + +enum af_vehicle_health_flags : uint8_t +{ + // Every blow noted on the hull since the last packet was scrape damage: the riders get no damage flash. + AF_VEHICLE_HEALTH_SCRAPE_ONLY = 0x1, }; -static_assert(sizeof(af_vehicle_health_packet) == sizeof(RF_GamePacketHeader) + 27); // Hull angles the stock obj_update row has no slot for; tick keys them to the matching ObjInterp keyframe. struct af_vehicle_orient_packet @@ -1072,7 +1080,8 @@ void af_send_vehicle_health_packet(rf::Player* player, int vehicle_handle, float int primary_ammo, int secondary_ammo, const uint16_t refill_ms[2]); void af_send_vehicle_health_packet_to_all(int vehicle_handle, float life, float max_life, int primary_ammo, int secondary_ammo, const uint16_t refill_ms[2], - bool is_reliable, const rf::Vector3* hit_dir = nullptr); + bool is_reliable, const rf::Vector3* hit_dir = nullptr, + uint8_t flags = 0); void af_process_vehicle_health_packet(const void* data, size_t len, const rf::NetAddr&); // client -> server: the hull pitch/bank of the vehicle the local player drives void af_send_vehicle_orient_request(int vehicle_handle, uint16_t tick, int16_t pitch, int16_t bank, @@ -1129,6 +1138,7 @@ void af_send_stats_pssk(const std::string& pssk); void af_send_vehicle_use_request(int vehicle_handle, uint8_t seat_index); void af_send_vehicle_crush_report(int vehicle_handle, int victim_handle); void af_send_vehicle_crash_report(int vehicle_handle, bool ground, float impact_dv); +void af_send_vehicle_scrape_report(int vehicle_handle, float slide_excess); // vote system (client -> server) void af_send_vote_call(const AfVoteCallParams& params); diff --git a/game_patch/multi/server_internal.h b/game_patch/multi/server_internal.h index 52c2c5660..f356408bd 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -581,7 +581,8 @@ struct GibConfig struct VehicleConfig { - // Hulls take damage from hitting the level or a mover hard, and from hard landings. + // Hulls take damage from hitting the level, a mover or a prop hard, from hard landings, and (the fighter) + // from scraping along the level. bool crash_damage = true; }; diff --git a/game_patch/multi/vehicles/physics/vphys_internal.h b/game_patch/multi/vehicles/physics/vphys_internal.h index 508cff8cd..d4b0d9f7b 100644 --- a/game_patch/multi/vehicles/physics/vphys_internal.h +++ b/game_patch/multi/vehicles/physics/vphys_internal.h @@ -363,14 +363,19 @@ struct VehicleSimBody btVector3 impact_pre_vel{0.0f, 0.0f, 0.0f}; bool impact_armed = false; bool impact_skip = false; + bool impact_prop_built = false; // a prop box was just built overlapping the hull: its pop is no crash bool impact_drilling = false; // the drill face's contacts are not crashes - // This frame's best level/mover chassis contact: the largest velocity change along its normal, + // This frame's best level/mover/prop chassis contact: the largest velocity change along its normal, // and the pre-step speed into the surface along that normal. bool impact_contact = false; float impact_contact_dvn = 0.0f; float impact_contact_ny = 0.0f; float impact_contact_approach = 0.0f; btVector3 impact_contact_n{0.0f, 1.0f, 0.0f}; + // This frame's fastest post-step slide along a touched level/mover/prop wall or ceiling, and along touched + // ground; 0 with none. + float scrape_speed = 0.0f; + float scrape_ground_speed = 0.0f; // Simulated time the hull has been touching anything, capped; 0 on a frame with no contact. float impact_grounded_s = 1.0f; // The open window: simulated time left (0 = closed), the signed running sum and its peak, and @@ -430,7 +435,9 @@ struct VehiclePhysicsWorld // Bullet requires the filter to match BOTH ways: (maskA & groupB) && (maskB & groupA). constexpr short vphys_group_hull = 0x80; constexpr short vphys_group_level = 0x40; -// A hull pairs with everything EXCEPT another hull: vehicle-vs-vehicle is vphys_apply_pair_response. +// The clutter props: Bullet's own static group and mask, which nothing else in the world is given. +constexpr short vphys_group_prop = btBroadphaseProxy::StaticFilter; +constexpr short vphys_mask_prop = btBroadphaseProxy::AllFilter ^ btBroadphaseProxy::StaticFilter;// A hull pairs with everything EXCEPT another hull: vehicle-vs-vehicle is vphys_apply_pair_response. constexpr short vphys_mask_no_hull = static_cast(~vphys_group_hull); enum class VehicleOrbitSeat diff --git a/game_patch/multi/vehicles/physics/vphys_step.cpp b/game_patch/multi/vehicles/physics/vphys_step.cpp index ac1a5de75..caf2b345a 100644 --- a/game_patch/multi/vehicles/physics/vphys_step.cpp +++ b/game_patch/multi/vehicles/physics/vphys_step.cpp @@ -319,7 +319,9 @@ namespace // Last, so every velocity write above is the step's input and never reads as an impact. b.impact_pre_vel = body->getLinearVelocity(); b.impact_armed = true; - b.impact_skip = reseeded || pushed || timer::get_i64(1000) < b.shove_brake_until_ms; + b.impact_skip = reseeded || pushed || b.impact_prop_built + || timer::get_i64(1000) < b.shove_brake_until_ms; + b.impact_prop_built = false; b.impact_drilling = is_car && car_drills_on(ep, p); } } @@ -371,7 +373,7 @@ namespace } for (int c = 0; c < pm->getNumContacts(); ++c) { const btManifoldPoint& cp = pm->getContactPoint(c); - if (cp.getDistance() > 0.02f) { + if (cp.getDistance() > vehicle_physics_touch_dist) { continue; } const float ny = cp.m_normalWorldOnB.y(); @@ -394,17 +396,22 @@ namespace return b && b->body == obj && b->impact_armed ? b : nullptr; } + // The level mesh, the movers and the clutter props. bool impact_is_level_object(const btCollisionObject* obj) { const btBroadphaseProxy* proxy = obj ? obj->getBroadphaseHandle() : nullptr; - return proxy && proxy->m_collisionFilterGroup == vphys_group_level; + return proxy + && (proxy->m_collisionFilterGroup == vphys_group_level + || proxy->m_collisionFilterGroup == vphys_group_prop); } - // The level mesh and movers only: another hull's box forms no pair, and hull on hull is the ram's. + // Another hull's box forms no pair, and hull on hull is the ram's. void vphys_impact_contact_pass() { for (const auto& owned : g_vphys.bodies) { owned->impact_contact = false; + owned->scrape_speed = 0.0f; + owned->scrape_ground_speed = 0.0f; } const int nm = g_vphys.dispatcher->getNumManifolds(); for (int m = 0; m < nm; ++m) { @@ -424,7 +431,7 @@ namespace const btRigidBody* other = btRigidBody::upcast(side > 0.0f ? pm->getBody1() : pm->getBody0()); for (int c = 0; c < pm->getNumContacts(); ++c) { const btManifoldPoint& cp = pm->getContactPoint(c); - if (cp.getDistance() > 0.02f) { + if (cp.getDistance() > vehicle_physics_touch_dist) { continue; } const btVector3 n = cp.m_normalWorldOnB * side; @@ -432,13 +439,17 @@ namespace && n.dot(fwd) < vehicle_crash_drill_face_dot) { continue; } + btVector3 surface_vel(0.0f, 0.0f, 0.0f); + if (other) { + const btVector3& p = side > 0.0f ? cp.getPositionWorldOnB() : cp.getPositionWorldOnA(); + surface_vel = other->getVelocityInLocalPoint(p - other->getCenterOfMassPosition()); + } + const btVector3 rel_post = b->body->getLinearVelocity() - surface_vel; + float& scrape = n.y() < vehicle_crash_ground_ny ? b->scrape_speed : b->scrape_ground_speed; + scrape = std::max(scrape, (rel_post - n * rel_post.dot(n)).length()); const float dvn = dv.dot(n); if (!b->impact_contact || dvn > b->impact_contact_dvn) { - btVector3 rel_pre = b->impact_pre_vel; - if (other) { - const btVector3& p = side > 0.0f ? cp.getPositionWorldOnB() : cp.getPositionWorldOnA(); - rel_pre -= other->getVelocityInLocalPoint(p - other->getCenterOfMassPosition()); - } + const btVector3 rel_pre = b->impact_pre_vel - surface_vel; b->impact_contact = true; b->impact_contact_dvn = dvn; b->impact_contact_ny = n.y(); @@ -456,6 +467,15 @@ namespace bool ground; }; + struct VehicleScrape + { + int vehicle_handle; + rf::Vector3 vel; // post-step, capped + float wall_slide; + float ground_slide; + float step_time; + }; + void vphys_impact_measure(VehicleSimBody& b, bool speed_clamped, float step_time, std::vector& out) { @@ -582,7 +602,8 @@ namespace body->setActivationState(ISLAND_SLEEPING); } - void vphys_body_post_step(BodyStepScratch& s, float step_time, std::vector& impacts) + void vphys_body_post_step(BodyStepScratch& s, float step_time, std::vector& impacts, + std::vector& scrapes) { VehicleSimBody& b = *s.b; rf::Entity* ep = s.ep; @@ -613,6 +634,10 @@ namespace rf::Vector3 capped_vel = vel; const bool speed_clamped = vphys_clamp_class_velocity(cls, capped_vel); + // Every counted step, a slide of 0 included: that is what flushes a scrape once the hull comes away. + if (b.impact_armed && !b.impact_skip && !speed_clamped && step_time > 0.0f) { + scrapes.push_back({b.vehicle_handle, vel, b.scrape_speed, b.scrape_ground_speed, step_time}); + } vphys_impact_measure(b, speed_clamped, step_time, impacts); if (speed_clamped) { vel = capped_vel; @@ -724,8 +749,9 @@ namespace vphys_impact_contact_pass(); } std::vector impacts; + std::vector scrapes; for (BodyStepScratch& s : step) { - vphys_body_post_step(s, step_time, impacts); + vphys_body_post_step(s, step_time, impacts, scrapes); } g_vphys.manifolds_dirty = false; // After the walk: the damage can destroy a hull, and its body with it. @@ -734,6 +760,11 @@ namespace vehicle_crash_impact(ep, impact.impact_dv, impact.ground); } } + for (const VehicleScrape& scrape : scrapes) { + if (rf::Entity* ep = rf::entity_from_handle(scrape.vehicle_handle)) { + vehicle_scrape_contact(ep, scrape.vel, scrape.wall_slide, scrape.ground_slide, scrape.step_time); + } + } } // The CALL to player_process_controls: this frame's input is in ai.ci and nothing has consumed it. @@ -1058,6 +1089,76 @@ float vehicle_physics_class_top_speed(int vdc_class) return cls < 0 ? 0.0f : vphys_class_top_speed(cls); } +float vehicle_physics_hull_standoff(const rf::Entity* ep) +{ + const int cls = ep ? vphys_class_for(ep) : -1; + if (cls < 0 || vphys_class_is_automobile(cls)) { + return 0.0f; + } + return hull_standoff(params_for_class(cls), ep); +} + +int vehicle_physics_sphere_contacts(const rf::Vector3& center, float radius, VehiclePhysicsContact* out, + int max_contacts) +{ + if (!g_vphys.world || !(radius > 0.0f) || max_contacts <= 0 || !vehicle_vector_is_finite(center)) { + return 0; + } + struct SphereContactCallback : btCollisionWorld::ContactResultCallback + { + const btCollisionObject* probe = nullptr; + VehiclePhysicsContact* out = nullptr; + int max_contacts = 0; + int num_contacts = 0; + + btScalar addSingleResult(btManifoldPoint& cp, const btCollisionObjectWrapper* wrap0, int, int, + const btCollisionObjectWrapper* wrap1, int, int) override + { + if (num_contacts >= max_contacts || cp.getDistance() > vehicle_physics_touch_dist) { + return 0.0f; + } + // m_normalWorldOnB points at object A. + const bool probe_is_a = wrap0->getCollisionObject() == probe; + const btCollisionObject* surface = probe_is_a ? wrap1->getCollisionObject() : wrap0->getCollisionObject(); + const btVector3 point = probe_is_a ? cp.getPositionWorldOnB() : cp.getPositionWorldOnA(); + const btVector3 normal = probe_is_a ? cp.m_normalWorldOnB : -cp.m_normalWorldOnB; + // Every triangle reports its own point, so one face split into several gives several of one touch. + constexpr float same_point_dist_sq = 0.25f * 0.25f; + constexpr float same_normal_dot = 0.9f; + for (int i = 0; i < num_contacts; ++i) { + if ((from_bt(point) - out[i].point).len_sq() < same_point_dist_sq + && from_bt(normal).dot_prod(out[i].normal) > same_normal_dot) { + return 0.0f; + } + } + btVector3 surface_vel(0.0f, 0.0f, 0.0f); + if (const btRigidBody* body = btRigidBody::upcast(surface)) { + surface_vel = body->getVelocityInLocalPoint(point - body->getCenterOfMassPosition()); + } + out[num_contacts++] = {from_bt(point), from_bt(normal), from_bt(surface_vel)}; + return 0.0f; + } + }; + + btSphereShape shape(radius); + btCollisionObject probe; + probe.setCollisionShape(&shape); + btTransform t; + t.setIdentity(); + t.setOrigin(to_bt(center)); + probe.setWorldTransform(t); + + SphereContactCallback cb; + cb.probe = &probe; + cb.out = out; + cb.max_contacts = max_contacts; + // As a hull: the level, the movers and the props, never another hull. + cb.m_collisionFilterGroup = vphys_group_hull; + cb.m_collisionFilterMask = vphys_group_level | vphys_group_prop; + g_vphys.world->contactTest(&probe, cb); + return cb.num_contacts; +} + float vehicle_physics_class_bounce_gain(int vdc_class) { const int cls = vphys_class_from_vdc(vdc_class); diff --git a/game_patch/multi/vehicles/physics/vphys_world.cpp b/game_patch/multi/vehicles/physics/vphys_world.cpp index 71b98b7b6..8b02cb225 100644 --- a/game_patch/multi/vehicles/physics/vphys_world.cpp +++ b/game_patch/multi/vehicles/physics/vphys_world.cpp @@ -1021,6 +1021,17 @@ void movers_update_all(bool step_will_run, float step_time, float engine_dt) if (moved_box) { mover_wake_bank(m, moved_lo, moved_hi); } + // A machine with no body to step never refreshes the broadphase or derives a kinematic velocity, yet + // the scrape probe asks this world for what a watched hull touches. + if (!step_will_run) { + const rf::Vector3 vel = moved && engine_dt > 0.0f ? (op->pos - m.carry_pos) * (1.0f / engine_dt) + : rf::Vector3{}; + m.body->setLinearVelocity(to_bt(vel)); + m.body->setAngularVelocity(btVector3(0.0f, 0.0f, 0.0f)); + if (moved) { + g_vphys.world->updateSingleAabb(m.body); + } + } // Only ahead of a substep: a sleeping hull woken on a skipped frame would just idle awake. if (consumed) { mover_wake_flush(m); @@ -1663,8 +1674,19 @@ namespace obstacle.info_index = w.info_index; obstacle.vmesh = w.op->vmesh; obstacle.built_half = obox.half; - // A prop keeps the default static group/mask addRigidBody gives it. - g_vphys.world->addRigidBody(obstacle.body); + g_vphys.world->addRigidBody(obstacle.body, vphys_group_prop, vphys_mask_prop); + btVector3 lo; + btVector3 hi; + obstacle.body->getAabb(lo, hi); + for (auto& owned : g_vphys.bodies) { + btVector3 blo; + btVector3 bhi; + if (owned->body) { + owned->body->getAabb(blo, bhi); + owned->impact_prop_built |= blo.x() <= hi.x() && bhi.x() >= lo.x() && blo.y() <= hi.y() + && bhi.y() >= lo.y() && blo.z() <= hi.z() && bhi.z() >= lo.z(); + } + } } } } diff --git a/game_patch/multi/vehicles/vehicle.cpp b/game_patch/multi/vehicles/vehicle.cpp index 454b2bedd..9a56e07ed 100644 --- a/game_patch/multi/vehicles/vehicle.cpp +++ b/game_patch/multi/vehicles/vehicle.cpp @@ -379,6 +379,56 @@ float vehicle_collision_reach_along(rf::Entity* ep, const rf::Vector3& dir) return reach; } +int vehicle_hull_probe(rf::Entity* ep, const rf::Vector3& vel, bool flyer, bool ends_only, + VehicleHullProbeHit (&out)[vehicle_hull_probe_max_hits]) +{ + int num_hits = 0; + const auto add_hit = [&](const rf::Vector3& point, const rf::Vector3& normal, const rf::Vector3& rel_vel, + int material) { + VehicleHullProbeHit& hit = out[num_hits++]; + hit.point = point; + hit.normal = normal; + hit.along = rel_vel - normal * rel_vel.dot_prod(normal); + hit.along_speed = hit.along.len(); + hit.material = material; + }; + // A flyer's rays leave movers to its sphere, which measures the slide against their own motion. + const auto cast = [&](const rf::Vector3& dir, float reach) { + rf::PCollisionOut o{}; + if (vphys_collide_solid_segment(ep->pos, ep->pos + dir * reach, rf::CF_PROCESS_INVISIBLE_FACES, o) + && (!flyer || o.obj_handle < 0)) { + add_hit(o.hit_point, o.hit_normal, vel, o.material); + } + }; + if (!flyer) { + // A car's sides only ever light effects. + constexpr float car_gap = 0.15f; + for (const rf::Vector3& dir : {ep->orient.rvec, ep->orient.rvec * -1.0f}) { + cast(dir, vehicle_collision_reach_along(ep, dir) + car_gap); + } + return num_hits; + } + // The body itself, at any angle: what its sphere touches is what its physics and its scrape damage feel. + const float standoff = vehicle_physics_hull_standoff(ep); + constexpr int end_rays = 2; + if (!ends_only && standoff > 0.0f) { + VehiclePhysicsContact contacts[vehicle_hull_probe_max_hits - end_rays]; + const int num_contacts = + vehicle_physics_sphere_contacts(ep->pos, standoff, contacts, static_cast(std::size(contacts))); + for (int i = 0; i < num_contacts; ++i) { + add_hit(contacts[i].point, contacts[i].normal, vel - contacts[i].surface_vel, rf::MATERIAL_DEFAULT); + } + } + // The nose and tail (end_rays of them), where the hull reaches past its sphere. + for (const rf::Vector3& dir : {ep->orient.fvec, ep->orient.fvec * -1.0f}) { + const float reach = vehicle_collision_reach_along(ep, dir); + if (reach > standoff) { + cast(dir, reach + vehicle_physics_touch_dist); + } + } + return num_hits; +} + bool vehicle_hull_is_turret(const rf::Entity* ep) { return ep && ep->info && ep->info->use_function == rf::ENTITY_USE_TURRET; @@ -494,6 +544,8 @@ void vehicle_client_do_frame() std::erase_if(g_vehicle_state.orient, handle_is_gone); std::erase_if(g_vehicle_state.orient_sent, handle_is_gone); std::erase_if(g_vehicle_state.observed_speed, handle_is_gone); + std::erase_if(g_vehicle_state.crash_cooldown, handle_is_gone); + std::erase_if(g_vehicle_state.scrape, handle_is_gone); // The mirror the auto-return label and the entry rules read; nothing else drops it on a // client, since vehicle_do_frame's own sweep is server side. std::erase_if(g_vehicle_state.hull_state, handle_is_gone); diff --git a/game_patch/multi/vehicles/vehicle.h b/game_patch/multi/vehicles/vehicle.h index 727f27063..a1f24cc25 100644 --- a/game_patch/multi/vehicles/vehicle.h +++ b/game_patch/multi/vehicles/vehicle.h @@ -98,6 +98,24 @@ bool vehicle_fp_apc_aim_split(const rf::Entity* vehicle, const rf::Entity* drive // How far the hull's cspheres reach beyond the origin along dir (a UNIT vector). float vehicle_collision_reach_along(rf::Entity* ep, const rf::Vector3& dir); +// One surface the hull scrape probe found the hull touching: a ray's hit or a sphere contact. +struct VehicleHullProbeHit +{ + rf::Vector3 point; + rf::Vector3 normal; + rf::Vector3 along; // the hull velocity's part along the surface; a sphere contact's is against a mover's motion + float along_speed = 0.0f; + int material = 0; // rays only: a sphere contact is MATERIAL_DEFAULT +}; +inline constexpr int vehicle_hull_probe_max_hits = 8; + +// The scrape probe the effects and the scrape damage share, with vel the hull's velocity. A flyer: what its +// physics sphere touches (the vehicle physics world's level, movers and props, at any angle; not with ends_only), +// and rays along its nose and tail where the hull reaches past that sphere. A car: rays along its sides. Returns +// how many of out it filled. +int vehicle_hull_probe(rf::Entity* ep, const rf::Vector3& vel, bool flyer, bool ends_only, + VehicleHullProbeHit (&out)[vehicle_hull_probe_max_hits]); + // A synced use_function-1 vehicle that is neither PF_AUTOMOBILE nor water-bound. bool vehicle_is_flyer(rf::Entity* ep); @@ -252,10 +270,11 @@ void vehicle_apply_orient_from_packet(int vehicle_handle, uint16_t tick, int16_t int16_t bank_q, int16_t aim_pitch_q, int16_t aim_head_q, int8_t steer_q); -// Client: store an af_vehicle_health update, and the values the HUD shows for a vehicle. +// Client: store an af_vehicle_health update, and the values the HUD shows for a vehicle. A scrape_only drop +// gives the riders no damage flash. void vehicle_store_health_from_packet(int vehicle_handle, float life, float max_life, int primary_ammo, int secondary_ammo, uint16_t primary_refill_ms, uint16_t secondary_refill_ms, - const rf::Vector3* hit_dir = nullptr); + const rf::Vector3* hit_dir = nullptr, bool scrape_only = false); // Total ammo (clip + reserve) for one weapon type, or -1 for a weapon with no ammo pool at all. // Only an exact 0 means EMPTY, so a caller gating on emptiness must test == 0, never <= 0. int vehicle_weapon_ammo(const rf::Entity* vehicle, int weapon_type); @@ -300,11 +319,17 @@ void vehicle_note_hull_damage(rf::Entity* vehicle, float life_before, int killer // Server: a client reported that the vehicle it drives ran a player over. void vehicle_server_handle_crush_report(rf::Player* pp, int vehicle_handle, int victim_handle); -// The machine simulating this hull measured an impact against the level or a mover: the velocity -// change along the contact normal, and whether it was ground rather than a wall. The server applies -// it; a driver's client reports it. +// The machine simulating this hull measured an impact against the level, a mover or a prop: the velocity +// change along the contact normal, and whether it was ground rather than a wall. The server applies it; a +// driver's client reports it. void vehicle_crash_impact(rf::Entity* vehicle, float impact_dv, bool ground); +// The machine simulating this hull, once per counted physics step: its velocity and its body's fastest slide +// along a wall or ceiling and along ground it touches (0 for none), over step_time of simulated time. The hull +// scrape probe's nose and tail rays add what reaches past the body. Banked and dealt, or reported. +void vehicle_scrape_contact(rf::Entity* vehicle, const rf::Vector3& vel, float wall_slide, float ground_slide, + float step_time); + // One authoritative pose of a hull (a received row, or the body this machine simulates), for the // run-over motion test. void vehicle_note_observed_pose(int vehicle_handle, const rf::Vector3& pos, float heading); @@ -313,6 +338,9 @@ void vehicle_note_observed_pose(int vehicle_handle, const rf::Vector3& pos, floa void vehicle_server_handle_crash_report(rf::Player* pp, int vehicle_handle, bool ground, float impact_dv); +// Server: a client reported its hull's banked slide (u of sliding past the class's free speed). +void vehicle_server_handle_scrape_report(rf::Player* pp, int vehicle_handle, float slide_excess); + // Server, from entity_damage: the VehicleDamageClass of the hull that ran this victim over, or -1. // Must be asked BEFORE the damage lands, while the victim still resolves. int vehicle_roadkill_damage_class(int victim_handle, int killer_handle); diff --git a/game_patch/multi/vehicles/vehicle_crash_tuning.h b/game_patch/multi/vehicles/vehicle_crash_tuning.h index 9f75b4bd7..055fe8749 100644 --- a/game_patch/multi/vehicles/vehicle_crash_tuning.h +++ b/game_patch/multi/vehicles/vehicle_crash_tuning.h @@ -2,39 +2,90 @@ #include "vehicle.h" -// All hull crash/landing tuning. Damage = spawn max life * frac * min((impact_dv - thr) / (ref_dv - thr), -// vehicle_crash_max_over_ref)^2, impact_dv being the pre-contact speed into the surface (u/s). +// All hull crash/landing tuning. Damage = spawn max life * max(frac * min((impact_dv - thr) / (ref_dv - thr), +// vehicle_crash_max_over_ref)^2, min_frac), impact_dv being the pre-contact speed into the surface (u/s). struct VehicleCrashTuning { float wall_threshold; // a wall/mover hit at or below this impact_dv is free float ground_threshold; // the same for a landing; every wheel contact is a landing float wall_ref_dv; // measured flat-out cruise speed, i.e. a full-speed head-on wall hit float wall_frac; // fraction of max life a wall hit at wall_ref_dv takes - float land_ref_dv; // cars: a 10 m drop's fall speed at the class air_gravity; fighter/sub: top speed + float land_ref_dv; // cars: a 10 m drop's fall speed at the class air_gravity; fighter cruise; sub top float land_frac; // fraction of max life a landing at land_ref_dv takes + float min_frac; // the least any hit past its threshold takes }; // Cars cruise past car_max_speed (the engine only cuts above it), so the wall ref is the measured cruise. +// The fighter's thrust and drag ramp settle it at ~21, short of its max_speed of 25. Its impact effects start at +// its thresholds (vehicle_fx.cpp), so a slam that shows costs hull; a descend tap's landing stays free. inline constexpr VehicleCrashTuning vehicle_crash_tuning[VDC_COUNT] = { - // wall thr, ground thr, wall ref, wall frac, land ref, land frac - {8.0f, 9.0f, 18.0f, 0.275f, 15.34f, 0.11f}, // VDC_JEEP: cruises at ~18 - {5.5f, 9.0f, 12.3f, 0.275f, 17.15f, 0.225f}, // VDC_APC: cruises at ~12.3 (car_max_speed 10.5) - {4.5f, 9.0f, 9.6f, 0.275f, 17.15f, 0.225f}, // VDC_DRILLER: cruises at ~9.6 (8.5) - {10.0f, 10.0f, 25.0f, 0.275f, 25.0f, 0.275f}, // VDC_FIGHTER - {6.0f, 6.0f, 15.0f, 0.275f, 15.0f, 0.275f}, // VDC_SUB - {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, // VDC_TURRET: no body, never measured + // wall thr, ground thr, wall ref, wall frac, land ref, land frac, min frac + {8.0f, 9.0f, 18.0f, 0.275f, 15.34f, 0.11f, 0.0f}, // VDC_JEEP: cruises at ~18 + {5.5f, 9.0f, 12.3f, 0.275f, 17.15f, 0.225f, 0.0f}, // VDC_APC: cruises at ~12.3 (car_max_speed 10.5) + {4.5f, 9.0f, 9.6f, 0.275f, 17.15f, 0.225f, 0.0f}, // VDC_DRILLER: cruises at ~9.6 (8.5) + {5.0f, 10.0f, 21.0f, 0.6f, 21.0f, 0.6f, 0.02f}, // VDC_FIGHTER + {6.0f, 6.0f, 15.0f, 0.275f, 15.0f, 0.275f, 0.0f}, // VDC_SUB + {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, // VDC_TURRET: no body, never measured }; +// Hull on hull, per VICTIM class. Damage = spawn max life * frac * ((closing - vehicle_ram_min_speed) / +// (ref_closing - vehicle_ram_min_speed))^2 * the other hull's mass over its own, clamped to +// [vehicle_ram_mass_ratio_min, mass_ratio_max]. Every row but the fighter's comes to about 0.26 * excess^2 at +// the class's tuned life (a turret keeps its tbl $Life of 400). +struct VehicleRamTuning +{ + float ref_closing; // u/s + float frac; // fraction of max life an equal-mass ram at ref_closing takes + float mass_ratio_max; +}; + +inline constexpr VehicleRamTuning vehicle_ram_tuning[VDC_COUNT] = { + {36.0f, 0.2f, 4.0f}, // VDC_JEEP: two jeeps head-on at cruise + {24.5f, 0.0165f, 4.0f}, // VDC_APC + {19.0f, 0.0068f, 4.0f}, // VDC_DRILLER + // Two fighters head-on at 18 each (cruise ~21) both go down; flying into a heavy hull costs about what the + // same wall hit does. + {36.0f, 1.0f, 2.0f}, // VDC_FIGHTER + {30.0f, 0.18f, 4.0f}, // VDC_SUB + {36.0f, 0.625f, 4.0f}, // VDC_TURRET: rammed only +}; + +// Sliding along a surface while touching it. Damage per second = spawn max life * frac_per_s * +// (slide speed - min_speed) / (ref_speed - min_speed) along a wall or ceiling; along the ground the free speed +// is ground_min_speed instead, over the same span from there to ref_speed. A zero frac_per_s takes none. A +// fighter's scrape effects start at the same speeds. +struct VehicleScrapeTuning +{ + float min_speed; // u/s along a wall or ceiling below which a touch is free + float ground_min_speed; // the same along the ground: a taxi or a slow touchdown is free + float ref_speed; + float frac_per_s; // fraction of max life per second of sliding at ref_speed +}; + +inline constexpr VehicleScrapeTuning vehicle_scrape_tuning[VDC_COUNT] = { + {0.0f, 0.0f, 0.0f, 0.0f}, // VDC_JEEP + {0.0f, 0.0f, 0.0f, 0.0f}, // VDC_APC + {0.0f, 0.0f, 0.0f, 0.0f}, // VDC_DRILLER + {5.0f, 8.0f, 21.0f, 0.1f}, // VDC_FIGHTER: ref at cruise + {0.0f, 0.0f, 0.0f, 0.0f}, // VDC_SUB + {0.0f, 0.0f, 0.0f, 0.0f}, // VDC_TURRET +}; + +// Scrape damage is banked and dealt, or reported, once per this window; a bank still unsent a window past it +// is the last drive's and is dropped. +inline constexpr int vehicle_scrape_flush_ms = 250; +// Server: the refilling budget of sliding time a hull's scrape reports may bill, at most this much. +inline constexpr int vehicle_scrape_report_max_window_ms = 500; + // Cap on (impact_dv - thr) / (ref_dv - thr): no hit costs more than frac x 1.5625 of max life. inline constexpr float vehicle_crash_max_over_ref = 1.25f; -// A hit under this fraction of max life is a scrape and deals nothing. +// A hit under this fraction of max life is a graze and deals nothing; a row's min_frac lifts every hit past its +// threshold above it. inline constexpr float vehicle_crash_min_life_frac = 0.01f; -// Server: at most one crash per hull per this window. +// Server: a hull's crashes inside this window bill only the largest one. inline constexpr int vehicle_crash_server_cooldown_ms = 400; -// Client: at most one report per hull per this window; longer than the server's so a late report lands. +// Client: the same for reports; longer than the server's so a late report lands. inline constexpr int vehicle_crash_client_cooldown_ms = 500; -// Server: at most one crash report per sender per this window. -inline constexpr int vehicle_crash_report_cooldown_ms = 200; // Simulated time an impact's velocity change is summed over (suspension spreads a landing). inline constexpr float vehicle_crash_window_s = 0.1f; // A ground window opened within this much contact time of airtime is a landing... diff --git a/game_patch/multi/vehicles/vehicle_damage.cpp b/game_patch/multi/vehicles/vehicle_damage.cpp index bacfa082a..39ba5ab38 100644 --- a/game_patch/multi/vehicles/vehicle_damage.cpp +++ b/game_patch/multi/vehicles/vehicle_damage.cpp @@ -74,12 +74,8 @@ namespace // Closing speed = relative velocity along the line between the two box centres. constexpr float vehicle_ram_min_speed = 5.0f; // world u/s of closing before a contact is a ram - // Damage goes as the SQUARE of the overspeed. - constexpr float vehicle_ram_damage_scale = 0.26f; - - // Ratio of the OTHER hull's mass to its own (rf::PhysicsData::mass), clamped. + // Floor on the ratio of the OTHER hull's mass to its own (rf::PhysicsData::mass); the ceiling is per class. constexpr float vehicle_ram_mass_ratio_min = 0.25f; - constexpr float vehicle_ram_mass_ratio_max = 4.0f; // Dwell before a hit is minted. Wall clock, not frames: the phantom it rejects is per sample. constexpr int64_t vehicle_ram_confirm_ms = 25; @@ -359,6 +355,7 @@ namespace int damage_class = -1; // the minting hull's VDC row, or -1 for an unattributed blow bool squash = false; // a run-over or the drill, never a hull-on-hull ram float speed = 0.0f; // the run-over hull's |v|; 0 for the drill and rams + bool scrape = false; // a hull's own scrape damage }; VehicleCrushMint g_vehicle_crush_mint; @@ -366,11 +363,11 @@ namespace { public: VehicleCrushMintScope(int victim_handle, const rf::Entity* vehicle, bool squash = false, - float speed = 0.0f) + float speed = 0.0f, bool scrape = false) : saved_(g_vehicle_crush_mint) { g_vehicle_crush_mint = - VehicleCrushMint{victim_handle, vehicle_damage_class(vehicle), squash, speed}; + VehicleCrushMint{victim_handle, vehicle_damage_class(vehicle), squash, speed, scrape}; } ~VehicleCrushMintScope() { @@ -535,15 +532,33 @@ namespace // The scope is what carries the blow past obj_damage's MP crush nullification. `vehicle` is the // hull dealing it, and null for a blow nothing is to be credited with. void vehicle_ram_mint_damage(int victim_handle, int killer_handle, float damage, - const rf::Entity* vehicle, bool squash = false) + const rf::Entity* vehicle, bool squash = false, bool scrape = false) { if (damage <= 0.0f) { return; } - const VehicleCrushMintScope scope{victim_handle, vehicle, squash}; + const VehicleCrushMintScope scope{victim_handle, vehicle, squash, 0.0f, scrape}; rf::obj_damage(victim_handle, damage, killer_handle, -1, rf::DT_CRUSH, nullptr, -1, 0); } + // One hull's share of a ram: its class row of vehicle_ram_tuning on its own spawn max life, times the + // other hull's mass over its own, clamped. + float vehicle_ram_damage(const rf::Entity* victim, float closing, float mass_ratio) + { + const int vdc = vehicle_damage_class(victim); + if (vdc < 0 || vdc >= VDC_COUNT) { + return 0.0f; + } + const VehicleRamTuning& t = vehicle_ram_tuning[vdc]; + const float span = t.ref_closing - vehicle_ram_min_speed; + if (span <= 0.0f) { + return 0.0f; + } + const float x = (closing - vehicle_ram_min_speed) / span; + return vehicle_hud_max_life(victim) * t.frac * x * x + * std::clamp(mass_ratio, vehicle_ram_mass_ratio_min, t.mass_ratio_max); + } + // Its seated driver, or the ex-driver it is still coasting for. Null for a turret or an expired // window, and the ram is then minted with killer -1, an unattributed environment blow. rf::Entity* vehicle_ram_credit(rf::Entity* rammer) @@ -559,6 +574,13 @@ namespace // `stock_indicated`: stock weapon damage has already lit a local rider's indicator (0x004C6183, 0x00489195). void vehicle_note_hull_hit(rf::Entity* vehicle, int killer_handle, bool stock_indicated) { + // Scrape damage flashes nobody: the health packets carrying only it say so. + VehicleHealthSync& health = g_vehicle_state.health_sync[vehicle->handle]; + if (vehicle_crush_damage_is_mint(vehicle->handle) && g_vehicle_crush_mint.scrape) { + health.scraped = true; + return; + } + health.hurt_unscraped = true; rf::Entity* source = killer_handle != -1 ? rf::entity_from_handle(killer_handle) : nullptr; if (rf::Entity* hull = vehicle_ridden_hull(source)) { source = hull; @@ -570,9 +592,8 @@ namespace const bool has_dir = dir.len_sq() > 1e-4f; if (has_dir) { dir.normalize(); - VehicleHealthSync& sync = g_vehicle_state.health_sync[vehicle->handle]; - sync.hit_dir = dir; - sync.hit_ms = timer::get_i64(1000); + health.hit_dir = dir; + health.hit_ms = timer::get_i64(1000); } // A listen host is sent no 0x67 of his own. if (!rf::is_dedicated_server) { @@ -791,14 +812,8 @@ void vehicle_server_ram_sweep() continue; // parking, nudging, grinding: free } - const float excess = eff_closing - vehicle_ram_min_speed; - const float base = vehicle_ram_damage_scale * excess * excess; - const float dmg_a = base - * std::clamp(b.mass / a.mass, vehicle_ram_mass_ratio_min, - vehicle_ram_mass_ratio_max); - const float dmg_b = base - * std::clamp(a.mass / b.mass, vehicle_ram_mass_ratio_min, - vehicle_ram_mass_ratio_max); + const float dmg_a = vehicle_ram_damage(a.ep, eff_closing, b.mass / a.mass); + const float dmg_b = vehicle_ram_damage(b.ep, eff_closing, a.mass / b.mass); // The RAMMER carries more of the closing speed; a stationary hull is never the rammer. const bool a_is_rammer = eff_a_toward >= eff_b_toward; @@ -847,7 +862,10 @@ namespace { // Over each report bound: gravity and drive keep acting across the summing window. constexpr float vehicle_crash_report_slack = 3.0f; - // Four buckets of this: the observed peak spans at least the last 375 ms of rows, at most 500. + // Over the scrape report bound: the observed peak is a row's speed, and a slide can run a little past it. + constexpr float vehicle_scrape_report_slack = 3.0f; + // Eight buckets of this: the observed peak spans at least the last 875 ms of rows, at most 1 s - room for + // a report sent after a landing's extended window and a laggy sender's trip. constexpr int64_t vehicle_observed_speed_bucket_ms = 125; // Net extremity speed a run-over needs: between a parked driven APC's measured creep (0.047 u/s on a // 20 deg slope, 0.075 u/s while a walker shoves it) and the 0.1 u/s that must count. @@ -857,6 +875,9 @@ namespace // A step past it every <= 200 ms needs only 0.05 u/s, well under the speed floor. constexpr float vehicle_crush_change_min_extent = 0.01f; constexpr int64_t vehicle_crush_motion_window_ms = 500; + // The motion rate is taken over the newest buckets only: a span of 250-375 ms plus the newest bucket's age. + constexpr int vehicle_crush_motion_buckets = 4; + static_assert(vehicle_crush_motion_buckets <= VehicleObservedSpeed::bucket_count); // The victim's 2.2 x 25 ms interp delay, the server's relay tick and a ~120 ms report round trip. constexpr int64_t vehicle_crush_motion_tail_ms = 200; @@ -923,7 +944,7 @@ namespace if (!s.changed || now - s.changed_ms > vehicle_crush_motion_tail_ms) { return {}; } - for (int i = VehicleObservedSpeed::bucket_count - 1; i >= 0; --i) { + for (int i = vehicle_crush_motion_buckets - 1; i >= 0; --i) { if (!s.bucket_posed[i] || now - s.bucket_pose_ms[i] > vehicle_crush_motion_window_ms) { continue; } @@ -979,7 +1000,7 @@ namespace return 0.0f; } const float x = std::min(over / ref_over, vehicle_crash_max_over_ref); - const float frac = (ground ? t.land_frac : t.wall_frac) * x * x; + const float frac = std::max((ground ? t.land_frac : t.wall_frac) * x * x, t.min_frac); return frac >= vehicle_crash_min_life_frac ? frac : 0.0f; } @@ -988,6 +1009,24 @@ namespace return vehicle_physics_class_max_impact_speed(vdc) + vehicle_crash_report_slack; } + // What of a crash's life fraction its hull's window still owes: all of it when none is open (opening one), + // else only what it adds over the window's largest, so a graze can never swallow the slam right after it. + float vehicle_crash_window_bill(int vehicle_handle, float frac, int window_ms) + { + VehicleCrashWindow& w = g_vehicle_state.crash_cooldown[vehicle_handle]; + if (!w.window.valid() || w.window.elapsed()) { + w.window.set(window_ms); + w.billed_frac = frac; + return frac; + } + const float extra = frac - w.billed_frac; + if (extra <= 0.0f) { + return 0.0f; + } + w.billed_frac = frac; + return extra; + } + // Killer -1 and no minting hull, as the rammer's own share: an environment blow on the hull alone. void vehicle_server_apply_crash(rf::Entity* vehicle, float impact_dv, bool ground) { @@ -999,20 +1038,42 @@ namespace if (frac <= 0.0f) { return; } - rf::Timestamp& cooldown = g_vehicle_state.crash_cooldown[vehicle->handle]; - if (cooldown.valid() && !cooldown.elapsed()) { + const float bill = vehicle_crash_window_bill(vehicle->handle, frac, vehicle_crash_server_cooldown_ms); + if (bill <= 0.0f) { return; } - cooldown.set(vehicle_crash_server_cooldown_ms); - vehicle_ram_mint_damage(vehicle->handle, -1, frac * vehicle_hud_max_life(vehicle), nullptr); + vehicle_ram_mint_damage(vehicle->handle, -1, bill * vehicle_hud_max_life(vehicle), nullptr); + } + + // Unattributed, like a crash, and under the same rule. + void vehicle_server_apply_scrape(rf::Entity* vehicle, float slide_excess) + { + if (!g_alpine_server_config_active_rules.vehicles.crash_damage || vehicle->life <= 0.0f + || rf::entity_is_dying(vehicle) || vehicle_hull_is_turret(vehicle) || !(slide_excess > 0.0f)) { + return; + } + const int vdc = vehicle_damage_class(vehicle); + if (vdc < 0 || vdc >= VDC_COUNT) { + return; + } + const VehicleScrapeTuning& t = vehicle_scrape_tuning[vdc]; + const float span = t.ref_speed - t.min_speed; + if (t.frac_per_s <= 0.0f || span <= 0.0f) { + return; + } + vehicle_ram_mint_damage(vehicle->handle, -1, + vehicle_hud_max_life(vehicle) * t.frac_per_s * slide_excess / span, nullptr, + false, true); } } // namespace void vehicle_crash_impact(rf::Entity* vehicle, float impact_dv, bool ground) { - if (!rf::is_multi || !vehicle || !std::isfinite(impact_dv)) { + if (!rf::is_multi || !vehicle || vehicle->life <= 0.0f || rf::entity_is_dying(vehicle) + || !std::isfinite(impact_dv)) { return; } + const int vdc = vehicle_damage_class(vehicle); if (rf::is_server) { vehicle_server_apply_crash(vehicle, impact_dv, ground); return; @@ -1021,31 +1082,22 @@ void vehicle_crash_impact(rf::Entity* vehicle, float impact_dv, bool ground) if (vehicle_local_driven_vehicle() != vehicle || vehicle_hull_is_turret(vehicle)) { return; } - const int vdc = vehicle_damage_class(vehicle); - if (vehicle_crash_life_frac(vdc, impact_dv, ground) <= 0.0f) { - return; - } - rf::Timestamp& cooldown = g_vehicle_state.crash_cooldown[vehicle->handle]; - if (cooldown.valid() && !cooldown.elapsed()) { + const float reported_dv = std::min(impact_dv, vehicle_crash_max_impact_dv(vdc)); + const float frac = vehicle_crash_life_frac(vdc, reported_dv, ground); + if (frac <= 0.0f || vehicle_crash_window_bill(vehicle->handle, frac, vehicle_crash_client_cooldown_ms) <= 0.0f) { return; } - cooldown.set(vehicle_crash_client_cooldown_ms); - af_send_vehicle_crash_report(vehicle->server_handle, ground, - std::min(impact_dv, vehicle_crash_max_impact_dv(vdc))); + af_send_vehicle_crash_report(vehicle->server_handle, ground, reported_dv); } void vehicle_server_handle_crash_report(rf::Player* pp, int vehicle_handle, bool ground, float impact_dv) { + // No per-sender cooldown: only the driver's own hull is billed, its window bills the largest hit once, and a + // reliable report retransmitted together with the next must not lose it. if (!rf::is_multi || !rf::is_server || !pp || !pp->net_data || pp->entity_handle == -1) { return; } - rf::Timestamp& report_cooldown = g_vehicle_state.crash_report_cooldown[pp->net_data->player_id]; - if (report_cooldown.valid() && !report_cooldown.elapsed()) { - return; - } - report_cooldown.set(vehicle_crash_report_cooldown_ms); - if (!std::isfinite(impact_dv) || impact_dv <= 0.0f) { return; } @@ -1064,6 +1116,116 @@ void vehicle_server_handle_crash_report(rf::Player* pp, int vehicle_handle, bool vehicle_server_apply_crash(vehicle, std::min({impact_dv, vehicle_crash_max_impact_dv(vdc), observed_dv}), ground); } +void vehicle_scrape_contact(rf::Entity* vehicle, const rf::Vector3& vel, float wall_slide, float ground_slide, + float step_time) +{ + if (!rf::is_multi || !vehicle || vehicle->life <= 0.0f || rf::entity_is_dying(vehicle) + || !vehicle_vector_is_finite(vel) || !std::isfinite(wall_slide) || !std::isfinite(ground_slide) + || !(step_time > 0.0f)) { + return; + } + const int vdc = vehicle_damage_class(vehicle); + if (vdc < 0 || vdc >= VDC_COUNT || vehicle_scrape_tuning[vdc].frac_per_s <= 0.0f) { + return; + } + // Only the hull this client drives is its to measure. + if (!rf::is_server && vehicle_local_driven_vehicle() != vehicle) { + return; + } + const VehicleScrapeTuning& t = vehicle_scrape_tuning[vdc]; + // The scrape effects' nose and tail rays, which reach past the body; its own contacts, mover-relative, cover + // the rest. No slide can outrun the hull. + if (vel.len() >= std::min(t.min_speed, t.ground_min_speed)) { + VehicleHullProbeHit hits[vehicle_hull_probe_max_hits]; + const int num_hits = vehicle_hull_probe(vehicle, vel, vehicle_is_flyer(vehicle), true, hits); + for (int i = 0; i < num_hits; ++i) { + float& slide = hits[i].normal.y >= vehicle_crash_ground_ny ? ground_slide : wall_slide; + slide = std::max(slide, hits[i].along_speed); + } + } + // In wall units: ground sliding is free up to its own speed, then billed over the same span to ref_speed. + const float wall_excess = std::max(wall_slide - t.min_speed, 0.0f); + const float ground_span = t.ref_speed - t.ground_min_speed; + const float ground_excess = ground_span > 0.0f + ? std::max(ground_slide - t.ground_min_speed, 0.0f) * (t.ref_speed - t.min_speed) / ground_span + : 0.0f; + const float excess = std::max(wall_excess, ground_excess) * step_time; + auto it = g_vehicle_state.scrape.find(vehicle->handle); + if (it == g_vehicle_state.scrape.end()) { + if (excess <= 0.0f) { + return; + } + it = g_vehicle_state.scrape.emplace(vehicle->handle, VehicleScrapeBank{}).first; + } + VehicleScrapeBank& bank = it->second; + const int64_t now = timer::get_i64(1000); + // A deadline long gone is a bank left by the last drive of this hull (its samples stopped with it). + if (bank.flush_at_ms != 0 && now - bank.flush_at_ms > vehicle_scrape_flush_ms) { + bank.slide_excess = 0.0f; + bank.flush_at_ms = 0; + } + bank.slide_excess += excess; + // Opened by the first slide and closed by its flush, so a hull that is not sliding keeps no deadline. + if (bank.flush_at_ms == 0) { + if (bank.slide_excess > 0.0f) { + bank.flush_at_ms = now + vehicle_scrape_flush_ms; + } + return; + } + if (now < bank.flush_at_ms) { + return; + } + const float slide_excess = bank.slide_excess; + bank.slide_excess = 0.0f; + bank.flush_at_ms = 0; + // Nothing may touch `bank` past here: the damage can destroy the hull, and its entry with it. + if (rf::is_server) { + vehicle_server_apply_scrape(vehicle, slide_excess); + } + else { + af_send_vehicle_scrape_report(vehicle->server_handle, slide_excess); + } +} + +void vehicle_server_handle_scrape_report(rf::Player* pp, int vehicle_handle, float slide_excess) +{ + if (!rf::is_multi || !rf::is_server || !pp || !pp->net_data || pp->entity_handle == -1) { + return; + } + if (!std::isfinite(slide_excess) || slide_excess <= 0.0f) { + return; + } + rf::Entity* vehicle = vehicle_live_synced_entity(vehicle_handle); + if (!vehicle || vehicle_hull_is_turret(vehicle)) { + return; + } + // Only the seated driver simulates the hull, so only he measured the slide. + if (vehicle_seat_leech(vehicle, 0) != pp->entity_handle) { + return; + } + const int vdc = vehicle_damage_class(vehicle); + if (vdc < 0 || vdc >= VDC_COUNT || vehicle_scrape_tuning[vdc].frac_per_s <= 0.0f) { + return; + } + // Clamped, not rejected: no more slide than the speed this server last saw allows over sliding time it + // banks by its own clock, so a flood of reports bills nothing more and needs no cooldown, and two that + // arrive together still share the time between them. + VehicleScrapeBank& bank = g_vehicle_state.scrape[vehicle->handle]; + const int64_t now = timer::get_i64(1000); + const float max_window_s = static_cast(vehicle_scrape_report_max_window_ms) * 0.001f; + const int64_t since_ms = std::max(now - bank.last_report_ms, 0); + bank.report_budget_s = std::min(bank.report_budget_s + static_cast(since_ms) * 0.001f, max_window_s); + bank.last_report_ms = now; + const float max_speed_excess = vehicle_observed_speed_peak(vehicle->handle) + vehicle_scrape_report_slack + - vehicle_scrape_tuning[vdc].min_speed; + if (!(max_speed_excess > 0.0f)) { + return; + } + const float billed = std::min(slide_excess, max_speed_excess * bank.report_budget_s); + bank.report_budget_s -= billed / max_speed_excess; + vehicle_server_apply_scrape(vehicle, billed); +} + void vehicle_note_observed_speed(int vehicle_handle, float speed) { if (!rf::is_server || !std::isfinite(speed)) { diff --git a/game_patch/multi/vehicles/vehicle_fx.cpp b/game_patch/multi/vehicles/vehicle_fx.cpp index 4c81ade3f..785df7503 100644 --- a/game_patch/multi/vehicles/vehicle_fx.cpp +++ b/game_patch/multi/vehicles/vehicle_fx.cpp @@ -9,6 +9,7 @@ #include #include #include "vehicle.h" +#include "vehicle_crash_tuning.h" #include "vehicle_fx.h" #include "vehicle_internal.h" #include "vehicle_physics.h" @@ -112,6 +113,12 @@ namespace "Amb_Water_Fall_02.wav", }; + // The fighter's scrape loop (alpinefaction.vpp). + constexpr const char* fx_scrape_sound_file = "af_fighter_scrape_loop.wav"; + // How long a scrape probe's finding keeps the loop going; the last fx_scrape_fade_s of it fades it out. + constexpr float fx_scrape_hold_s = 0.2f; + constexpr float fx_scrape_fade_s = 0.1f; + enum FxBitmap { FXB_SMOKE, @@ -283,6 +290,7 @@ namespace float debris_cooldown = 0.0f; float scrape_snd_timer = 0.0f; float scrape_flash_timer = 0.0f; + float scrape_burst_timer = 0.0f; float splash_snd_timer = 0.0f; float fire_light_timer = 0.0f; bool bottom_in_water = false; @@ -297,6 +305,13 @@ namespace int burn_snd = -1; float wash_snd_retry = 0.0f; float burn_snd_retry = 0.0f; + // Held across the probes: a probe's slide, 0 to 1 over the damage span, and where it was. + float scrape_hold = 0.0f; + float scrape_level = 0.0f; + rf::Vector3 scrape_pos{}; + int scrape_snd = -1; + float scrape_snd_retry = 0.0f; + float scrape_snd_pitch = 0.0f; // last ratio set on scrape_snd; 0 for none yet bool hold_loops = false; // a skipped frame decided nothing about them float dust_accum = 0.0f; @@ -322,6 +337,7 @@ namespace int g_fx_num_emitters = 0; std::array g_fx_foley = fx_unloaded(); std::array g_fx_wash_sounds = fx_unloaded(); + int g_fx_scrape_sound = -1; rf::ImpactSoundSet* g_fx_big_debris_iss = nullptr; rf::ImpactSoundSet* g_fx_small_debris_iss = nullptr; bool g_fx_sounds_loaded = false; @@ -403,6 +419,11 @@ namespace xlog::warn("[vehicle_fx] failed to load sound '{}'", fx_wash_sound_files[i]); } } + // Full volume out to 10: loud in the cockpit, and plain to anyone near the hull. + g_fx_scrape_sound = rf::snd_get_handle(fx_scrape_sound_file, 10.0f, 1.0f, 1.0f); + if (g_fx_scrape_sound < 0 && rf::sound_enabled) { + xlog::warn("[vehicle_fx] failed to load sound '{}'", fx_scrape_sound_file); + } g_fx_big_debris_iss = fx_find_iss("big debris bounce"); g_fx_small_debris_iss = fx_find_iss("small debris bounce"); } @@ -585,6 +606,7 @@ namespace fx_destroy_emitters(fx); fx_stop_loop(fx.wash_snd); fx_stop_loop(fx.burn_snd); + fx_stop_loop(fx.scrape_snd); } void fx_spawn(HullFx& fx, const ParticleSpec& s, const rf::Vector3& pos, const rf::Vector3& vel, @@ -834,6 +856,10 @@ namespace if (fx.impact_cooldown > 0.0f) { return; } + // A fighter's effects start where its crash damage does, so a slam that shows costs hull and a soft + // landing shows nothing. + const float wall_min = flyer ? vehicle_crash_tuning[VDC_FIGHTER].wall_threshold : fx_impact_min_dv; + const float land_min = flyer ? vehicle_crash_tuning[VDC_FIGHTER].ground_threshold : fx_impact_min_dv; const bool was_airborne = fx.airborne_age < fx_landing_airborne_window; const float land_dv = was_airborne && fx.vel_slow.y < 0.0f ? std::max(fx.vel.y - fx.vel_slow.y, 0.0f) : 0.0f; const rf::Vector3 slow_h{fx.vel_slow.x, 0.0f, fx.vel_slow.z}; @@ -841,21 +867,21 @@ namespace float wall_dv = 0.0f; float wall_kept = 0.0f; rf::Vector3 wall_dir{}; - if (slow_h_speed > fx_impact_min_dv) { + if (slow_h_speed > wall_min) { wall_dir = slow_h * (1.0f / slow_h_speed); wall_kept = std::max(rf::Vector3{fx.vel.x, 0.0f, fx.vel.z}.dot_prod(wall_dir), 0.0f); wall_dv = slow_h_speed - wall_kept; } - const float dv = std::max(land_dv, wall_dv); - if (dv < fx_impact_min_dv) { + const bool landing = land_dv >= land_min && land_dv >= wall_dv; + if (!landing && wall_dv < wall_min) { return; } // A wall only takes the speed it stopped. - const float impact_speed = land_dv >= wall_dv ? fx.peak_fall : fx.peak_h - wall_kept; - const float strength = - std::clamp((impact_speed - fx_impact_min_dv) / (fx_impact_full_dv - fx_impact_min_dv), 0.15f, 1.0f); + const float impact_speed = landing ? fx.peak_fall : fx.peak_h - wall_kept; + const float min_dv = landing ? land_min : wall_min; + const float strength = std::clamp((impact_speed - min_dv) / (fx_impact_full_dv - min_dv), 0.15f, 1.0f); - const rf::Vector3 dir = land_dv >= wall_dv ? rf::Vector3{0.0f, -1.0f, 0.0f} : wall_dir; + const rf::Vector3 dir = landing ? rf::Vector3{0.0f, -1.0f, 0.0f} : wall_dir; rf::PCollisionOut o{}; // Generous reach: suspension and wheels start slowing a landing hull before its body is near the ground. if (!fx_cast(ep.pos, ep.pos + dir * (vehicle_collision_reach_along(&ep, dir) + 2.5f), o)) { @@ -874,11 +900,14 @@ namespace fx_impact(ep, fx, o, strength, lod, water ? liquid_y : std::nullopt); } - // Body contact while moving along a wall (and, for flyers, a ceiling, or a floor while airborne). + // Body contact while moving along a wall (and, for flyers, a ceiling or the ground, or nose or tail first). + // A flyer runs the probe and the free speeds its scrape damage does, so what sparks costs hull, and looks and + // sounds the same on any surface; a car's follows the material. void fx_scrape(rf::Entity& ep, HullFx& fx, bool flyer, float lod) { fx_tick(fx.scrape_snd_timer); fx_tick(fx.scrape_flash_timer); + fx_tick(fx.scrape_burst_timer); fx.scrape_timer -= g_fx_dt; if (fx.scrape_timer > 0.0f) { return; @@ -887,51 +916,69 @@ namespace if (fx.vel.len() < 3.0f) { return; } - const rf::Vector3 dirs[] = {ep.orient.rvec, ep.orient.rvec * -1.0f, ep.orient.uvec, ep.orient.uvec * -1.0f}; - // A fighter resting or taxiing on its belly is not scraping it. - const int num_dirs = !flyer ? 2 : fx_grounded(ep, fx) ? 3 : 4; - for (int i = 0; i < num_dirs; ++i) { - const rf::Vector3& dir = dirs[i]; - rf::PCollisionOut o{}; - if (!fx_cast(ep.pos, ep.pos + dir * (vehicle_collision_reach_along(&ep, dir) + 0.15f), o)) { - continue; + VehicleHullProbeHit hits[vehicle_hull_probe_max_hits]; + const int num_hits = vehicle_hull_probe(&ep, fx.vel, flyer, false, hits); + float loop_level = -1.0f; + rf::Vector3 loop_pos{}; + for (int i = 0; i < num_hits; ++i) { + const VehicleHullProbeHit& o = hits[i]; + const rf::Vector3& along = o.along; + const float along_speed = o.along_speed; + float free_speed = 3.0f; + if (flyer) { + const VehicleScrapeTuning& scrape = vehicle_scrape_tuning[VDC_FIGHTER]; + free_speed = o.normal.y >= vehicle_crash_ground_ny ? scrape.ground_min_speed : scrape.min_speed; + const float level = + std::clamp((along_speed - free_speed) / std::max(scrape.ref_speed - free_speed, 1.0f), 0.0f, 1.0f); + if (along_speed >= free_speed && level > loop_level) { + loop_level = level; + loop_pos = o.point; + } } - // A side ray tipped into the ground by a rolled hull finds the floor, not a wall. - if (i < 2 && o.hit_normal.dot_prod(ep.orient.uvec) > 0.5f) { - continue; + else if (o.normal.dot_prod(ep.orient.uvec) > 0.5f) { + continue; // a side ray tipped into the ground by a rolled hull finds the floor, not a wall } - const rf::Vector3 along = fx.vel - o.hit_normal * fx.vel.dot_prod(o.hit_normal); - const float along_speed = along.len(); - if (along_speed < 3.0f) { + if (along_speed < free_speed) { continue; } - const SurfaceFx s = surface_fx(o.material); - const int count = static_cast(std::min(along_speed * 0.15f, 3.0f) * lod + 0.5f); + const SurfaceFx s = flyer ? SurfaceFx{0.0f, {}, true} : surface_fx(o.material); + const int count = flyer ? static_cast(std::min(along_speed * 0.25f, 5.0f) * lod + 0.5f) + : static_cast(std::min(along_speed * 0.15f, 3.0f) * lod + 0.5f); if (s.sparks) { for (int k = 0; k < count; ++k) { - const rf::Vector3 vel = along * -fx_rand(0.2f, 0.5f) + o.hit_normal * fx_rand(1.0f, 3.0f) - + fx_rand_dir_off(o.hit_normal) * 1.5f; - fx_spawn(fx, spark_spec, o.hit_point, vel); + const rf::Vector3 vel = along * -fx_rand(0.2f, 0.5f) + o.normal * fx_rand(1.0f, 3.0f) + + fx_rand_dir_off(o.normal) * 1.5f; + fx_spawn(fx, spark_spec, o.point, vel); + } + if (flyer && fx.scrape_burst_timer <= 0.0f) { + fx.scrape_burst_timer = fx_rand(0.2f, 0.35f); + fx_burst_off(fx, spark_spec, static_cast((4.0f + 0.4f * along_speed) * lod), o.point, + o.normal, 3.0f + 0.2f * along_speed); } + // Under a flyer's scrape loop, a crackle rather than the whole sound. if (fx.scrape_snd_timer <= 0.0f) { fx.scrape_snd_timer = fx_rand(0.12f, 0.25f); - fx_play(FXS_SPARK, o.hit_point, std::clamp(along_speed / 15.0f, 0.3f, 1.0f)); + fx_play(FXS_SPARK, o.point, std::clamp(along_speed / 15.0f, 0.3f, 1.0f) * (flyer ? 0.5f : 1.0f)); } if (fx.scrape_flash_timer <= 0.0f) { fx.scrape_flash_timer = fx_rand(0.25f, 0.4f); - fx_flash(o.hit_point + o.hit_normal * 0.3f, 2.5f, 0.5f, 90); + fx_flash(o.point + o.normal * 0.3f, 2.5f, 0.5f, 90); } } else if (s.dust > 0.0f) { - fx_spawn(fx, dust_spec(s, 0.8f, fx.spawn_seq++ % 2 != 0), o.hit_point, - o.hit_normal * 0.8f + along * -0.1f); + fx_spawn(fx, dust_spec(s, 0.8f, fx.spawn_seq++ % 2 != 0), o.point, o.normal * 0.8f + along * -0.1f); if (fx.scrape_snd_timer <= 0.0f) { fx.scrape_snd_timer = fx_rand(0.4f, 0.7f); - fx_play_iss(g_fx_small_debris_iss, o.material, o.hit_point, + fx_play_iss(g_fx_small_debris_iss, o.material, o.point, std::clamp(along_speed / 20.0f, 0.2f, 0.6f)); } } } + if (loop_level >= 0.0f) { + fx.scrape_hold = fx_scrape_hold_s; + fx.scrape_level = loop_level; + fx.scrape_pos = loop_pos; + } } // Wheels and tracks: dust behind them on dry ground, spray where they run through water. @@ -1167,6 +1214,24 @@ namespace fx_stop_loop(fx.wash_snd); fx.wash_snd_retry = 0.0f; } + // Louder and higher the faster the slide; out over the hold's last stretch once the probes find none. + if (fx.scrape_hold > 0.0f && g_fx_scrape_sound >= 0) { + const float volume = + (0.45f + 0.55f * fx.scrape_level) * std::min(fx.scrape_hold / fx_scrape_fade_s, 1.0f); + fx_update_loop(fx.scrape_snd, fx.scrape_snd_retry, fx.scrape_pos, fx.vel, volume, [&] { + fx.scrape_snd_pitch = 0.0f; + return snd_play_3d_looping(g_fx_scrape_sound, fx.scrape_pos, volume, rf::SOUND_GROUP_EFFECTS); + }); + const float pitch = 0.9f + 0.35f * fx.scrape_level; + if (pitch != fx.scrape_snd_pitch) { + snd_instance_set_pitch(fx.scrape_snd, pitch); + fx.scrape_snd_pitch = pitch; + } + } + else { + fx_stop_loop(fx.scrape_snd); + fx.scrape_snd_retry = 0.0f; + } if (fx.burning) { fx_update_loop(fx.burn_snd, fx.burn_snd_retry, fx.vent, fx.vel, 1.0f, [&] { return fx_play_handle(fx_sound_handle(FXS_BURNING), fx.vent, 1.0f); }); @@ -1256,6 +1321,7 @@ void vehicle_fx_do_frame() } fx.wash_level = 0.0f; fx.burning = false; + fx.scrape_hold = std::max(fx.scrape_hold - dt, 0.0f); for (int layer = 0; layer < fx_num_layers; ++layer) { if (const rf::ParticleEmitter* emitter = fx.emitters[layer]) { const int n = fx_count_particles(emitter); diff --git a/game_patch/multi/vehicles/vehicle_internal.h b/game_patch/multi/vehicles/vehicle_internal.h index 7cff95f2d..83c723244 100644 --- a/game_patch/multi/vehicles/vehicle_internal.h +++ b/game_patch/multi/vehicles/vehicle_internal.h @@ -42,6 +42,11 @@ struct VehicleHealthSync bool settled_sent = true; // nothing has changed since the last reliable send rf::Vector3 hit_dir{}; // travel direction of the latest attributed hit, for the riders' indicators int64_t hit_ms = -1; + // Since the last send: scrape damage landed, and any other noted blow did; and whether the last send was a + // scrape-only stream packet. + bool scraped = false; + bool hurt_unscraped = false; + bool last_streamed_scrape_only = false; }; // Client: the last af_vehicle_health for a vehicle, by local object handle. @@ -214,6 +219,25 @@ struct VehicleSpawnSlot bool given_up = false; }; +// One hull's open crash window and the largest life fraction billed (server) or reported (client) in it: a +// harder hit inside the window takes only what it adds over that. +struct VehicleCrashWindow +{ + rf::Timestamp window; + float billed_frac = 0.0f; +}; + +// The machine simulating a hull: units of slide past the class's free speed banked toward the next scrape +// flush, due at flush_at_ms (timer::get_i64(1000); 0 = none open). Server: when the last client scrape report +// for the hull landed, and the refilling budget of sliding time the reports may still bill. +struct VehicleScrapeBank +{ + float slide_excess = 0.0f; + int64_t flush_at_ms = 0; + int64_t last_report_ms = 0; + float report_budget_s = 0.0f; +}; + // Server: who a COASTING hull still answers for, and until when - the attribution and crush windows // are one. Keyed by NET ID: the ex-driver can disconnect, die and respawn inside the window. struct VehicleCoastMemory @@ -255,7 +279,7 @@ struct VehicleLethalKiller // every machine, the FIRST pose (received row, or this machine's own body) noted in each bucket, and when. struct VehicleObservedSpeed { - static constexpr int bucket_count = 4; + static constexpr int bucket_count = 8; float bucket_peak[bucket_count]{}; // [0] is the current bucket rf::Vector3 bucket_pos[bucket_count]{}; float bucket_heading[bucket_count]{}; @@ -348,10 +372,9 @@ struct VehicleModuleState std::unordered_map crush_report_cooldown; // by player id // One roadkill per victim per cooldown: one contact can span several physics substeps. std::unordered_map crush_cooldown; // by victim entity handle - // One crash per hull per cooldown; the client keeps a slightly longer window to spare the report. - std::unordered_map crash_cooldown; // by vehicle handle - // Server: bounds one sender's crash reports, apart from his roadkill reports. - std::unordered_map crash_report_cooldown; // by player id + // The client keeps a slightly longer window to spare the report. + std::unordered_map crash_cooldown; // by vehicle handle + std::unordered_map scrape; // by vehicle handle // Server: an entry exists only inside the memory window. std::unordered_map coast_memory; std::map ram_pairs; diff --git a/game_patch/multi/vehicles/vehicle_physics.h b/game_patch/multi/vehicles/vehicle_physics.h index 11b14c773..d67266234 100644 --- a/game_patch/multi/vehicles/vehicle_physics.h +++ b/game_patch/multi/vehicles/vehicle_physics.h @@ -124,6 +124,27 @@ bool vehicle_physics_server_owns(int vehicle_handle); void vehicle_physics_entity_hull_box(const rf::Entity* ep, rf::Vector3* out_half, rf::Vector3* out_center); +// The radius of a flyer's or sub's physics sphere, which holds its origin this far off any surface; 0 for a +// wheeled hull or anything the physics does not shape. +float vehicle_physics_hull_standoff(const rf::Entity* ep); + +// A hull surface at or within this distance of the level is touching it. +inline constexpr float vehicle_physics_touch_dist = 0.02f; + +struct VehiclePhysicsContact +{ + rf::Vector3 point; // on the surface + rf::Vector3 normal; // out of the surface + rf::Vector3 surface_vel; // the surface's own velocity there: a mover's, else zero +}; + +// The level, mover and prop surfaces a sphere at center touches in this machine's vehicle physics world - what a +// hull body of that radius collides with, to vehicle_physics_touch_dist; props exist only around the hulls this +// machine simulates. One contact per surface, however finely it is split into triangles. Fills at most +// max_contacts; 0 when there is no world. +int vehicle_physics_sphere_contacts(const rf::Vector3& center, float radius, VehiclePhysicsContact* out, + int max_contacts); + // The SERVER body's live velocity; p_data.vel is deliberately zero for one, so it is no source. bool vehicle_physics_server_velocity(int vehicle_handle, rf::Vector3* out); diff --git a/game_patch/multi/vehicles/vehicle_seats.cpp b/game_patch/multi/vehicles/vehicle_seats.cpp index f569fa843..571d398d1 100644 --- a/game_patch/multi/vehicles/vehicle_seats.cpp +++ b/game_patch/multi/vehicles/vehicle_seats.cpp @@ -1150,7 +1150,6 @@ void vehicle_on_player_disconnect(rf::Player* pp) const int player_id = pp->net_data->player_id; g_vehicle_state.use_cooldown.erase(player_id); g_vehicle_state.crush_report_cooldown.erase(player_id); - g_vehicle_state.crash_report_cooldown.erase(player_id); g_vehicle_state.orient_relay_cooldown.erase(player_id); g_vehicle_state.horn_cooldown.erase(player_id); } diff --git a/game_patch/multi/vehicles/vehicle_sync.cpp b/game_patch/multi/vehicles/vehicle_sync.cpp index 4a115830e..0b739e637 100644 --- a/game_patch/multi/vehicles/vehicle_sync.cpp +++ b/game_patch/multi/vehicles/vehicle_sync.cpp @@ -879,12 +879,21 @@ void vehicle_server_stop_fire(rf::Entity* vehicle) // the demo recorder is a virtual observer player. void vehicle_broadcast_health(rf::Entity* vehicle, bool is_reliable, const rf::Vector3* hit_dir) { + // Every send takes the blows noted since the last one. A settle keeps a scrape-only stream's word: it carries + // the scrape residue the stream's 1-life floor held back, or a lost streamed drop. + VehicleHealthSync& sync = g_vehicle_state.health_sync[vehicle->handle]; + const bool scrape_only = (sync.scraped || sync.last_streamed_scrape_only) && !sync.hurt_unscraped; + sync.scraped = false; + sync.hurt_unscraped = false; + sync.last_streamed_scrape_only = !is_reliable && scrape_only; + const uint8_t flags = scrape_only ? AF_VEHICLE_HEALTH_SCRAPE_ONLY : 0; + uint16_t refill_ms[2]; vehicle_wire_refill_ms(vehicle, timer::get_i64(1000), refill_ms); af_send_vehicle_health_packet_to_all(vehicle->handle, vehicle->life, vehicle_hud_max_life(vehicle), vehicle_weapon_ammo(vehicle, vehicle->ai.current_primary_weapon), vehicle_weapon_ammo(vehicle, vehicle->ai.current_secondary_weapon), - refill_ms, is_reliable, hit_dir); + refill_ms, is_reliable, hit_dir, flags); } // Streaming UNRELIABLE, settling RELIABLE: each unreliable packet supersedes the last, so only the @@ -949,6 +958,7 @@ void vehicle_drop_combat_state(int vehicle_handle) g_vehicle_state.lethal_killer.erase(vehicle_handle); g_vehicle_state.observed_speed.erase(vehicle_handle); g_vehicle_state.crash_cooldown.erase(vehicle_handle); + g_vehicle_state.scrape.erase(vehicle_handle); g_vehicle_state.health.erase(vehicle_handle); g_vehicle_state.ammo_mirror.erase(vehicle_handle); g_vehicle_state.hull_state.erase(vehicle_handle); @@ -1917,7 +1927,7 @@ void vehicle_rider_damage_feedback(int vehicle_handle, const rf::Vector3* hit_di void vehicle_store_health_from_packet(int vehicle_handle, float life, float max_life, int primary_ammo, int secondary_ammo, uint16_t primary_refill_ms, uint16_t secondary_refill_ms, - const rf::Vector3* hit_dir) + const rf::Vector3* hit_dir, bool scrape_only) { rf::Object* obj = rf::obj_from_remote_handle(vehicle_handle); if (!obj) { @@ -1931,7 +1941,8 @@ void vehicle_store_health_from_packet(int vehicle_handle, float life, float max_ const auto prev = g_vehicle_state.health.find(obj->handle); const bool hull_hurt = prev != g_vehicle_state.health.end() && life < prev->second.life; g_vehicle_state.health[obj->handle] = VehicleHealth{life, max_life}; - if (hull_hurt) { + // A scrape says so with its scrape effects and the gauge, not a flash every quarter second. + if (hull_hurt && !scrape_only) { vehicle_rider_damage_feedback(obj->handle, hit_dir); } diff --git a/resources/CMakeLists.txt b/resources/CMakeLists.txt index 47d90ed90..083877f14 100644 --- a/resources/CMakeLists.txt +++ b/resources/CMakeLists.txt @@ -96,6 +96,7 @@ add_packfile(alpinefaction.vpp sounds/af_spray1.ogg sounds/af_award1.ogg sounds/af_jeep_horn.ogg + sounds/af_fighter_scrape_loop.wav sounds/af_killsound1.wav sounds/af_radmsg_000.ogg sounds/af_radmsg_001.ogg diff --git a/resources/licensing-info.txt b/resources/licensing-info.txt index 14115c316..9e8e5611e 100644 --- a/resources/licensing-info.txt +++ b/resources/licensing-info.txt @@ -116,6 +116,9 @@ leeor_net: mcpable: * af_jeep_horn.ogg (CC0: https://freesound.org/s/131930/) +F.M.Audio: +* af_fighter_scrape_loop.wav (CC-BY 4.0: https://freesound.org/s/620428/) + ############################################################################### ## stb_image, stb_vorbis ############################################################################### diff --git a/resources/sounds/af_fighter_scrape_loop.wav b/resources/sounds/af_fighter_scrape_loop.wav new file mode 100644 index 0000000000000000000000000000000000000000..a60db8fb42d02430a0cbeae837108cdef003affe GIT binary patch literal 106766 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

^(Mto$*isoJu9{UbL?<6xvzs>BG4Eu zej#NE^#FAi^#OGq^$X!3wi$a4mxP%QZ3FklKuKjJC1EEPfh+|-4xA6%16)<>!7Y&Y zh*glo-cWxX)D8Os$^nmq$q+>Je!#wQEx>T$?QjRlbdg2 zEU4W(*J}<;gn!4LMHPX6LGGceutU+$5JNC5yb}KrAV|M}&;ZuY1;{Rcforz2A26v- z1V^}BmOR@4_dbC9Hw3V!^#Hx77~e|!Bn#D6=z8TiY8auJWLo3e>sJLV&a2jGPJ_3~ z!?GPTO)$SPYYeT*0`W(At7(}J4f*F=>n;gYA(vy0Lymj6zNO$pLASfa=Ci(XuJGjs z2D*yOpUt0rc`%)yV*a4}XDK$lQwGJ4#N*Y4#$P6!0k6P{qg7lB)P6{ZR|?FlfkfwM z?RZU?;f;~3o1nBy#z>Z`M>=_+H1|pa&CCuIfZCmJ0I%n8_$$b6|5G6K2?o7J+6a#b z{~`D=D%}tv<>yiHWGr1zHDbl+%_Ke>6!s_D91|X1$RP{*Ch`;Dp$O*8P+ruaFeE#i zj$>?PPG^R)PO`&+wBDfb(Xscr7qGS1K1>5Ai_i=Cnvg@Qp$;Jwpl%}`z`r0<2|0w- zsCfuA`2tr=WP%Gp|DoF`LE>ff6l@y#1m!Dn9c}<>6wC_SiS3R0=Q(V@9F!oFKr!~C z=Kr}U0l6!|X>o7#M7a8S|AJq6FPi#TWBhI29r|CgkNSF7gr~%uu4&h&nQt1$+0HrQ z%wx3MwF6CW)w0f^9WKdW`Pz;VRSC6f@fH0x{aB4uEm1To7V76)4w{RM4dxTJjrvNl zvok`;c5HWZ>_|tgcL{7eHiobVeIPj9BX^U41b&u#sDBs6fS(F&^?rk(h~I&}J(I4% zOHf^?hnO;S9+0V>g;+~Ci>rYbV1{vKhi~WUX^|umZUXS_qL4meXTb~Lse}RKZ^#wk z;pi61Cc<1;A7~izC~h!540Rtyf;1K))SVO747zDbyU@wewZEhWLKTJOI51M zR;}-ZHQcVdRMl8n({w_#q9 zx>9XDm8z$z4fMfBWzEM0>c=cPTi{drh==E=uzGXlolI+af6rPLc&p80h*7E zCV!;WlKbKs;9lriLfKu!Kxej=QLtnuyvi08jFSj|~UQqK@I z!y$3**4HTR%N0_oB1G>qI+P_+u3^0Ag5!jm3b0fb8h0xubgz-0&=;7zMzFC?eYPi3 zGTdeluJd%;Uiey3pWqn|m2tYA=jEF4*|3czcpkL?s#8HO;TBUrkcQ$0Q!nrbhZYe>A%EkTR2WeU$76mlhYJ-k6-k%kHt|og zW`%x?@bES>=Y}kZ=0#1WauDI@GID=f3WZIEQIe_mIGhMx_({$^fYAAft|K%c3bEKw zQ`AKvh#gDoMH|RG5-JV9DVP;n5`Hr7R=hFnJ9jYuj3Ae}8%xKeQf9N>^T6Sap%QwK z62^`bUKD2XUxyju`=^w~Xn_mv{BT0l^vJ&vQ^T)CgVW2iclDl>@*pNRrZQ%7_&_d) zvsJJ&k{LcabS(QF>kdCRN)j_KCN55%s?9o@iH_6q+CqK`!vwv#z1hT&ErLLJeMF*Q zN{BQhpU2}FnNP_Z$aHo$Zv@o?Yxb@5zkvo2?~(m5k;D|*bm{^8I^-1acyJ697RYn3 zY=6ARP*U

JnT%d=g;2Y)5`XM`acE=vm8vBOe6Tm& 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

VX-K&CSg2789UVvn=uaUT1PO*;`|NGLF$+ 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 literal 0 HcmV?d00001 From 59aae342df5e081dafa8bc93d56401edd4e82ac8 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 18 Oct 2026 19:03:21 -0230 Subject: [PATCH 8/8] fixes from review --- .github/workflows/ci.yml | 2 +- docs/CHANGELOG.md | 2 +- game_patch/graphics/d3d11/gr_d3d11.cpp | 2 +- game_patch/graphics/d3d11/gr_d3d11.h | 15 ++++ .../d3d11/gr_d3d11_dynamic_geometry.cpp | 11 +-- .../graphics/d3d11/gr_d3d11_particle.cpp | 30 +------ game_patch/graphics/weather.cpp | 6 +- game_patch/hud/hud_internal.h | 5 +- game_patch/hud/hud_status.cpp | 9 +- game_patch/multi/jetpack.cpp | 9 +- .../multi/vehicles/physics/vphys_internal.h | 3 +- .../multi/vehicles/physics/vphys_step.cpp | 14 ++- .../multi/vehicles/physics/vphys_world.cpp | 17 ++-- game_patch/multi/vehicles/vehicle.cpp | 10 ++- game_patch/multi/vehicles/vehicle.h | 3 +- .../multi/vehicles/vehicle_crash_tuning.h | 6 +- game_patch/multi/vehicles/vehicle_damage.cpp | 37 ++++++-- game_patch/multi/vehicles/vehicle_fx.cpp | 85 ++++++++++--------- game_patch/multi/vehicles/vehicle_internal.h | 4 +- game_patch/object/entity.cpp | 29 ++++--- game_patch/object/particle_sim.cpp | 5 +- game_patch/rf/particle_emitter.h | 7 +- 22 files changed, 174 insertions(+), 137 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f7891e4b..a93aafdf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Create a build environment run: cmake -E make_directory ./build - # Shaders would need Wine + vkd3d, which miscompiles them; the packfile ships from the MSVC job + # Shaders would need Wine + vkd3d, which crashes on some of them; the packfile ships from the MSVC job - name: Configure CMake working-directory: ./build run: | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 42d244d08..c1a2282b5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -127,7 +127,7 @@ Version 1.5.0 (Trillium): Not yet released - Test distant particles for world collision less often (`cl_particlecollidelod`) - Raise the particle pool size to 8192 (`cl_particlepoolsize`) - Raise the particle emitter limit from 128 to 8192 - - Render particles, plankton, rain and snow on the GPU with instancing, and simulate rain and snow motion on the GPU (Direct3D 11 renderer only) + - Render particles, plankton, rain and snow on the GPU with instancing, and simulate rain and snow motion on the GPU (Direct3D 11 renderer only), toggleable with `dbg_particlegpu` - Add `dbg_particles` overlay and `dbg_particles_log` to measure particle performance, and `dbg_particlewarmup` to toggle replaying a hidden particle emitter when it comes back into view - Raise the pitch of hit sounds as consecutive hits land, toggleable with `cl_hitsounds_streakpitch` - Raise the limit of simultaneously playing sounds from 64 to 127 diff --git a/game_patch/graphics/d3d11/gr_d3d11.cpp b/game_patch/graphics/d3d11/gr_d3d11.cpp index 0f83417d4..458ffde83 100644 --- a/game_patch/graphics/d3d11/gr_d3d11.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11.cpp @@ -901,7 +901,7 @@ namespace gr::d3d11 void Renderer::tmapper(int nv, const rf::gr::Vertex **vertices, int vertex_attributes, rf::gr::Mode mode) { // A pending batch was collected before this poly and has to be drawn before it. No-op - // unless a batched pass (particles, weather, plankton) is open. + // unless a batched pass (particles, plankton) is open. particle_renderer_->flush_batch(); std::array tex_handles{rf::gr::screen.current_texture_1, rf::gr::screen.current_texture_2}; dyn_geo_renderer_->add_poly(nv, vertices, vertex_attributes, tex_handles, mode); diff --git a/game_patch/graphics/d3d11/gr_d3d11.h b/game_patch/graphics/d3d11/gr_d3d11.h index 89ae21d71..1073454a2 100644 --- a/game_patch/graphics/d3d11/gr_d3d11.h +++ b/game_patch/graphics/d3d11/gr_d3d11.h @@ -300,6 +300,21 @@ namespace gr::d3d11 return mode.get_alpha_source() != rf::gr::ALPHA_SOURCE_TEXTURE; } + // White, with src's colour and alpha wherever the mode takes them from the vertex. + static inline rf::Color vertex_color_for_mode(rf::gr::Mode mode, const rf::Color& src) + { + rf::Color color{255, 255, 255, 255}; + if (mode_uses_vertex_color(mode)) { + color.red = src.red; + color.green = src.green; + color.blue = src.blue; + } + if (mode_uses_vertex_alpha(mode)) { + color.alpha = src.alpha; + } + return color; + } + static inline std::array normalize_texture_handles_for_mode(rf::gr::Mode mode, const std::array& textures) { switch (mode.get_texture_source()) { diff --git a/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp b/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp index a99ae4c3c..bfb4ef404 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp @@ -71,16 +71,7 @@ namespace gr::d3d11 static inline rf::Color get_vertex_color_from_screen(rf::gr::Mode mode) { - rf::Color color{255, 255, 255, 255}; - if (mode_uses_vertex_color(mode)) { - color.red = rf::gr::screen.current_color.red; - color.green = rf::gr::screen.current_color.green; - color.blue = rf::gr::screen.current_color.blue; - } - if (mode_uses_vertex_alpha(mode)) { - color.alpha = rf::gr::screen.current_color.alpha; - } - return color; + return vertex_color_for_mode(mode, rf::gr::screen.current_color); } inline std::array DynamicGeometryRenderer::convert_pos(const rf::gr::Vertex& v, bool is_3d) diff --git a/game_patch/graphics/d3d11/gr_d3d11_particle.cpp b/game_patch/graphics/d3d11/gr_d3d11_particle.cpp index 5f8ce8f67..fa1335d7a 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_particle.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_particle.cpp @@ -288,15 +288,7 @@ namespace gr::d3d11 // Stock feeds the colour through gr_set_color, so the quad picks it up from the screen // colour exactly where the dynamic geometry batcher would have. - rf::Color color{255, 255, 255, 255}; - if (mode_uses_vertex_color(mode)) { - color.red = particle.clr_current.red; - color.green = particle.clr_current.green; - color.blue = particle.clr_current.blue; - } - if (mode_uses_vertex_alpha(mode)) { - color.alpha = particle.clr_current.alpha; - } + const rf::Color color = vertex_color_for_mode(mode, particle.clr_current); State state{ Primitive::billboard, @@ -359,15 +351,7 @@ namespace gr::d3d11 if (!instancing_ok_ || !rf::gr::perspective_projection) { return false; } - rf::Color color{255, 255, 255, 255}; - if (mode_uses_vertex_color(mode)) { - color.red = rf::gr::screen.current_color.red; - color.green = rf::gr::screen.current_color.green; - color.blue = rf::gr::screen.current_color.blue; - } - if (mode_uses_vertex_alpha(mode)) { - color.alpha = rf::gr::screen.current_color.alpha; - } + const rf::Color color = vertex_color_for_mode(mode, rf::gr::screen.current_color); const int bm_handle = rf::gr::screen.current_texture_1; const State state{ Primitive::billboard, @@ -523,15 +507,7 @@ namespace gr::d3d11 upload_view(); // Colour from the mode, exactly as add_billboard takes it from the screen state - rf::Color color{255, 255, 255, 255}; - if (mode_uses_vertex_color(draw.mode)) { - color.red = draw.color.red; - color.green = draw.color.green; - color.blue = draw.color.blue; - } - if (mode_uses_vertex_alpha(draw.mode)) { - color.alpha = draw.color.alpha; - } + const rf::Color color = vertex_color_for_mode(draw.mode, draw.color); auto vec = [](const rf::Vector3& v) { return std::array{v.x, v.y, v.z}; diff --git a/game_patch/graphics/weather.cpp b/game_patch/graphics/weather.cpp index 3ac4ca6f9..4ce5549fa 100644 --- a/game_patch/graphics/weather.cpp +++ b/game_patch/graphics/weather.cpp @@ -949,11 +949,11 @@ bool weather_draw_gpu(int index, WeatherRegion& region, WeatherRegionState& stat draw.count = state.length; draw.box_min = rf::Vector3{box.center.x - box.half_x, box.center.y - box.half_y, box.center.z - box.half_z}; draw.box_size = rf::Vector3{box.half_x * 2.0f, box.half_y * 2.0f, box.half_z * 2.0f}; - double lateral_laps = 0.0; + double unused_laps = 0.0; double vertical_laps = 0.0; - draw.box_shift.x = weather_lattice_shift(velocity.x, draw.box_min.x, draw.box_size.x, lateral_laps); + draw.box_shift.x = weather_lattice_shift(velocity.x, draw.box_min.x, draw.box_size.x, unused_laps); draw.box_shift.y = weather_lattice_shift(velocity.y, draw.box_min.y, draw.box_size.y, vertical_laps); - draw.box_shift.z = weather_lattice_shift(velocity.z, draw.box_min.z, draw.box_size.z, lateral_laps); + draw.box_shift.z = weather_lattice_shift(velocity.z, draw.box_min.z, draw.box_size.z, unused_laps); // Only ever hashed, so the lap count wrapping round 32 bits is harmless draw.wrap_base = static_cast(static_cast(std::fmod(vertical_laps, 4294967296.0))); draw.salt = static_cast(index); diff --git a/game_patch/hud/hud_internal.h b/game_patch/hud/hud_internal.h index ef7da897b..8cd7459ee 100644 --- a/game_patch/hud/hud_internal.h +++ b/game_patch/hud/hud_internal.h @@ -84,7 +84,10 @@ int hud_side_gauge_label_y(const HudSideGaugeRect& rect); // Its label above and an optional value below; alpha scales the whole gauge. void hud_draw_side_gauge(bool left, float frac, const rf::Color& fill, const char* label, const char* value, float alpha); -// Centred text over a shadow pass, so it keeps its edges over bright geometry. +// Centred text over a shadow pass, so it keeps its edges over bright geometry; the gauge and hint defaults. +inline constexpr int hud_shadowed_text_alpha = 200; +inline constexpr int hud_shadowed_text_shadow_alpha = 160; +inline constexpr int hud_shadowed_text_shadow_offset_px = 1; void hud_draw_shadowed_text(int x, int y, const char* text, int font, const rf::Color& color, rf::ubyte shadow_alpha, int shadow_offset); // A vehicle HUD bar's alpha: rising while it has something to show, fading once full. One per bar; diff --git a/game_patch/hud/hud_status.cpp b/game_patch/hud/hud_status.cpp index 7c174ece5..02d85d008 100644 --- a/game_patch/hud/hud_status.cpp +++ b/game_patch/hud/hud_status.cpp @@ -137,10 +137,13 @@ void hud_draw_side_gauge(bool left, float frac, const rf::Color& fill, const cha } const int font = hud_get_small_font(); - const rf::Color text_color{255, 255, 255, scaled(200)}; - hud_draw_shadowed_text(x + w / 2, hud_side_gauge_label_y(rect), label, font, text_color, scaled(160), 1); + const rf::Color text_color{255, 255, 255, scaled(hud_shadowed_text_alpha)}; + const rf::ubyte shadow_alpha = scaled(hud_shadowed_text_shadow_alpha); + hud_draw_shadowed_text(x + w / 2, hud_side_gauge_label_y(rect), label, font, text_color, shadow_alpha, + hud_shadowed_text_shadow_offset_px); if (value) { - hud_draw_shadowed_text(x + w / 2, y + h + hud_side_gauge_text_gap, value, font, text_color, scaled(160), 1); + hud_draw_shadowed_text(x + w / 2, y + h + hud_side_gauge_text_gap, value, font, text_color, shadow_alpha, + hud_shadowed_text_shadow_offset_px); } } diff --git a/game_patch/multi/jetpack.cpp b/game_patch/multi/jetpack.cpp index 5614b05da..8d95ae0ed 100644 --- a/game_patch/multi/jetpack.cpp +++ b/game_patch/multi/jetpack.cpp @@ -62,9 +62,6 @@ constexpr int JETPACK_STATE_SYNC_INTERVAL_MS = 100; // a keybind hint offers itself under the reticle until the pack is first used. constexpr int JETPACK_REVEAL_MS = 2500; constexpr const char* JETPACK_GAUGE_LABEL = "JETPACK"; -constexpr int JETPACK_TEXT_ALPHA = 200; -constexpr int JETPACK_TEXT_SHADOW_ALPHA = 160; -constexpr int JETPACK_TEXT_SHADOW_OFFSET_PX = 1; constexpr int JETPACK_HINT_FADE_MS = 150; constexpr int JETPACK_HINT_OFFSET_PX = 48; // below screen center constexpr int JETPACK_HINT_OFFSET_PX_BIG = 72; @@ -803,10 +800,10 @@ void jetpack_draw_thrust_hint(int alpha_scale) const int offset = big ? JETPACK_HINT_OFFSET_PX_BIG : JETPACK_HINT_OFFSET_PX; const int x = rf::gr::clip_width() / 2; const int y = rf::gr::clip_height() / 2 + offset; - const rf::Color color{255, 255, 255, static_cast((JETPACK_TEXT_ALPHA * alpha_scale) / 255)}; + const rf::Color color{255, 255, 255, static_cast((hud_shadowed_text_alpha * alpha_scale) / 255)}; hud_draw_shadowed_text(x, y, g_jetpack_hint_text.c_str(), hud_get_default_font(), color, - static_cast((JETPACK_TEXT_SHADOW_ALPHA * alpha_scale) / 255), - JETPACK_TEXT_SHADOW_OFFSET_PX); + static_cast((hud_shadowed_text_shadow_alpha * alpha_scale) / 255), + hud_shadowed_text_shadow_offset_px); } } // namespace diff --git a/game_patch/multi/vehicles/physics/vphys_internal.h b/game_patch/multi/vehicles/physics/vphys_internal.h index d4b0d9f7b..9e890d645 100644 --- a/game_patch/multi/vehicles/physics/vphys_internal.h +++ b/game_patch/multi/vehicles/physics/vphys_internal.h @@ -437,7 +437,8 @@ constexpr short vphys_group_hull = 0x80; constexpr short vphys_group_level = 0x40; // The clutter props: Bullet's own static group and mask, which nothing else in the world is given. constexpr short vphys_group_prop = btBroadphaseProxy::StaticFilter; -constexpr short vphys_mask_prop = btBroadphaseProxy::AllFilter ^ btBroadphaseProxy::StaticFilter;// A hull pairs with everything EXCEPT another hull: vehicle-vs-vehicle is vphys_apply_pair_response. +constexpr short vphys_mask_prop = btBroadphaseProxy::AllFilter ^ btBroadphaseProxy::StaticFilter; +// A hull pairs with everything EXCEPT another hull: vehicle-vs-vehicle is vphys_apply_pair_response. constexpr short vphys_mask_no_hull = static_cast(~vphys_group_hull); enum class VehicleOrbitSeat diff --git a/game_patch/multi/vehicles/physics/vphys_step.cpp b/game_patch/multi/vehicles/physics/vphys_step.cpp index caf2b345a..d51f8ee46 100644 --- a/game_patch/multi/vehicles/physics/vphys_step.cpp +++ b/game_patch/multi/vehicles/physics/vphys_step.cpp @@ -634,9 +634,17 @@ namespace rf::Vector3 capped_vel = vel; const bool speed_clamped = vphys_clamp_class_velocity(cls, capped_vel); - // Every counted step, a slide of 0 included: that is what flushes a scrape once the hull comes away. - if (b.impact_armed && !b.impact_skip && !speed_clamped && step_time > 0.0f) { - scrapes.push_back({b.vehicle_handle, vel, b.scrape_speed, b.scrape_ground_speed, step_time}); + // Every counted step, a slide of 0 included: that is what flushes a scrape once the hull comes away. A + // clamped step counts too, at the capped speed. + if (b.impact_armed && !b.impact_skip && step_time > 0.0f) { + float wall_slide = b.scrape_speed; + float ground_slide = b.scrape_ground_speed; + if (speed_clamped) { + const float cap = vphys_class_speed_cap(cls); + wall_slide = std::min(wall_slide, cap); + ground_slide = std::min(ground_slide, cap); + } + scrapes.push_back({b.vehicle_handle, capped_vel, wall_slide, ground_slide, step_time}); } vphys_impact_measure(b, speed_clamped, step_time, impacts); if (speed_clamped) { diff --git a/game_patch/multi/vehicles/physics/vphys_world.cpp b/game_patch/multi/vehicles/physics/vphys_world.cpp index 8b02cb225..b4e3aedb8 100644 --- a/game_patch/multi/vehicles/physics/vphys_world.cpp +++ b/game_patch/multi/vehicles/physics/vphys_world.cpp @@ -1024,10 +1024,16 @@ void movers_update_all(bool step_will_run, float step_time, float engine_dt) // A machine with no body to step never refreshes the broadphase or derives a kinematic velocity, yet // the scrape probe asks this world for what a watched hull touches. if (!step_will_run) { - const rf::Vector3 vel = moved && engine_dt > 0.0f ? (op->pos - m.carry_pos) * (1.0f / engine_dt) - : rf::Vector3{}; - m.body->setLinearVelocity(to_bt(vel)); - m.body->setAngularVelocity(btVector3(0.0f, 0.0f, 0.0f)); + // As saveKinematicState derives it, angular part included: a turntable's surface moves too. + btVector3 lin_vel(0.0f, 0.0f, 0.0f); + btVector3 ang_vel(0.0f, 0.0f, 0.0f); + if (moved && engine_dt > 0.0f) { + const btTransform from(to_bt(m.carry_orient), to_bt(m.carry_pos)); + const btTransform to(to_bt(op->orient), to_bt(op->pos)); + btTransformUtil::calculateVelocity(from, to, engine_dt, lin_vel, ang_vel); + } + m.body->setLinearVelocity(lin_vel); + m.body->setAngularVelocity(ang_vel); if (moved) { g_vphys.world->updateSingleAabb(m.body); } @@ -1683,8 +1689,7 @@ namespace btVector3 bhi; if (owned->body) { owned->body->getAabb(blo, bhi); - owned->impact_prop_built |= blo.x() <= hi.x() && bhi.x() >= lo.x() && blo.y() <= hi.y() - && bhi.y() >= lo.y() && blo.z() <= hi.z() && bhi.z() >= lo.z(); + owned->impact_prop_built |= TestAabbAgainstAabb2(blo, bhi, lo, hi); } } } diff --git a/game_patch/multi/vehicles/vehicle.cpp b/game_patch/multi/vehicles/vehicle.cpp index 9a56e07ed..cd301fbbd 100644 --- a/game_patch/multi/vehicles/vehicle.cpp +++ b/game_patch/multi/vehicles/vehicle.cpp @@ -88,7 +88,6 @@ namespace constexpr float vehicle_strand_warn_s = 2.0f; // a sub out of the water constexpr float vehicle_hazard_drain_s = 3.0f; // empties a full hull in this constexpr float vehicle_hazard_recover_rate = 2.0f; // the clock runs back this much faster once clear - constexpr float vehicle_hazard_credit_s = 5.0f; // credit reach past the time spent in the medium constexpr float vehicle_void_probe = 300.0f; // world units of clear air below = out of the level constexpr float vehicle_void_time = 2.0f; // held this long before the kill @@ -385,6 +384,9 @@ int vehicle_hull_probe(rf::Entity* ep, const rf::Vector3& vel, bool flyer, bool int num_hits = 0; const auto add_hit = [&](const rf::Vector3& point, const rf::Vector3& normal, const rf::Vector3& rel_vel, int material) { + if (num_hits >= vehicle_hull_probe_max_hits) { + return; + } VehicleHullProbeHit& hit = out[num_hits++]; hit.point = point; hit.normal = normal; @@ -703,8 +705,8 @@ namespace return max_life / vehicle_hazard_drain_s * (0.4f + 1.2f * progress); } - // The Bullet body is released first so nothing fights the death animation. The blow itself credits nobody; - // a drown or strand scuttle may have named the destroyer first. + // The Bullet body is released first so nothing fights the death animation. The blow is unattributed: it + // goes to a hit from the last vehicle_hit_credit_s, or one a drown or strand scuttle named first. void vehicle_server_scuttle(rf::Entity* ep, float damage) { const int handle = ep->handle; @@ -954,7 +956,7 @@ void vehicle_do_frame() const float drain = vehicle_hazard_drain_rate(vehicle_hud_max_life(ep), drained_s) * rf::frametime; ep->life = std::max(ep->life - drain, last_life); if (ep->life <= last_life || drained_s >= 2.0f * vehicle_hazard_drain_s) { - vehicle_note_hazard_destroyer(ep, hazard_s + vehicle_hazard_credit_s); + vehicle_note_hazard_destroyer(ep, hazard_s + vehicle_hit_credit_s); vehicle_server_scuttle(ep, vehicle_scuttle_lethal_damage(ep)); continue; } diff --git a/game_patch/multi/vehicles/vehicle.h b/game_patch/multi/vehicles/vehicle.h index a1f24cc25..9f606f22b 100644 --- a/game_patch/multi/vehicles/vehicle.h +++ b/game_patch/multi/vehicles/vehicle.h @@ -7,6 +7,7 @@ #include #include "../../rf/math/vector.h" #include "../../rf/math/matrix.h" +#include "../../rf/physics.h" namespace rf { @@ -105,7 +106,7 @@ struct VehicleHullProbeHit rf::Vector3 normal; rf::Vector3 along; // the hull velocity's part along the surface; a sphere contact's is against a mover's motion float along_speed = 0.0f; - int material = 0; // rays only: a sphere contact is MATERIAL_DEFAULT + int material = rf::MATERIAL_DEFAULT; // rays only: a sphere contact keeps the default }; inline constexpr int vehicle_hull_probe_max_hits = 8; diff --git a/game_patch/multi/vehicles/vehicle_crash_tuning.h b/game_patch/multi/vehicles/vehicle_crash_tuning.h index 055fe8749..8c1678b0f 100644 --- a/game_patch/multi/vehicles/vehicle_crash_tuning.h +++ b/game_patch/multi/vehicles/vehicle_crash_tuning.h @@ -82,10 +82,8 @@ inline constexpr float vehicle_crash_max_over_ref = 1.25f; // A hit under this fraction of max life is a graze and deals nothing; a row's min_frac lifts every hit past its // threshold above it. inline constexpr float vehicle_crash_min_life_frac = 0.01f; -// Server: a hull's crashes inside this window bill only the largest one. -inline constexpr int vehicle_crash_server_cooldown_ms = 400; -// Client: the same for reports; longer than the server's so a late report lands. -inline constexpr int vehicle_crash_client_cooldown_ms = 500; +// A hull's crashes inside this window bill only what each adds over the largest; the client's reports alike. +inline constexpr int vehicle_crash_bill_window_ms = 400; // Simulated time an impact's velocity change is summed over (suspension spreads a landing). inline constexpr float vehicle_crash_window_s = 0.1f; // A ground window opened within this much contact time of airtime is a landing... diff --git a/game_patch/multi/vehicles/vehicle_damage.cpp b/game_patch/multi/vehicles/vehicle_damage.cpp index 39ba5ab38..8d42beb46 100644 --- a/game_patch/multi/vehicles/vehicle_damage.cpp +++ b/game_patch/multi/vehicles/vehicle_damage.cpp @@ -96,6 +96,9 @@ namespace // A sleeping hull does not react late, it does not react at all: wake the pair before contact. constexpr float vehicle_ram_wake_closing = 0.5f; + // A ram is head-on, each hull the other's rammer, when the slower closes at least this share of the faster. + constexpr float vehicle_ram_mutual_ratio = 0.5f; + // Per second, scaled by frametime at the point of use; dealt as DT_CRUSH through the ram mint. constexpr float vehicle_drill_damage_per_sec = 200.0f; // Matches the driller's physics drill probe reach, so damage and carve describe the same bits. @@ -815,14 +818,22 @@ void vehicle_server_ram_sweep() const float dmg_a = vehicle_ram_damage(a.ep, eff_closing, b.mass / a.mass); const float dmg_b = vehicle_ram_damage(b.ep, eff_closing, a.mass / b.mass); - // The RAMMER carries more of the closing speed; a stationary hull is never the rammer. + // The RAMMER carries more of the closing speed; a stationary hull is never the rammer. Head-on, with + // neither clearly faster, each hull rammed the other. const bool a_is_rammer = eff_a_toward >= eff_b_toward; + const bool mutual = std::min(eff_a_toward, eff_b_toward) + >= vehicle_ram_mutual_ratio * std::max(eff_a_toward, eff_b_toward); rf::Entity* rammer = a_is_rammer ? a.ep : b.ep; rf::Entity* rammed = a_is_rammer ? b.ep : a.ep; const float rammer_damage = a_is_rammer ? dmg_a : dmg_b; const float rammed_damage = a_is_rammer ? dmg_b : dmg_a; rf::Entity* credit = vehicle_ram_credit(rammer); const int killer_handle = credit ? credit->handle : -1; + rf::Entity* rammed_credit = mutual ? vehicle_ram_credit(rammed) : nullptr; + const int rammed_killer_handle = rammed_credit ? rammed_credit->handle : -1; + // A team-mate's blow the friendly-fire rule would drop: the rammer still pays his own share. + const bool mutual_credit = mutual && rammed_killer_handle != -1 + && !vehicle_occupied_by_friendly(rammer, rammed_killer_handle); // Latch BEFORE the damage: obj_damage can destroy either hull, and a hull's death runs // vehicle_drop_combat_state, which erases this entry. Nothing may touch `pair` after. @@ -851,7 +862,13 @@ void vehicle_server_ram_sweep() const int rammer_handle = rammer->handle; const int rammed_handle = rammed->handle; vehicle_ram_mint_damage(rammed_handle, killer_handle, rammed_damage, rammer); - // The rammer's share is nobody's kill, so killer -1 and no minting hull - which also + if (mutual_credit) { + // Taken as the other's blow too, so the tuning table scales it as it does the rammed share. + vehicle_ram_mint_damage(rammer_handle, rammed_killer_handle, rammer_damage, + rf::entity_from_handle(rammed_handle)); + continue; + } + // Otherwise the rammer's share is nobody's kill, so killer -1 and no minting hull - which also // exempts it from the tuning table, which never scales an unattributed blow. vehicle_ram_mint_damage(rammer_handle, -1, rammer_damage, nullptr); } @@ -1038,7 +1055,7 @@ namespace if (frac <= 0.0f) { return; } - const float bill = vehicle_crash_window_bill(vehicle->handle, frac, vehicle_crash_server_cooldown_ms); + const float bill = vehicle_crash_window_bill(vehicle->handle, frac, vehicle_crash_bill_window_ms); if (bill <= 0.0f) { return; } @@ -1084,7 +1101,7 @@ void vehicle_crash_impact(rf::Entity* vehicle, float impact_dv, bool ground) } const float reported_dv = std::min(impact_dv, vehicle_crash_max_impact_dv(vdc)); const float frac = vehicle_crash_life_frac(vdc, reported_dv, ground); - if (frac <= 0.0f || vehicle_crash_window_bill(vehicle->handle, frac, vehicle_crash_client_cooldown_ms) <= 0.0f) { + if (frac <= 0.0f || vehicle_crash_window_bill(vehicle->handle, frac, vehicle_crash_bill_window_ms) <= 0.0f) { return; } af_send_vehicle_crash_report(vehicle->server_handle, ground, reported_dv); @@ -1642,8 +1659,8 @@ namespace if (vehicle_blast_damage_is_mint(victim->handle)) { rf::Entity* vehicle = vehicle_synced_entity(g_vehicle_blast_mint.vehicle_handle); if (vehicle) { - // An unattributed lethal blow (drowned or stranded with no recent hit to blame, returned, - // void, own ram share) is the rider's own death; whoever softened the hull is paid in assists. + // An unattributed lethal blow with no recent hit to blame (drowned, stranded, returned, void, a + // crash or scrape, own ram share) is the rider's own death; whoever softened it is paid in assists. auto lethal = g_vehicle_state.lethal_killer.find(vehicle->handle); if (lethal != g_vehicle_state.lethal_killer.end() && lethal->second.entity_handle == -1) { return victim->handle; @@ -1833,8 +1850,8 @@ int vehicle_occupant_death_blast_hull(int victim_handle, int damage_type) ? g_vehicle_blast_mint.vehicle_handle : -1; } -// Ahead of a drown or strand scuttle, which is unattributed: the last player blow that hurt the hull -// within window_s names its destroyer, so the riders it takes die to him rather than by their own hand. +// Ahead of an unattributed lethal blow (a drown or strand scuttle, a crash): the last player blow that hurt the +// hull within window_s names its destroyer, so the riders it takes die to him rather than by their own hand. void vehicle_note_hazard_destroyer(rf::Entity* vehicle, float window_s) { auto it = g_vehicle_state.last_hurt.find(vehicle->handle); @@ -1907,6 +1924,10 @@ void vehicle_note_hull_damage(rf::Entity* vehicle, float life_before, int killer return lethal; }; if (vehicle->life <= 0.0f) { + // An unattributed lethal blow (a crash, a scrape, the own ram share) goes to whoever just hurt the hull. + if (killer_handle == -1) { + vehicle_note_hazard_destroyer(vehicle, vehicle_hit_credit_s); + } g_vehicle_state.lethal_killer.try_emplace(vehicle->handle, blow()); } else if (real_damage > 0.0f) { diff --git a/game_patch/multi/vehicles/vehicle_fx.cpp b/game_patch/multi/vehicles/vehicle_fx.cpp index 785df7503..4a4181804 100644 --- a/game_patch/multi/vehicles/vehicle_fx.cpp +++ b/game_patch/multi/vehicles/vehicle_fx.cpp @@ -83,19 +83,19 @@ namespace enum FxSound { - FXS_IMPACT_METAL, - FXS_IMPACT_ROCK, - FXS_IMPACT_GLASS, - FXS_SPLASH_SMALL, - FXS_SPLASH_MEDIUM, - FXS_SPLASH_LARGE, - FXS_SPLASH_HUGE, - FXS_SPARK, - FXS_BURNING, - FXS_COUNT, + fx_sound_impact_metal, + fx_sound_impact_rock, + fx_sound_impact_glass, + fx_sound_splash_small, + fx_sound_splash_medium, + fx_sound_splash_large, + fx_sound_splash_huge, + fx_sound_spark, + fx_sound_burning, + fx_sound_count, }; - constexpr const char* fx_foley_names[FXS_COUNT] = { + constexpr const char* fx_foley_names[fx_sound_count] = { "Riot Impact Metal", "Riot Impact Rock", "Glass Land", @@ -121,16 +121,16 @@ namespace enum FxBitmap { - FXB_SMOKE, - FXB_DARK_SMOKE, - FXB_SPARK, - FXB_FIRE, - FXB_BUBBLE, - FXB_COUNT, + fx_bitmap_smoke, + fx_bitmap_dark_smoke, + fx_bitmap_spark, + fx_bitmap_fire, + fx_bitmap_bubble, + fx_bitmap_count, }; // Stock bitmaps the stock emitters.tbl already uses. - constexpr const char* fx_bitmap_filenames[FXB_COUNT] = { + constexpr const char* fx_bitmap_filenames[fx_bitmap_count] = { "smoketest.tga", "smokedark.tga", "Flame01_02.tga", @@ -154,35 +154,35 @@ namespace }; constexpr ParticleSpec spark_spec{ - FXB_SPARK, {255, 255, 255, 255}, {128, 128, 128, 0}, 0.08f, 0.13f, 0.0f, 0.25f, 0.5f, 2.0f, 0.0f, + fx_bitmap_spark, {255, 255, 255, 255}, {128, 128, 128, 0}, 0.08f, 0.13f, 0.0f, 0.25f, 0.5f, 2.0f, 0.0f, rf::PTF_GLOW | rf::PTF_VEL_STRETCH, }; // The bubble bitmap's white specks read as droplets; the stock corona has an orange core. constexpr ParticleSpec spray_spec{ - FXB_BUBBLE, {220, 235, 255, 220}, {220, 235, 255, 0}, 0.12f, 0.25f, -0.1f, 0.4f, 0.8f, 1.0f, 0.0f, + fx_bitmap_bubble, {220, 235, 255, 220}, {220, 235, 255, 0}, 0.12f, 0.25f, -0.1f, 0.4f, 0.8f, 1.0f, 0.0f, rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT, }; constexpr ParticleSpec mist_spec{ - FXB_SMOKE, {225, 235, 245, 110}, {225, 235, 245, 0}, 0.3f, 0.6f, 1.5f, 0.8f, 1.4f, -0.05f, -1.5f, + fx_bitmap_smoke, {225, 235, 245, 110}, {225, 235, 245, 0}, 0.3f, 0.6f, 1.5f, 0.8f, 1.4f, -0.05f, -1.5f, rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT, }; constexpr ParticleSpec smoke_spec{ - FXB_SMOKE, {120, 120, 120, 90}, {90, 90, 90, 0}, 0.3f, 0.5f, 0.4f, 1.2f, 2.0f, -0.12f, -1.0f, + fx_bitmap_smoke, {120, 120, 120, 90}, {90, 90, 90, 0}, 0.3f, 0.5f, 0.4f, 1.2f, 2.0f, -0.12f, -1.0f, rf::PTF_RANDOM_ORIENT, }; constexpr ParticleSpec dark_smoke_spec{ - FXB_DARK_SMOKE, {255, 255, 255, 150}, {255, 255, 255, 0}, 0.4f, 0.6f, 0.9f, 2.4f, 3.4f, -0.3f, -1.0f, + fx_bitmap_dark_smoke, {255, 255, 255, 150}, {255, 255, 255, 0}, 0.4f, 0.6f, 0.9f, 2.4f, 3.4f, -0.3f, -1.0f, rf::PTF_RANDOM_ORIENT, }; constexpr ParticleSpec fire_spec{ - FXB_FIRE, {255, 255, 255, 190}, {255, 255, 255, 0}, 0.35f, 0.6f, -0.3f, 0.45f, 0.8f, -0.6f, -3.0f, + fx_bitmap_fire, {255, 255, 255, 190}, {255, 255, 255, 0}, 0.35f, 0.6f, -0.3f, 0.45f, 0.8f, -0.6f, -3.0f, rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT, }; constexpr ParticleSpec bubble_spec{ - FXB_BUBBLE, {255, 255, 255, 160}, {255, 255, 255, 0}, 0.05f, 0.12f, 0.0f, 1.0f, 2.0f, -0.5f, 0.0f, 0, + fx_bitmap_bubble, {255, 255, 255, 160}, {255, 255, 255, 0}, 0.05f, 0.12f, 0.0f, 1.0f, 2.0f, -0.5f, 0.0f, 0, }; - constexpr int fx_num_layers = FXB_COUNT * 2; + constexpr int fx_num_layers = fx_bitmap_count * 2; int fx_layer(const ParticleSpec& s) { @@ -229,7 +229,7 @@ namespace rf::Color dest = clr; dest.alpha = 0; const unsigned flags = haze ? rf::PTF_GLOW | rf::PTF_RANDOM_ORIENT : rf::PTF_RANDOM_ORIENT; - return {FXB_SMOKE, clr, dest, 0.25f * size, 0.45f * size, 0.8f, 0.6f, 1.1f, -0.05f, -2.0f, flags}; + return {fx_bitmap_smoke, clr, dest, 0.25f * size, 0.45f * size, 0.8f, 0.6f, 1.1f, -0.05f, -2.0f, flags}; } // A landing or crash throws a heavier, longer-lived cloud than a rolling wheel. @@ -331,11 +331,11 @@ namespace } std::unordered_map g_hull_fx; - std::array g_fx_bitmaps = fx_unloaded(); + std::array g_fx_bitmaps = fx_unloaded(); bool g_fx_bitmaps_loaded = false; int g_fx_live_particles = 0; int g_fx_num_emitters = 0; - std::array g_fx_foley = fx_unloaded(); + std::array g_fx_foley = fx_unloaded(); std::array g_fx_wash_sounds = fx_unloaded(); int g_fx_scrape_sound = -1; rf::ImpactSoundSet* g_fx_big_debris_iss = nullptr; @@ -383,7 +383,7 @@ namespace return; } g_fx_bitmaps_loaded = true; - for (int i = 0; i < FXB_COUNT; ++i) { + for (int i = 0; i < fx_bitmap_count; ++i) { g_fx_bitmaps[i] = rf::bm::load(fx_bitmap_filenames[i], -1, true); if (g_fx_bitmaps[i] < 0) { xlog::warn("[vehicle_fx] failed to load bitmap '{}'", fx_bitmap_filenames[i]); @@ -406,7 +406,7 @@ namespace return; } g_fx_sounds_loaded = true; - for (int i = 0; i < FXS_COUNT; ++i) { + for (int i = 0; i < fx_sound_count; ++i) { g_fx_foley[i] = rf::foley_lookup_by_name(fx_foley_names[i]); if (g_fx_foley[i] < 0) { xlog::warn("[vehicle_fx] no foley '{}'", fx_foley_names[i]); @@ -580,7 +580,7 @@ namespace type.pos = fx.last_pos; type.min_spawn_delay = 1.0f; type.max_spawn_delay = 1.0f; - type.bitmap = g_fx_bitmaps[FXB_SMOKE]; + type.bitmap = g_fx_bitmaps[fx_bitmap_smoke]; type.num_frames = 1; // Parent 0 is no object (handle signatures start at 1) but counts as parented, which stock needs to fit // the cull radius to the particles (0x00497E0A). Never ticked: particle_emitter_create does not link it. @@ -765,11 +765,11 @@ namespace { switch (material) { case rf::MATERIAL_METAL: - return FXS_IMPACT_METAL; + return fx_sound_impact_metal; case rf::MATERIAL_GLASS: - return FXS_IMPACT_GLASS; + return fx_sound_impact_glass; default: - return FXS_IMPACT_ROCK; + return fx_sound_impact_rock; } } @@ -808,7 +808,8 @@ namespace 3.0f + 4.0f * strength, (3.0f + 4.0f * strength) * size, size); fx_ring(fx, mist_spec, static_cast((4.0f + 8.0f * strength) * amount), surface, 2.0f * size, 1.5f, 1.0f * size, 1.5f * size); - constexpr FxSound splashes[] = {FXS_SPLASH_SMALL, FXS_SPLASH_MEDIUM, FXS_SPLASH_LARGE, FXS_SPLASH_HUGE}; + constexpr FxSound splashes[] = {fx_sound_splash_small, fx_sound_splash_medium, fx_sound_splash_large, + fx_sound_splash_huge}; const int tier = (strength < 0.35f ? 0 : strength < 0.7f ? 1 : 2) + (size > 1.0f ? 1 : 0); fx_play(splashes[tier], surface, 0.5f + 0.5f * strength); } @@ -958,7 +959,8 @@ namespace // Under a flyer's scrape loop, a crackle rather than the whole sound. if (fx.scrape_snd_timer <= 0.0f) { fx.scrape_snd_timer = fx_rand(0.12f, 0.25f); - fx_play(FXS_SPARK, o.point, std::clamp(along_speed / 15.0f, 0.3f, 1.0f) * (flyer ? 0.5f : 1.0f)); + fx_play(fx_sound_spark, o.point, + std::clamp(along_speed / 15.0f, 0.3f, 1.0f) * (flyer ? 0.5f : 1.0f)); } if (fx.scrape_flash_timer <= 0.0f) { fx.scrape_flash_timer = fx_rand(0.25f, 0.4f); @@ -1020,7 +1022,7 @@ namespace if (speed > 1.5f && fx.splash_snd_timer <= 0.0f) { const float pace = std::clamp((speed - 1.5f) / 12.0f, 0.0f, 1.0f); fx.splash_snd_timer = 0.6f - 0.4f * pace; - fx_play(FXS_SPLASH_SMALL, rf::Vector3{ep.pos.x, *liquid_y, ep.pos.z}, 0.3f + 0.4f * pace); + fx_play(fx_sound_splash_small, rf::Vector3{ep.pos.x, *liquid_y, ep.pos.z}, 0.3f + 0.4f * pace); } return; } @@ -1111,7 +1113,7 @@ namespace } if (speed > 3.0f && fx.splash_snd_timer <= 0.0f) { fx.splash_snd_timer = fx_rand(0.8f, 1.6f); - fx_play(FXS_SPLASH_SMALL, rf::Vector3{bow.x, *liquid_y, bow.z}, 0.3f); + fx_play(fx_sound_splash_small, rf::Vector3{bow.x, *liquid_y, bow.z}, 0.3f); } } } @@ -1234,7 +1236,7 @@ namespace } if (fx.burning) { fx_update_loop(fx.burn_snd, fx.burn_snd_retry, fx.vent, fx.vel, 1.0f, - [&] { return fx_play_handle(fx_sound_handle(FXS_BURNING), fx.vent, 1.0f); }); + [&] { return fx_play_handle(fx_sound_handle(fx_sound_burning), fx.vent, 1.0f); }); } else { fx_stop_loop(fx.burn_snd); @@ -1253,8 +1255,9 @@ namespace bool fx_active() { + // A demo seek's fast-forward frames are culled when it ends, as the tracers are. return rf::is_multi && !rf::is_dedicated_server && g_alpine_game_config.vehicle_effects - && vehicle_level_has_factories(); + && vehicle_level_has_factories() && !demo_playback_is_seeking(); } ConsoleCommand2 vehiclefx_cmd{ diff --git a/game_patch/multi/vehicles/vehicle_internal.h b/game_patch/multi/vehicles/vehicle_internal.h index 83c723244..9bd41b20f 100644 --- a/game_patch/multi/vehicles/vehicle_internal.h +++ b/game_patch/multi/vehicles/vehicle_internal.h @@ -337,7 +337,7 @@ struct VehicleModuleState std::unordered_map fire_rearm; std::unordered_map health_sync; std::unordered_map last_damager; // vehicle handle -> attacker entity handle - // What a drown or strand scuttle credits, if the hull went in soon after. + // What an unattributed lethal blow (a scuttle, a crash) credits, if it came soon after. std::unordered_map last_hurt; std::unordered_map lethal_killer; // What bounds a crash report and gates a run-over; fed by received rows and local bodies. @@ -484,6 +484,8 @@ inline constexpr float vehicle_torpedo_arm_s = 0.2f; inline constexpr float vehicle_torpedo_arm_pilot_s = 0.3f; // Sets off the hull's armed torpedoes; false when there were none. still_flying gets how many are left in flight. bool vehicle_detonate_armed_torpedoes(rf::Entity* hull, float arm_s, int* still_flying = nullptr); +// How far back the last player blow on a hull is credited with an unattributed lethal one. +inline constexpr float vehicle_hit_credit_s = 5.0f; void vehicle_note_hazard_destroyer(rf::Entity* vehicle, float window_s); // Server: re-state a turret factory whose hull just changed occupancy; other slots are unaffected. void vehicle_slot_announce_for_hull(int vehicle_handle); diff --git a/game_patch/object/entity.cpp b/game_patch/object/entity.cpp index 7c0157a59..f6555ad55 100644 --- a/game_patch/object/entity.cpp +++ b/game_patch/object/entity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -703,14 +704,17 @@ ConsoleCommand2 cl_gibflames_cmd{ "Toggle flames on gib chunks", }; -// The live shell casings, oldest first: past the cap the oldest still alive goes. -struct ShellCasingRecord +namespace { - int handle; - int64_t spawn_ms; -}; -static std::deque g_shell_casings; -static constexpr std::size_t max_live_shell_casings = 500; + // The live shell casings, oldest first: past the cap the oldest still alive goes. + struct ShellCasingRecord + { + int handle; + rf::Timestamp expired; // game time, as the casing's own lifetime + }; + std::deque g_shell_casings; + constexpr std::size_t max_live_shell_casings = 500; +} void shell_casings_level_init() { @@ -731,13 +735,14 @@ FunHook entity_eject_shell_hook{ if (tail == tail_before || tail == &rf::debris_list) { return; } - const int64_t now = timer::get_i64(1000); - g_shell_casings.push_back({tail->handle, now}); + ShellCasingRecord& record = g_shell_casings.emplace_back(); + record.handle = tail->handle; + record.expired.set(AlpineGameSettings::max_shell_casing_lifetime_ms); while (g_shell_casings.size() > max_live_shell_casings) { const ShellCasingRecord oldest = g_shell_casings.front(); g_shell_casings.pop_front(); // Older than any casing lives: already gone, and its handle may since name another object. - if (now - oldest.spawn_ms > AlpineGameSettings::max_shell_casing_lifetime_ms) { + if (oldest.expired.elapsed()) { continue; } rf::Object* casing = rf::obj_from_handle(oldest.handle); @@ -802,7 +807,9 @@ ConsoleCommand2 cl_shelllifetime_cmd{ "cl_shelllifetime", [](std::optional lifetime_s) { if (lifetime_s && std::isfinite(*lifetime_s)) { - g_alpine_game_config.set_shell_casing_lifetime_ms(static_cast(std::lround(*lifetime_s * 1000.0f))); + const float clamped_s = std::clamp(*lifetime_s, AlpineGameSettings::min_shell_casing_lifetime_ms / 1000.0f, + AlpineGameSettings::max_shell_casing_lifetime_ms / 1000.0f); + g_alpine_game_config.set_shell_casing_lifetime_ms(static_cast(std::lround(clamped_s * 1000.0f))); } rf::console::print("Shell casing lifetime is {:.1f} s", g_alpine_game_config.shell_casing_lifetime_ms / 1000.0f); diff --git a/game_patch/object/particle_sim.cpp b/game_patch/object/particle_sim.cpp index beef7e2e2..ef55e7751 100644 --- a/game_patch/object/particle_sim.cpp +++ b/game_patch/object/particle_sim.cpp @@ -549,7 +549,7 @@ CodeInjection particle_collide_lod_injection{ 0x004951E6, [](auto& regs) { rf::Particle* particle = regs.esi; - if (!collide_lod_enabled() || (particle->flags2 & rf::PTF2_DAMAGES) + if (!collide_lod_enabled() || !rf::particle_collide_world_enabled || (particle->flags2 & rf::PTF2_DAMAGES) || (particle->flags & rf::PTF_HIT_CALLBACK)) { return; } @@ -790,7 +790,8 @@ ConsoleCommand2 gpu_particles_cmd{ rf::console::print("GPU particle rendering: {}{}", g_gpu_particles ? "on" : "off", is_d3d11() ? "" : " (Direct3D 11 only, currently inactive)"); }, - "Toggles GPU rendering of particles, plankton and weather (off = stock CPU path)", + "Toggle GPU rendering of particles, plankton and weather (off = stock CPU path)", + "dbg_particlegpu", }; ConsoleCommand2 particle_collide_lod_cmd{ diff --git a/game_patch/rf/particle_emitter.h b/game_patch/rf/particle_emitter.h index 6251be1a2..685de0093 100644 --- a/game_patch/rf/particle_emitter.h +++ b/game_patch/rf/particle_emitter.h @@ -33,7 +33,7 @@ enum ParticleFlags : unsigned PTF_HIT_CALLBACK = 0x20, PTF_ACCELERATE = 0x40, PTF_EXPLODE = 0x80, - PTF_LOOP = 0x100, // cycle the frame set at 15 fps instead of once over the lifetime + PTF_LOOP = 0x100, // stock frame index frac(age) * 15 / n each second, not once over the lifetime PTF_RANDOM_ORIENT = 0x200, PTF_COLLIDE_LIQUID = 0x400, PTF_COLLIDE_AND_DIE = 0x800, @@ -300,7 +300,8 @@ static auto& particle_create = addr_as_ref(0x00496840); -// Pool 0 holds 500 particles, pool 1 holds 1100. Particle::pool_id selects one. +// Stock pool 0 holds 500 particles and pool 1 1100, until particle_sim's relink_pools resizes them. +// Particle::pool_id selects one. static auto& particle_pools = addr_as_ref(0x007A3B00); // List sentinel: only next/prev are meaningful, the rest of the record is not an emitter. @@ -325,6 +326,8 @@ static auto& particle_list_move = addr_as_ref(0x // Moves one particle by dt with world collision, leaving pos at the post move position. static auto& particle_move_collide = addr_as_ref(0x00495880); +// Set by the level's particle init; while 0, particle_move_collide only clears PTF_COLLIDE and moves nothing. +static auto& particle_collide_world_enabled = addr_as_ref(0x007A3AE8); // Array of particle emitter type template pointers loaded from emitters.tbl (64 slots) static auto& g_particle_emitter_types = addr_as_ref(0x007B2770);