Pārlūkot izejas kodu

Add new StoneImpact effect type with distinct debris shader

djeada 5 dienas atpakaļ
vecāks
revīzija
ed50a9a81d

+ 1 - 1
assets/data/troops/base.json

@@ -257,7 +257,7 @@
         "speed": 1.0,
         "vision_range": 20.0,
         "ranged_range": 15.0,
-        "ranged_damage": 80,
+        "ranged_damage": 180,
         "ranged_cooldown": 5.0,
         "melee_range": 1.5,
         "melee_damage": 5,

+ 74 - 2
assets/shaders/combat_dust.frag

@@ -10,6 +10,8 @@ out vec4 frag_color;
 
 uniform vec3 u_dust_color;
 uniform float u_time;
+uniform vec3 u_center;
+uniform float u_radius;
 uniform int u_effect_type;
 
 void main() {
@@ -27,7 +29,7 @@ void main() {
 
   float final_alpha = v_alpha * particle_alpha * (0.5 + 0.5 * combined_noise);
 
-  vec3 color;
+  vec3 color = u_dust_color;
 
   if (u_effect_type == 0) {
 
@@ -38,7 +40,7 @@ void main() {
     color += vec3(scatter);
 
     frag_color = vec4(color, final_alpha * 0.6);
-  } else {
+  } else if (u_effect_type == 1) {
 
     float flame_height = v_texcoord.y;
     float angle_t = v_texcoord.x;
@@ -95,5 +97,75 @@ void main() {
 
     color = clamp(color, 0.0, 4.0);
     frag_color = vec4(color, clamp(flame_alpha, 0.0, 1.0));
+  } else if (u_effect_type == 2) {
+
+    float height = v_texcoord.y;
+    float angle_t = v_texcoord.x;
+    float t = u_time * 0.5;
+
+    float chunk_count = 24.0;
+    float chunk_id = floor(angle_t * chunk_count);
+    float chunk_local = fract(angle_t * chunk_count);
+    float chunk_hash = fract(sin(chunk_id * 127.1 + 311.7) * 43758.5453);
+
+    float noise1 =
+        fract(sin(dot(v_texcoord * 20.0 + t * 0.3, vec2(12.9898, 78.233))) *
+              43758.5453);
+    float noise2 = fract(
+        sin(dot(v_texcoord * 45.0 + chunk_id, vec2(93.989, 67.345))) * 23421.6);
+    float combined_noise = mix(noise1, noise2, 0.5);
+
+    vec3 dust_dark = vec3(0.35, 0.30, 0.22);
+    vec3 dust_mid = vec3(0.55, 0.48, 0.38);
+    vec3 dust_light = vec3(0.75, 0.68, 0.55);
+    vec3 rock_dark = vec3(0.25, 0.22, 0.18);
+    vec3 rock_mid = vec3(0.40, 0.36, 0.30);
+
+    float is_rock = step(0.6, chunk_hash) * step(height, 0.5);
+
+    if (is_rock > 0.5) {
+      color = mix(rock_dark, rock_mid, combined_noise);
+      color *= 0.85 + 0.15 * sin(t * 5.0 + chunk_id);
+    } else {
+      if (height < 0.25) {
+        color = mix(dust_dark, dust_mid, height / 0.25);
+      } else if (height < 0.6) {
+        color = mix(dust_mid, dust_light, (height - 0.25) / 0.35);
+      } else {
+        color = mix(dust_light, vec3(0.9, 0.85, 0.75), (height - 0.6) / 0.4);
+      }
+
+      float billow = 0.9 + 0.1 * sin(t * 2.0 + chunk_id * 1.5 + height * 4.0);
+      color *= billow;
+    }
+
+    color *= 0.9 + 0.2 * combined_noise;
+
+    float phase = smoothstep(0.0, 0.15, t);
+    float decay = 1.0 - smoothstep(2.5, 5.0, t);
+
+    float core_glow = (1.0 - height) * (1.0 - smoothstep(0.0, 0.4, t)) * 0.5;
+    color += vec3(1.0, 0.8, 0.4) * core_glow;
+
+    color *= v_intensity * 1.1;
+
+    float chunk_fade =
+        smoothstep(0.0, 0.15, chunk_local) * smoothstep(1.0, 0.85, chunk_local);
+    float density = 0.5 + 0.5 * (1.0 - height);
+
+    float radial = length(v_world_pos.xz - u_center.xz) / max(u_radius, 0.01);
+    float radial_fade = 1.0 - smoothstep(0.8, 2.0, radial);
+
+    float impact_alpha = v_alpha * chunk_fade * density * radial_fade *
+                         (0.8 + 0.2 * combined_noise);
+
+    impact_alpha = clamp(impact_alpha * 1.3, 0.0, 0.95);
+
+    color = clamp(color, 0.0, 1.5);
+    frag_color = vec4(color, impact_alpha);
+  } else {
+
+    color = u_dust_color;
+    frag_color = vec4(color, final_alpha * 0.6);
   }
 }

+ 54 - 2
assets/shaders/combat_dust.vert

@@ -27,7 +27,6 @@ void main() {
   float normalized_dist = dist / max(u_radius, 0.001);
 
   if (u_effect_type == 0) {
-
     float swirl_angle = u_time * 1.5 + normalized_dist * 3.14159;
     float swirl_strength = 0.15 * (1.0 - normalized_dist);
     pos.x += sin(swirl_angle) * swirl_strength;
@@ -42,7 +41,7 @@ void main() {
     float edge_fade = smoothstep(1.0, 0.7, normalized_dist);
     float time_pulse = 0.7 + 0.3 * sin(u_time * 1.5);
     v_alpha = edge_fade * time_pulse * u_intensity;
-  } else {
+  } else if (u_effect_type == 1) {
 
     float height = a_texcoord.y;
     float angle_t = a_texcoord.x;
@@ -81,6 +80,59 @@ void main() {
     float tongue_edge_fade =
         smoothstep(0.0, 0.2, tongue_local) * smoothstep(1.0, 0.8, tongue_local);
     v_alpha = height_fade * tongue_edge_fade * flicker * u_intensity * 1.5;
+  } else {
+
+    float height = a_texcoord.y;
+    float angle_t = a_texcoord.x;
+    float angle = angle_t * 6.28318;
+    float t = u_time;
+
+    float phase = smoothstep(0.0, 0.15, t);
+    float decay = 1.0 - smoothstep(2.5, 5.0, t);
+    float life = phase * decay;
+
+    float chunk_id = floor(angle_t * 24.0);
+    float chunk_hash = fract(sin(chunk_id * 127.1 + 311.7) * 43758.5453);
+    float chunk_speed = 0.7 + chunk_hash * 0.6;
+    float chunk_angle_offset = (chunk_hash - 0.5) * 0.4;
+
+    float ejection_angle = angle + chunk_angle_offset;
+    vec2 dir = vec2(cos(ejection_angle), sin(ejection_angle));
+
+    float base_spread = mix(0.3, 2.2, height);
+    float time_spread = t * chunk_speed * 1.8;
+    float spread = base_spread + time_spread * (0.6 + 0.4 * chunk_hash);
+
+    float turbulence = sin(t * 3.5 + chunk_id * 2.1) * 0.15 * (1.0 - height);
+    vec2 perp = vec2(-dir.y, dir.x);
+    pos.xz += dir * spread + perp * turbulence;
+
+    float initial_velocity = 4.5 + 2.0 * chunk_hash;
+    float gravity_accel = 9.8;
+    float upward = height * initial_velocity * t - 0.5 * gravity_accel * t * t;
+    upward = max(upward, -0.3);
+
+    float dust_rise = (1.0 - height) * 0.8 * t * decay;
+    pos.y += upward + dust_rise;
+
+    float rotation = t * (2.0 + chunk_hash * 3.0);
+    float wobble = sin(rotation) * 0.1 * height;
+    pos.x += wobble * dir.y;
+    pos.z -= wobble * dir.x;
+
+    float radial = length(pos.xz);
+    float radial_fade = 1.0 - smoothstep(1.5, 3.0, radial);
+
+    float height_fade =
+        smoothstep(0.0, 0.1, height) * (1.0 - smoothstep(0.7, 1.0, height));
+
+    float dust_density = (1.0 - height) * 0.6 + 0.4;
+
+    float flicker = 0.85 + 0.15 * sin(t * 8.0 + chunk_id * 4.0);
+
+    v_alpha = clamp(life * radial_fade * height_fade * dust_density * flicker *
+                        u_intensity * 1.2,
+                    0.0, 1.0);
   }
 
   v_world_pos = (u_model * vec4(pos, 1.0)).xyz;

+ 12 - 2
game/systems/formation_planner.h

@@ -8,6 +8,7 @@
 #include "nation_registry.h"
 #include <QVector3D>
 #include <cmath>
+#include <unordered_map>
 #include <vector>
 
 namespace Game::Systems {
@@ -113,9 +114,18 @@ public:
             formation_type, unit_infos, center, spacing);
 
     std::vector<QVector3D> positions;
-    positions.reserve(formation_positions.size());
+    positions.resize(units.size(), center);
+
+    std::unordered_map<Engine::Core::EntityID, size_t> unit_to_original_idx;
+    for (size_t i = 0; i < units.size(); ++i) {
+      unit_to_original_idx[units[i]] = i;
+    }
+
     for (const auto &fpos : formation_positions) {
-      positions.push_back(fpos.position);
+      auto it = unit_to_original_idx.find(fpos.entity_id);
+      if (it != unit_to_original_idx.end()) {
+        positions[it->second] = fpos.position;
+      }
     }
 
     return positions;

+ 14 - 2
game/systems/formation_system.cpp

@@ -123,6 +123,7 @@ auto RomanFormation::calculateFormationPositions(
       pos.position =
           QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
       pos.facing_angle = forward_facing;
+      pos.entity_id = infantry[i].entity_id;
       positions.push_back(pos);
     }
 
@@ -148,6 +149,7 @@ auto RomanFormation::calculateFormationPositions(
       pos.position =
           QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
       pos.facing_angle = forward_facing;
+      pos.entity_id = archers[i].entity_id;
       positions.push_back(pos);
     }
 
@@ -175,6 +177,7 @@ auto RomanFormation::calculateFormationPositions(
       pos.position =
           QVector3D(center.x() + x_offset, center.y(), cavalry_z_offset);
       pos.facing_angle = forward_facing;
+      pos.entity_id = cavalry[i].entity_id;
       positions.push_back(pos);
     }
   }
@@ -192,6 +195,7 @@ auto RomanFormation::calculateFormationPositions(
       pos.position =
           QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
       pos.facing_angle = forward_facing;
+      pos.entity_id = siege[i].entity_id;
       positions.push_back(pos);
     }
 
@@ -211,6 +215,7 @@ auto RomanFormation::calculateFormationPositions(
       pos.position =
           QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
       pos.facing_angle = forward_facing;
+      pos.entity_id = support[i].entity_id;
       positions.push_back(pos);
     }
   }
@@ -226,10 +231,11 @@ auto BarbarianFormation::calculateFormationPositions(
   auto simple_pos =
       calculatePositions(static_cast<int>(units.size()), center, base_spacing);
 
-  for (const auto &pos : simple_pos) {
+  for (size_t i = 0; i < simple_pos.size() && i < units.size(); ++i) {
     FormationPosition fpos;
-    fpos.position = pos;
+    fpos.position = simple_pos[i];
     fpos.facing_angle = 0.0F;
+    fpos.entity_id = units[i].entity_id;
     positions.push_back(fpos);
   }
 
@@ -317,6 +323,7 @@ auto CarthageFormation::calculateFormationPositions(
       pos.position =
           QVector3D(center.x() + x_offset, center.y(), center.z() + row_offset);
       pos.facing_angle = forward_facing;
+      pos.entity_id = siege[i].entity_id;
       positions.push_back(pos);
     }
 
@@ -344,6 +351,7 @@ auto CarthageFormation::calculateFormationPositions(
       pos.position =
           QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
       pos.facing_angle = forward_facing;
+      pos.entity_id = center_units[i].entity_id;
       positions.push_back(pos);
     }
 
@@ -371,6 +379,7 @@ auto CarthageFormation::calculateFormationPositions(
       pos.position = QVector3D(center.x() + x_offset, center.y(),
                                cavalry_z_offset + z_forward);
       pos.facing_angle = forward_facing;
+      pos.entity_id = cavalry[static_cast<size_t>(i)].entity_id;
       positions.push_back(pos);
     }
 
@@ -382,6 +391,8 @@ auto CarthageFormation::calculateFormationPositions(
       pos.position = QVector3D(center.x() + x_offset, center.y(),
                                cavalry_z_offset + z_forward);
       pos.facing_angle = forward_facing;
+      pos.entity_id =
+          cavalry[static_cast<size_t>(right_flank_count + i)].entity_id;
       positions.push_back(pos);
     }
   }
@@ -399,6 +410,7 @@ auto CarthageFormation::calculateFormationPositions(
       pos.position =
           QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
       pos.facing_angle = forward_facing;
+      pos.entity_id = support[i].entity_id;
       positions.push_back(pos);
     }
   }

+ 1 - 0
game/systems/formation_system.h

@@ -25,6 +25,7 @@ struct UnitFormationInfo {
 struct FormationPosition {
   QVector3D position;
   float facing_angle;
+  Engine::Core::EntityID entity_id{0};
 };
 
 } // namespace Game::Systems

+ 14 - 2
render/draw_queue.h

@@ -169,6 +169,14 @@ struct BuildingFlameCmd {
   float time = 0.0F;
 };
 
+struct StoneImpactCmd {
+  QVector3D position{0, 0, 0};
+  QVector3D color{0.75F, 0.65F, 0.50F};
+  float radius = 4.0F;
+  float intensity = 1.2F;
+  float time = 0.0F;
+};
+
 struct ModeIndicatorCmd {
   QMatrix4x4 model;
   QMatrix4x4 mvp;
@@ -183,7 +191,7 @@ using DrawCmd =
                  PlantBatchCmd, PineBatchCmd, OliveBatchCmd, FireCampBatchCmd,
                  RainBatchCmd, TerrainChunkCmd, PrimitiveBatchCmd,
                  HealingBeamCmd, HealerAuraCmd, CombatDustCmd, BuildingFlameCmd,
-                 ModeIndicatorCmd>;
+                 StoneImpactCmd, ModeIndicatorCmd>;
 
 enum class DrawCmdType : std::uint8_t {
   Grid = 0,
@@ -205,7 +213,8 @@ enum class DrawCmdType : std::uint8_t {
   HealerAura = 16,
   CombatDust = 17,
   BuildingFlame = 18,
-  ModeIndicator = 19
+  StoneImpact = 19,
+  ModeIndicator = 20
 };
 
 constexpr std::size_t MeshCmdIndex =
@@ -246,6 +255,8 @@ constexpr std::size_t CombatDustCmdIndex =
     static_cast<std::size_t>(DrawCmdType::CombatDust);
 constexpr std::size_t BuildingFlameCmdIndex =
     static_cast<std::size_t>(DrawCmdType::BuildingFlame);
+constexpr std::size_t StoneImpactCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::StoneImpact);
 constexpr std::size_t ModeIndicatorCmdIndex =
     static_cast<std::size_t>(DrawCmdType::ModeIndicator);
 
@@ -276,6 +287,7 @@ public:
   void submit(const HealerAuraCmd &c) { m_items.emplace_back(c); }
   void submit(const CombatDustCmd &c) { m_items.emplace_back(c); }
   void submit(const BuildingFlameCmd &c) { m_items.emplace_back(c); }
+  void submit(const StoneImpactCmd &c) { m_items.emplace_back(c); }
   void submit(const ModeIndicatorCmd &c) { m_items.emplace_back(c); }
 
   [[nodiscard]] auto empty() const -> bool { return m_items.empty(); }

+ 82 - 22
render/entity/combat_dust_renderer.cpp

@@ -5,6 +5,8 @@
 #include "../../game/systems/projectile_system.h"
 #include "../../game/systems/stone_projectile.h"
 #include "../scene_renderer.h"
+#include <algorithm>
+#include <unordered_set>
 
 namespace Render::GL {
 
@@ -25,15 +27,45 @@ constexpr float kFlameColorG = 0.4F;
 constexpr float kFlameColorB = 0.1F;
 constexpr float kBuildingHealthThreshold = 0.5F;
 
-constexpr float kStoneImpactRadius = 4.0F;
-constexpr float kStoneImpactIntensity = 1.2F;
-constexpr float kStoneImpactColorR = 0.85F;
-constexpr float kStoneImpactColorG = 0.75F;
-constexpr float kStoneImpactColorB = 0.55F;
-constexpr float kStoneImpactYOffset = 0.15F;
-constexpr float kStoneImpactThreshold = 0.95F;
+constexpr float kStoneImpactRadius = 0.6F;
+constexpr float kStoneImpactIntensity = 1.5F;
+constexpr float kStoneImpactColorR = 0.75F;
+constexpr float kStoneImpactColorG = 0.65F;
+constexpr float kStoneImpactColorB = 0.45F;
+constexpr float kStoneImpactYOffset = 0.1F;
+constexpr float kStoneImpactDuration = 10.0F;
+constexpr float kStoneImpactTriggerProgress = 0.99F;
+
+std::unordered_set<const void *> g_tracked_projectiles;
 } // namespace
 
+auto StoneImpactTracker::instance() -> StoneImpactTracker & {
+  static StoneImpactTracker instance;
+  return instance;
+}
+
+void StoneImpactTracker::add_impact(const QVector3D &position,
+                                    float current_time, float radius,
+                                    float intensity) {
+  StoneImpactEffect effect;
+  effect.position = position;
+  effect.start_time = current_time;
+  effect.duration = kStoneImpactDuration;
+  effect.radius = radius;
+  effect.intensity = intensity;
+  m_impacts.push_back(effect);
+}
+
+void StoneImpactTracker::update(float current_time) {
+  m_impacts.erase(
+      std::remove_if(m_impacts.begin(), m_impacts.end(),
+                     [current_time](const StoneImpactEffect &impact) {
+                       return (current_time - impact.start_time) >
+                              impact.duration;
+                     }),
+      m_impacts.end());
+}
+
 void render_combat_dust(Renderer *renderer, ResourceManager *,
                         Engine::Core::World *world) {
   if (renderer == nullptr || world == nullptr) {
@@ -123,14 +155,12 @@ void render_combat_dust(Renderer *renderer, ResourceManager *,
   }
 
   auto *projectile_sys = world->get_system<Game::Systems::ProjectileSystem>();
+  auto &impact_tracker = StoneImpactTracker::instance();
+
   if (projectile_sys != nullptr) {
     const auto &projectiles = projectile_sys->projectiles();
 
     for (const auto &projectile : projectiles) {
-      if (!projectile->is_active()) {
-        continue;
-      }
-
       auto *stone_proj = dynamic_cast<const Game::Systems::StoneProjectile *>(
           projectile.get());
       if (stone_proj == nullptr) {
@@ -138,28 +168,58 @@ void render_combat_dust(Renderer *renderer, ResourceManager *,
       }
 
       float progress = stone_proj->get_progress();
-      if (progress < kStoneImpactThreshold) {
+      if (progress < kStoneImpactTriggerProgress) {
+        continue;
+      }
+
+      const void *proj_ptr = static_cast<const void *>(stone_proj);
+      if (g_tracked_projectiles.find(proj_ptr) != g_tracked_projectiles.end()) {
         continue;
       }
 
-      const QVector3D delta = stone_proj->get_end() - stone_proj->get_start();
-      QVector3D impact_pos =
-          stone_proj->get_start() + delta * stone_proj->get_progress();
+      g_tracked_projectiles.insert(proj_ptr);
+
+      QVector3D impact_pos = stone_proj->get_end();
 
       if (!visibility.is_entity_visible(impact_pos.x(), impact_pos.z(),
-                                        kVisibilityCheckRadius)) {
+                                        kVisibilityCheckRadius * 2.0F)) {
         continue;
       }
 
-      QVector3D position(impact_pos.x(),
-                         stone_proj->get_end().y() + kStoneImpactYOffset,
+      QVector3D position(impact_pos.x(), impact_pos.y() + kStoneImpactYOffset,
                          impact_pos.z());
-      QVector3D color(kStoneImpactColorR, kStoneImpactColorG,
-                      kStoneImpactColorB);
 
-      renderer->building_flame(position, color, kStoneImpactRadius,
-                               kStoneImpactIntensity, animation_time);
+      impact_tracker.add_impact(position, animation_time, kStoneImpactRadius,
+                                kStoneImpactIntensity);
+    }
+  }
+
+  std::erase_if(g_tracked_projectiles, [projectile_sys](const void *ptr) {
+    if (projectile_sys == nullptr) {
+      return true;
+    }
+    const auto &projectiles = projectile_sys->projectiles();
+    for (const auto &p : projectiles) {
+      if (static_cast<const void *>(p.get()) == ptr) {
+        return false;
+      }
     }
+    return true;
+  });
+
+  impact_tracker.update(animation_time);
+
+  QVector3D color(kStoneImpactColorR, kStoneImpactColorG, kStoneImpactColorB);
+  for (const auto &impact : impact_tracker.impacts()) {
+    if (!visibility.is_entity_visible(impact.position.x(), impact.position.z(),
+                                      impact.radius)) {
+      continue;
+    }
+
+    float impact_time = animation_time - impact.start_time;
+
+    renderer->stone_impact(impact.position, color, impact.radius,
+                           impact.intensity, impact_time);
   }
 }
 

+ 27 - 0
render/entity/combat_dust_renderer.h

@@ -1,4 +1,6 @@
 #pragma once
+#include <QVector3D>
+#include <vector>
 
 namespace Engine::Core {
 class World;
@@ -8,6 +10,31 @@ namespace Render::GL {
 class Renderer;
 class ResourceManager;
 
+struct StoneImpactEffect {
+  QVector3D position;
+  float start_time{0.0F};
+  float duration{5.0F};
+  float radius{6.0F};
+  float intensity{1.5F};
+};
+
+class StoneImpactTracker {
+public:
+  static auto instance() -> StoneImpactTracker &;
+
+  void add_impact(const QVector3D &position, float current_time,
+                  float radius = 6.0F, float intensity = 1.5F);
+  void update(float current_time);
+  [[nodiscard]] auto impacts() const -> const std::vector<StoneImpactEffect> & {
+    return m_impacts;
+  }
+  void clear() { m_impacts.clear(); }
+
+private:
+  StoneImpactTracker() = default;
+  std::vector<StoneImpactEffect> m_impacts;
+};
+
 void render_combat_dust(Renderer *renderer, ResourceManager *resources,
                         Engine::Core::World *world);
 

+ 12 - 0
render/gl/backend.cpp

@@ -1652,6 +1652,18 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
       m_lastBoundShader = nullptr;
       break;
     }
+    case StoneImpactCmdIndex: {
+      const auto &impact = std::get<StoneImpactCmdIndex>(cmd);
+      if (m_combatDustPipeline == nullptr ||
+          !m_combatDustPipeline->is_initialized()) {
+        break;
+      }
+      m_combatDustPipeline->render_single_stone_impact(
+          impact.position, impact.color, impact.radius, impact.intensity,
+          impact.time, view_proj);
+      m_lastBoundShader = nullptr;
+      break;
+    }
     case ModeIndicatorCmdIndex: {
       const auto &mc = std::get<ModeIndicatorCmdIndex>(cmd);
 

+ 52 - 0
render/gl/backend/combat_dust_pipeline.cpp

@@ -591,4 +591,56 @@ void CombatDustPipeline::render_single_flame(const QVector3D &position,
   }
 }
 
+void CombatDustPipeline::render_single_stone_impact(
+    const QVector3D &position, const QVector3D &color, float radius,
+    float intensity, float time, const QMatrix4x4 &view_proj) {
+  if (!is_initialized()) {
+    return;
+  }
+  if (intensity < kMinDustIntensity) {
+    return;
+  }
+
+  initializeOpenGLFunctions();
+
+  GLboolean cull_enabled = glIsEnabled(GL_CULL_FACE);
+  GLboolean depth_mask_enabled = GL_TRUE;
+  glGetBooleanv(GL_DEPTH_WRITEMASK, &depth_mask_enabled);
+
+  glDisable(GL_CULL_FACE);
+  glEnable(GL_DEPTH_TEST);
+  glDepthMask(GL_FALSE);
+  glEnable(GL_BLEND);
+  glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+
+  m_dust_shader->use();
+  glBindVertexArray(m_vao);
+
+  QMatrix4x4 model;
+  model.setToIdentity();
+  model.translate(position);
+  model.scale(radius);
+
+  QMatrix4x4 mvp = view_proj * model;
+
+  m_dust_shader->set_uniform(m_uniforms.mvp, mvp);
+  m_dust_shader->set_uniform(m_uniforms.model, model);
+  m_dust_shader->set_uniform(m_uniforms.time, time);
+  m_dust_shader->set_uniform(m_uniforms.center, position);
+  m_dust_shader->set_uniform(m_uniforms.radius, radius);
+  m_dust_shader->set_uniform(m_uniforms.intensity, intensity);
+  m_dust_shader->set_uniform(m_uniforms.dust_color, color);
+  m_dust_shader->set_uniform(m_uniforms.effect_type,
+                             static_cast<int>(EffectType::StoneImpact));
+
+  glDrawElements(GL_TRIANGLES, m_index_count, GL_UNSIGNED_INT, nullptr);
+
+  glBindVertexArray(0);
+
+  glDepthMask(depth_mask_enabled);
+  if (cull_enabled) {
+    glEnable(GL_CULL_FACE);
+  }
+}
+
 } // namespace Render::GL::BackendPipelines

+ 6 - 1
render/gl/backend/combat_dust_pipeline.h

@@ -18,7 +18,7 @@ class Camera;
 
 namespace BackendPipelines {
 
-enum class EffectType { Dust, Flame };
+enum class EffectType { Dust, Flame, StoneImpact };
 
 struct CombatDustData {
   QVector3D position;
@@ -58,6 +58,11 @@ public:
                            float radius, float intensity, float time,
                            const QMatrix4x4 &view_proj);
 
+  void render_single_stone_impact(const QVector3D &position,
+                                  const QVector3D &color, float radius,
+                                  float intensity, float time,
+                                  const QMatrix4x4 &view_proj);
+
   void clear_data() { m_dust_data.clear(); }
 
   void add_dust_zone(const QVector3D &position, float radius, float intensity,

+ 13 - 0
render/scene_renderer.cpp

@@ -377,6 +377,19 @@ void Renderer::building_flame(const QVector3D &position, const QVector3D &color,
   }
 }
 
+void Renderer::stone_impact(const QVector3D &position, const QVector3D &color,
+                            float radius, float intensity, float time) {
+  StoneImpactCmd cmd;
+  cmd.position = position;
+  cmd.color = color;
+  cmd.radius = radius;
+  cmd.intensity = intensity;
+  cmd.time = time;
+  if (m_active_queue != nullptr) {
+    m_active_queue->submit(cmd);
+  }
+}
+
 void Renderer::mode_indicator(const QMatrix4x4 &model, int mode_type,
                               const QVector3D &color, float alpha) {
   ModeIndicatorCmd cmd;

+ 2 - 0
render/scene_renderer.h

@@ -136,6 +136,8 @@ public:
                    float radius, float intensity, float time) override;
   void building_flame(const QVector3D &position, const QVector3D &color,
                       float radius, float intensity, float time);
+  void stone_impact(const QVector3D &position, const QVector3D &color,
+                    float radius, float intensity, float time) override;
   void mode_indicator(const QMatrix4x4 &model, int mode_type,
                       const QVector3D &color, float alpha = 1.0F) override;
   void terrain_chunk(Mesh *mesh, const QMatrix4x4 &model,

+ 22 - 0
render/submitter.h

@@ -35,6 +35,8 @@ public:
                            float radius, float intensity, float time) = 0;
   virtual void combat_dust(const QVector3D &position, const QVector3D &color,
                            float radius, float intensity, float time) = 0;
+  virtual void stone_impact(const QVector3D &position, const QVector3D &color,
+                            float radius, float intensity, float time) = 0;
   virtual void mode_indicator(const QMatrix4x4 &model, int mode_type,
                               const QVector3D &color, float alpha = 1.0F) = 0;
 };
@@ -182,6 +184,19 @@ public:
     cmd.time = time;
     m_queue->submit(cmd);
   }
+  void stone_impact(const QVector3D &position, const QVector3D &color,
+                    float radius, float intensity, float time) override {
+    if (m_queue == nullptr) {
+      return;
+    }
+    StoneImpactCmd cmd;
+    cmd.position = position;
+    cmd.color = color;
+    cmd.radius = radius;
+    cmd.intensity = intensity;
+    cmd.time = time;
+    m_queue->submit(cmd);
+  }
   void mode_indicator(const QMatrix4x4 &model, int mode_type,
                       const QVector3D &color, float alpha = 1.0F) override {
     if (m_queue == nullptr) {
@@ -287,6 +302,13 @@ public:
     }
   }
 
+  void stone_impact(const QVector3D &position, const QVector3D &color,
+                    float radius, float intensity, float time) override {
+    if (m_fallback != nullptr) {
+      m_fallback->stone_impact(position, color, radius, intensity, time);
+    }
+  }
+
   void mode_indicator(const QMatrix4x4 &model, int mode_type,
                       const QVector3D &color, float alpha = 1.0F) override {
     if (m_fallback != nullptr) {

+ 6 - 0
tests/render/helmet_renderers_test.cpp

@@ -62,6 +62,12 @@ public:
     // Not used in helmet rendering
   }
 
+  void stone_impact(const QVector3D & /*position*/, const QVector3D & /*color*/,
+                    float /*radius*/, float /*intensity*/,
+                    float /*time*/) override {
+    // Not used in helmet rendering
+  }
+
   void mode_indicator(const QMatrix4x4 & /*model*/, int /*mode_type*/,
                       const QVector3D & /*color*/, float /*alpha*/) override {
     // Not used in helmet rendering

+ 4 - 0
tests/render/horse_equipment_renderers_test.cpp

@@ -61,6 +61,10 @@ public:
   void combat_dust(const QVector3D & /*position*/, const QVector3D & /*color*/,
                    float /*radius*/, float /*intensity*/,
                    float /*time*/) override {}
+
+  void stone_impact(const QVector3D & /*position*/, const QVector3D & /*color*/,
+                    float /*radius*/, float /*intensity*/,
+                    float /*time*/) override {}
   void mode_indicator(const QMatrix4x4 & /*model*/, int /*mode_type*/,
                       const QVector3D & /*color*/, float /*alpha*/) override {}