diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad29422f5..a93aafdf5 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 crashes on some of 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/docs/CHANGELOG.md b/docs/CHANGELOG.md index b31c6f54d..c1a2282b5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -121,6 +121,18 @@ Version 1.5.0 (Trillium): Not yet released - Add `Select All`, `Fit UVs` and `Auto Unwrap`, and a `Color each face` option to the level editor `UV Unwrap` window - Add 256 x 256 and 512 x 512 thumbnail sizes and a `Format` filter to the level editor texture browser, make it resizable and scroll it with the mouse wheel - Draw the sky in the level editor's `Render Everything` mode when `Draw Sky` is on, as the portal render modes do +- 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), 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 +- 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 - Add `$Looping Sound` `mapname_info.tbl` option to make custom sound files loop - Make the `Looping` checkbox on `Play_Sound` and `Music_Start` events work (RFL v306+) - Allow `sv_gametype` (`gt`) to start a local game on a level in a chosen game type @@ -261,6 +273,8 @@ Version 1.5.0 (Trillium): Not yet released - Fix the `Material` filter in the level editor texture browser not working properly - Fix a very long `Name` filter in the level editor texture browser corrupting the editor's memory - Fix the level editor `UV Unwrap` window selecting vertices when a mouse button pressed outside its view is released over it +- 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 - Fix listen server hosts only seeing automated server messages in the console instead of in chat - Fix music not starting while the music volume is muted or very low diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index 977a62df6..45b93b157 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 @@ -196,6 +198,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 @@ -348,6 +352,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..458ffde83 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, 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..1073454a2 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,37 @@ 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; + } + + // 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 e40be0fa0..bfb4ef404 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_dynamic_geometry.cpp @@ -69,34 +69,9 @@ 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}; - 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_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..fa1335d7a --- /dev/null +++ b/game_patch/graphics/d3d11/gr_d3d11_particle.cpp @@ -0,0 +1,566 @@ +#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. + const rf::Color color = vertex_color_for_mode(mode, particle.clr_current); + + 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; + } + 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, + 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 + 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}; + }; + 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..4ce5549fa 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 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, 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, 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); + 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 b8f00b872..185605405 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" @@ -466,6 +467,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..8cd7459ee 100644 --- a/game_patch/hud/hud_internal.h +++ b/game_patch/hud/hud_internal.h @@ -71,6 +71,39 @@ 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; 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; +// 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..02d85d008 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,154 @@ 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(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, shadow_alpha, + hud_shadowed_text_shadow_offset_px); + } +} + +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 +230,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 +323,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 37b7ef860..9c8dd8a0c 100644 --- a/game_patch/hud/hud_world.cpp +++ b/game_patch/hud/hud_world.cpp @@ -1630,7 +1630,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 7ef362254..5965390e1 100644 --- a/game_patch/hud/hud_world.h +++ b/game_patch/hud/hud_world.h @@ -127,6 +127,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 266295b3a..5041e1b69 100644 --- a/game_patch/hud/multi_hud.cpp +++ b/game_patch/hud/multi_hud.cpp @@ -2639,6 +2639,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", [] { @@ -2731,6 +2742,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 39aa3885a..ccff8243f 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: case rf::NG_TYPE_SCAV: return clip_h - (big ? 80 : 55) - 10; @@ -2377,6 +2378,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 @@ -2406,10 +2424,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}; @@ -2435,10 +2451,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( @@ -2467,19 +2479,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}; @@ -2495,9 +2503,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) ); @@ -2527,9 +2532,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; } @@ -2640,8 +2643,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) ); @@ -2671,9 +2672,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 6efddc322..5d45ce204 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" @@ -249,6 +250,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. @@ -267,6 +269,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); @@ -375,6 +378,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 cd33fba42..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"]); @@ -600,6 +616,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(); @@ -1028,6 +1064,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"); @@ -1072,6 +1112,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"); @@ -1151,6 +1195,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"); @@ -1542,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"; @@ -1618,6 +1670,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"; @@ -1741,6 +1798,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"; @@ -1752,6 +1810,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"; @@ -1771,6 +1830,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 6afee7d80..c0597284b 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -104,11 +104,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 @@ -193,6 +195,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; @@ -285,6 +288,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; @@ -384,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/misc/player.cpp b/game_patch/misc/player.cpp index 06df8dcb6..66ceac486 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 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; + 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 && !died && 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.cpp b/game_patch/multi/alpine_packets.cpp index 69b0f974a..ad25fc7c3 100644 --- a/game_patch/multi/alpine_packets.cpp +++ b/game_patch/multi/alpine_packets.cpp @@ -1988,6 +1988,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: { @@ -4121,10 +4124,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; @@ -4160,7 +4165,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; @@ -4168,7 +4173,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); } @@ -4189,7 +4194,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, @@ -4271,7 +4277,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; @@ -4281,25 +4287,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 cd4bd510d..9e79b545a 100644 --- a/game_patch/multi/alpine_packets.h +++ b/game_patch/multi/alpine_packets.h @@ -345,8 +345,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 @@ -356,7 +357,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); @@ -682,7 +683,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, @@ -690,6 +692,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 @@ -712,8 +715,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 @@ -1088,7 +1098,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, @@ -1145,6 +1156,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/demo/demo_playback.cpp b/game_patch/multi/demo/demo_playback.cpp index 05712a6bd..336481125 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,8 @@ namespace rf::particle_level_release(); 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..8d95ae0ed 100644 --- a/game_patch/multi/jetpack.cpp +++ b/game_patch/multi/jetpack.cpp @@ -62,10 +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; 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; @@ -786,62 +782,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 +800,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((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((hud_shadowed_text_shadow_alpha * alpha_scale) / 255), + hud_shadowed_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/server_internal.h b/game_patch/multi/server_internal.h index abf2f03dd..a4e44e378 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -634,7 +634,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_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/physics/vphys_internal.h b/game_patch/multi/vehicles/physics/vphys_internal.h index cea1fd737..9e890d645 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,6 +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; +// 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); @@ -535,8 +543,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/physics/vphys_step.cpp b/game_patch/multi/vehicles/physics/vphys_step.cpp index ac1a5de75..d51f8ee46 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,18 @@ 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. 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) { vel = capped_vel; @@ -724,8 +757,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 +768,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 +1097,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..b4e3aedb8 100644 --- a/game_patch/multi/vehicles/physics/vphys_world.cpp +++ b/game_patch/multi/vehicles/physics/vphys_world.cpp @@ -1021,6 +1021,23 @@ 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) { + // 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); + } + } // 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 +1680,18 @@ 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 |= TestAabbAgainstAabb2(blo, bhi, lo, hi); + } + } } } } diff --git a/game_patch/multi/vehicles/vehicle.cpp b/game_patch/multi/vehicles/vehicle.cpp index a0183e235..cd301fbbd 100644 --- a/game_patch/multi/vehicles/vehicle.cpp +++ b/game_patch/multi/vehicles/vehicle.cpp @@ -16,7 +16,9 @@ #include "vehicle_render.h" #include "vehicle_spawn.h" #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" @@ -81,8 +83,11 @@ 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_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 @@ -373,11 +378,69 @@ 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) { + if (num_hits >= vehicle_hull_probe_max_hits) { + return; + } + 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; } +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 @@ -428,6 +491,8 @@ void vehicle_client_do_frame() if (!rf::is_dedicated_server) { 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(); } @@ -481,6 +546,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); @@ -504,9 +571,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) { @@ -544,6 +611,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) { @@ -551,6 +619,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; } @@ -588,12 +662,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 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; 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); @@ -601,6 +719,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) { @@ -688,15 +876,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(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(); @@ -721,7 +913,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; } @@ -742,7 +934,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. @@ -753,24 +945,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_hit_credit_s); + vehicle_server_scuttle(ep, vehicle_scuttle_lethal_damage(ep)); + continue; } } @@ -894,7 +1082,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 @@ -928,5 +1116,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.h b/game_patch/multi/vehicles/vehicle.h index f842af973..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 { @@ -98,10 +99,30 @@ 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 = rf::MATERIAL_DEFAULT; // rays only: a sphere contact keeps the 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); 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); @@ -250,15 +271,26 @@ 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); 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); @@ -288,11 +320,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); @@ -301,6 +339,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); @@ -343,7 +384,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_crash_tuning.h b/game_patch/multi/vehicles/vehicle_crash_tuning.h index 9f75b4bd7..8c1678b0f 100644 --- a/game_patch/multi/vehicles/vehicle_crash_tuning.h +++ b/game_patch/multi/vehicles/vehicle_crash_tuning.h @@ -2,39 +2,88 @@ #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. -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. -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; +// 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 ddb5e65d6..8d42beb46 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; @@ -100,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. @@ -359,6 +358,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 +366,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 +535,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 +577,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 +595,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,23 +815,25 @@ 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. + // 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. @@ -836,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); } @@ -847,7 +879,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 +892,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 +961,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 +1017,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 +1026,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 +1055,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_bill_window_ms); + if (bill <= 0.0f) { + return; + } + 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; } - cooldown.set(vehicle_crash_server_cooldown_ms); - vehicle_ram_mint_damage(vehicle->handle, -1, frac * vehicle_hud_max_life(vehicle), nullptr); + 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 +1099,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) { + 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_bill_window_ms) <= 0.0f) { return; } - rf::Timestamp& cooldown = g_vehicle_state.crash_cooldown[vehicle->handle]; - if (cooldown.valid() && !cooldown.elapsed()) { - 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 +1133,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)) { @@ -1480,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, 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 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; @@ -1540,7 +1719,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 +1850,34 @@ int vehicle_occupant_death_blast_hull(int victim_handle, int damage_type) ? g_vehicle_blast_mint.vehicle_handle : -1; } +// 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); + 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 +1899,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 +1921,23 @@ 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) { + // 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) { 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 +2297,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 new file mode 100644 index 000000000..4a4181804 --- /dev/null +++ b/game_patch/multi/vehicles/vehicle_fx.cpp @@ -0,0 +1,1440 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "vehicle.h" +#include "vehicle_crash_tuning.h" +#include "vehicle_fx.h" +#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" +#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" +#include "../../rf/sound/sound.h" +#include "../../sound/sound.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; + // 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; + + // 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; + // 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; + + constexpr float fx_downwash_height = 10.0f; + // 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 + { + 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[fx_sound_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", + }; + + // 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 + { + 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[fx_bitmap_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{ + 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{ + 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{ + 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{ + 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{ + 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{ + 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{ + 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 = fx_bitmap_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 {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. + 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; + 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; + 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; + 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; + 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; + + // 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; + // 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; + 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; + }; + + template + constexpr std::array fx_unloaded() + { + std::array handles{}; + handles.fill(-1); + return handles; + } + + std::unordered_map g_hull_fx; + 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(); + 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; + 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; + + 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; + } + + void fx_load_bitmaps() + { + if (g_fx_bitmaps_loaded) { + return; + } + g_fx_bitmaps_loaded = true; + 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]); + } + } + } + + 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 < 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]); + } + } + 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]); + } + } + // 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"); + } + + // 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_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. + rf::ParticleEmitter* fx_ensure_emitter(HullFx& fx, int layer) + { + rf::ParticleEmitter*& emitter = fx.emitters[layer]; + if (emitter) { + return emitter; + } + 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[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. + emitter = rf::particle_emitter_create(0, type, fx.room, fx.last_pos, false); + if (!emitter) { + fx.emitter_retry.set(1000); + return nullptr; + } + fx.emitter_idle[layer] = 0.0f; + ++g_fx_num_emitters; + return emitter; + } + + void fx_destroy_emitters(HullFx& fx) + { + for (int layer = 0; layer < fx_num_layers; ++layer) { + fx_destroy_emitter(fx, layer); + } + } + + void fx_release(HullFx& fx) + { + 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, + 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. 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; + } + + // 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, 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 = + 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); + } + } + + // 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); + } + + 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; + } + + FxSound fx_impact_sound(int material) + { + switch (material) { + case rf::MATERIAL_METAL: + return fx_sound_impact_metal; + case rf::MATERIAL_GLASS: + return fx_sound_impact_glass; + default: + return fx_sound_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[] = {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); + } + + void fx_impact(rf::Entity& ep, HullFx& fx, const rf::PCollisionOut& hit, float strength, float lod, + std::optional water_y) + { + if (water_y) { + 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) { + // 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, 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); + } + } + + // 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_tick(fx.impact_cooldown); + fx_tick(fx.debris_cooldown); + 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}; + 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 > 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 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 = 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 = 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)) { + 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; + } + 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 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; + } + fx.scrape_timer = fx_probe_interval; + if (fx.vel.len() < 3.0f) { + return; + } + 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; + } + } + 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 + } + if (along_speed < free_speed) { + continue; + } + 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.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(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); + 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.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.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. + 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(); + 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) { + // 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]; + 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(fx_sound_splash_small, rf::Vector3{ep.pos.x, *liquid_y, ep.pos.z}, 0.3f + 0.4f * pace); + } + 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.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} + + 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); + 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 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.spawn_seq++ % 2 != 0), 1, center, radius, + 3.0f + 5.0f * intensity, 0.4f + 0.8f * intensity); + } + 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 { + 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) + { + fx_tick(fx.splash_snd_timer); + 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) { + 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(fx_sound_splash_small, rf::Vector3{bow.x, *liquid_y, bow.z}, 0.3f); + } + } + } + + // 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); + fx_splash(fx, rf::Vector3{ep.pos.x, *liquid_y, ep.pos.z}, strength, lod, 1.25f); + } + 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; + } + // 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 + 7.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, 20.0f * lod); + for (int i = 0; i < n_fire; ++i) { + 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; + } + // 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(fx_sound_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) + { + 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() + { + // 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() && !demo_playback_is_seeking(); + } + + 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, with their sounds and lights, for vehicles in multiplayer", + "cl_vehiclefx [bool]", + }; +} // namespace + +void vehicle_fx_clear() +{ + for (auto& [handle, fx] : g_hull_fx) { + fx_release(fx); + } + g_hull_fx.clear(); +} + +void vehicle_fx_level_init(bool load_assets) +{ + // Emitter records outlive the level; the bitmap and sound handles do not. + vehicle_fx_clear(); + 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() +{ + if (!fx_active()) { + if (!g_hull_fx.empty()) { + vehicle_fx_clear(); + } + 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_release(fx); + it = g_hull_fx.erase(it); + continue; + } + 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); + 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; + fx.cam_dist = (entity.pos - cam_pos).len(); + if (entity.room) { + fx.room = entity.room; + } + // 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; + } + } + 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.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 = fx.cam_dist; + 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); + } + } + + // 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() +{ + 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..ea4cc6433 --- /dev/null +++ b/game_patch/multi/vehicles/vehicle_fx.h @@ -0,0 +1,11 @@ +#pragma once + +// 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(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(); +void vehicle_fx_install(); diff --git a/game_patch/multi/vehicles/vehicle_internal.h b/game_patch/multi/vehicles/vehicle_internal.h index 91f54f88b..9bd41b20f 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]{}; @@ -297,6 +321,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 +337,8 @@ struct VehicleModuleState std::unordered_map fire_rearm; std::unordered_map health_sync; std::unordered_map last_damager; // vehicle handle -> attacker entity handle + // 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. std::unordered_map observed_speed; @@ -320,9 +353,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; @@ -333,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; @@ -349,9 +387,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 +476,17 @@ 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); +// 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); // 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 a5fb815ac..d67266234 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); @@ -22,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(); @@ -117,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 2eb95c6d3..571d398d1 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); @@ -1149,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_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..0b739e637 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; @@ -878,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 @@ -944,9 +954,11 @@ 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); + 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); @@ -954,7 +966,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 +1682,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 +1726,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 +1802,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 +1898,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(); @@ -1819,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) { @@ -1833,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/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/entity.cpp b/game_patch/object/entity.cpp index 366881c05..f6555ad55 100644 --- a/game_patch/object/entity.cpp +++ b/game_patch/object/entity.cpp @@ -3,8 +3,12 @@ #include #include #include +#include +#include #include #include +#include +#include #include #include #include @@ -700,6 +704,130 @@ ConsoleCommand2 cl_gibflames_cmd{ "Toggle flames on gib chunks", }; +namespace +{ + // 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() +{ + 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; + } + 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 (oldest.expired.elapsed()) { + 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)) { + 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); + }, + "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 +1094,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 +1137,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.cpp b/game_patch/object/object.cpp index 0b8b5f39a..45e6b5153 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/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); 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() || !rf::particle_collide_world_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)"); + }, + "Toggle GPU rendering of particles, plankton and weather (off = stock CPU path)", + "dbg_particlegpu", +}; + +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 4f2cf5fe0..685de0093 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" @@ -29,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, // 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, @@ -46,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 }; @@ -100,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); @@ -127,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; @@ -135,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 { @@ -214,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 @@ -278,11 +300,50 @@ static auto& particle_create = addr_as_ref(0x00496840); +// 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. +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); +// 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); static auto& g_num_particle_emitter_types = addr_as_ref(0x007BD99C); static auto& particle_emitter_type_lookup = addr_as_ref(0x00497550); +// 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/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; diff --git a/game_patch/rf/sound/sound.h b/game_patch/rf/sound/sound.h index d4c4ab44b..950e7c263 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 dbf63d32b..a99ca1abf 100644 --- a/game_patch/rf/weapon.h +++ b/game_patch/rf/weapon.h @@ -321,6 +321,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 99080c815..d44662ecd 100644 --- a/game_patch/sound/sound.cpp +++ b/game_patch/sound/sound.cpp @@ -286,68 +286,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) { @@ -704,6 +714,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 31af951d4..85d61cfff 100644 --- a/game_patch/sound/sound.h +++ b/game_patch/sound/sound.h @@ -29,7 +29,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); // Sound files the loading level makes loop (mapname_info.tbl $Looping Sound, RED's Looping checkbox on v306+). 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 ccbbebba8..8a79ddb6c 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) @@ -93,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 @@ -394,6 +398,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 @@ -430,4 +437,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() diff --git a/resources/licensing-info.txt b/resources/licensing-info.txt index 62595673f..3e0855842 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/) + Quaternius: * af_scavpumap1.dds and af-scavpumap1.v3m (CC0: https://opengameart.org/content/sci-fi-essentials-kit/) 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..a271ef269 --- /dev/null +++ b/resources/shaders/particle_vs.hlsl @@ -0,0 +1,318 @@ +// 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); +} + +// 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 +{ + 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 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 = 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 + 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 = 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; +} + +#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 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; + 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; + 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); + float dc = input.inst_axes.x; + float e0 = input.inst_axes.y; + float e4 = input.inst_axes.z; + float f2 = input.inst_axes.w; + 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 + + 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; + 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 = 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; +} + +#endif diff --git a/resources/sounds/af_fighter_scrape_loop.wav b/resources/sounds/af_fighter_scrape_loop.wav new file mode 100644 index 000000000..a60db8fb4 Binary files /dev/null and b/resources/sounds/af_fighter_scrape_loop.wav differ