Browse Source

Merge pull request #32 from djeada/refactor/cpu_optimizations

Refactor/cpu optimizations
Adam Djellouli 2 months ago
parent
commit
b82feaf955
67 changed files with 4673 additions and 1191 deletions
  1. 1 0
      README.md
  2. 38 12
      app/game_engine.cpp
  3. 5 1
      app/game_engine.h
  4. 16 0
      assets/maps/test_map.json
  5. 2 3
      assets/shaders/basic.vert
  6. 16 0
      assets/shaders/cylinder_instanced.frag
  7. 55 0
      assets/shaders/cylinder_instanced.vert
  8. 16 0
      assets/shaders/fog_instanced.frag
  9. 30 0
      assets/shaders/fog_instanced.vert
  10. 12 0
      assets/shaders/grass_instanced.frag
  11. 59 0
      assets/shaders/grass_instanced.vert
  12. 109 0
      assets/shaders/terrain_chunk.frag
  13. 20 0
      assets/shaders/terrain_chunk.vert
  14. 5 0
      game/core/component.h
  15. 2 1
      game/core/event_manager.h
  16. 29 0
      game/core/world.cpp
  17. 3 0
      game/core/world.h
  18. 6 0
      game/map/level_loader.cpp
  19. 1 0
      game/map/map_definition.h
  20. 110 0
      game/map/map_loader.cpp
  21. 34 2
      game/map/map_transformer.cpp
  22. 3 0
      game/map/map_transformer.h
  23. 76 0
      game/map/terrain.cpp
  24. 34 0
      game/map/terrain.h
  25. 2 0
      game/map/terrain_service.cpp
  26. 3 0
      game/map/terrain_service.h
  27. 128 27
      game/map/visibility_service.cpp
  28. 44 5
      game/map/visibility_service.h
  29. 400 96
      game/systems/ai_system.cpp
  30. 11 2
      game/systems/ai_system.h
  31. 4 4
      game/systems/combat_system.cpp
  32. 275 68
      game/systems/command_service.cpp
  33. 7 0
      game/systems/command_service.h
  34. 132 51
      game/systems/movement_system.cpp
  35. 238 80
      game/systems/pathfinding.cpp
  36. 57 14
      game/systems/pathfinding.h
  37. 2 0
      game/systems/production_system.cpp
  38. 10 0
      game/units/archer.cpp
  39. 4 0
      game/units/barracks.cpp
  40. 1 0
      game/units/unit.h
  41. 1 0
      render/CMakeLists.txt
  42. 190 17
      render/draw_queue.h
  43. 77 54
      render/entity/archer_renderer.cpp
  44. 41 43
      render/entity/arrow_vfx_renderer.cpp
  45. 7 4
      render/geom/flag.cpp
  46. 92 14
      render/geom/transforms.cpp
  47. 7 0
      render/geom/transforms.h
  48. 763 60
      render/gl/backend.cpp
  49. 127 0
      render/gl/backend.h
  50. 176 135
      render/gl/camera.cpp
  51. 94 16
      render/gl/shader.cpp
  52. 25 0
      render/gl/shader.h
  53. 19 0
      render/gl/shader_cache.h
  54. 417 0
      render/ground/biome_renderer.cpp
  55. 47 0
      render/ground/biome_renderer.h
  56. 29 107
      render/ground/fog_renderer.cpp
  57. 3 8
      render/ground/fog_renderer.h
  58. 25 0
      render/ground/grass_gpu.h
  59. 112 11
      render/ground/ground_renderer.cpp
  60. 43 0
      render/ground/ground_renderer.h
  61. 31 0
      render/ground/terrain_gpu.h
  62. 171 312
      render/ground/terrain_renderer.cpp
  63. 7 13
      render/ground/terrain_renderer.h
  64. 92 13
      render/scene_renderer.cpp
  65. 17 1
      render/scene_renderer.h
  66. 43 0
      render/submitter.h
  67. 17 17
      ui/qml/HUDTop.qml

+ 1 - 0
README.md

@@ -283,3 +283,4 @@ MIT License - see LICENSE file for details.
 ## Acknowledgments
 ## Acknowledgments
 
 
 Built with modern C++20, Qt 6, and OpenGL 3.3 Core. Special thanks to the open-source community for excellent documentation and tools.
 Built with modern C++20, Qt 6, and OpenGL 3.3 Core. Special thanks to the open-source community for excellent documentation and tools.
+

+ 38 - 12
app/game_engine.cpp

@@ -8,8 +8,10 @@
 #include <QVariant>
 #include <QVariant>
 
 
 #include "game/core/component.h"
 #include "game/core/component.h"
+#include "game/core/event_manager.h"
 #include "game/core/world.h"
 #include "game/core/world.h"
 #include "game/map/level_loader.h"
 #include "game/map/level_loader.h"
+#include "game/map/map_transformer.h"
 #include "game/map/terrain_service.h"
 #include "game/map/terrain_service.h"
 #include "game/map/visibility_service.h"
 #include "game/map/visibility_service.h"
 #include "game/systems/ai_system.h"
 #include "game/systems/ai_system.h"
@@ -28,11 +30,11 @@
 #include "game/systems/selection_system.h"
 #include "game/systems/selection_system.h"
 #include "game/systems/terrain_alignment_system.h"
 #include "game/systems/terrain_alignment_system.h"
 #include "render/geom/arrow.h"
 #include "render/geom/arrow.h"
-#include "game/core/event_manager.h"
 #include "render/geom/patrol_flags.h"
 #include "render/geom/patrol_flags.h"
 #include "render/gl/bootstrap.h"
 #include "render/gl/bootstrap.h"
 #include "render/gl/camera.h"
 #include "render/gl/camera.h"
 #include "render/gl/resources.h"
 #include "render/gl/resources.h"
+#include "render/ground/biome_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
 #include "render/ground/ground_renderer.h"
 #include "render/ground/terrain_renderer.h"
 #include "render/ground/terrain_renderer.h"
@@ -51,6 +53,7 @@ GameEngine::GameEngine() {
   m_camera = std::make_unique<Render::GL::Camera>();
   m_camera = std::make_unique<Render::GL::Camera>();
   m_ground = std::make_unique<Render::GL::GroundRenderer>();
   m_ground = std::make_unique<Render::GL::GroundRenderer>();
   m_terrain = std::make_unique<Render::GL::TerrainRenderer>();
   m_terrain = std::make_unique<Render::GL::TerrainRenderer>();
+  m_biome = std::make_unique<Render::GL::BiomeRenderer>();
   m_fog = std::make_unique<Render::GL::FogRenderer>();
   m_fog = std::make_unique<Render::GL::FogRenderer>();
 
 
   std::unique_ptr<Engine::Core::System> arrowSys =
   std::unique_ptr<Engine::Core::System> arrowSys =
@@ -77,10 +80,8 @@ GameEngine::GameEngine() {
   QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
   QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
   m_pickingService = std::make_unique<Game::Systems::PickingService>();
   m_pickingService = std::make_unique<Game::Systems::PickingService>();
 
 
-  // subscribe to unit died events to track enemy defeats
   Engine::Core::EventManager::instance().subscribe<Engine::Core::UnitDiedEvent>(
   Engine::Core::EventManager::instance().subscribe<Engine::Core::UnitDiedEvent>(
       [this](const Engine::Core::UnitDiedEvent &e) {
       [this](const Engine::Core::UnitDiedEvent &e) {
-        // increment only if the unit belonged to an enemy
         if (e.ownerId != m_runtime.localOwnerId) {
         if (e.ownerId != m_runtime.localOwnerId) {
           m_enemyTroopsDefeated++;
           m_enemyTroopsDefeated++;
           emit enemyTroopsDefeatedChanged();
           emit enemyTroopsDefeatedChanged();
@@ -395,7 +396,9 @@ void GameEngine::onClickSelect(qreal sx, qreal sy, bool additive) {
     }
     }
     auto targets = Game::Systems::FormationPlanner::spreadFormation(
     auto targets = Game::Systems::FormationPlanner::spreadFormation(
         int(selected.size()), hit, 1.0f);
         int(selected.size()), hit, 1.0f);
-    Game::Systems::CommandService::moveUnits(*m_world, selected, targets);
+    Game::Systems::CommandService::MoveOptions opts;
+    opts.groupMove = selected.size() > 1;
+    Game::Systems::CommandService::moveUnits(*m_world, selected, targets, opts);
     syncSelectionFlags();
     syncSelectionFlags();
     return;
     return;
   }
   }
@@ -464,13 +467,20 @@ void GameEngine::update(float dt) {
 
 
     auto &visibilityService = Game::Map::VisibilityService::instance();
     auto &visibilityService = Game::Map::VisibilityService::instance();
     if (visibilityService.isInitialized()) {
     if (visibilityService.isInitialized()) {
-      visibilityService.update(*m_world, m_runtime.localOwnerId);
+
+      m_runtime.visibilityUpdateCounter++;
+      if (m_runtime.visibilityUpdateCounter >= 3) {
+        m_runtime.visibilityUpdateCounter = 0;
+        visibilityService.update(*m_world, m_runtime.localOwnerId);
+      }
+
       const auto newVersion = visibilityService.version();
       const auto newVersion = visibilityService.version();
       if (newVersion != m_runtime.visibilityVersion) {
       if (newVersion != m_runtime.visibilityVersion) {
         if (m_fog) {
         if (m_fog) {
-          m_fog->updateMask(
-              visibilityService.getWidth(), visibilityService.getHeight(),
-              visibilityService.getTileSize(), visibilityService.cells());
+          m_fog->updateMask(visibilityService.getWidth(),
+                            visibilityService.getHeight(),
+                            visibilityService.getTileSize(),
+                            visibilityService.snapshotCells());
         }
         }
         m_runtime.visibilityVersion = newVersion;
         m_runtime.visibilityVersion = newVersion;
       }
       }
@@ -517,6 +527,9 @@ void GameEngine::render(int pixelWidth, int pixelHeight) {
     if (auto *res = m_renderer->resources())
     if (auto *res = m_renderer->resources())
       m_terrain->submit(*m_renderer, *res);
       m_terrain->submit(*m_renderer, *res);
   }
   }
+  if (m_biome && m_renderer) {
+    m_biome->submit(*m_renderer);
+  }
   if (m_fog && m_renderer) {
   if (m_fog && m_renderer) {
     if (auto *res = m_renderer->resources())
     if (auto *res = m_renderer->resources())
       m_fog->submit(*m_renderer, *res);
       m_fog->submit(*m_renderer, *res);
@@ -924,19 +937,32 @@ void GameEngine::startSkirmish(const QString &mapPath) {
 
 
     Game::Systems::BuildingCollisionRegistry::instance().clear();
     Game::Systems::BuildingCollisionRegistry::instance().clear();
 
 
+    Game::Map::MapTransformer::setLocalOwnerId(m_runtime.localOwnerId);
+
     auto lr = Game::Map::LevelLoader::loadFromAssets(m_level.mapName, *m_world,
     auto lr = Game::Map::LevelLoader::loadFromAssets(m_level.mapName, *m_world,
                                                      *m_renderer, *m_camera);
                                                      *m_renderer, *m_camera);
+    auto &terrainService = Game::Map::TerrainService::instance();
+
     if (m_ground) {
     if (m_ground) {
       if (lr.ok)
       if (lr.ok)
         m_ground->configure(lr.tileSize, lr.gridWidth, lr.gridHeight);
         m_ground->configure(lr.tileSize, lr.gridWidth, lr.gridHeight);
       else
       else
         m_ground->configureExtent(50.0f);
         m_ground->configureExtent(50.0f);
+      if (terrainService.isInitialized())
+        m_ground->setBiome(terrainService.biomeSettings());
     }
     }
 
 
     if (m_terrain) {
     if (m_terrain) {
-      auto &terrainService = Game::Map::TerrainService::instance();
       if (terrainService.isInitialized() && terrainService.getHeightMap()) {
       if (terrainService.isInitialized() && terrainService.getHeightMap()) {
-        m_terrain->configure(*terrainService.getHeightMap());
+        m_terrain->configure(*terrainService.getHeightMap(),
+                             terrainService.biomeSettings());
+      }
+    }
+
+    if (m_biome) {
+      if (terrainService.isInitialized() && terrainService.getHeightMap()) {
+        m_biome->configure(*terrainService.getHeightMap(),
+                           terrainService.biomeSettings());
       }
       }
     }
     }
 
 
@@ -947,11 +973,11 @@ void GameEngine::startSkirmish(const QString &mapPath) {
     auto &visibilityService = Game::Map::VisibilityService::instance();
     auto &visibilityService = Game::Map::VisibilityService::instance();
     visibilityService.initialize(mapWidth, mapHeight, lr.tileSize);
     visibilityService.initialize(mapWidth, mapHeight, lr.tileSize);
     if (m_world)
     if (m_world)
-      visibilityService.update(*m_world, m_runtime.localOwnerId);
+      visibilityService.computeImmediate(*m_world, m_runtime.localOwnerId);
     if (m_fog && visibilityService.isInitialized()) {
     if (m_fog && visibilityService.isInitialized()) {
       m_fog->updateMask(
       m_fog->updateMask(
           visibilityService.getWidth(), visibilityService.getHeight(),
           visibilityService.getWidth(), visibilityService.getHeight(),
-          visibilityService.getTileSize(), visibilityService.cells());
+          visibilityService.getTileSize(), visibilityService.snapshotCells());
       m_runtime.visibilityVersion = visibilityService.version();
       m_runtime.visibilityVersion = visibilityService.version();
     } else {
     } else {
       m_runtime.visibilityVersion = 0;
       m_runtime.visibilityVersion = 0;

+ 5 - 1
app/game_engine.h

@@ -28,6 +28,7 @@ class Camera;
 class ResourceManager;
 class ResourceManager;
 class GroundRenderer;
 class GroundRenderer;
 class TerrainRenderer;
 class TerrainRenderer;
+class BiomeRenderer;
 class FogRenderer;
 class FogRenderer;
 } // namespace GL
 } // namespace GL
 } // namespace Render
 } // namespace Render
@@ -65,7 +66,8 @@ public:
       int maxTroopsPerPlayer READ maxTroopsPerPlayer NOTIFY troopCountChanged)
       int maxTroopsPerPlayer READ maxTroopsPerPlayer NOTIFY troopCountChanged)
   Q_PROPERTY(
   Q_PROPERTY(
       QVariantList availableMaps READ availableMaps NOTIFY availableMapsChanged)
       QVariantList availableMaps READ availableMaps NOTIFY availableMapsChanged)
-  Q_PROPERTY(int enemyTroopsDefeated READ enemyTroopsDefeated NOTIFY enemyTroopsDefeatedChanged)
+  Q_PROPERTY(int enemyTroopsDefeated READ enemyTroopsDefeated NOTIFY
+                 enemyTroopsDefeatedChanged)
 
 
   Q_INVOKABLE void onMapClicked(qreal sx, qreal sy);
   Q_INVOKABLE void onMapClicked(qreal sx, qreal sy);
   Q_INVOKABLE void onRightClick(qreal sx, qreal sy);
   Q_INVOKABLE void onRightClick(qreal sx, qreal sy);
@@ -139,6 +141,7 @@ private:
     QString cursorMode = "normal";
     QString cursorMode = "normal";
     int lastTroopCount = 0;
     int lastTroopCount = 0;
     std::uint64_t visibilityVersion = 0;
     std::uint64_t visibilityVersion = 0;
+    int visibilityUpdateCounter = 0;
   };
   };
   struct ViewportState {
   struct ViewportState {
     int width = 0;
     int width = 0;
@@ -174,6 +177,7 @@ private:
   std::shared_ptr<Render::GL::ResourceManager> m_resources;
   std::shared_ptr<Render::GL::ResourceManager> m_resources;
   std::unique_ptr<Render::GL::GroundRenderer> m_ground;
   std::unique_ptr<Render::GL::GroundRenderer> m_ground;
   std::unique_ptr<Render::GL::TerrainRenderer> m_terrain;
   std::unique_ptr<Render::GL::TerrainRenderer> m_terrain;
+  std::unique_ptr<Render::GL::BiomeRenderer> m_biome;
   std::unique_ptr<Render::GL::FogRenderer> m_fog;
   std::unique_ptr<Render::GL::FogRenderer> m_fog;
   Game::Systems::SelectionSystem *m_selectionSystem = nullptr;
   Game::Systems::SelectionSystem *m_selectionSystem = nullptr;
   std::unique_ptr<Game::Systems::PickingService> m_pickingService;
   std::unique_ptr<Game::Systems::PickingService> m_pickingService;

+ 16 - 0
assets/maps/test_map.json

@@ -7,6 +7,22 @@
     "height": 300,
     "height": 300,
     "tileSize": 1.0
     "tileSize": 1.0
   },
   },
+  "biome": {
+    "seed": 24567,
+    "patchDensity": 3.4,
+    "patchJitter": 0.85,
+    "bladeHeight": [0.6, 1.45],
+    "bladeWidth": [0.028, 0.058],
+    "swayStrength": 0.28,
+    "swaySpeed": 1.35,
+    "heightNoise": [0.22, 0.07],
+    "grassPrimary": [0.28, 0.6, 0.32],
+    "grassSecondary": [0.42, 0.72, 0.34],
+    "grassDry": [0.58, 0.5, 0.36],
+    "soilColor": [0.28, 0.24, 0.18],
+    "rockLow": [0.5, 0.48, 0.46],
+    "rockHigh": [0.68, 0.69, 0.73]
+  },
   "camera": {
   "camera": {
     "center": [0, 0, 0],
     "center": [0, 0, 0],
     "distance": 25.0,
     "distance": 25.0,

+ 2 - 3
assets/shaders/basic.vert

@@ -4,9 +4,8 @@ layout (location = 0) in vec3 a_position;
 layout (location = 1) in vec3 a_normal;
 layout (location = 1) in vec3 a_normal;
 layout (location = 2) in vec2 a_texCoord;
 layout (location = 2) in vec2 a_texCoord;
 
 
+uniform mat4 u_mvp;
 uniform mat4 u_model;
 uniform mat4 u_model;
-uniform mat4 u_view;
-uniform mat4 u_projection;
 
 
 out vec3 v_normal;
 out vec3 v_normal;
 out vec2 v_texCoord;
 out vec2 v_texCoord;
@@ -16,5 +15,5 @@ void main() {
     v_normal = mat3(transpose(inverse(u_model))) * a_normal;
     v_normal = mat3(transpose(inverse(u_model))) * a_normal;
     v_texCoord = a_texCoord;
     v_texCoord = a_texCoord;
     v_worldPos = vec3(u_model * vec4(a_position, 1.0));
     v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-    gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
+    gl_Position = u_mvp * vec4(a_position, 1.0);
 }
 }

+ 16 - 0
assets/shaders/cylinder_instanced.frag

@@ -0,0 +1,16 @@
+#version 330 core
+
+in vec3 v_worldPos;
+in vec3 v_normal;
+in vec3 v_color;
+in float v_alpha;
+
+out vec4 FragColor;
+
+void main() {
+    vec3 normal = normalize(v_normal);
+    vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
+    float diff = max(dot(normal, lightDir), 0.2);
+    vec3 color = v_color * diff;
+    FragColor = vec4(color, v_alpha);
+}

+ 55 - 0
assets/shaders/cylinder_instanced.vert

@@ -0,0 +1,55 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+layout(location = 3) in vec3 i_start;
+layout(location = 4) in vec3 i_end;
+layout(location = 5) in float i_radius;
+layout(location = 6) in float i_alpha;
+layout(location = 7) in vec3 i_color;
+
+uniform mat4 u_viewProj;
+
+out vec3 v_worldPos;
+out vec3 v_normal;
+out vec3 v_color;
+out float v_alpha;
+
+const float EPSILON = 1e-5;
+
+void main() {
+    vec3 axis = i_end - i_start;
+    float len = length(axis);
+    vec3 dir = len > EPSILON ? axis / len : vec3(0.0, 1.0, 0.0);
+
+    vec3 up = abs(dir.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
+    vec3 tangent = normalize(cross(up, dir));
+    if (length(tangent) < EPSILON) {
+        tangent = vec3(1.0, 0.0, 0.0);
+    }
+    vec3 bitangent = cross(dir, tangent);
+    if (length(bitangent) < EPSILON) {
+        bitangent = vec3(0.0, 0.0, 1.0);
+    } else {
+        bitangent = normalize(bitangent);
+    }
+    tangent = normalize(cross(bitangent, dir));
+
+    vec3 localPos = a_position;
+    float along = (localPos.y + 0.5) * len;
+    vec3 radial = tangent * localPos.x + bitangent * localPos.z;
+
+    vec3 worldPos = i_start + dir * along + radial * i_radius;
+
+    vec3 localNormal = a_normal;
+    vec3 worldNormal = normalize(tangent * localNormal.x + dir * localNormal.y + bitangent * localNormal.z);
+
+    v_worldPos = worldPos;
+    v_normal = worldNormal;
+    v_color = i_color;
+    v_alpha = i_alpha;
+
+    gl_Position = u_viewProj * vec4(worldPos, 1.0);
+}

+ 16 - 0
assets/shaders/fog_instanced.frag

@@ -0,0 +1,16 @@
+#version 330 core
+
+in vec3 v_worldPos;
+in vec3 v_normal;
+in vec3 v_color;
+in float v_alpha;
+
+out vec4 FragColor;
+
+void main() {
+    vec3 normal = normalize(v_normal);
+    vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
+    float diff = max(dot(normal, lightDir), 0.2);
+    vec3 color = v_color * diff;
+    FragColor = vec4(color, v_alpha);
+}

+ 30 - 0
assets/shaders/fog_instanced.vert

@@ -0,0 +1,30 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+layout(location = 3) in vec3 i_center;
+layout(location = 4) in float i_size;
+layout(location = 5) in vec3 i_color;
+layout(location = 6) in float i_alpha;
+
+uniform mat4 u_viewProj;
+
+out vec3 v_worldPos;
+out vec3 v_normal;
+out vec3 v_color;
+out float v_alpha;
+
+void main() {
+    vec3 worldPos = vec3(i_center.x + a_position.x * i_size,
+                         i_center.y + a_position.y,
+                         i_center.z + a_position.z * i_size);
+
+    v_worldPos = worldPos;
+    v_normal = vec3(0.0, 1.0, 0.0);
+    v_color = i_color;
+    v_alpha = i_alpha;
+
+    gl_Position = u_viewProj * vec4(worldPos, 1.0);
+}

+ 12 - 0
assets/shaders/grass_instanced.frag

@@ -0,0 +1,12 @@
+#version 330 core
+
+in vec3 v_color;
+in float v_alpha;
+
+out vec4 fragColor;
+
+void main() {
+    if (v_alpha <= 0.02)
+        discard;
+    fragColor = vec4(v_color, v_alpha);
+}

+ 59 - 0
assets/shaders/grass_instanced.vert

@@ -0,0 +1,59 @@
+#version 330 core
+
+layout (location = 0) in vec3 a_position;
+layout (location = 1) in vec2 a_uv;
+layout (location = 2) in vec4 a_posHeight;
+layout (location = 3) in vec4 a_colorWidth;
+layout (location = 4) in vec4 a_swayParams;
+
+uniform mat4 u_viewProj;
+uniform float u_time;
+uniform float u_windStrength;
+uniform float u_windSpeed;
+uniform vec3 u_soilColor;
+uniform vec3 u_lightDir;
+
+out vec3 v_color;
+out float v_alpha;
+
+void main() {
+    vec3 basePos = a_posHeight.xyz;
+    float bladeHeight = a_posHeight.w;
+
+    vec3 bladeColor = a_colorWidth.xyz;
+    float bladeWidth = a_colorWidth.w;
+
+    float swayStrength = a_swayParams.x * u_windStrength;
+    float swaySpeed = a_swayParams.y * u_windSpeed;
+    float swayPhase = a_swayParams.z;
+    float orientation = a_swayParams.w;
+
+    float tip = clamp(a_uv.y, 0.0, 1.0);
+    float sway = sin(u_time * swaySpeed + swayPhase) * swayStrength;
+    float bend = smoothstep(0.0, 1.0, tip);
+    float swayOffset = sway * bend;
+
+    vec3 localPos = vec3(a_position.x * bladeWidth + swayOffset,
+                         a_position.y * bladeHeight,
+                         0.0);
+
+    float sinO = sin(orientation);
+    float cosO = cos(orientation);
+    vec3 rotated = vec3(localPos.x * cosO - localPos.z * sinO,
+                        localPos.y,
+                        localPos.x * sinO + localPos.z * cosO);
+
+    vec3 worldPos = basePos + rotated;
+
+    vec3 lightDir = normalize(u_lightDir);
+    vec3 normal = normalize(vec3(sinO, 1.6, cosO));
+    float lightTerm = clamp(dot(normal, lightDir), 0.0, 1.0);
+    float tipHighlight = mix(0.7, 1.0, tip);
+    vec3 soilBlend = mix(u_soilColor, bladeColor, tip);
+    v_color = soilBlend * (0.7 + 0.3 * lightTerm) * tipHighlight;
+
+    float edgeFade = 1.0 - smoothstep(0.35, 0.5, abs(a_uv.x - 0.5));
+    v_alpha = clamp(0.35 + 0.45 * tip, 0.25, 0.85) * edgeFade;
+
+    gl_Position = u_viewProj * vec4(worldPos, 1.0);
+}

+ 109 - 0
assets/shaders/terrain_chunk.frag

@@ -0,0 +1,109 @@
+#version 330 core
+
+in vec3 v_worldPos;
+in vec3 v_normal;
+in vec2 v_uv;
+
+layout (location = 0) out vec4 FragColor;
+
+uniform vec3 u_grassPrimary;
+uniform vec3 u_grassSecondary;
+uniform vec3 u_grassDry;
+uniform vec3 u_soilColor;
+uniform vec3 u_rockLow;
+uniform vec3 u_rockHigh;
+uniform vec3 u_tint;
+uniform vec2 u_noiseOffset;
+uniform float u_tileSize;
+uniform float u_macroNoiseScale;
+uniform float u_detailNoiseScale;
+uniform float u_slopeRockThreshold;
+uniform float u_slopeRockSharpness;
+uniform float u_soilBlendHeight;
+uniform float u_soilBlendSharpness;
+uniform float u_heightNoiseStrength;
+uniform float u_heightNoiseFrequency;
+uniform float u_ambientBoost;
+uniform float u_rockDetailStrength;
+uniform vec3 u_lightDir;
+
+float hash21(vec2 p) {
+    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
+}
+
+float noise21(vec2 p) {
+    vec2 i = floor(p);
+    vec2 f = fract(p);
+
+    float a = hash21(i);
+    float b = hash21(i + vec2(1.0, 0.0));
+    float c = hash21(i + vec2(0.0, 1.0));
+    float d = hash21(i + vec2(1.0, 1.0));
+
+    vec2 u = f * f * (3.0 - 2.0 * f);
+    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
+}
+
+float fbm(vec2 p) {
+    float value = 0.0;
+    float amplitude = 0.5;
+    for (int i = 0; i < 4; ++i) {
+        value += noise21(p) * amplitude;
+        p = p * 2.07 + 13.17;
+        amplitude *= 0.5;
+    }
+    return value;
+}
+
+void main() {
+    vec3 normal = normalize(v_normal);
+    vec3 upDir = vec3(0.0, 1.0, 0.0);
+    float slope = clamp(1.0 - max(dot(normal, upDir), 0.0), 0.0, 1.0);
+
+    float tileScale = max(u_tileSize, 0.0001);
+    vec2 worldCoord = (v_worldPos.xz / tileScale) + u_noiseOffset;
+
+    float macroNoise = fbm(worldCoord * u_macroNoiseScale);
+    float detailNoise = noise21(worldCoord * (u_detailNoiseScale * 2.5));
+    float erosionNoise = noise21(worldCoord * (u_detailNoiseScale * 4.0) + 17.0);
+
+    float lushFactor = smoothstep(0.2, 0.8, macroNoise);
+    vec3 lushGrass = mix(u_grassPrimary, u_grassSecondary, lushFactor);
+
+    float dryness = clamp(0.55 * slope + 0.45 * detailNoise, 0.0, 1.0);
+    vec3 grassColor = mix(lushGrass, u_grassDry, dryness);
+
+    float soilNoise = (noise21(worldCoord * (u_heightNoiseFrequency * 6.0) + 9.7) - 0.5) * u_heightNoiseStrength;
+    float soilWidth = max(0.01, 1.0 / max(u_soilBlendSharpness, 0.001));
+    float soilEdgeA = u_soilBlendHeight - soilNoise;
+    float soilEdgeB = u_soilBlendHeight + soilWidth;
+    float soilEdgeMin = min(soilEdgeA, soilEdgeB);
+    float soilEdgeMax = max(soilEdgeA, soilEdgeB);
+    float soilMix = 1.0 - smoothstep(soilEdgeMin, soilEdgeMax, v_worldPos.y);
+    soilMix = clamp(soilMix, 0.0, 1.0);
+    vec3 soilBlend = mix(grassColor, u_soilColor, soilMix);
+
+    float slopeCut = smoothstep(u_slopeRockThreshold,
+                                u_slopeRockThreshold + max(0.02, 1.0 / max(u_slopeRockSharpness, 0.1)),
+                                slope);
+    float rockMask = clamp(pow(slopeCut, u_slopeRockSharpness) +
+                           (erosionNoise - 0.5) * u_rockDetailStrength,
+                           0.0, 1.0);
+
+    float rockLerp = clamp(0.35 + detailNoise * 0.65, 0.0, 1.0);
+    vec3 rockColor = mix(u_rockLow, u_rockHigh, rockLerp);
+    rockColor = mix(rockColor, rockColor * 1.15, clamp(u_rockDetailStrength * 1.4, 0.0, 1.0));
+
+    vec3 terrainColor = mix(soilBlend, rockColor, rockMask);
+    terrainColor *= u_tint;
+
+    vec3 lightDir = normalize(u_lightDir);
+    float ndl = max(dot(normal, lightDir), 0.0);
+    float ambient = 0.35;
+    float fresnel = pow(1.0 - max(dot(normal, normalize(vec3(0.0, 1.0, 0.0))), 0.0), 2.0);
+
+    float shade = ambient + ndl * 0.75 + fresnel * 0.12;
+    vec3 litColor = terrainColor * shade * u_ambientBoost;
+
+    FragColor = vec4(clamp(litColor, 0.0, 1.0), 1.0);
+}

+ 20 - 0
assets/shaders/terrain_chunk.vert

@@ -0,0 +1,20 @@
+#version 330 core
+
+layout (location = 0) in vec3 a_position;
+layout (location = 1) in vec3 a_normal;
+layout (location = 2) in vec2 a_uv;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_worldPos;
+out vec3 v_normal;
+out vec2 v_uv;
+
+void main() {
+    vec4 worldPos = u_model * vec4(a_position, 1.0);
+    v_worldPos = worldPos.xyz;
+    v_normal = normalize(mat3(u_model) * a_normal);
+    v_uv = a_uv;
+    gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 5 - 0
game/core/component.h

@@ -131,4 +131,9 @@ public:
   bool rallySet;
   bool rallySet;
 };
 };
 
 
+class AIControlledComponent : public Component {
+public:
+  AIControlledComponent() = default;
+};
+
 } // namespace Engine::Core
 } // namespace Engine::Core

+ 2 - 1
game/core/event_manager.h

@@ -62,7 +62,8 @@ public:
 
 
 class UnitDiedEvent : public Event {
 class UnitDiedEvent : public Event {
 public:
 public:
-  UnitDiedEvent(EntityID unitId, int ownerId) : unitId(unitId), ownerId(ownerId) {}
+  UnitDiedEvent(EntityID unitId, int ownerId)
+      : unitId(unitId), ownerId(ownerId) {}
   EntityID unitId;
   EntityID unitId;
   int ownerId;
   int ownerId;
 };
 };

+ 29 - 0
game/core/world.cpp

@@ -1,4 +1,5 @@
 #include "world.h"
 #include "world.h"
+#include "component.h"
 
 
 namespace Engine::Core {
 namespace Engine::Core {
 
 
@@ -36,4 +37,32 @@ void World::update(float deltaTime) {
   }
   }
 }
 }
 
 
+std::vector<Entity *> World::getUnitsOwnedBy(int ownerId) {
+  std::vector<Entity *> result;
+  result.reserve(m_entities.size());
+  for (auto &[id, entity] : m_entities) {
+    auto *unit = entity->getComponent<UnitComponent>();
+    if (!unit)
+      continue;
+    if (unit->ownerId == ownerId) {
+      result.push_back(entity.get());
+    }
+  }
+  return result;
+}
+
+std::vector<Entity *> World::getUnitsNotOwnedBy(int ownerId) {
+  std::vector<Entity *> result;
+  result.reserve(m_entities.size());
+  for (auto &[id, entity] : m_entities) {
+    auto *unit = entity->getComponent<UnitComponent>();
+    if (!unit)
+      continue;
+    if (unit->ownerId != ownerId) {
+      result.push_back(entity.get());
+    }
+  }
+  return result;
+}
+
 } // namespace Engine::Core
 } // namespace Engine::Core

+ 3 - 0
game/core/world.h

@@ -33,6 +33,9 @@ public:
     return result;
     return result;
   }
   }
 
 
+  std::vector<Entity *> getUnitsOwnedBy(int ownerId);
+  std::vector<Entity *> getUnitsNotOwnedBy(int ownerId);
+
 private:
 private:
   EntityID m_nextEntityId = 1;
   EntityID m_nextEntityId = 1;
   std::unordered_map<EntityID, std::unique_ptr<Entity>> m_entities;
   std::unordered_map<EntityID, std::unique_ptr<Entity>> m_entities;

+ 6 - 0
game/map/level_loader.cpp

@@ -58,6 +58,8 @@ LevelLoadResult LevelLoader::loadFromAssets(const QString &mapPath,
         sp.position = QVector3D(0.0f, 0.0f, 0.0f);
         sp.position = QVector3D(0.0f, 0.0f, 0.0f);
         sp.playerId = 0;
         sp.playerId = 0;
         sp.unitType = "archer";
         sp.unitType = "archer";
+        sp.aiControlled =
+            (sp.playerId != Game::Map::MapTransformer::localOwnerId());
         if (auto unit = reg->create("archer", world, sp)) {
         if (auto unit = reg->create("archer", world, sp)) {
           res.playerUnitId = unit->id();
           res.playerUnitId = unit->id();
         } else {
         } else {
@@ -82,6 +84,8 @@ LevelLoadResult LevelLoader::loadFromAssets(const QString &mapPath,
         sp.position = QVector3D(-4.0f, 0.0f, -3.0f);
         sp.position = QVector3D(-4.0f, 0.0f, -3.0f);
         sp.playerId = 1;
         sp.playerId = 1;
         sp.unitType = "barracks";
         sp.unitType = "barracks";
+        sp.aiControlled =
+            (sp.playerId != Game::Map::MapTransformer::localOwnerId());
         reg2->create("barracks", world, sp);
         reg2->create("barracks", world, sp);
       }
       }
     }
     }
@@ -103,6 +107,8 @@ LevelLoadResult LevelLoader::loadFromAssets(const QString &mapPath,
       sp.position = QVector3D(0.0f, 0.0f, 0.0f);
       sp.position = QVector3D(0.0f, 0.0f, 0.0f);
       sp.playerId = 0;
       sp.playerId = 0;
       sp.unitType = "archer";
       sp.unitType = "archer";
+      sp.aiControlled =
+          (sp.playerId != Game::Map::MapTransformer::localOwnerId());
       if (auto unit = reg->create("archer", world, sp)) {
       if (auto unit = reg->create("archer", world, sp)) {
         res.playerUnitId = unit->id();
         res.playerUnitId = unit->id();
       }
       }

+ 1 - 0
game/map/map_definition.h

@@ -40,6 +40,7 @@ struct MapDefinition {
   CameraDefinition camera;
   CameraDefinition camera;
   std::vector<UnitSpawn> spawns;
   std::vector<UnitSpawn> spawns;
   std::vector<TerrainFeature> terrain;
   std::vector<TerrainFeature> terrain;
+  BiomeSettings biome;
   CoordSystem coordSystem = CoordSystem::Grid;
   CoordSystem coordSystem = CoordSystem::Grid;
   int maxTroopsPerPlayer = 50;
   int maxTroopsPerPlayer = 50;
 };
 };

+ 110 - 0
game/map/map_loader.cpp

@@ -5,6 +5,8 @@
 #include <QJsonDocument>
 #include <QJsonDocument>
 #include <QJsonObject>
 #include <QJsonObject>
 #include <QString>
 #include <QString>
+#include <algorithm>
+#include <cstdint>
 
 
 namespace Game::Map {
 namespace Game::Map {
 
 
@@ -44,6 +46,110 @@ static bool readCamera(const QJsonObject &obj, CameraDefinition &cam) {
   return true;
   return true;
 }
 }
 
 
+static QVector3D readVector3(const QJsonValue &value,
+                             const QVector3D &fallback) {
+  if (!value.isArray())
+    return fallback;
+  auto arr = value.toArray();
+  if (arr.size() != 3)
+    return fallback;
+  return QVector3D(float(arr[0].toDouble(fallback.x())),
+                   float(arr[1].toDouble(fallback.y())),
+                   float(arr[2].toDouble(fallback.z())));
+}
+
+static void readBiome(const QJsonObject &obj, BiomeSettings &out) {
+  if (obj.contains("seed"))
+    out.seed = static_cast<std::uint32_t>(
+        std::max(0.0, obj.value("seed").toDouble(out.seed)));
+  if (obj.contains("patchDensity"))
+    out.patchDensity =
+        float(obj.value("patchDensity").toDouble(out.patchDensity));
+  if (obj.contains("patchJitter"))
+    out.patchJitter = float(obj.value("patchJitter").toDouble(out.patchJitter));
+  if (obj.contains("bladeHeight")) {
+    auto arr = obj.value("bladeHeight").toArray();
+    if (arr.size() == 2) {
+      out.bladeHeightMin = float(arr[0].toDouble(out.bladeHeightMin));
+      out.bladeHeightMax = float(arr[1].toDouble(out.bladeHeightMax));
+    }
+  }
+  if (obj.contains("bladeWidth")) {
+    auto arr = obj.value("bladeWidth").toArray();
+    if (arr.size() == 2) {
+      out.bladeWidthMin = float(arr[0].toDouble(out.bladeWidthMin));
+      out.bladeWidthMax = float(arr[1].toDouble(out.bladeWidthMax));
+    }
+  }
+  if (obj.contains("backgroundBladeDensity"))
+    out.backgroundBladeDensity =
+        float(obj.value("backgroundBladeDensity")
+                  .toDouble(out.backgroundBladeDensity));
+  if (obj.contains("swayStrength"))
+    out.swayStrength =
+        float(obj.value("swayStrength").toDouble(out.swayStrength));
+  if (obj.contains("swaySpeed"))
+    out.swaySpeed = float(obj.value("swaySpeed").toDouble(out.swaySpeed));
+  if (obj.contains("heightNoise")) {
+    auto arr = obj.value("heightNoise").toArray();
+    if (arr.size() == 2) {
+      out.heightNoiseAmplitude =
+          float(arr[0].toDouble(out.heightNoiseAmplitude));
+      out.heightNoiseFrequency =
+          float(arr[1].toDouble(out.heightNoiseFrequency));
+    }
+  }
+
+  if (obj.contains("grassPrimary"))
+    out.grassPrimary = readVector3(obj.value("grassPrimary"), out.grassPrimary);
+  if (obj.contains("grassSecondary"))
+    out.grassSecondary =
+        readVector3(obj.value("grassSecondary"), out.grassSecondary);
+  if (obj.contains("grassDry"))
+    out.grassDry = readVector3(obj.value("grassDry"), out.grassDry);
+  if (obj.contains("soilColor"))
+    out.soilColor = readVector3(obj.value("soilColor"), out.soilColor);
+  if (obj.contains("rockLow"))
+    out.rockLow = readVector3(obj.value("rockLow"), out.rockLow);
+  if (obj.contains("rockHigh"))
+    out.rockHigh = readVector3(obj.value("rockHigh"), out.rockHigh);
+  if (obj.contains("terrainMacroNoiseScale"))
+    out.terrainMacroNoiseScale =
+        float(obj.value("terrainMacroNoiseScale")
+                  .toDouble(out.terrainMacroNoiseScale));
+  if (obj.contains("terrainDetailNoiseScale"))
+    out.terrainDetailNoiseScale =
+        float(obj.value("terrainDetailNoiseScale")
+                  .toDouble(out.terrainDetailNoiseScale));
+  if (obj.contains("terrainSoilHeight"))
+    out.terrainSoilHeight =
+        float(obj.value("terrainSoilHeight").toDouble(out.terrainSoilHeight));
+  if (obj.contains("terrainSoilSharpness"))
+    out.terrainSoilSharpness = float(
+        obj.value("terrainSoilSharpness").toDouble(out.terrainSoilSharpness));
+  if (obj.contains("terrainRockThreshold"))
+    out.terrainRockThreshold = float(
+        obj.value("terrainRockThreshold").toDouble(out.terrainRockThreshold));
+  if (obj.contains("terrainRockSharpness"))
+    out.terrainRockSharpness = float(
+        obj.value("terrainRockSharpness").toDouble(out.terrainRockSharpness));
+  if (obj.contains("terrainAmbientBoost"))
+    out.terrainAmbientBoost = float(
+        obj.value("terrainAmbientBoost").toDouble(out.terrainAmbientBoost));
+  if (obj.contains("terrainRockDetailStrength"))
+    out.terrainRockDetailStrength =
+        float(obj.value("terrainRockDetailStrength")
+                  .toDouble(out.terrainRockDetailStrength));
+  if (obj.contains("backgroundSwayVariance"))
+    out.backgroundSwayVariance =
+        float(obj.value("backgroundSwayVariance")
+                  .toDouble(out.backgroundSwayVariance));
+  if (obj.contains("backgroundScatterRadius"))
+    out.backgroundScatterRadius =
+        float(obj.value("backgroundScatterRadius")
+                  .toDouble(out.backgroundScatterRadius));
+}
+
 static void readSpawns(const QJsonArray &arr, std::vector<UnitSpawn> &out) {
 static void readSpawns(const QJsonArray &arr, std::vector<UnitSpawn> &out) {
   out.clear();
   out.clear();
   out.reserve(arr.size());
   out.reserve(arr.size());
@@ -169,6 +275,10 @@ bool MapLoader::loadFromJsonFile(const QString &path, MapDefinition &outMap,
                 outMap.coordSystem);
                 outMap.coordSystem);
   }
   }
 
 
+  if (root.contains("biome") && root.value("biome").isObject()) {
+    readBiome(root.value("biome").toObject(), outMap.biome);
+  }
+
   return true;
   return true;
 }
 }
 
 

+ 34 - 2
game/map/map_transformer.cpp

@@ -11,7 +11,8 @@ namespace Game::Map {
 
 
 namespace {
 namespace {
 std::shared_ptr<Game::Units::UnitFactoryRegistry> s_registry;
 std::shared_ptr<Game::Units::UnitFactoryRegistry> s_registry;
-}
+int s_localOwnerId = 1;
+} // namespace
 
 
 void MapTransformer::setFactoryRegistry(
 void MapTransformer::setFactoryRegistry(
     std::shared_ptr<Game::Units::UnitFactoryRegistry> reg) {
     std::shared_ptr<Game::Units::UnitFactoryRegistry> reg) {
@@ -22,6 +23,10 @@ MapTransformer::getFactoryRegistry() {
   return s_registry;
   return s_registry;
 }
 }
 
 
+void MapTransformer::setLocalOwnerId(int ownerId) { s_localOwnerId = ownerId; }
+
+int MapTransformer::localOwnerId() { return s_localOwnerId; }
+
 MapRuntime
 MapRuntime
 MapTransformer::applyToWorld(const MapDefinition &def,
 MapTransformer::applyToWorld(const MapDefinition &def,
                              Engine::Core::World &world,
                              Engine::Core::World &world,
@@ -73,6 +78,7 @@ MapTransformer::applyToWorld(const MapDefinition &def,
       sp.position = QVector3D(worldX, 0.0f, worldZ);
       sp.position = QVector3D(worldX, 0.0f, worldZ);
       sp.playerId = s.playerId;
       sp.playerId = s.playerId;
       sp.unitType = s.type.toStdString();
       sp.unitType = s.type.toStdString();
+      sp.aiControlled = (s.playerId != s_localOwnerId);
       auto obj = s_registry->create(s.type.toStdString(), world, sp);
       auto obj = s_registry->create(s.type.toStdString(), world, sp);
       if (obj) {
       if (obj) {
         e = world.getEntity(obj->id());
         e = world.getEntity(obj->id());
@@ -94,6 +100,24 @@ MapTransformer::applyToWorld(const MapDefinition &def,
       u->ownerId = s.playerId;
       u->ownerId = s.playerId;
       u->visionRange = 14.0f;
       u->visionRange = 14.0f;
 
 
+      if (s.playerId != s_localOwnerId) {
+        e->addComponent<Engine::Core::AIControlledComponent>();
+      }
+
+      if (auto *existingMv =
+              e->getComponent<Engine::Core::MovementComponent>()) {
+        existingMv->goalX = worldX;
+        existingMv->goalY = worldZ;
+        existingMv->targetX = worldX;
+        existingMv->targetY = worldZ;
+      } else if (auto *mv =
+                     e->addComponent<Engine::Core::MovementComponent>()) {
+        mv->goalX = worldX;
+        mv->goalY = worldZ;
+        mv->targetX = worldX;
+        mv->targetY = worldZ;
+      }
+
       QVector3D tc;
       QVector3D tc;
       switch (u->ownerId) {
       switch (u->ownerId) {
       case 1:
       case 1:
@@ -125,7 +149,15 @@ MapTransformer::applyToWorld(const MapDefinition &def,
         atk->damage = 12;
         atk->damage = 12;
         atk->cooldown = 1.2f;
         atk->cooldown = 1.2f;
       }
       }
-      e->addComponent<Engine::Core::MovementComponent>();
+      if (!e->getComponent<Engine::Core::MovementComponent>()) {
+        auto *mv = e->addComponent<Engine::Core::MovementComponent>();
+        if (mv) {
+          mv->goalX = worldX;
+          mv->goalY = worldZ;
+          mv->targetX = worldX;
+          mv->targetY = worldZ;
+        }
+      }
       rt.unitIds.push_back(e->getId());
       rt.unitIds.push_back(e->getId());
     }
     }
 
 

+ 3 - 0
game/map/map_transformer.h

@@ -31,6 +31,9 @@ public:
   static void
   static void
   setFactoryRegistry(std::shared_ptr<Game::Units::UnitFactoryRegistry> reg);
   setFactoryRegistry(std::shared_ptr<Game::Units::UnitFactoryRegistry> reg);
   static std::shared_ptr<Game::Units::UnitFactoryRegistry> getFactoryRegistry();
   static std::shared_ptr<Game::Units::UnitFactoryRegistry> getFactoryRegistry();
+
+  static void setLocalOwnerId(int ownerId);
+  static int localOwnerId();
 };
 };
 
 
 } // namespace Game::Map
 } // namespace Game::Map

+ 76 - 0
game/map/terrain.cpp

@@ -2,11 +2,48 @@
 #include <QDebug>
 #include <QDebug>
 #include <algorithm>
 #include <algorithm>
 #include <cmath>
 #include <cmath>
+#include <cstdint>
 
 
 namespace {
 namespace {
 constexpr float kDegToRad = static_cast<float>(M_PI) / 180.0f;
 constexpr float kDegToRad = static_cast<float>(M_PI) / 180.0f;
+inline std::uint32_t hashCoords(int x, int z, std::uint32_t seed) {
+  std::uint32_t ux = static_cast<std::uint32_t>(x) * 73856093u;
+  std::uint32_t uz = static_cast<std::uint32_t>(z) * 19349663u;
+  std::uint32_t s = seed * 83492791u + 0x9e3779b9u;
+  return ux ^ uz ^ s;
 }
 }
 
 
+inline float hashToFloat01(std::uint32_t h) {
+  h ^= h >> 17;
+  h *= 0xed5ad4bbu;
+  h ^= h >> 11;
+  h *= 0xac4c1b51u;
+  h ^= h >> 15;
+  h *= 0x31848babu;
+  h ^= h >> 14;
+  return (h & 0x00FFFFFFu) / float(0x01000000);
+}
+
+inline float valueNoise2D(float x, float z, std::uint32_t seed) {
+  int ix0 = static_cast<int>(std::floor(x));
+  int iz0 = static_cast<int>(std::floor(z));
+  int ix1 = ix0 + 1;
+  int iz1 = iz0 + 1;
+
+  float tx = x - static_cast<float>(ix0);
+  float tz = z - static_cast<float>(iz0);
+
+  float n00 = hashToFloat01(hashCoords(ix0, iz0, seed));
+  float n10 = hashToFloat01(hashCoords(ix1, iz0, seed));
+  float n01 = hashToFloat01(hashCoords(ix0, iz1, seed));
+  float n11 = hashToFloat01(hashCoords(ix1, iz1, seed));
+
+  float nx0 = n00 * (1.0f - tx) + n10 * tx;
+  float nx1 = n01 * (1.0f - tx) + n11 * tx;
+  return nx0 * (1.0f - tz) + nx1 * tz;
+}
+} // namespace
+
 namespace Game::Map {
 namespace Game::Map {
 
 
 TerrainHeightMap::TerrainHeightMap(int width, int height, float tileSize)
 TerrainHeightMap::TerrainHeightMap(int width, int height, float tileSize)
@@ -350,4 +387,43 @@ float TerrainHeightMap::calculateFeatureHeight(const TerrainFeature &feature,
   return feature.height * heightFactor;
   return feature.height * heightFactor;
 }
 }
 
 
+void TerrainHeightMap::applyBiomeVariation(const BiomeSettings &settings) {
+  if (m_heights.empty())
+    return;
+
+  const float amplitude = std::max(0.0f, settings.heightNoiseAmplitude);
+  if (amplitude <= 0.0001f)
+    return;
+
+  const float frequency = std::max(0.0001f, settings.heightNoiseFrequency);
+  const float halfWidth = m_width * 0.5f - 0.5f;
+  const float halfHeight = m_height * 0.5f - 0.5f;
+
+  for (int z = 0; z < m_height; ++z) {
+    for (int x = 0; x < m_width; ++x) {
+      int idx = indexAt(x, z);
+      TerrainType type = m_terrainTypes[idx];
+      if (type == TerrainType::Mountain)
+        continue;
+
+      float worldX = (static_cast<float>(x) - halfWidth) * m_tileSize;
+      float worldZ = (static_cast<float>(z) - halfHeight) * m_tileSize;
+      float sampleX = worldX * frequency;
+      float sampleZ = worldZ * frequency;
+
+      float baseNoise = valueNoise2D(sampleX, sampleZ, settings.seed);
+      float detailNoise = valueNoise2D(sampleX * 2.0f, sampleZ * 2.0f,
+                                       settings.seed ^ 0xA21C9E37u);
+
+      float blended = 0.65f * baseNoise + 0.35f * detailNoise;
+      float perturb = (blended - 0.5f) * 2.0f * amplitude;
+
+      if (type == TerrainType::Hill)
+        perturb *= 0.6f;
+
+      m_heights[idx] = std::max(0.0f, m_heights[idx] + perturb);
+    }
+  }
+}
+
 } // namespace Game::Map
 } // namespace Game::Map

+ 34 - 0
game/map/terrain.h

@@ -1,6 +1,7 @@
 #pragma once
 #pragma once
 
 
 #include <QVector3D>
 #include <QVector3D>
+#include <cstdint>
 #include <memory>
 #include <memory>
 #include <vector>
 #include <vector>
 
 
@@ -8,6 +9,37 @@ namespace Game::Map {
 
 
 enum class TerrainType { Flat, Hill, Mountain };
 enum class TerrainType { Flat, Hill, Mountain };
 
 
+struct BiomeSettings {
+  QVector3D grassPrimary{0.30f, 0.60f, 0.28f};
+  QVector3D grassSecondary{0.44f, 0.70f, 0.32f};
+  QVector3D grassDry{0.72f, 0.66f, 0.48f};
+  QVector3D soilColor{0.28f, 0.24f, 0.18f};
+  QVector3D rockLow{0.48f, 0.46f, 0.44f};
+  QVector3D rockHigh{0.68f, 0.69f, 0.73f};
+  float patchDensity = 4.5f;
+  float patchJitter = 0.95f;
+  float backgroundBladeDensity = 0.65f;
+  float bladeHeightMin = 0.55f;
+  float bladeHeightMax = 1.35f;
+  float bladeWidthMin = 0.025f;
+  float bladeWidthMax = 0.055f;
+  float swayStrength = 0.25f;
+  float swaySpeed = 1.4f;
+  float heightNoiseAmplitude = 0.16f;
+  float heightNoiseFrequency = 0.05f;
+  float terrainMacroNoiseScale = 0.035f;
+  float terrainDetailNoiseScale = 0.14f;
+  float terrainSoilHeight = 0.6f;
+  float terrainSoilSharpness = 3.8f;
+  float terrainRockThreshold = 0.42f;
+  float terrainRockSharpness = 3.1f;
+  float terrainAmbientBoost = 1.08f;
+  float terrainRockDetailStrength = 0.35f;
+  float backgroundSwayVariance = 0.2f;
+  float backgroundScatterRadius = 0.35f;
+  std::uint32_t seed = 1337u;
+};
+
 struct TerrainFeature {
 struct TerrainFeature {
   TerrainType type;
   TerrainType type;
   float centerX;
   float centerX;
@@ -45,6 +77,8 @@ public:
     return m_terrainTypes;
     return m_terrainTypes;
   }
   }
 
 
+  void applyBiomeVariation(const BiomeSettings &settings);
+
 private:
 private:
   int m_width;
   int m_width;
   int m_height;
   int m_height;

+ 2 - 0
game/map/terrain_service.cpp

@@ -15,6 +15,8 @@ void TerrainService::initialize(const MapDefinition &mapDef) {
       mapDef.grid.width, mapDef.grid.height, mapDef.grid.tileSize);
       mapDef.grid.width, mapDef.grid.height, mapDef.grid.tileSize);
 
 
   m_heightMap->buildFromFeatures(mapDef.terrain);
   m_heightMap->buildFromFeatures(mapDef.terrain);
+  m_biomeSettings = mapDef.biome;
+  m_heightMap->applyBiomeVariation(m_biomeSettings);
 
 
   qDebug() << "TerrainService initialized with" << mapDef.terrain.size()
   qDebug() << "TerrainService initialized with" << mapDef.terrain.size()
            << "terrain features";
            << "terrain features";

+ 3 - 0
game/map/terrain_service.h

@@ -29,6 +29,8 @@ public:
 
 
   const TerrainHeightMap *getHeightMap() const { return m_heightMap.get(); }
   const TerrainHeightMap *getHeightMap() const { return m_heightMap.get(); }
 
 
+  const BiomeSettings &biomeSettings() const { return m_biomeSettings; }
+
   bool isInitialized() const { return m_heightMap != nullptr; }
   bool isInitialized() const { return m_heightMap != nullptr; }
 
 
 private:
 private:
@@ -39,6 +41,7 @@ private:
   TerrainService &operator=(const TerrainService &) = delete;
   TerrainService &operator=(const TerrainService &) = delete;
 
 
   std::unique_ptr<TerrainHeightMap> m_heightMap;
   std::unique_ptr<TerrainHeightMap> m_heightMap;
+  BiomeSettings m_biomeSettings;
 };
 };
 
 
 } // namespace Game::Map
 } // namespace Game::Map

+ 128 - 27
game/map/visibility_service.cpp

@@ -9,7 +9,18 @@ namespace Game::Map {
 
 
 namespace {
 namespace {
 constexpr float kDefaultVisionRange = 12.0f;
 constexpr float kDefaultVisionRange = 12.0f;
+
+bool inBoundsStatic(int x, int z, int width, int height) {
+  return x >= 0 && x < width && z >= 0 && z < height;
+}
+
+int indexStatic(int x, int z, int width) { return z * width + x; }
+
+int worldToGridStatic(float worldCoord, float half, float tileSize) {
+  float gridCoord = worldCoord / tileSize + half;
+  return static_cast<int>(std::floor(gridCoord + 0.5f));
 }
 }
+} // namespace
 
 
 VisibilityService &VisibilityService::instance() {
 VisibilityService &VisibilityService::instance() {
   static VisibilityService s_instance;
   static VisibilityService s_instance;
@@ -17,6 +28,7 @@ VisibilityService &VisibilityService::instance() {
 }
 }
 
 
 void VisibilityService::initialize(int width, int height, float tileSize) {
 void VisibilityService::initialize(int width, int height, float tileSize) {
+  std::unique_lock lock(m_cellsMutex);
   m_width = std::max(1, width);
   m_width = std::max(1, width);
   m_height = std::max(1, height);
   m_height = std::max(1, height);
   m_tileSize = std::max(0.0001f, tileSize);
   m_tileSize = std::max(0.0001f, tileSize);
@@ -24,26 +36,55 @@ void VisibilityService::initialize(int width, int height, float tileSize) {
   m_halfHeight = m_height * 0.5f - 0.5f;
   m_halfHeight = m_height * 0.5f - 0.5f;
   const int count = m_width * m_height;
   const int count = m_width * m_height;
   m_cells.assign(count, static_cast<std::uint8_t>(VisibilityState::Unseen));
   m_cells.assign(count, static_cast<std::uint8_t>(VisibilityState::Unseen));
-  m_currentVisible.assign(count, 0);
-  m_version++;
+  m_version.store(1, std::memory_order_release);
+  m_generation.store(0, std::memory_order_release);
   m_initialized = true;
   m_initialized = true;
 }
 }
 
 
 void VisibilityService::reset() {
 void VisibilityService::reset() {
   if (!m_initialized)
   if (!m_initialized)
     return;
     return;
+  std::unique_lock lock(m_cellsMutex);
   std::fill(m_cells.begin(), m_cells.end(),
   std::fill(m_cells.begin(), m_cells.end(),
             static_cast<std::uint8_t>(VisibilityState::Unseen));
             static_cast<std::uint8_t>(VisibilityState::Unseen));
-  std::fill(m_currentVisible.begin(), m_currentVisible.end(), 0);
-  m_version++;
+  m_version.fetch_add(1, std::memory_order_release);
+}
+
+bool VisibilityService::update(Engine::Core::World &world, int playerId) {
+  if (!m_initialized)
+    return false;
+
+  bool integrated = integrateCompletedJob();
+
+  if (!m_jobActive.load(std::memory_order_acquire)) {
+    auto sources = gatherVisionSources(world, playerId);
+    auto payload = composeJobPayload(sources);
+    startAsyncJob(std::move(payload));
+  }
+
+  return integrated;
 }
 }
 
 
-void VisibilityService::update(Engine::Core::World &world, int playerId) {
+void VisibilityService::computeImmediate(Engine::Core::World &world,
+                                         int playerId) {
   if (!m_initialized)
   if (!m_initialized)
     return;
     return;
 
 
-  std::fill(m_currentVisible.begin(), m_currentVisible.end(), 0);
+  auto sources = gatherVisionSources(world, playerId);
+  auto payload = composeJobPayload(sources);
+  auto result = executeJob(std::move(payload));
+
+  if (result.changed) {
+    std::unique_lock lock(m_cellsMutex);
+    m_cells = std::move(result.cells);
+    m_version.fetch_add(1, std::memory_order_release);
+  }
+}
 
 
+std::vector<VisibilityService::VisionSource>
+VisibilityService::gatherVisionSources(Engine::Core::World &world,
+                                       int playerId) const {
+  std::vector<VisionSource> sources;
   auto entities = world.getEntitiesWith<Engine::Core::TransformComponent>();
   auto entities = world.getEntitiesWith<Engine::Core::TransformComponent>();
   const float rangePadding = m_tileSize * 0.5f;
   const float rangePadding = m_tileSize * 0.5f;
 
 
@@ -58,58 +99,111 @@ void VisibilityService::update(Engine::Core::World &world, int playerId) {
       continue;
       continue;
 
 
     const float visionRange = std::max(unit->visionRange, kDefaultVisionRange);
     const float visionRange = std::max(unit->visionRange, kDefaultVisionRange);
-
     const int centerX = worldToGrid(transform->position.x, m_halfWidth);
     const int centerX = worldToGrid(transform->position.x, m_halfWidth);
     const int centerZ = worldToGrid(transform->position.z, m_halfHeight);
     const int centerZ = worldToGrid(transform->position.z, m_halfHeight);
     if (!inBounds(centerX, centerZ))
     if (!inBounds(centerX, centerZ))
       continue;
       continue;
+
     const int cellRadius =
     const int cellRadius =
         std::max(1, static_cast<int>(std::ceil(visionRange / m_tileSize)));
         std::max(1, static_cast<int>(std::ceil(visionRange / m_tileSize)));
+    const float expandedRangeSq =
+        (visionRange + rangePadding) * (visionRange + rangePadding);
+
+    sources.push_back({centerX, centerZ, cellRadius, expandedRangeSq});
+  }
 
 
-    for (int dz = -cellRadius; dz <= cellRadius; ++dz) {
-      const int gz = centerZ + dz;
-      if (!inBounds(centerX, gz))
+  return sources;
+}
+
+VisibilityService::JobPayload VisibilityService::composeJobPayload(
+    const std::vector<VisionSource> &sources) const {
+  std::shared_lock lock(m_cellsMutex);
+  const auto gen = const_cast<std::atomic<std::uint64_t> &>(m_generation)
+                       .fetch_add(1, std::memory_order_relaxed);
+  return JobPayload{m_width, m_height, m_tileSize, m_cells, sources, gen};
+}
+
+void VisibilityService::startAsyncJob(JobPayload &&payload) {
+  m_jobActive.store(true, std::memory_order_release);
+  m_pendingJob = std::async(std::launch::async, executeJob, std::move(payload));
+}
+
+bool VisibilityService::integrateCompletedJob() {
+  if (!m_jobActive.load(std::memory_order_acquire))
+    return false;
+
+  if (m_pendingJob.wait_for(std::chrono::seconds(0)) !=
+      std::future_status::ready)
+    return false;
+
+  auto result = m_pendingJob.get();
+  m_jobActive.store(false, std::memory_order_release);
+
+  if (result.changed) {
+    std::unique_lock lock(m_cellsMutex);
+    m_cells = std::move(result.cells);
+    m_version.fetch_add(1, std::memory_order_release);
+    return true;
+  }
+
+  return false;
+}
+
+VisibilityService::JobResult VisibilityService::executeJob(JobPayload payload) {
+  const int count = payload.width * payload.height;
+  std::vector<std::uint8_t> currentVisible(count, 0);
+
+  const float halfWidth = payload.width * 0.5f - 0.5f;
+  const float halfHeight = payload.height * 0.5f - 0.5f;
+
+  for (const auto &src : payload.sources) {
+    for (int dz = -src.cellRadius; dz <= src.cellRadius; ++dz) {
+      const int gz = src.centerZ + dz;
+      if (!inBoundsStatic(src.centerX, gz, payload.width, payload.height))
         continue;
         continue;
-      const float worldDz = dz * m_tileSize;
-      for (int dx = -cellRadius; dx <= cellRadius; ++dx) {
-        const int gx = centerX + dx;
-        if (!inBounds(gx, gz))
+      const float worldDz = dz * payload.tileSize;
+      for (int dx = -src.cellRadius; dx <= src.cellRadius; ++dx) {
+        const int gx = src.centerX + dx;
+        if (!inBoundsStatic(gx, gz, payload.width, payload.height))
           continue;
           continue;
-        const float worldDx = dx * m_tileSize;
+        const float worldDx = dx * payload.tileSize;
         const float distSq = worldDx * worldDx + worldDz * worldDz;
         const float distSq = worldDx * worldDx + worldDz * worldDz;
-        if (distSq <=
-            (visionRange + rangePadding) * (visionRange + rangePadding)) {
-          const int idx = index(gx, gz);
-          m_currentVisible[idx] = 1;
+        if (distSq <= src.expandedRangeSq) {
+          const int idx = indexStatic(gx, gz, payload.width);
+          currentVisible[idx] = 1;
         }
         }
       }
       }
     }
     }
   }
   }
 
 
   bool changed = false;
   bool changed = false;
-  for (int idx = 0; idx < static_cast<int>(m_cells.size()); ++idx) {
-    const std::uint8_t nowVisible = m_currentVisible[idx];
+  for (int idx = 0; idx < count; ++idx) {
+    const std::uint8_t nowVisible = currentVisible[idx];
 
 
     if (nowVisible) {
     if (nowVisible) {
-      if (m_cells[idx] != static_cast<std::uint8_t>(VisibilityState::Visible)) {
-        m_cells[idx] = static_cast<std::uint8_t>(VisibilityState::Visible);
+      if (payload.cells[idx] !=
+          static_cast<std::uint8_t>(VisibilityState::Visible)) {
+        payload.cells[idx] =
+            static_cast<std::uint8_t>(VisibilityState::Visible);
         changed = true;
         changed = true;
       }
       }
     } else {
     } else {
-      if (m_cells[idx] == static_cast<std::uint8_t>(VisibilityState::Visible)) {
-        m_cells[idx] = static_cast<std::uint8_t>(VisibilityState::Explored);
+      if (payload.cells[idx] ==
+          static_cast<std::uint8_t>(VisibilityState::Visible)) {
+        payload.cells[idx] =
+            static_cast<std::uint8_t>(VisibilityState::Explored);
         changed = true;
         changed = true;
       }
       }
     }
     }
   }
   }
 
 
-  if (changed)
-    ++m_version;
+  return JobResult{std::move(payload.cells), payload.generation, changed};
 }
 }
 
 
 VisibilityState VisibilityService::stateAt(int gridX, int gridZ) const {
 VisibilityState VisibilityService::stateAt(int gridX, int gridZ) const {
   if (!m_initialized || !inBounds(gridX, gridZ))
   if (!m_initialized || !inBounds(gridX, gridZ))
     return VisibilityState::Visible;
     return VisibilityState::Visible;
+  std::shared_lock lock(m_cellsMutex);
   return static_cast<VisibilityState>(m_cells[index(gridX, gridZ)]);
   return static_cast<VisibilityState>(m_cells[index(gridX, gridZ)]);
 }
 }
 
 
@@ -120,6 +214,7 @@ bool VisibilityService::isVisibleWorld(float worldX, float worldZ) const {
   const int gz = worldToGrid(worldZ, m_halfHeight);
   const int gz = worldToGrid(worldZ, m_halfHeight);
   if (!inBounds(gx, gz))
   if (!inBounds(gx, gz))
     return false;
     return false;
+  std::shared_lock lock(m_cellsMutex);
   return m_cells[index(gx, gz)] ==
   return m_cells[index(gx, gz)] ==
          static_cast<std::uint8_t>(VisibilityState::Visible);
          static_cast<std::uint8_t>(VisibilityState::Visible);
 }
 }
@@ -131,11 +226,17 @@ bool VisibilityService::isExploredWorld(float worldX, float worldZ) const {
   const int gz = worldToGrid(worldZ, m_halfHeight);
   const int gz = worldToGrid(worldZ, m_halfHeight);
   if (!inBounds(gx, gz))
   if (!inBounds(gx, gz))
     return false;
     return false;
+  std::shared_lock lock(m_cellsMutex);
   const auto state = m_cells[index(gx, gz)];
   const auto state = m_cells[index(gx, gz)];
   return state == static_cast<std::uint8_t>(VisibilityState::Visible) ||
   return state == static_cast<std::uint8_t>(VisibilityState::Visible) ||
          state == static_cast<std::uint8_t>(VisibilityState::Explored);
          state == static_cast<std::uint8_t>(VisibilityState::Explored);
 }
 }
 
 
+std::vector<std::uint8_t> VisibilityService::snapshotCells() const {
+  std::shared_lock lock(m_cellsMutex);
+  return m_cells;
+}
+
 bool VisibilityService::inBounds(int x, int z) const {
 bool VisibilityService::inBounds(int x, int z) const {
   return x >= 0 && x < m_width && z >= 0 && z < m_height;
   return x >= 0 && x < m_width && z >= 0 && z < m_height;
 }
 }

+ 44 - 5
game/map/visibility_service.h

@@ -1,6 +1,10 @@
 #pragma once
 #pragma once
 
 
+#include <atomic>
 #include <cstdint>
 #include <cstdint>
+#include <future>
+#include <mutex>
+#include <shared_mutex>
 #include <vector>
 #include <vector>
 
 
 namespace Engine {
 namespace Engine {
@@ -24,7 +28,8 @@ public:
 
 
   void initialize(int width, int height, float tileSize);
   void initialize(int width, int height, float tileSize);
   void reset();
   void reset();
-  void update(Engine::Core::World &world, int playerId);
+  bool update(Engine::Core::World &world, int playerId);
+  void computeImmediate(Engine::Core::World &world, int playerId);
 
 
   bool isInitialized() const { return m_initialized; }
   bool isInitialized() const { return m_initialized; }
 
 
@@ -36,14 +41,45 @@ public:
   bool isVisibleWorld(float worldX, float worldZ) const;
   bool isVisibleWorld(float worldX, float worldZ) const;
   bool isExploredWorld(float worldX, float worldZ) const;
   bool isExploredWorld(float worldX, float worldZ) const;
 
 
-  const std::vector<std::uint8_t> &cells() const { return m_cells; }
-  std::uint64_t version() const { return m_version; }
+  std::vector<std::uint8_t> snapshotCells() const;
+  std::uint64_t version() const {
+    return m_version.load(std::memory_order_relaxed);
+  }
 
 
 private:
 private:
   bool inBounds(int x, int z) const;
   bool inBounds(int x, int z) const;
   int index(int x, int z) const;
   int index(int x, int z) const;
   int worldToGrid(float worldCoord, float half) const;
   int worldToGrid(float worldCoord, float half) const;
 
 
+  struct VisionSource {
+    int centerX;
+    int centerZ;
+    int cellRadius;
+    float expandedRangeSq;
+  };
+
+  struct JobPayload {
+    int width;
+    int height;
+    float tileSize;
+    std::vector<std::uint8_t> cells;
+    std::vector<VisionSource> sources;
+    std::uint64_t generation;
+  };
+
+  struct JobResult {
+    std::vector<std::uint8_t> cells;
+    std::uint64_t generation;
+    bool changed;
+  };
+
+  std::vector<VisionSource> gatherVisionSources(Engine::Core::World &world,
+                                                int playerId) const;
+  JobPayload composeJobPayload(const std::vector<VisionSource> &sources) const;
+  void startAsyncJob(JobPayload &&payload);
+  bool integrateCompletedJob();
+  static JobResult executeJob(JobPayload payload);
+
   VisibilityService() = default;
   VisibilityService() = default;
 
 
   bool m_initialized = false;
   bool m_initialized = false;
@@ -53,9 +89,12 @@ private:
   float m_halfWidth = 0.0f;
   float m_halfWidth = 0.0f;
   float m_halfHeight = 0.0f;
   float m_halfHeight = 0.0f;
 
 
+  mutable std::shared_mutex m_cellsMutex;
   std::vector<std::uint8_t> m_cells;
   std::vector<std::uint8_t> m_cells;
-  std::vector<std::uint8_t> m_currentVisible;
-  std::uint64_t m_version = 0;
+  std::atomic<std::uint64_t> m_version{0};
+  std::atomic<std::uint64_t> m_generation{0};
+  std::future<JobResult> m_pendingJob;
+  std::atomic<bool> m_jobActive{false};
 };
 };
 
 
 } // namespace Map
 } // namespace Map

+ 400 - 96
game/systems/ai_system.cpp

@@ -1,10 +1,15 @@
+
+
 #include "ai_system.h"
 #include "ai_system.h"
 #include "../core/component.h"
 #include "../core/component.h"
 #include "../core/world.h"
 #include "../core/world.h"
 #include "command_service.h"
 #include "command_service.h"
 #include "formation_planner.h"
 #include "formation_planner.h"
+
 #include <algorithm>
 #include <algorithm>
 #include <cmath>
 #include <cmath>
+#include <limits>
+#include <utility>
 
 
 namespace Game::Systems {
 namespace Game::Systems {
 
 
@@ -22,6 +27,7 @@ AISystem::AISystem() {
 
 
 AISystem::~AISystem() {
 AISystem::~AISystem() {
   m_shouldStop.store(true, std::memory_order_release);
   m_shouldStop.store(true, std::memory_order_release);
+  { std::lock_guard<std::mutex> lock(m_jobMutex); }
   m_jobCondition.notify_all();
   m_jobCondition.notify_all();
   if (m_aiThread.joinable()) {
   if (m_aiThread.joinable()) {
     m_aiThread.join();
     m_aiThread.join();
@@ -30,7 +36,6 @@ AISystem::~AISystem() {
 
 
 void AISystem::registerBehavior(std::unique_ptr<AIBehavior> behavior) {
 void AISystem::registerBehavior(std::unique_ptr<AIBehavior> behavior) {
   m_behaviors.push_back(std::move(behavior));
   m_behaviors.push_back(std::move(behavior));
-
   std::sort(m_behaviors.begin(), m_behaviors.end(),
   std::sort(m_behaviors.begin(), m_behaviors.end(),
             [](const std::unique_ptr<AIBehavior> &a,
             [](const std::unique_ptr<AIBehavior> &a,
                const std::unique_ptr<AIBehavior> &b) {
                const std::unique_ptr<AIBehavior> &b) {
@@ -45,7 +50,7 @@ void AISystem::update(Engine::Core::World *world, float deltaTime) {
   processResults(world);
   processResults(world);
 
 
   m_globalUpdateTimer += deltaTime;
   m_globalUpdateTimer += deltaTime;
-  if (m_globalUpdateTimer < 0.5f)
+  if (m_globalUpdateTimer < 0.3f)
     return;
     return;
 
 
   if (m_workerBusy.load(std::memory_order_acquire))
   if (m_workerBusy.load(std::memory_order_acquire))
@@ -74,10 +79,13 @@ AISnapshot AISystem::buildSnapshot(Engine::Core::World *world) const {
   AISnapshot snapshot;
   AISnapshot snapshot;
   snapshot.playerId = m_enemyAI.playerId;
   snapshot.playerId = m_enemyAI.playerId;
 
 
-  auto entities = world->getEntitiesWith<Engine::Core::UnitComponent>();
-  snapshot.friendlies.reserve(entities.size());
+  auto friendlies = world->getUnitsOwnedBy(snapshot.playerId);
+  snapshot.friendlies.reserve(friendlies.size());
+
+  for (auto *entity : friendlies) {
+    if (!entity->hasComponent<Engine::Core::AIControlledComponent>())
+      continue;
 
 
-  for (auto *entity : entities) {
     auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
     auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
     if (!unit || unit->health <= 0)
     if (!unit || unit->health <= 0)
       continue;
       continue;
@@ -116,13 +124,31 @@ AISnapshot AISystem::buildSnapshot(Engine::Core::World *world) const {
       data.production.rallyZ = production->rallyZ;
       data.production.rallyZ = production->rallyZ;
     }
     }
 
 
-    if (unit->ownerId == snapshot.playerId) {
-      snapshot.friendlies.push_back(std::move(data));
-    } else if (data.isBuilding) {
-      snapshot.enemyBuildings.push_back(std::move(data));
-    } else {
-      snapshot.enemyUnits.push_back(std::move(data));
-    }
+    snapshot.friendlies.push_back(std::move(data));
+  }
+
+  auto others = world->getUnitsNotOwnedBy(snapshot.playerId);
+  snapshot.visibleEnemies.reserve(others.size());
+
+  for (auto *entity : others) {
+    if (entity->hasComponent<Engine::Core::AIControlledComponent>())
+      continue;
+
+    auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
+    if (!unit || unit->health <= 0)
+      continue;
+
+    auto *transform = entity->getComponent<Engine::Core::TransformComponent>();
+    if (!transform)
+      continue;
+
+    ContactSnapshot contact;
+    contact.id = entity->getId();
+    contact.isBuilding =
+        entity->hasComponent<Engine::Core::BuildingComponent>();
+    contact.position =
+        QVector3D(transform->position.x, 0.0f, transform->position.z);
+    snapshot.visibleEnemies.push_back(std::move(contact));
   }
   }
 
 
   return snapshot;
   return snapshot;
@@ -144,36 +170,126 @@ void AISystem::processResults(Engine::Core::World *world) {
   }
   }
 }
 }
 
 
+static void replicateLastTargetIfNeeded(const std::vector<QVector3D> &from,
+                                        size_t wanted,
+                                        std::vector<QVector3D> &out) {
+  out.clear();
+  if (from.empty())
+    return;
+  out.reserve(wanted);
+  for (size_t i = 0; i < wanted; ++i) {
+    out.push_back(i < from.size() ? from[i] : from.back());
+  }
+}
+
+static bool isEntityEngaged(const EntitySnapshot &entity,
+                            const std::vector<ContactSnapshot> &enemies) {
+  if (entity.maxHealth > 0 && entity.health < entity.maxHealth)
+    return true;
+
+  constexpr float ENGAGED_RADIUS = 7.5f;
+  const float engagedSq = ENGAGED_RADIUS * ENGAGED_RADIUS;
+
+  for (const auto &enemy : enemies) {
+    float distSq = (enemy.position - entity.position).lengthSquared();
+    if (distSq <= engagedSq)
+      return true;
+  }
+  return false;
+}
+
 void AISystem::applyCommands(Engine::Core::World *world,
 void AISystem::applyCommands(Engine::Core::World *world,
                              const std::vector<AICommand> &commands) {
                              const std::vector<AICommand> &commands) {
+  if (!world)
+    return;
+  const int aiOwnerId = m_enemyAI.playerId;
+
   for (const auto &command : commands) {
   for (const auto &command : commands) {
     switch (command.type) {
     switch (command.type) {
-    case AICommandType::MoveUnits:
-      if (!command.units.empty() &&
-          command.units.size() == command.moveTargets.size()) {
-
-        CommandService::MoveOptions opts;
-        opts.allowDirectFallback = false;
-        opts.clearAttackIntent = true;
-        CommandService::moveUnits(*world, command.units, command.moveTargets,
-                                  opts);
+    case AICommandType::MoveUnits: {
+      if (command.units.empty())
+        break;
+
+      std::vector<QVector3D> expandedTargets;
+      if (command.moveTargets.size() != command.units.size()) {
+        replicateLastTargetIfNeeded(command.moveTargets, command.units.size(),
+                                    expandedTargets);
+      } else {
+        expandedTargets = command.moveTargets;
+      }
+
+      if (expandedTargets.empty())
+        break;
+
+      std::vector<Engine::Core::EntityID> ownedUnits;
+      std::vector<QVector3D> ownedTargets;
+      ownedUnits.reserve(command.units.size());
+      ownedTargets.reserve(command.units.size());
+
+      for (std::size_t idx = 0; idx < command.units.size(); ++idx) {
+        auto entityId = command.units[idx];
+        auto *entity = world->getEntity(entityId);
+        if (!entity)
+          continue;
+
+        auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
+        if (!unit || unit->ownerId != aiOwnerId)
+          continue;
+
+        ownedUnits.push_back(entityId);
+        ownedTargets.push_back(expandedTargets[idx]);
       }
       }
+
+      if (ownedUnits.empty())
+        break;
+
+      CommandService::MoveOptions opts;
+      opts.allowDirectFallback = true;
+      opts.clearAttackIntent = false;
+      opts.groupMove = ownedUnits.size() > 1;
+      CommandService::moveUnits(*world, ownedUnits, ownedTargets, opts);
       break;
       break;
-    case AICommandType::AttackTarget:
-      if (!command.units.empty() && command.targetId != 0) {
-        CommandService::attackTarget(*world, command.units, command.targetId,
-                                     command.shouldChase);
+    }
+    case AICommandType::AttackTarget: {
+      if (command.units.empty() || command.targetId == 0)
+        break;
+      std::vector<Engine::Core::EntityID> ownedUnits;
+      ownedUnits.reserve(command.units.size());
+
+      for (auto entityId : command.units) {
+        auto *entity = world->getEntity(entityId);
+        if (!entity)
+          continue;
+        auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
+        if (!unit || unit->ownerId != aiOwnerId)
+          continue;
+        ownedUnits.push_back(entityId);
       }
       }
+
+      if (ownedUnits.empty())
+        break;
+
+      CommandService::attackTarget(*world, ownedUnits, command.targetId,
+                                   command.shouldChase);
       break;
       break;
+    }
     case AICommandType::StartProduction: {
     case AICommandType::StartProduction: {
       auto *entity = world->getEntity(command.buildingId);
       auto *entity = world->getEntity(command.buildingId);
       if (!entity)
       if (!entity)
         break;
         break;
+
       auto *production =
       auto *production =
           entity->getComponent<Engine::Core::ProductionComponent>();
           entity->getComponent<Engine::Core::ProductionComponent>();
       if (!production || production->inProgress)
       if (!production || production->inProgress)
         break;
         break;
-      production->productType = command.productType;
+
+      auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
+      if (unit && unit->ownerId != aiOwnerId)
+        break;
+
+      if (!command.productType.empty())
+        production->productType = command.productType;
+
       production->timeRemaining = production->buildTime;
       production->timeRemaining = production->buildTime;
       production->inProgress = true;
       production->inProgress = true;
       break;
       break;
@@ -192,6 +308,10 @@ void AISystem::updateContext(const AISnapshot &snapshot, AIContext &ctx) {
   ctx.averageHealth = 1.0f;
   ctx.averageHealth = 1.0f;
   ctx.rallyX = 0.0f;
   ctx.rallyX = 0.0f;
   ctx.rallyZ = 0.0f;
   ctx.rallyZ = 0.0f;
+  ctx.barracksUnderThreat = false;
+  ctx.nearbyThreatCount = 0;
+  ctx.closestThreatDistance = std::numeric_limits<float>::infinity();
+  ctx.basePosition = QVector3D();
 
 
   float totalHealthRatio = 0.0f;
   float totalHealthRatio = 0.0f;
 
 
@@ -202,6 +322,7 @@ void AISystem::updateContext(const AISnapshot &snapshot, AIContext &ctx) {
         ctx.primaryBarracks = entity.id;
         ctx.primaryBarracks = entity.id;
         ctx.rallyX = entity.position.x() - 5.0f;
         ctx.rallyX = entity.position.x() - 5.0f;
         ctx.rallyZ = entity.position.z();
         ctx.rallyZ = entity.position.z();
+        ctx.basePosition = entity.position;
       }
       }
       continue;
       continue;
     }
     }
@@ -221,10 +342,28 @@ void AISystem::updateContext(const AISnapshot &snapshot, AIContext &ctx) {
     }
     }
   }
   }
 
 
-  if (ctx.totalUnits > 0) {
-    ctx.averageHealth = totalHealthRatio / static_cast<float>(ctx.totalUnits);
-  } else {
-    ctx.averageHealth = 1.0f;
+  ctx.averageHealth =
+      (ctx.totalUnits > 0)
+          ? (totalHealthRatio / static_cast<float>(ctx.totalUnits))
+          : 1.0f;
+
+  if (ctx.primaryBarracks != 0) {
+    constexpr float DEFEND_RADIUS = 16.0f;
+    const float defendRadiusSq = DEFEND_RADIUS * DEFEND_RADIUS;
+
+    for (const auto &enemy : snapshot.visibleEnemies) {
+      float distSq = (enemy.position - ctx.basePosition).lengthSquared();
+      if (distSq <= defendRadiusSq) {
+        ctx.barracksUnderThreat = true;
+        ctx.nearbyThreatCount++;
+        float dist = std::sqrt(std::max(distSq, 0.0f));
+        ctx.closestThreatDistance = std::min(ctx.closestThreatDistance, dist);
+      }
+    }
+
+    if (!ctx.barracksUnderThreat) {
+      ctx.closestThreatDistance = std::numeric_limits<float>::infinity();
+    }
   }
   }
 }
 }
 
 
@@ -232,46 +371,60 @@ void AISystem::updateStateMachine(AIContext &ctx, float deltaTime) {
   ctx.stateTimer += deltaTime;
   ctx.stateTimer += deltaTime;
   ctx.decisionTimer += deltaTime;
   ctx.decisionTimer += deltaTime;
 
 
-  if (ctx.decisionTimer < 5.0f)
+  AIState previousState = ctx.state;
+  if (ctx.barracksUnderThreat && ctx.state != AIState::Defending) {
+    ctx.state = AIState::Defending;
+  }
+
+  if (ctx.decisionTimer < 2.0f) {
+    if (ctx.state != previousState)
+      ctx.stateTimer = 0.0f;
     return;
     return;
+  }
   ctx.decisionTimer = 0.0f;
   ctx.decisionTimer = 0.0f;
-
-  AIState previousState = ctx.state;
+  previousState = ctx.state;
 
 
   switch (ctx.state) {
   switch (ctx.state) {
   case AIState::Idle:
   case AIState::Idle:
-    if (ctx.idleUnits >= 3) {
+    if (ctx.idleUnits >= 2) {
       ctx.state = AIState::Gathering;
       ctx.state = AIState::Gathering;
     } else if (ctx.averageHealth < 0.5f && ctx.totalUnits > 0) {
     } else if (ctx.averageHealth < 0.5f && ctx.totalUnits > 0) {
       ctx.state = AIState::Defending;
       ctx.state = AIState::Defending;
     }
     }
     break;
     break;
+
   case AIState::Gathering:
   case AIState::Gathering:
-    if (ctx.totalUnits >= 5 && ctx.idleUnits < 2) {
+    if (ctx.totalUnits >= 4 && ctx.idleUnits <= 1) {
       ctx.state = AIState::Attacking;
       ctx.state = AIState::Attacking;
     } else if (ctx.totalUnits < 2) {
     } else if (ctx.totalUnits < 2) {
       ctx.state = AIState::Idle;
       ctx.state = AIState::Idle;
     }
     }
     break;
     break;
+
   case AIState::Attacking:
   case AIState::Attacking:
     if (ctx.averageHealth < 0.3f) {
     if (ctx.averageHealth < 0.3f) {
       ctx.state = AIState::Retreating;
       ctx.state = AIState::Retreating;
-    } else if (ctx.totalUnits < 3) {
+    } else if (ctx.totalUnits < 2) {
       ctx.state = AIState::Gathering;
       ctx.state = AIState::Gathering;
     }
     }
     break;
     break;
+
   case AIState::Defending:
   case AIState::Defending:
-    if (ctx.averageHealth > 0.7f) {
-      ctx.state = AIState::Idle;
-    } else if (ctx.totalUnits >= 5 && ctx.averageHealth > 0.5f) {
+    if (ctx.barracksUnderThreat) {
+
+    } else if (ctx.totalUnits >= 4 && ctx.averageHealth > 0.5f) {
       ctx.state = AIState::Attacking;
       ctx.state = AIState::Attacking;
+    } else if (ctx.averageHealth > 0.7f) {
+      ctx.state = AIState::Idle;
     }
     }
     break;
     break;
+
   case AIState::Retreating:
   case AIState::Retreating:
-    if (ctx.stateTimer > 8.0f) {
+    if (ctx.stateTimer > 6.0f) {
       ctx.state = AIState::Defending;
       ctx.state = AIState::Defending;
     }
     }
     break;
     break;
+
   case AIState::Expanding:
   case AIState::Expanding:
     ctx.state = AIState::Idle;
     ctx.state = AIState::Idle;
     break;
     break;
@@ -321,36 +474,44 @@ void AISystem::workerLoop() {
       m_hasPendingJob = false;
       m_hasPendingJob = false;
     }
     }
 
 
-    AIResult result;
-    result.context = job.context;
+    try {
+      AIResult result;
+      result.context = job.context;
 
 
-    updateContext(job.snapshot, result.context);
-    updateStateMachine(result.context, job.deltaTime);
-    executeBehaviors(job.snapshot, result.context, job.deltaTime,
-                     result.commands);
+      updateContext(job.snapshot, result.context);
+      updateStateMachine(result.context, job.deltaTime);
+      executeBehaviors(job.snapshot, result.context, job.deltaTime,
+                       result.commands);
 
 
-    {
-      std::lock_guard<std::mutex> lock(m_resultMutex);
-      m_results.push(std::move(result));
+      {
+        std::lock_guard<std::mutex> lock(m_resultMutex);
+        m_results.push(std::move(result));
+      }
+    } catch (...) {
     }
     }
 
 
     m_workerBusy.store(false, std::memory_order_release);
     m_workerBusy.store(false, std::memory_order_release);
   }
   }
+
+  m_workerBusy.store(false, std::memory_order_release);
 }
 }
 
 
 void ProductionBehavior::execute(const AISnapshot &snapshot, AIContext &context,
 void ProductionBehavior::execute(const AISnapshot &snapshot, AIContext &context,
                                  float deltaTime,
                                  float deltaTime,
                                  std::vector<AICommand> &outCommands) {
                                  std::vector<AICommand> &outCommands) {
   m_productionTimer += deltaTime;
   m_productionTimer += deltaTime;
-  if (m_productionTimer < 2.0f)
+  if (m_productionTimer < 1.5f)
     return;
     return;
   m_productionTimer = 0.0f;
   m_productionTimer = 0.0f;
 
 
+  static bool produceArcher = true;
+
   for (const auto &entity : snapshot.friendlies) {
   for (const auto &entity : snapshot.friendlies) {
     if (!entity.isBuilding || entity.unitType != "barracks")
     if (!entity.isBuilding || entity.unitType != "barracks")
       continue;
       continue;
     if (!entity.production.hasComponent)
     if (!entity.production.hasComponent)
       continue;
       continue;
+
     const auto &prod = entity.production;
     const auto &prod = entity.production;
     if (prod.inProgress || prod.producedCount >= prod.maxUnits)
     if (prod.inProgress || prod.producedCount >= prod.maxUnits)
       continue;
       continue;
@@ -358,9 +519,11 @@ void ProductionBehavior::execute(const AISnapshot &snapshot, AIContext &context,
     AICommand command;
     AICommand command;
     command.type = AICommandType::StartProduction;
     command.type = AICommandType::StartProduction;
     command.buildingId = entity.id;
     command.buildingId = entity.id;
-    command.productType = "archer";
+    command.productType = produceArcher ? "archer" : "swordsman";
     outCommands.push_back(std::move(command));
     outCommands.push_back(std::move(command));
   }
   }
+
+  produceArcher = !produceArcher;
 }
 }
 
 
 bool ProductionBehavior::shouldExecute(const AISnapshot &snapshot,
 bool ProductionBehavior::shouldExecute(const AISnapshot &snapshot,
@@ -374,7 +537,7 @@ void GatherBehavior::execute(const AISnapshot &snapshot, AIContext &context,
                              float deltaTime,
                              float deltaTime,
                              std::vector<AICommand> &outCommands) {
                              std::vector<AICommand> &outCommands) {
   m_gatherTimer += deltaTime;
   m_gatherTimer += deltaTime;
-  if (m_gatherTimer < 4.0f)
+  if (m_gatherTimer < 2.0f)
     return;
     return;
   m_gatherTimer = 0.0f;
   m_gatherTimer = 0.0f;
 
 
@@ -384,9 +547,12 @@ void GatherBehavior::execute(const AISnapshot &snapshot, AIContext &context,
   QVector3D rallyPoint(context.rallyX, 0.0f, context.rallyZ);
   QVector3D rallyPoint(context.rallyX, 0.0f, context.rallyZ);
 
 
   std::vector<const EntitySnapshot *> idleEntities;
   std::vector<const EntitySnapshot *> idleEntities;
+  idleEntities.reserve(snapshot.friendlies.size());
   for (const auto &entity : snapshot.friendlies) {
   for (const auto &entity : snapshot.friendlies) {
     if (entity.isBuilding)
     if (entity.isBuilding)
       continue;
       continue;
+    if (isEntityEngaged(entity, snapshot.visibleEnemies))
+      continue;
     if (!entity.movement.hasComponent || !entity.movement.hasTarget) {
     if (!entity.movement.hasComponent || !entity.movement.hasTarget) {
       idleEntities.push_back(&entity);
       idleEntities.push_back(&entity);
     }
     }
@@ -396,7 +562,7 @@ void GatherBehavior::execute(const AISnapshot &snapshot, AIContext &context,
     return;
     return;
 
 
   auto formationTargets = FormationPlanner::spreadFormation(
   auto formationTargets = FormationPlanner::spreadFormation(
-      static_cast<int>(idleEntities.size()), rallyPoint, 1.2f);
+      static_cast<int>(idleEntities.size()), rallyPoint, 1.4f);
 
 
   std::vector<Engine::Core::EntityID> unitsToMove;
   std::vector<Engine::Core::EntityID> unitsToMove;
   std::vector<QVector3D> targetsToUse;
   std::vector<QVector3D> targetsToUse;
@@ -406,10 +572,10 @@ void GatherBehavior::execute(const AISnapshot &snapshot, AIContext &context,
   for (size_t i = 0; i < idleEntities.size(); ++i) {
   for (size_t i = 0; i < idleEntities.size(); ++i) {
     const auto *entity = idleEntities[i];
     const auto *entity = idleEntities[i];
     const auto &target = formationTargets[i];
     const auto &target = formationTargets[i];
-    float dx = entity->position.x() - target.x();
-    float dz = entity->position.z() - target.z();
-    float distanceSq = dx * dx + dz * dz;
-    if (distanceSq < 0.25f * 0.25f)
+    const float dx = entity->position.x() - target.x();
+    const float dz = entity->position.z() - target.z();
+    const float distanceSq = dx * dx + dz * dz;
+    if (distanceSq < 0.35f * 0.35f)
       continue;
       continue;
     unitsToMove.push_back(entity->id);
     unitsToMove.push_back(entity->id);
     targetsToUse.push_back(target);
     targetsToUse.push_back(target);
@@ -427,42 +593,78 @@ void GatherBehavior::execute(const AISnapshot &snapshot, AIContext &context,
 
 
 bool GatherBehavior::shouldExecute(const AISnapshot &snapshot,
 bool GatherBehavior::shouldExecute(const AISnapshot &snapshot,
                                    const AIContext &context) const {
                                    const AIContext &context) const {
-  (void)context;
-  int idleCount = 0;
-  for (const auto &entity : snapshot.friendlies) {
-    if (entity.isBuilding)
-      continue;
-    if (!entity.movement.hasComponent || !entity.movement.hasTarget) {
-      idleCount++;
-    }
-  }
-  return idleCount >= 2;
+  (void)snapshot;
+  if (context.primaryBarracks == 0)
+    return false;
+  if (context.state == AIState::Retreating)
+    return false;
+  return context.idleUnits > 0;
 }
 }
 
 
 void AttackBehavior::execute(const AISnapshot &snapshot, AIContext &context,
 void AttackBehavior::execute(const AISnapshot &snapshot, AIContext &context,
                              float deltaTime,
                              float deltaTime,
                              std::vector<AICommand> &outCommands) {
                              std::vector<AICommand> &outCommands) {
   m_attackTimer += deltaTime;
   m_attackTimer += deltaTime;
-  if (m_attackTimer < 3.0f)
+  if (m_attackTimer < 1.25f)
     return;
     return;
   m_attackTimer = 0.0f;
   m_attackTimer = 0.0f;
 
 
-  Engine::Core::EntityID targetId = 0;
-  if (!snapshot.enemyBuildings.empty()) {
-    targetId = snapshot.enemyBuildings.front().id;
-  } else if (!snapshot.enemyUnits.empty()) {
-    targetId = snapshot.enemyUnits.front().id;
+  std::vector<const EntitySnapshot *> readyUnits;
+  readyUnits.reserve(snapshot.friendlies.size());
+
+  QVector3D groupCenter;
+  for (const auto &entity : snapshot.friendlies) {
+    if (entity.isBuilding)
+      continue;
+    if (isEntityEngaged(entity, snapshot.visibleEnemies))
+      continue;
+    readyUnits.push_back(&entity);
+    groupCenter += entity.position;
   }
   }
 
 
+  if (readyUnits.empty())
+    return;
+
+  if (snapshot.visibleEnemies.empty())
+    return;
+
+  groupCenter /= static_cast<float>(readyUnits.size());
+
+  Engine::Core::EntityID targetId = 0;
+  float bestScore = -std::numeric_limits<float>::infinity();
+
+  auto considerTarget = [&](const ContactSnapshot &enemy) {
+    float score = 0.0f;
+    float distanceToGroup = (enemy.position - groupCenter).length();
+    score -= distanceToGroup;
+
+    if (!enemy.isBuilding)
+      score += 4.0f;
+
+    if (context.primaryBarracks != 0) {
+      float distanceToBase = (enemy.position - context.basePosition).length();
+      score += std::max(0.0f, 12.0f - distanceToBase);
+    }
+
+    if (context.state == AIState::Attacking && !enemy.isBuilding)
+      score += 2.0f;
+
+    if (score > bestScore) {
+      bestScore = score;
+      targetId = enemy.id;
+    }
+  };
+
+  for (const auto &enemy : snapshot.visibleEnemies)
+    considerTarget(enemy);
+
   if (targetId == 0)
   if (targetId == 0)
     return;
     return;
 
 
   std::vector<Engine::Core::EntityID> attackers;
   std::vector<Engine::Core::EntityID> attackers;
-  for (const auto &entity : snapshot.friendlies) {
-    if (entity.isBuilding)
-      continue;
-    attackers.push_back(entity.id);
-  }
+  attackers.reserve(readyUnits.size());
+  for (const auto *entity : readyUnits)
+    attackers.push_back(entity->id);
 
 
   if (attackers.empty())
   if (attackers.empty())
     return;
     return;
@@ -471,26 +673,50 @@ void AttackBehavior::execute(const AISnapshot &snapshot, AIContext &context,
   command.type = AICommandType::AttackTarget;
   command.type = AICommandType::AttackTarget;
   command.units = std::move(attackers);
   command.units = std::move(attackers);
   command.targetId = targetId;
   command.targetId = targetId;
-  command.shouldChase = true;
+  command.shouldChase =
+      (context.state == AIState::Attacking) || context.barracksUnderThreat;
   outCommands.push_back(std::move(command));
   outCommands.push_back(std::move(command));
 }
 }
 
 
 bool AttackBehavior::shouldExecute(const AISnapshot &snapshot,
 bool AttackBehavior::shouldExecute(const AISnapshot &snapshot,
                                    const AIContext &context) const {
                                    const AIContext &context) const {
-  (void)context;
-  int unitCount = 0;
+  int readyUnits = 0;
   for (const auto &entity : snapshot.friendlies) {
   for (const auto &entity : snapshot.friendlies) {
-    if (!entity.isBuilding)
-      unitCount++;
+    if (entity.isBuilding)
+      continue;
+    if (isEntityEngaged(entity, snapshot.visibleEnemies))
+      continue;
+    ++readyUnits;
   }
   }
-  return unitCount >= 4;
+
+  if (readyUnits == 0)
+    return false;
+
+  if (context.state == AIState::Retreating)
+    return false;
+
+  if (context.state == AIState::Attacking)
+    return true;
+
+  const bool hasTargets = !snapshot.visibleEnemies.empty();
+  if (!hasTargets)
+    return false;
+
+  if (context.state == AIState::Defending) {
+
+    return context.barracksUnderThreat && readyUnits >= 2;
+  }
+
+  if (readyUnits >= 2)
+    return true;
+  return (context.averageHealth > 0.7f && readyUnits >= 1);
 }
 }
 
 
 void DefendBehavior::execute(const AISnapshot &snapshot, AIContext &context,
 void DefendBehavior::execute(const AISnapshot &snapshot, AIContext &context,
                              float deltaTime,
                              float deltaTime,
                              std::vector<AICommand> &outCommands) {
                              std::vector<AICommand> &outCommands) {
   m_defendTimer += deltaTime;
   m_defendTimer += deltaTime;
-  if (m_defendTimer < 3.0f)
+  if (m_defendTimer < 1.5f)
     return;
     return;
   m_defendTimer = 0.0f;
   m_defendTimer = 0.0f;
 
 
@@ -506,30 +732,98 @@ void DefendBehavior::execute(const AISnapshot &snapshot, AIContext &context,
       break;
       break;
     }
     }
   }
   }
-
   if (!foundBarracks)
   if (!foundBarracks)
     return;
     return;
 
 
-  std::vector<const EntitySnapshot *> defenders;
+  std::vector<const EntitySnapshot *> readyDefenders;
+  std::vector<const EntitySnapshot *> engagedDefenders;
+  readyDefenders.reserve(snapshot.friendlies.size());
+  engagedDefenders.reserve(snapshot.friendlies.size());
+
   for (const auto &entity : snapshot.friendlies) {
   for (const auto &entity : snapshot.friendlies) {
     if (entity.isBuilding)
     if (entity.isBuilding)
       continue;
       continue;
-    defenders.push_back(&entity);
+    if (isEntityEngaged(entity, snapshot.visibleEnemies)) {
+      engagedDefenders.push_back(&entity);
+    } else {
+      readyDefenders.push_back(&entity);
+    }
+  }
+
+  if (readyDefenders.empty() && engagedDefenders.empty())
+    return;
+
+  auto sortByDistance = [&](std::vector<const EntitySnapshot *> &list) {
+    std::sort(list.begin(), list.end(),
+              [&](const EntitySnapshot *a, const EntitySnapshot *b) {
+                float da = (a->position - defendPos).lengthSquared();
+                float db = (b->position - defendPos).lengthSquared();
+                return da < db;
+              });
+  };
+
+  sortByDistance(readyDefenders);
+  sortByDistance(engagedDefenders);
+
+  const std::size_t totalAvailable =
+      readyDefenders.size() + engagedDefenders.size();
+  std::size_t desiredCount = totalAvailable;
+  if (context.barracksUnderThreat) {
+    desiredCount = std::min<std::size_t>(
+        desiredCount,
+        static_cast<std::size_t>(std::max(3, context.nearbyThreatCount * 2)));
+  } else {
+    desiredCount =
+        std::min<std::size_t>(desiredCount, static_cast<std::size_t>(6));
   }
   }
 
 
-  if (defenders.empty())
+  std::size_t readyCount = std::min(desiredCount, readyDefenders.size());
+  readyDefenders.resize(readyCount);
+
+  if (readyDefenders.empty())
     return;
     return;
 
 
+  if (context.barracksUnderThreat) {
+    Engine::Core::EntityID targetId = 0;
+    float bestDistSq = std::numeric_limits<float>::infinity();
+    auto considerTarget = [&](const ContactSnapshot &candidate) {
+      float d = (candidate.position - defendPos).lengthSquared();
+      if (d < bestDistSq) {
+        bestDistSq = d;
+        targetId = candidate.id;
+      }
+    };
+    for (const auto &enemy : snapshot.visibleEnemies)
+      considerTarget(enemy);
+
+    if (targetId != 0) {
+      std::vector<Engine::Core::EntityID> units;
+      units.reserve(readyDefenders.size());
+      for (auto *d : readyDefenders)
+        units.push_back(d->id);
+
+      if (!units.empty()) {
+        AICommand attack;
+        attack.type = AICommandType::AttackTarget;
+        attack.units = std::move(units);
+        attack.targetId = targetId;
+        attack.shouldChase = true;
+        outCommands.push_back(std::move(attack));
+        return;
+      }
+    }
+  }
+
   auto targets = FormationPlanner::spreadFormation(
   auto targets = FormationPlanner::spreadFormation(
-      static_cast<int>(defenders.size()), defendPos, 3.0f);
+      static_cast<int>(readyDefenders.size()), defendPos, 3.0f);
 
 
   std::vector<Engine::Core::EntityID> unitsToMove;
   std::vector<Engine::Core::EntityID> unitsToMove;
   std::vector<QVector3D> targetsToUse;
   std::vector<QVector3D> targetsToUse;
-  unitsToMove.reserve(defenders.size());
-  targetsToUse.reserve(defenders.size());
+  unitsToMove.reserve(readyDefenders.size());
+  targetsToUse.reserve(readyDefenders.size());
 
 
-  for (size_t i = 0; i < defenders.size(); ++i) {
-    const auto *entity = defenders[i];
+  for (size_t i = 0; i < readyDefenders.size(); ++i) {
+    const auto *entity = readyDefenders[i];
     const auto &target = targets[i];
     const auto &target = targets[i];
     float dx = entity->position.x() - target.x();
     float dx = entity->position.x() - target.x();
     float dz = entity->position.z() - target.z();
     float dz = entity->position.z() - target.z();
@@ -553,7 +847,17 @@ void DefendBehavior::execute(const AISnapshot &snapshot, AIContext &context,
 bool DefendBehavior::shouldExecute(const AISnapshot &snapshot,
 bool DefendBehavior::shouldExecute(const AISnapshot &snapshot,
                                    const AIContext &context) const {
                                    const AIContext &context) const {
   (void)snapshot;
   (void)snapshot;
-  return context.averageHealth < 0.6f;
+  if (context.primaryBarracks == 0)
+    return false;
+
+  if (context.barracksUnderThreat)
+    return true;
+  if (context.state == AIState::Defending && context.idleUnits > 0)
+    return true;
+  if (context.averageHealth < 0.6f && context.totalUnits > 0)
+    return true;
+
+  return false;
 }
 }
 
 
-} // namespace Game::Systems
+} // namespace Game::Systems

+ 11 - 2
game/systems/ai_system.h

@@ -56,6 +56,10 @@ struct AIContext {
   int idleUnits = 0;
   int idleUnits = 0;
   int combatUnits = 0;
   int combatUnits = 0;
   float averageHealth = 1.0f;
   float averageHealth = 1.0f;
+  bool barracksUnderThreat = false;
+  int nearbyThreatCount = 0;
+  float closestThreatDistance = 0.0f;
+  QVector3D basePosition;
 };
 };
 
 
 struct MovementSnapshot {
 struct MovementSnapshot {
@@ -88,11 +92,16 @@ struct EntitySnapshot {
   ProductionSnapshot production;
   ProductionSnapshot production;
 };
 };
 
 
+struct ContactSnapshot {
+  Engine::Core::EntityID id = 0;
+  bool isBuilding = false;
+  QVector3D position;
+};
+
 struct AISnapshot {
 struct AISnapshot {
   int playerId = 0;
   int playerId = 0;
   std::vector<EntitySnapshot> friendlies;
   std::vector<EntitySnapshot> friendlies;
-  std::vector<EntitySnapshot> enemyUnits;
-  std::vector<EntitySnapshot> enemyBuildings;
+  std::vector<ContactSnapshot> visibleEnemies;
 };
 };
 
 
 enum class AICommandType { MoveUnits, AttackTarget, StartProduction };
 enum class AICommandType { MoveUnits, AttackTarget, StartProduction };

+ 4 - 4
game/systems/combat_system.cpp

@@ -1,6 +1,6 @@
 #include "combat_system.h"
 #include "combat_system.h"
-#include "../core/event_manager.h"
 #include "../core/component.h"
 #include "../core/component.h"
+#include "../core/event_manager.h"
 #include "../core/world.h"
 #include "../core/world.h"
 #include "../visuals/team_colors.h"
 #include "../visuals/team_colors.h"
 #include "arrow_system.h"
 #include "arrow_system.h"
@@ -107,8 +107,9 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
                 float scaleZ = targetTransform->scale.z;
                 float scaleZ = targetTransform->scale.z;
                 float targetRadius = std::max(scaleX, scaleZ) * 0.5f;
                 float targetRadius = std::max(scaleX, scaleZ) * 0.5f;
                 QVector3D direction = targetPos - attackerPos;
                 QVector3D direction = targetPos - attackerPos;
-                float distance = direction.length();
-                if (distance > 0.001f) {
+                float distanceSq = direction.lengthSquared();
+                if (distanceSq > 0.000001f) {
+                  float distance = std::sqrt(distanceSq);
                   direction /= distance;
                   direction /= distance;
                   float desiredDistance =
                   float desiredDistance =
                       targetRadius + std::max(range - 0.2f, 0.2f);
                       targetRadius + std::max(range - 0.2f, 0.2f);
@@ -310,7 +311,6 @@ void CombatSystem::dealDamage(Engine::Core::Entity *target, int damage) {
 
 
     if (unit->health <= 0) {
     if (unit->health <= 0) {
 
 
-      // publish unit died event
       Engine::Core::EventManager::instance().publish(
       Engine::Core::EventManager::instance().publish(
           Engine::Core::UnitDiedEvent(target->getId(), unit->ownerId));
           Engine::Core::UnitDiedEvent(target->getId(), unit->ownerId));
 
 

+ 275 - 68
game/systems/command_service.cpp

@@ -5,6 +5,7 @@
 #include <QVector3D>
 #include <QVector3D>
 #include <algorithm>
 #include <algorithm>
 #include <cmath>
 #include <cmath>
+#include <limits>
 
 
 namespace Game {
 namespace Game {
 namespace Systems {
 namespace Systems {
@@ -62,9 +63,25 @@ QVector3D CommandService::gridToWorld(const Point &gridPos) {
 void CommandService::clearPendingRequest(Engine::Core::EntityID entityId) {
 void CommandService::clearPendingRequest(Engine::Core::EntityID entityId) {
   std::lock_guard<std::mutex> lock(s_pendingMutex);
   std::lock_guard<std::mutex> lock(s_pendingMutex);
   auto it = s_entityToRequest.find(entityId);
   auto it = s_entityToRequest.find(entityId);
-  if (it != s_entityToRequest.end()) {
-    s_pendingRequests.erase(it->second);
-    s_entityToRequest.erase(it);
+  if (it == s_entityToRequest.end())
+    return;
+
+  std::uint64_t requestId = it->second;
+  s_entityToRequest.erase(it);
+
+  auto pendingIt = s_pendingRequests.find(requestId);
+  if (pendingIt == s_pendingRequests.end())
+    return;
+
+  auto members = pendingIt->second.groupMembers;
+  s_pendingRequests.erase(pendingIt);
+
+  for (auto memberId : members) {
+    auto memberEntry = s_entityToRequest.find(memberId);
+    if (memberEntry != s_entityToRequest.end() &&
+        memberEntry->second == requestId) {
+      s_entityToRequest.erase(memberEntry);
+    }
   }
   }
 }
 }
 
 
@@ -81,6 +98,11 @@ void CommandService::moveUnits(Engine::Core::World &world,
   if (units.size() != targets.size())
   if (units.size() != targets.size())
     return;
     return;
 
 
+  if (options.groupMove && units.size() > 1) {
+    moveGroup(world, units, targets, options);
+    return;
+  }
+
   for (size_t i = 0; i < units.size(); ++i) {
   for (size_t i = 0; i < units.size(); ++i) {
     auto *e = world.getEntity(units[i]);
     auto *e = world.getEntity(units[i]);
     if (!e)
     if (!e)
@@ -218,7 +240,8 @@ void CommandService::moveUnits(Engine::Core::World &world,
 
 
         {
         {
           std::lock_guard<std::mutex> lock(s_pendingMutex);
           std::lock_guard<std::mutex> lock(s_pendingMutex);
-          s_pendingRequests[requestId] = {units[i], targets[i], options};
+          s_pendingRequests[requestId] = {
+              units[i], targets[i], options, {}, {}};
           s_entityToRequest[units[i]] = requestId;
           s_entityToRequest[units[i]] = requestId;
         }
         }
 
 
@@ -237,6 +260,156 @@ void CommandService::moveUnits(Engine::Core::World &world,
   }
   }
 }
 }
 
 
+void CommandService::moveGroup(Engine::Core::World &world,
+                               const std::vector<Engine::Core::EntityID> &units,
+                               const std::vector<QVector3D> &targets,
+                               const MoveOptions &options) {
+  struct MemberInfo {
+    Engine::Core::EntityID id;
+    Engine::Core::Entity *entity;
+    Engine::Core::TransformComponent *transform;
+    Engine::Core::MovementComponent *movement;
+    QVector3D target;
+  };
+
+  std::vector<MemberInfo> members;
+  members.reserve(units.size());
+
+  for (size_t i = 0; i < units.size(); ++i) {
+    auto *entity = world.getEntity(units[i]);
+    if (!entity)
+      continue;
+
+    auto *transform = entity->getComponent<Engine::Core::TransformComponent>();
+    if (!transform)
+      continue;
+
+    auto *movement = entity->getComponent<Engine::Core::MovementComponent>();
+    if (!movement)
+      movement = entity->addComponent<Engine::Core::MovementComponent>();
+    if (!movement)
+      continue;
+
+    if (options.clearAttackIntent) {
+      entity->removeComponent<Engine::Core::AttackTargetComponent>();
+    }
+
+    members.push_back({units[i], entity, transform, movement, targets[i]});
+  }
+
+  if (members.empty())
+    return;
+
+  if (members.size() == 1) {
+    std::vector<Engine::Core::EntityID> singleUnit = {members[0].id};
+    std::vector<QVector3D> singleTarget = {members[0].target};
+    MoveOptions singleOptions = options;
+    singleOptions.groupMove = false;
+    moveUnits(world, singleUnit, singleTarget, singleOptions);
+    return;
+  }
+
+  QVector3D average(0.0f, 0.0f, 0.0f);
+  for (const auto &member : members)
+    average += member.target;
+  average /= static_cast<float>(members.size());
+
+  std::size_t leaderIndex = 0;
+  float bestDistSq = std::numeric_limits<float>::infinity();
+  for (std::size_t i = 0; i < members.size(); ++i) {
+    float distSq = (members[i].target - average).lengthSquared();
+    if (distSq < bestDistSq) {
+      bestDistSq = distSq;
+      leaderIndex = i;
+    }
+  }
+
+  auto &leader = members[leaderIndex];
+  QVector3D leaderTarget = leader.target;
+
+  for (auto &member : members) {
+    clearPendingRequest(member.id);
+    auto *mv = member.movement;
+    mv->goalX = member.target.x();
+    mv->goalY = member.target.z();
+    mv->targetX = member.transform->position.x;
+    mv->targetY = member.transform->position.z;
+    mv->hasTarget = false;
+    mv->vx = 0.0f;
+    mv->vz = 0.0f;
+    mv->path.clear();
+    mv->pathPending = false;
+    mv->pendingRequestId = 0;
+  }
+
+  if (!s_pathfinder) {
+    for (auto &member : members) {
+      member.movement->targetX = member.target.x();
+      member.movement->targetY = member.target.z();
+      member.movement->hasTarget = true;
+    }
+    return;
+  }
+
+  Point start =
+      worldToGrid(leader.transform->position.x, leader.transform->position.z);
+  Point end = worldToGrid(leaderTarget.x(), leaderTarget.z());
+
+  if (start == end) {
+    for (auto &member : members) {
+      member.movement->targetX = member.target.x();
+      member.movement->targetY = member.target.z();
+      member.movement->hasTarget = true;
+    }
+    return;
+  }
+
+  int dx = std::abs(end.x - start.x);
+  int dz = std::abs(end.y - start.y);
+  bool useDirectPath = (dx + dz) <= CommandService::DIRECT_PATH_THRESHOLD;
+  if (!options.allowDirectFallback) {
+    useDirectPath = false;
+  }
+
+  if (useDirectPath) {
+    for (auto &member : members) {
+      member.movement->targetX = member.target.x();
+      member.movement->targetY = member.target.z();
+      member.movement->hasTarget = true;
+    }
+    return;
+  }
+
+  std::uint64_t requestId =
+      s_nextRequestId.fetch_add(1, std::memory_order_relaxed);
+
+  for (auto &member : members) {
+    member.movement->pathPending = true;
+    member.movement->pendingRequestId = requestId;
+  }
+
+  PendingPathRequest pending;
+  pending.entityId = leader.id;
+  pending.target = leaderTarget;
+  pending.options = options;
+  pending.groupMembers.reserve(members.size());
+  pending.groupTargets.reserve(members.size());
+  for (const auto &member : members) {
+    pending.groupMembers.push_back(member.id);
+    pending.groupTargets.push_back(member.target);
+  }
+
+  {
+    std::lock_guard<std::mutex> lock(s_pendingMutex);
+    s_pendingRequests[requestId] = std::move(pending);
+    for (const auto &member : members) {
+      s_entityToRequest[member.id] = requestId;
+    }
+  }
+
+  s_pathfinder->submitPathRequest(requestId, start, end);
+}
+
 void CommandService::processPathResults(Engine::Core::World &world) {
 void CommandService::processPathResults(Engine::Core::World &world) {
   if (!s_pathfinder)
   if (!s_pathfinder)
     return;
     return;
@@ -256,12 +429,6 @@ void CommandService::processPathResults(Engine::Core::World &world) {
         requestInfo = pendingIt->second;
         requestInfo = pendingIt->second;
         s_pendingRequests.erase(pendingIt);
         s_pendingRequests.erase(pendingIt);
 
 
-        auto entityIt = s_entityToRequest.find(requestInfo.entityId);
-        if (entityIt != s_entityToRequest.end() &&
-            entityIt->second == result.requestId) {
-          s_entityToRequest.erase(entityIt);
-        }
-
         found = true;
         found = true;
       }
       }
     }
     }
@@ -270,77 +437,117 @@ void CommandService::processPathResults(Engine::Core::World &world) {
       continue;
       continue;
     }
     }
 
 
-    auto *entity = world.getEntity(requestInfo.entityId);
-    if (!entity)
-      continue;
-
-    auto *movement = entity->getComponent<Engine::Core::MovementComponent>();
-    if (!movement) {
-      continue;
-    }
+    const auto &pathPoints = result.path;
 
 
-    auto *transform = entity->getComponent<Engine::Core::TransformComponent>();
-    if (!transform) {
-      continue;
-    }
+    const float skipThresholdSq = CommandService::WAYPOINT_SKIP_THRESHOLD_SQ;
+    const bool hasPath = pathPoints.size() > 1;
+
+    auto applyToMember = [&](Engine::Core::EntityID memberId,
+                             const QVector3D &target, const QVector3D &offset) {
+      auto *memberEntity = world.getEntity(memberId);
+      if (!memberEntity)
+        return;
+
+      auto *movementComponent =
+          memberEntity->getComponent<Engine::Core::MovementComponent>();
+      if (!movementComponent)
+        return;
+
+      auto *memberTransform =
+          memberEntity->getComponent<Engine::Core::TransformComponent>();
+      if (!memberTransform)
+        return;
+
+      if (!movementComponent->pathPending ||
+          movementComponent->pendingRequestId != result.requestId) {
+        movementComponent->pathPending = false;
+        movementComponent->pendingRequestId = 0;
+        return;
+      }
 
 
-    if (!movement->pathPending ||
-        movement->pendingRequestId != result.requestId) {
-      continue;
-    }
+      movementComponent->pathPending = false;
+      movementComponent->pendingRequestId = 0;
+      movementComponent->path.clear();
+      movementComponent->goalX = target.x();
+      movementComponent->goalY = target.z();
+      movementComponent->vx = 0.0f;
+      movementComponent->vz = 0.0f;
+
+      if (hasPath) {
+        movementComponent->path.reserve(pathPoints.size() - 1);
+        for (size_t idx = 1; idx < pathPoints.size(); ++idx) {
+          QVector3D worldPos = gridToWorld(pathPoints[idx]);
+          movementComponent->path.push_back(
+              {worldPos.x() + offset.x(), worldPos.z() + offset.z()});
+        }
 
 
-    movement->pathPending = false;
-    movement->pendingRequestId = 0;
-    movement->path.clear();
-    movement->goalX = requestInfo.target.x();
-    movement->goalY = requestInfo.target.z();
+        while (!movementComponent->path.empty()) {
+          float dx = movementComponent->path.front().first -
+                     memberTransform->position.x;
+          float dz = movementComponent->path.front().second -
+                     memberTransform->position.z;
+          if (dx * dx + dz * dz <= skipThresholdSq) {
+            movementComponent->path.erase(movementComponent->path.begin());
+          } else {
+            break;
+          }
+        }
 
 
-    const auto &pathPoints = result.path;
+        if (!movementComponent->path.empty()) {
+          movementComponent->targetX = movementComponent->path.front().first;
+          movementComponent->targetY = movementComponent->path.front().second;
+          movementComponent->hasTarget = true;
+          return;
+        }
+      }
 
 
-    if (pathPoints.size() <= 1) {
       if (requestInfo.options.allowDirectFallback) {
       if (requestInfo.options.allowDirectFallback) {
-        movement->targetX = requestInfo.target.x();
-        movement->targetY = requestInfo.target.z();
-        movement->hasTarget = true;
+        movementComponent->targetX = target.x();
+        movementComponent->targetY = target.z();
+        movementComponent->hasTarget = true;
       } else {
       } else {
-        movement->hasTarget = false;
-        movement->vx = 0.0f;
-        movement->vz = 0.0f;
+        movementComponent->hasTarget = false;
       }
       }
-      continue;
-    }
+    };
 
 
-    movement->path.reserve(pathPoints.size() > 1 ? pathPoints.size() - 1 : 0);
-    for (size_t idx = 1; idx < pathPoints.size(); ++idx) {
-      const auto &point = pathPoints[idx];
-      QVector3D worldPos = gridToWorld(point);
-      movement->path.push_back({worldPos.x(), worldPos.z()});
-    }
+    auto removeEntry = [&](Engine::Core::EntityID id) {
+      auto entry = s_entityToRequest.find(id);
+      if (entry != s_entityToRequest.end() &&
+          entry->second == result.requestId) {
+        s_entityToRequest.erase(entry);
+      }
+    };
 
 
-    const float skipThresholdSq = CommandService::WAYPOINT_SKIP_THRESHOLD_SQ;
-    while (!movement->path.empty()) {
-      float dx = movement->path.front().first - transform->position.x;
-      float dz = movement->path.front().second - transform->position.z;
-      if (dx * dx + dz * dz <= skipThresholdSq) {
-        movement->path.erase(movement->path.begin());
-      } else {
-        break;
+    {
+      std::lock_guard<std::mutex> lock(s_pendingMutex);
+      removeEntry(requestInfo.entityId);
+      for (auto memberId : requestInfo.groupMembers) {
+        removeEntry(memberId);
       }
       }
     }
     }
 
 
-    if (!movement->path.empty()) {
-      movement->targetX = movement->path[0].first;
-      movement->targetY = movement->path[0].second;
-      movement->hasTarget = true;
-    } else {
-      if (requestInfo.options.allowDirectFallback) {
-        movement->targetX = requestInfo.target.x();
-        movement->targetY = requestInfo.target.z();
-        movement->hasTarget = true;
-      } else {
-        movement->hasTarget = false;
-        movement->vx = 0.0f;
-        movement->vz = 0.0f;
+    QVector3D leaderTarget = requestInfo.target;
+    std::vector<Engine::Core::EntityID> processed;
+    processed.reserve(requestInfo.groupMembers.size() + 1);
+
+    auto addMember = [&](Engine::Core::EntityID id, const QVector3D &target) {
+      if (std::find(processed.begin(), processed.end(), id) != processed.end())
+        return;
+      QVector3D offset = target - leaderTarget;
+      applyToMember(id, target, offset);
+      processed.push_back(id);
+    };
+
+    addMember(requestInfo.entityId, leaderTarget);
+
+    if (!requestInfo.groupMembers.empty()) {
+      const std::size_t count = requestInfo.groupMembers.size();
+      for (std::size_t idx = 0; idx < count; ++idx) {
+        auto memberId = requestInfo.groupMembers[idx];
+        QVector3D target = (idx < requestInfo.groupTargets.size())
+                               ? requestInfo.groupTargets[idx]
+                               : leaderTarget;
+        addMember(memberId, target);
       }
       }
     }
     }
   }
   }

+ 7 - 0
game/systems/command_service.h

@@ -27,6 +27,7 @@ public:
   struct MoveOptions {
   struct MoveOptions {
     bool allowDirectFallback = true;
     bool allowDirectFallback = true;
     bool clearAttackIntent = true;
     bool clearAttackIntent = true;
+    bool groupMove = false;
   };
   };
 
 
   static constexpr int DIRECT_PATH_THRESHOLD = 8;
   static constexpr int DIRECT_PATH_THRESHOLD = 8;
@@ -58,6 +59,8 @@ private:
     Engine::Core::EntityID entityId;
     Engine::Core::EntityID entityId;
     QVector3D target;
     QVector3D target;
     MoveOptions options;
     MoveOptions options;
+    std::vector<Engine::Core::EntityID> groupMembers;
+    std::vector<QVector3D> groupTargets;
   };
   };
 
 
   static std::unique_ptr<Pathfinding> s_pathfinder;
   static std::unique_ptr<Pathfinding> s_pathfinder;
@@ -70,6 +73,10 @@ private:
   static Point worldToGrid(float worldX, float worldZ);
   static Point worldToGrid(float worldX, float worldZ);
   static QVector3D gridToWorld(const Point &gridPos);
   static QVector3D gridToWorld(const Point &gridPos);
   static void clearPendingRequest(Engine::Core::EntityID entityId);
   static void clearPendingRequest(Engine::Core::EntityID entityId);
+  static void moveGroup(Engine::Core::World &world,
+                        const std::vector<Engine::Core::EntityID> &units,
+                        const std::vector<QVector3D> &targets,
+                        const MoveOptions &options);
 };
 };
 
 
 } // namespace Systems
 } // namespace Systems

+ 132 - 51
game/systems/movement_system.cpp

@@ -15,53 +15,67 @@ static constexpr float REPATH_COOLDOWN_SECONDS = 0.4f;
 
 
 namespace {
 namespace {
 
 
-bool isSegmentWalkable(const QVector3D &from, const QVector3D &to,
-                       Engine::Core::EntityID ignoreEntity) {
+bool isPointAllowed(const QVector3D &pos, Engine::Core::EntityID ignoreEntity) {
   auto &registry = BuildingCollisionRegistry::instance();
   auto &registry = BuildingCollisionRegistry::instance();
   auto &terrainService = Game::Map::TerrainService::instance();
   auto &terrainService = Game::Map::TerrainService::instance();
   Pathfinding *pathfinder = CommandService::getPathfinder();
   Pathfinding *pathfinder = CommandService::getPathfinder();
 
 
-  auto samplePoint = [&](const QVector3D &pos) {
-    if (registry.isPointInBuilding(pos.x(), pos.z(), ignoreEntity)) {
+  if (registry.isPointInBuilding(pos.x(), pos.z(), ignoreEntity)) {
+    return false;
+  }
+
+  if (pathfinder) {
+    int gridX =
+        static_cast<int>(std::round(pos.x() - pathfinder->getGridOffsetX()));
+    int gridZ =
+        static_cast<int>(std::round(pos.z() - pathfinder->getGridOffsetZ()));
+    if (!pathfinder->isWalkable(gridX, gridZ)) {
       return false;
       return false;
     }
     }
-
-    if (pathfinder) {
-      int gridX =
-          static_cast<int>(std::round(pos.x() - pathfinder->getGridOffsetX()));
-      int gridZ =
-          static_cast<int>(std::round(pos.z() - pathfinder->getGridOffsetZ()));
-      if (!pathfinder->isWalkable(gridX, gridZ)) {
-        return false;
-      }
-    } else if (terrainService.isInitialized()) {
-      int gridX = static_cast<int>(std::round(pos.x()));
-      int gridZ = static_cast<int>(std::round(pos.z()));
-      if (!terrainService.isWalkable(gridX, gridZ)) {
-        return false;
-      }
+  } else if (terrainService.isInitialized()) {
+    int gridX = static_cast<int>(std::round(pos.x()));
+    int gridZ = static_cast<int>(std::round(pos.z()));
+    if (!terrainService.isWalkable(gridX, gridZ)) {
+      return false;
     }
     }
+  }
 
 
-    return true;
-  };
+  return true;
+}
 
 
+bool isSegmentWalkable(const QVector3D &from, const QVector3D &to,
+                       Engine::Core::EntityID ignoreEntity) {
   QVector3D delta = to - from;
   QVector3D delta = to - from;
   float distance = delta.length();
   float distance = delta.length();
 
 
+  bool startAllowed = isPointAllowed(from, ignoreEntity);
+  bool endAllowed = isPointAllowed(to, ignoreEntity);
+
   if (distance < 0.001f) {
   if (distance < 0.001f) {
-    return samplePoint(from);
+    return endAllowed;
   }
   }
 
 
   int steps = std::max(1, static_cast<int>(std::ceil(distance)) * 2);
   int steps = std::max(1, static_cast<int>(std::ceil(distance)) * 2);
   QVector3D step = delta / static_cast<float>(steps);
   QVector3D step = delta / static_cast<float>(steps);
-  QVector3D pos = from;
-  for (int i = 0; i <= steps; ++i, pos += step) {
-    if (!samplePoint(pos)) {
+  bool exitedBlockedZone = startAllowed;
+
+  for (int i = 1; i <= steps; ++i) {
+    QVector3D pos = from + step * static_cast<float>(i);
+    bool allowed = isPointAllowed(pos, ignoreEntity);
+
+    if (!exitedBlockedZone) {
+      if (allowed) {
+        exitedBlockedZone = true;
+      }
+      continue;
+    }
+
+    if (!allowed) {
       return false;
       return false;
     }
     }
   }
   }
 
 
-  return true;
+  return endAllowed && exitedBlockedZone;
 }
 }
 
 
 } // namespace
 } // namespace
@@ -85,6 +99,19 @@ void MovementSystem::moveUnit(Engine::Core::Entity *entity,
     return;
     return;
   }
   }
 
 
+  QVector3D finalGoal(movement->goalX, 0.0f, movement->goalY);
+  bool destinationAllowed = isPointAllowed(finalGoal, entity->getId());
+
+  if (movement->hasTarget && !destinationAllowed) {
+    movement->path.clear();
+    movement->hasTarget = false;
+    movement->pathPending = false;
+    movement->pendingRequestId = 0;
+    movement->vx = 0.0f;
+    movement->vz = 0.0f;
+    return;
+  }
+
   if (movement->repathCooldown > 0.0f) {
   if (movement->repathCooldown > 0.0f) {
     movement->repathCooldown =
     movement->repathCooldown =
         std::max(0.0f, movement->repathCooldown - deltaTime);
         std::max(0.0f, movement->repathCooldown - deltaTime);
@@ -95,8 +122,28 @@ void MovementSystem::moveUnit(Engine::Core::Entity *entity,
   const float damping = 6.0f;
   const float damping = 6.0f;
 
 
   if (!movement->hasTarget) {
   if (!movement->hasTarget) {
-    movement->vx *= std::max(0.0f, 1.0f - damping * deltaTime);
-    movement->vz *= std::max(0.0f, 1.0f - damping * deltaTime);
+    QVector3D currentPos(transform->position.x, 0.0f, transform->position.z);
+    float goalDistSq = (finalGoal - currentPos).lengthSquared();
+    constexpr float kStuckDistanceSq = 0.6f * 0.6f;
+
+    bool requestedRecoveryMove = false;
+    if (!movement->pathPending && movement->repathCooldown <= 0.0f &&
+        goalDistSq > kStuckDistanceSq && std::isfinite(goalDistSq) &&
+        destinationAllowed) {
+      CommandService::MoveOptions opts;
+      opts.clearAttackIntent = false;
+      opts.allowDirectFallback = true;
+      std::vector<Engine::Core::EntityID> ids = {entity->getId()};
+      std::vector<QVector3D> targets = {finalGoal};
+      CommandService::moveUnits(*world, ids, targets, opts);
+      movement->repathCooldown = REPATH_COOLDOWN_SECONDS;
+      requestedRecoveryMove = true;
+    }
+
+    if (!requestedRecoveryMove) {
+      movement->vx *= std::max(0.0f, 1.0f - damping * deltaTime);
+      movement->vz *= std::max(0.0f, 1.0f - damping * deltaTime);
+    }
   } else {
   } else {
     QVector3D currentPos(transform->position.x, 0.0f, transform->position.z);
     QVector3D currentPos(transform->position.x, 0.0f, transform->position.z);
     QVector3D segmentTarget(movement->targetX, 0.0f, movement->targetY);
     QVector3D segmentTarget(movement->targetX, 0.0f, movement->targetY);
@@ -104,35 +151,69 @@ void MovementSystem::moveUnit(Engine::Core::Entity *entity,
       segmentTarget = QVector3D(movement->path.front().first, 0.0f,
       segmentTarget = QVector3D(movement->path.front().first, 0.0f,
                                 movement->path.front().second);
                                 movement->path.front().second);
     }
     }
-    QVector3D finalGoal(movement->goalX, 0.0f, movement->goalY);
+    auto refreshSegmentTarget = [&]() {
+      if (!movement->path.empty()) {
+        movement->targetX = movement->path.front().first;
+        movement->targetY = movement->path.front().second;
+        segmentTarget = QVector3D(movement->targetX, 0.0f, movement->targetY);
+      } else {
+        segmentTarget = QVector3D(movement->targetX, 0.0f, movement->targetY);
+      }
+    };
 
 
-    if (!isSegmentWalkable(currentPos, segmentTarget, entity->getId())) {
-      bool issuedPathRequest = false;
-      if (!movement->pathPending && movement->repathCooldown <= 0.0f) {
-        float goalDistSq = (finalGoal - currentPos).lengthSquared();
-        if (goalDistSq > 0.01f) {
-          CommandService::MoveOptions opts;
-          opts.clearAttackIntent = false;
-          opts.allowDirectFallback = false;
-          std::vector<Engine::Core::EntityID> ids = {entity->getId()};
-          std::vector<QVector3D> targets = {
-              QVector3D(movement->goalX, 0.0f, movement->goalY)};
-          CommandService::moveUnits(*world, ids, targets, opts);
-          movement->repathCooldown = REPATH_COOLDOWN_SECONDS;
-          issuedPathRequest = true;
+    auto tryAdvancePastBlockedSegment = [&]() {
+      bool recovered = false;
+      int skipsRemaining = MAX_WAYPOINT_SKIP_COUNT;
+      while (!movement->path.empty() && skipsRemaining-- > 0) {
+        movement->path.erase(movement->path.begin());
+        refreshSegmentTarget();
+        if (isSegmentWalkable(currentPos, segmentTarget, entity->getId())) {
+          recovered = true;
+          break;
         }
         }
       }
       }
 
 
-      if (!issuedPathRequest) {
-        movement->pathPending = false;
-        movement->pendingRequestId = 0;
+      if (!recovered && movement->path.empty()) {
+        refreshSegmentTarget();
+        if (isSegmentWalkable(currentPos, segmentTarget, entity->getId())) {
+          recovered = true;
+        }
       }
       }
 
 
-      movement->path.clear();
-      movement->hasTarget = false;
-      movement->vx = 0.0f;
-      movement->vz = 0.0f;
-      return;
+      return recovered;
+    };
+
+    if (!isSegmentWalkable(currentPos, segmentTarget, entity->getId())) {
+      if (tryAdvancePastBlockedSegment()) {
+
+      } else {
+        bool issuedPathRequest = false;
+        if (!movement->pathPending && movement->repathCooldown <= 0.0f) {
+          float goalDistSq = (finalGoal - currentPos).lengthSquared();
+          if (goalDistSq > 0.01f && destinationAllowed) {
+            CommandService::MoveOptions opts;
+            opts.clearAttackIntent = false;
+            opts.allowDirectFallback = false;
+            std::vector<Engine::Core::EntityID> ids = {entity->getId()};
+            std::vector<QVector3D> targets = {
+                QVector3D(movement->goalX, 0.0f, movement->goalY)};
+            CommandService::moveUnits(*world, ids, targets, opts);
+            movement->repathCooldown = REPATH_COOLDOWN_SECONDS;
+            issuedPathRequest = true;
+          }
+        }
+
+        if (!issuedPathRequest) {
+          movement->pathPending = false;
+          movement->pendingRequestId = 0;
+        }
+
+        movement->path.clear();
+        movement->hasTarget = false;
+        movement->vx = 0.0f;
+        movement->vz = 0.0f;
+        return;
+      }
     }
     }
 
 
     float arriveRadius = std::clamp(maxSpeed * deltaTime * 2.0f, 0.05f, 0.25f);
     float arriveRadius = std::clamp(maxSpeed * deltaTime * 2.0f, 0.05f, 0.25f);

+ 238 - 80
game/systems/pathfinding.cpp

@@ -2,16 +2,18 @@
 #include "../map/terrain_service.h"
 #include "../map/terrain_service.h"
 #include "building_collision_registry.h"
 #include "building_collision_registry.h"
 #include <algorithm>
 #include <algorithm>
+#include <array>
 #include <cmath>
 #include <cmath>
 #include <limits>
 #include <limits>
-#include <queue>
+#include <utility>
 
 
 namespace Game::Systems {
 namespace Game::Systems {
 
 
 Pathfinding::Pathfinding(int width, int height)
 Pathfinding::Pathfinding(int width, int height)
     : m_width(width), m_height(height), m_gridCellSize(1.0f),
     : m_width(width), m_height(height), m_gridCellSize(1.0f),
       m_gridOffsetX(0.0f), m_gridOffsetZ(0.0f) {
       m_gridOffsetX(0.0f), m_gridOffsetZ(0.0f) {
-  m_obstacles.resize(height, std::vector<bool>(width, false));
+  m_obstacles.resize(height, std::vector<std::uint8_t>(width, 0));
+  ensureWorkingBuffers();
   m_obstaclesDirty.store(true, std::memory_order_release);
   m_obstaclesDirty.store(true, std::memory_order_release);
   m_workerThread = std::thread(&Pathfinding::workerLoop, this);
   m_workerThread = std::thread(&Pathfinding::workerLoop, this);
 }
 }
@@ -31,7 +33,7 @@ void Pathfinding::setGridOffset(float offsetX, float offsetZ) {
 
 
 void Pathfinding::setObstacle(int x, int y, bool isObstacle) {
 void Pathfinding::setObstacle(int x, int y, bool isObstacle) {
   if (x >= 0 && x < m_width && y >= 0 && y < m_height) {
   if (x >= 0 && x < m_width && y >= 0 && y < m_height) {
-    m_obstacles[y][x] = isObstacle;
+    m_obstacles[y][x] = static_cast<std::uint8_t>(isObstacle);
   }
   }
 }
 }
 
 
@@ -39,7 +41,7 @@ bool Pathfinding::isWalkable(int x, int y) const {
   if (x < 0 || x >= m_width || y < 0 || y >= m_height) {
   if (x < 0 || x >= m_width || y < 0 || y >= m_height) {
     return false;
     return false;
   }
   }
-  return !m_obstacles[y][x];
+  return m_obstacles[y][x] == 0;
 }
 }
 
 
 void Pathfinding::markObstaclesDirty() {
 void Pathfinding::markObstaclesDirty() {
@@ -59,7 +61,7 @@ void Pathfinding::updateBuildingObstacles() {
   }
   }
 
 
   for (auto &row : m_obstacles) {
   for (auto &row : m_obstacles) {
-    std::fill(row.begin(), row.end(), false);
+    std::fill(row.begin(), row.end(), static_cast<std::uint8_t>(0));
   }
   }
 
 
   auto &terrainService = Game::Map::TerrainService::instance();
   auto &terrainService = Game::Map::TerrainService::instance();
@@ -79,7 +81,7 @@ void Pathfinding::updateBuildingObstacles() {
         }
         }
 
 
         if (blocked) {
         if (blocked) {
-          m_obstacles[z][x] = true;
+          m_obstacles[z][x] = static_cast<std::uint8_t>(1);
         }
         }
       }
       }
     }
     }
@@ -95,7 +97,7 @@ void Pathfinding::updateBuildingObstacles() {
       int gridZ = static_cast<int>(std::round(cell.second - m_gridOffsetZ));
       int gridZ = static_cast<int>(std::round(cell.second - m_gridOffsetZ));
 
 
       if (gridX >= 0 && gridX < m_width && gridZ >= 0 && gridZ < m_height) {
       if (gridX >= 0 && gridX < m_width && gridZ >= 0 && gridZ < m_height) {
-        m_obstacles[gridZ][gridX] = true;
+        m_obstacles[gridZ][gridX] = static_cast<std::uint8_t>(1);
       }
       }
     }
     }
   }
   }
@@ -139,141 +141,297 @@ std::vector<Pathfinding::PathResult> Pathfinding::fetchCompletedPaths() {
 }
 }
 
 
 std::vector<Point> Pathfinding::findPathInternal(const Point &start,
 std::vector<Point> Pathfinding::findPathInternal(const Point &start,
-                                                 const Point &end) const {
+                                                 const Point &end) {
+  ensureWorkingBuffers();
+
   if (!isWalkable(start.x, start.y) || !isWalkable(end.x, end.y)) {
   if (!isWalkable(start.x, start.y) || !isWalkable(end.x, end.y)) {
     return {};
     return {};
   }
   }
 
 
-  if (start == end) {
+  const int startIdx = toIndex(start);
+  const int endIdx = toIndex(end);
+
+  if (startIdx == endIdx) {
     return {start};
     return {start};
   }
   }
 
 
-  struct QueueNode {
-    Point position;
-    int fCost;
-    int gCost;
-  };
-
-  auto compare = [](const QueueNode &a, const QueueNode &b) {
-    return a.fCost > b.fCost;
-  };
+  const std::uint32_t generation = nextGeneration();
 
 
-  std::priority_queue<QueueNode, std::vector<QueueNode>, decltype(compare)>
-      openQueue(compare);
+  m_openHeap.clear();
 
 
-  std::vector<std::vector<bool>> closedList(m_height,
-                                            std::vector<bool>(m_width, false));
-  std::vector<std::vector<int>> gCosts(
-      m_height, std::vector<int>(m_width, std::numeric_limits<int>::max()));
-  std::vector<std::vector<Point>> parents(
-      m_height, std::vector<Point>(m_width, Point(-1, -1)));
+  setGCost(startIdx, generation, 0);
+  setParent(startIdx, generation, startIdx);
 
 
-  gCosts[start.y][start.x] = 0;
-  parents[start.y][start.x] = start;
-  openQueue.push({start, calculateHeuristic(start, end), 0});
+  pushOpenNode({startIdx, calculateHeuristic(start, end), 0});
 
 
   const int maxIterations = std::max(m_width * m_height, 1);
   const int maxIterations = std::max(m_width * m_height, 1);
   int iterations = 0;
   int iterations = 0;
-  bool pathFound = false;
 
 
-  while (!openQueue.empty() && iterations < maxIterations) {
-    iterations++;
+  int finalCost = -1;
 
 
-    QueueNode current = openQueue.top();
-    openQueue.pop();
+  while (!m_openHeap.empty() && iterations < maxIterations) {
+    ++iterations;
 
 
-    const Point &currentPos = current.position;
+    QueueNode current = popOpenNode();
 
 
-    if (current.gCost > gCosts[currentPos.y][currentPos.x]) {
+    if (current.gCost > getGCost(current.index, generation)) {
       continue;
       continue;
     }
     }
 
 
-    if (closedList[currentPos.y][currentPos.x]) {
+    if (isClosed(current.index, generation)) {
       continue;
       continue;
     }
     }
 
 
-    closedList[currentPos.y][currentPos.x] = true;
+    setClosed(current.index, generation);
 
 
-    if (currentPos == end) {
-      pathFound = true;
+    if (current.index == endIdx) {
+      finalCost = current.gCost;
       break;
       break;
     }
     }
 
 
-    for (const auto &neighborPos : getNeighbors(currentPos)) {
-      if (!isWalkable(neighborPos.x, neighborPos.y) ||
-          closedList[neighborPos.y][neighborPos.x]) {
+    const Point currentPoint = toPoint(current.index);
+    std::array<Point, 8> neighbors{};
+    const std::size_t neighborCount = collectNeighbors(currentPoint, neighbors);
+
+    for (std::size_t i = 0; i < neighborCount; ++i) {
+      const Point &neighbor = neighbors[i];
+      if (!isWalkable(neighbor.x, neighbor.y)) {
         continue;
         continue;
       }
       }
 
 
-      int tentativeGCost = current.gCost + 1;
-
-      if (tentativeGCost < gCosts[neighborPos.y][neighborPos.x]) {
-        gCosts[neighborPos.y][neighborPos.x] = tentativeGCost;
-        parents[neighborPos.y][neighborPos.x] = currentPos;
+      const int neighborIdx = toIndex(neighbor);
+      if (isClosed(neighborIdx, generation)) {
+        continue;
+      }
 
 
-        int hCost = calculateHeuristic(neighborPos, end);
-        openQueue.push({neighborPos, tentativeGCost + hCost, tentativeGCost});
+      const int tentativeGCost = current.gCost + 1;
+      if (tentativeGCost >= getGCost(neighborIdx, generation)) {
+        continue;
       }
       }
+
+      setGCost(neighborIdx, generation, tentativeGCost);
+      setParent(neighborIdx, generation, current.index);
+
+      const int hCost = calculateHeuristic(neighbor, end);
+      pushOpenNode({neighborIdx, tentativeGCost + hCost, tentativeGCost});
     }
     }
   }
   }
 
 
-  if (!pathFound) {
+  if (finalCost < 0) {
     return {};
     return {};
   }
   }
 
 
-  return reconstructPath(start, end, parents);
+  std::vector<Point> path;
+  path.reserve(finalCost + 1);
+  buildPath(startIdx, endIdx, generation, finalCost + 1, path);
+  return path;
 }
 }
 
 
 int Pathfinding::calculateHeuristic(const Point &a, const Point &b) const {
 int Pathfinding::calculateHeuristic(const Point &a, const Point &b) const {
-
   return std::abs(a.x - b.x) + std::abs(a.y - b.y);
   return std::abs(a.x - b.x) + std::abs(a.y - b.y);
 }
 }
 
 
-std::vector<Point> Pathfinding::getNeighbors(const Point &point) const {
-  std::vector<Point> neighbors;
+void Pathfinding::ensureWorkingBuffers() {
+  const std::size_t totalCells =
+      static_cast<std::size_t>(m_width) * static_cast<std::size_t>(m_height);
+
+  if (m_closedGeneration.size() != totalCells) {
+    m_closedGeneration.assign(totalCells, 0);
+    m_gCostGeneration.assign(totalCells, 0);
+    m_gCostValues.assign(totalCells, std::numeric_limits<int>::max());
+    m_parentGeneration.assign(totalCells, 0);
+    m_parentValues.assign(totalCells, -1);
+  }
+
+  const std::size_t minOpenCapacity = std::max<std::size_t>(totalCells / 8, 64);
+  if (m_openHeap.capacity() < minOpenCapacity) {
+    m_openHeap.reserve(minOpenCapacity);
+  }
+}
+
+std::uint32_t Pathfinding::nextGeneration() {
+  auto next = ++m_generationCounter;
+  if (next == 0) {
+    resetGenerations();
+    next = ++m_generationCounter;
+  }
+  return next;
+}
+
+void Pathfinding::resetGenerations() {
+  std::fill(m_closedGeneration.begin(), m_closedGeneration.end(), 0);
+  std::fill(m_gCostGeneration.begin(), m_gCostGeneration.end(), 0);
+  std::fill(m_parentGeneration.begin(), m_parentGeneration.end(), 0);
+  std::fill(m_gCostValues.begin(), m_gCostValues.end(),
+            std::numeric_limits<int>::max());
+  std::fill(m_parentValues.begin(), m_parentValues.end(), -1);
+  m_generationCounter = 0;
+}
+
+bool Pathfinding::isClosed(int index, std::uint32_t generation) const {
+  return index >= 0 &&
+         static_cast<std::size_t>(index) < m_closedGeneration.size() &&
+         m_closedGeneration[static_cast<std::size_t>(index)] == generation;
+}
+
+void Pathfinding::setClosed(int index, std::uint32_t generation) {
+  if (index >= 0 &&
+      static_cast<std::size_t>(index) < m_closedGeneration.size()) {
+    m_closedGeneration[static_cast<std::size_t>(index)] = generation;
+  }
+}
+
+int Pathfinding::getGCost(int index, std::uint32_t generation) const {
+  if (index < 0 ||
+      static_cast<std::size_t>(index) >= m_gCostGeneration.size()) {
+    return std::numeric_limits<int>::max();
+  }
+  if (m_gCostGeneration[static_cast<std::size_t>(index)] == generation) {
+    return m_gCostValues[static_cast<std::size_t>(index)];
+  }
+  return std::numeric_limits<int>::max();
+}
 
 
+void Pathfinding::setGCost(int index, std::uint32_t generation, int cost) {
+  if (index >= 0 &&
+      static_cast<std::size_t>(index) < m_gCostGeneration.size()) {
+    const auto idx = static_cast<std::size_t>(index);
+    m_gCostGeneration[idx] = generation;
+    m_gCostValues[idx] = cost;
+  }
+}
+
+bool Pathfinding::hasParent(int index, std::uint32_t generation) const {
+  return index >= 0 &&
+         static_cast<std::size_t>(index) < m_parentGeneration.size() &&
+         m_parentGeneration[static_cast<std::size_t>(index)] == generation;
+}
+
+int Pathfinding::getParent(int index, std::uint32_t generation) const {
+  if (hasParent(index, generation)) {
+    return m_parentValues[static_cast<std::size_t>(index)];
+  }
+  return -1;
+}
+
+void Pathfinding::setParent(int index, std::uint32_t generation,
+                            int parentIndex) {
+  if (index >= 0 &&
+      static_cast<std::size_t>(index) < m_parentGeneration.size()) {
+    const auto idx = static_cast<std::size_t>(index);
+    m_parentGeneration[idx] = generation;
+    m_parentValues[idx] = parentIndex;
+  }
+}
+
+std::size_t Pathfinding::collectNeighbors(const Point &point,
+                                          std::array<Point, 8> &buffer) const {
+  std::size_t count = 0;
   for (int dx = -1; dx <= 1; ++dx) {
   for (int dx = -1; dx <= 1; ++dx) {
     for (int dy = -1; dy <= 1; ++dy) {
     for (int dy = -1; dy <= 1; ++dy) {
-      if (dx == 0 && dy == 0)
+      if (dx == 0 && dy == 0) {
         continue;
         continue;
+      }
 
 
-      int x = point.x + dx;
-      int y = point.y + dy;
+      const int x = point.x + dx;
+      const int y = point.y + dy;
 
 
-      if (x >= 0 && x < m_width && y >= 0 && y < m_height) {
-        if (dx != 0 && dy != 0) {
+      if (x < 0 || x >= m_width || y < 0 || y >= m_height) {
+        continue;
+      }
 
 
-          if (!isWalkable(point.x + dx, point.y) ||
-              !isWalkable(point.x, point.y + dy)) {
-            continue;
-          }
+      if (dx != 0 && dy != 0) {
+        if (!isWalkable(point.x + dx, point.y) ||
+            !isWalkable(point.x, point.y + dy)) {
+          continue;
         }
         }
-        neighbors.emplace_back(x, y);
       }
       }
+
+      buffer[count++] = Point{x, y};
     }
     }
   }
   }
-
-  return neighbors;
+  return count;
 }
 }
 
 
-std::vector<Point> Pathfinding::reconstructPath(
-    const Point &start, const Point &end,
-    const std::vector<std::vector<Point>> &parents) const {
-  std::vector<Point> path;
-  Point current = end;
-  path.push_back(current);
+void Pathfinding::buildPath(int startIndex, int endIndex,
+                            std::uint32_t generation, int expectedLength,
+                            std::vector<Point> &outPath) const {
+  outPath.clear();
+  if (expectedLength > 0) {
+    outPath.reserve(static_cast<std::size_t>(expectedLength));
+  }
+  int current = endIndex;
+
+  while (current >= 0) {
+    outPath.push_back(toPoint(current));
+    if (current == startIndex) {
+      std::reverse(outPath.begin(), outPath.end());
+      return;
+    }
+
+    if (!hasParent(current, generation)) {
+      outPath.clear();
+      return;
+    }
 
 
-  while (!(current == start)) {
-    const Point &parent = parents[current.y][current.x];
-    if (parent.x == -1 && parent.y == -1) {
-      return {};
+    const int parent = getParent(current, generation);
+    if (parent == current || parent < 0) {
+      outPath.clear();
+      return;
     }
     }
     current = parent;
     current = parent;
-    path.push_back(current);
   }
   }
 
 
-  std::reverse(path.begin(), path.end());
-  return path;
+  outPath.clear();
+}
+
+bool Pathfinding::heapLess(const QueueNode &lhs, const QueueNode &rhs) const {
+  if (lhs.fCost != rhs.fCost) {
+    return lhs.fCost < rhs.fCost;
+  }
+  return lhs.gCost < rhs.gCost;
+}
+
+void Pathfinding::pushOpenNode(const QueueNode &node) {
+  m_openHeap.push_back(node);
+  std::size_t index = m_openHeap.size() - 1;
+  while (index > 0) {
+    std::size_t parent = (index - 1) / 2;
+    if (heapLess(m_openHeap[parent], m_openHeap[index])) {
+      break;
+    }
+    std::swap(m_openHeap[parent], m_openHeap[index]);
+    index = parent;
+  }
+}
+
+Pathfinding::QueueNode Pathfinding::popOpenNode() {
+  QueueNode top = m_openHeap.front();
+  QueueNode last = m_openHeap.back();
+  m_openHeap.pop_back();
+  if (!m_openHeap.empty()) {
+    m_openHeap[0] = last;
+    std::size_t index = 0;
+    const std::size_t size = m_openHeap.size();
+    while (true) {
+      std::size_t left = index * 2 + 1;
+      std::size_t right = left + 1;
+      std::size_t smallest = index;
+
+      if (left < size && !heapLess(m_openHeap[smallest], m_openHeap[left])) {
+        smallest = left;
+      }
+      if (right < size && !heapLess(m_openHeap[smallest], m_openHeap[right])) {
+        smallest = right;
+      }
+      if (smallest == index) {
+        break;
+      }
+      std::swap(m_openHeap[index], m_openHeap[smallest]);
+      index = smallest;
+    }
+  }
+  return top;
 }
 }
 
 
 void Pathfinding::workerLoop() {
 void Pathfinding::workerLoop() {

+ 57 - 14
game/systems/pathfinding.h

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include <array>
 #include <atomic>
 #include <atomic>
 #include <condition_variable>
 #include <condition_variable>
 #include <cstdint>
 #include <cstdint>
@@ -14,9 +15,13 @@ namespace Game::Systems {
 class BuildingCollisionRegistry;
 class BuildingCollisionRegistry;
 
 
 struct Point {
 struct Point {
-  int x, y;
-  Point(int x = 0, int y = 0) : x(x), y(y) {}
-  bool operator==(const Point &other) const {
+  int x = 0;
+  int y = 0;
+
+  constexpr Point() = default;
+  constexpr Point(int x_, int y_) : x(x_), y(y_) {}
+
+  constexpr bool operator==(const Point &other) const {
     return x == other.x && y == other.y;
     return x == other.x && y == other.y;
   }
   }
 };
 };
@@ -53,8 +58,49 @@ public:
   std::vector<PathResult> fetchCompletedPaths();
   std::vector<PathResult> fetchCompletedPaths();
 
 
 private:
 private:
+  std::vector<Point> findPathInternal(const Point &start, const Point &end);
+
+  int calculateHeuristic(const Point &a, const Point &b) const;
+
+  void ensureWorkingBuffers();
+  std::uint32_t nextGeneration();
+  void resetGenerations();
+
+  inline int toIndex(int x, int y) const { return y * m_width + x; }
+  inline int toIndex(const Point &p) const { return toIndex(p.x, p.y); }
+  inline Point toPoint(int index) const {
+    return Point(index % m_width, index / m_width);
+  }
+
+  bool isClosed(int index, std::uint32_t generation) const;
+  void setClosed(int index, std::uint32_t generation);
+
+  int getGCost(int index, std::uint32_t generation) const;
+  void setGCost(int index, std::uint32_t generation, int cost);
+
+  bool hasParent(int index, std::uint32_t generation) const;
+  int getParent(int index, std::uint32_t generation) const;
+  void setParent(int index, std::uint32_t generation, int parentIndex);
+
+  std::size_t collectNeighbors(const Point &point,
+                               std::array<Point, 8> &buffer) const;
+  void buildPath(int startIndex, int endIndex, std::uint32_t generation,
+                 int expectedLength, std::vector<Point> &outPath) const;
+
+  struct QueueNode {
+    int index;
+    int fCost;
+    int gCost;
+  };
+
+  bool heapLess(const QueueNode &lhs, const QueueNode &rhs) const;
+  void pushOpenNode(const QueueNode &node);
+  QueueNode popOpenNode();
+
+  void workerLoop();
+
   int m_width, m_height;
   int m_width, m_height;
-  std::vector<std::vector<bool>> m_obstacles;
+  std::vector<std::vector<std::uint8_t>> m_obstacles;
   float m_gridCellSize;
   float m_gridCellSize;
   float m_gridOffsetX, m_gridOffsetZ;
   float m_gridOffsetX, m_gridOffsetZ;
   std::atomic<bool> m_obstaclesDirty;
   std::atomic<bool> m_obstaclesDirty;
@@ -72,16 +118,13 @@ private:
   std::mutex m_resultMutex;
   std::mutex m_resultMutex;
   std::queue<PathResult> m_resultQueue;
   std::queue<PathResult> m_resultQueue;
 
 
-  std::vector<Point> findPathInternal(const Point &start,
-                                      const Point &end) const;
-
-  int calculateHeuristic(const Point &a, const Point &b) const;
-  std::vector<Point> getNeighbors(const Point &point) const;
-  std::vector<Point>
-  reconstructPath(const Point &start, const Point &end,
-                  const std::vector<std::vector<Point>> &parents) const;
-
-  void workerLoop();
+  mutable std::vector<std::uint32_t> m_closedGeneration;
+  mutable std::vector<std::uint32_t> m_gCostGeneration;
+  mutable std::vector<int> m_gCostValues;
+  mutable std::vector<std::uint32_t> m_parentGeneration;
+  mutable std::vector<int> m_parentValues;
+  mutable std::vector<QueueNode> m_openHeap;
+  mutable std::uint32_t m_generationCounter{0};
 };
 };
 
 
 } // namespace Game::Systems
 } // namespace Game::Systems

+ 2 - 0
game/systems/production_system.cpp

@@ -43,6 +43,8 @@ void ProductionSystem::update(Engine::Core::World *world, float deltaTime) {
           sp.position = spawnPos;
           sp.position = spawnPos;
           sp.playerId = u->ownerId;
           sp.playerId = u->ownerId;
           sp.unitType = prod->productType;
           sp.unitType = prod->productType;
+          sp.aiControlled =
+              e->hasComponent<Engine::Core::AIControlledComponent>();
           reg->create(prod->productType, *world, sp);
           reg->create(prod->productType, *world, sp);
         }
         }
 
 

+ 10 - 0
game/units/archer.cpp

@@ -49,12 +49,22 @@ void Archer::init(const SpawnParams &params) {
   m_u->ownerId = params.playerId;
   m_u->ownerId = params.playerId;
   m_u->visionRange = 16.0f;
   m_u->visionRange = 16.0f;
 
 
+  if (params.aiControlled) {
+    e->addComponent<Engine::Core::AIControlledComponent>();
+  }
+
   QVector3D tc = teamColor(m_u->ownerId);
   QVector3D tc = teamColor(m_u->ownerId);
   m_r->color[0] = tc.x();
   m_r->color[0] = tc.x();
   m_r->color[1] = tc.y();
   m_r->color[1] = tc.y();
   m_r->color[2] = tc.z();
   m_r->color[2] = tc.z();
 
 
   m_mv = e->addComponent<Engine::Core::MovementComponent>();
   m_mv = e->addComponent<Engine::Core::MovementComponent>();
+  if (m_mv) {
+    m_mv->goalX = params.position.x();
+    m_mv->goalY = params.position.z();
+    m_mv->targetX = params.position.x();
+    m_mv->targetY = params.position.z();
+  }
 
 
   m_atk = e->addComponent<Engine::Core::AttackComponent>();
   m_atk = e->addComponent<Engine::Core::AttackComponent>();
   m_atk->range = 6.0f;
   m_atk->range = 6.0f;

+ 4 - 0
game/units/barracks.cpp

@@ -37,6 +37,10 @@ void Barracks::init(const SpawnParams &params) {
   m_u->ownerId = params.playerId;
   m_u->ownerId = params.playerId;
   m_u->visionRange = 22.0f;
   m_u->visionRange = 22.0f;
 
 
+  if (params.aiControlled) {
+    e->addComponent<Engine::Core::AIControlledComponent>();
+  }
+
   QVector3D tc = Game::Visuals::teamColorForOwner(m_u->ownerId);
   QVector3D tc = Game::Visuals::teamColorForOwner(m_u->ownerId);
   m_r->color[0] = tc.x();
   m_r->color[0] = tc.x();
   m_r->color[1] = tc.y();
   m_r->color[1] = tc.y();

+ 1 - 0
game/units/unit.h

@@ -26,6 +26,7 @@ struct SpawnParams {
   QVector3D position{0, 0, 0};
   QVector3D position{0, 0, 0};
   int playerId = 0;
   int playerId = 0;
   std::string unitType;
   std::string unitType;
+  bool aiControlled = false;
 };
 };
 
 
 class Unit {
 class Unit {

+ 1 - 0
render/CMakeLists.txt

@@ -15,6 +15,7 @@ add_library(render_gl STATIC
     ground/ground_renderer.cpp
     ground/ground_renderer.cpp
     ground/fog_renderer.cpp
     ground/fog_renderer.cpp
     ground/terrain_renderer.cpp
     ground/terrain_renderer.cpp
+    ground/biome_renderer.cpp
     entity/registry.cpp
     entity/registry.cpp
     entity/archer_renderer.cpp
     entity/archer_renderer.cpp
     entity/barracks_renderer.cpp
     entity/barracks_renderer.cpp

+ 190 - 17
render/draw_queue.h

@@ -1,14 +1,19 @@
 #pragma once
 #pragma once
 
 
+#include "ground/grass_gpu.h"
+#include "ground/terrain_gpu.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
 #include <algorithm>
 #include <algorithm>
+#include <cstddef>
+#include <cstdint>
 #include <variant>
 #include <variant>
 #include <vector>
 #include <vector>
 
 
 namespace Render::GL {
 namespace Render::GL {
 class Mesh;
 class Mesh;
 class Texture;
 class Texture;
+class Buffer;
 } // namespace Render::GL
 } // namespace Render::GL
 
 
 namespace Render::GL {
 namespace Render::GL {
@@ -17,13 +22,50 @@ struct MeshCmd {
   Mesh *mesh = nullptr;
   Mesh *mesh = nullptr;
   Texture *texture = nullptr;
   Texture *texture = nullptr;
   QMatrix4x4 model;
   QMatrix4x4 model;
+  QMatrix4x4 mvp;
   QVector3D color{1, 1, 1};
   QVector3D color{1, 1, 1};
   float alpha = 1.0f;
   float alpha = 1.0f;
 };
 };
 
 
+struct CylinderCmd {
+  QVector3D start{0.0f, -0.5f, 0.0f};
+  QVector3D end{0.0f, 0.5f, 0.0f};
+  QVector3D color{1.0f, 1.0f, 1.0f};
+  float radius = 1.0f;
+  float alpha = 1.0f;
+};
+
+struct FogInstanceData {
+  QVector3D center{0.0f, 0.25f, 0.0f};
+  QVector3D color{0.05f, 0.05f, 0.05f};
+  float alpha = 1.0f;
+  float size = 1.0f;
+};
+
+struct FogBatchCmd {
+  const FogInstanceData *instances = nullptr;
+  std::size_t count = 0;
+};
+
+struct GrassBatchCmd {
+  Buffer *instanceBuffer = nullptr;
+  std::size_t instanceCount = 0;
+  GrassBatchParams params;
+};
+
+struct TerrainChunkCmd {
+  Mesh *mesh = nullptr;
+  QMatrix4x4 model;
+  TerrainChunkParams params;
+  std::uint16_t sortKey = 0x8000u;
+  bool depthWrite = true;
+  float depthBias = 0.0f;
+};
+
 struct GridCmd {
 struct GridCmd {
 
 
   QMatrix4x4 model;
   QMatrix4x4 model;
+  QMatrix4x4 mvp;
   QVector3D color{0.2f, 0.25f, 0.2f};
   QVector3D color{0.2f, 0.25f, 0.2f};
   float cellSize = 1.0f;
   float cellSize = 1.0f;
   float thickness = 0.06f;
   float thickness = 0.06f;
@@ -32,6 +74,7 @@ struct GridCmd {
 
 
 struct SelectionRingCmd {
 struct SelectionRingCmd {
   QMatrix4x4 model;
   QMatrix4x4 model;
+  QMatrix4x4 mvp;
   QVector3D color{0, 0, 0};
   QVector3D color{0, 0, 0};
   float alphaInner = 0.6f;
   float alphaInner = 0.6f;
   float alphaOuter = 0.25f;
   float alphaOuter = 0.25f;
@@ -39,44 +82,174 @@ struct SelectionRingCmd {
 
 
 struct SelectionSmokeCmd {
 struct SelectionSmokeCmd {
   QMatrix4x4 model;
   QMatrix4x4 model;
+  QMatrix4x4 mvp;
   QVector3D color{1, 1, 1};
   QVector3D color{1, 1, 1};
   float baseAlpha = 0.15f;
   float baseAlpha = 0.15f;
 };
 };
 
 
 using DrawCmd =
 using DrawCmd =
-    std::variant<MeshCmd, GridCmd, SelectionRingCmd, SelectionSmokeCmd>;
+    std::variant<GridCmd, SelectionRingCmd, SelectionSmokeCmd, CylinderCmd,
+                 MeshCmd, FogBatchCmd, GrassBatchCmd, TerrainChunkCmd>;
+
+enum class DrawCmdType : std::uint8_t {
+  Grid = 0,
+  SelectionRing = 1,
+  SelectionSmoke = 2,
+  Cylinder = 3,
+  Mesh = 4,
+  FogBatch = 5,
+  GrassBatch = 6,
+  TerrainChunk = 7
+};
+
+constexpr std::size_t MeshCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::Mesh);
+constexpr std::size_t GridCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::Grid);
+constexpr std::size_t SelectionRingCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::SelectionRing);
+constexpr std::size_t SelectionSmokeCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::SelectionSmoke);
+constexpr std::size_t CylinderCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::Cylinder);
+constexpr std::size_t FogBatchCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::FogBatch);
+constexpr std::size_t GrassBatchCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::GrassBatch);
+constexpr std::size_t TerrainChunkCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::TerrainChunk);
+
+inline DrawCmdType drawCmdType(const DrawCmd &cmd) {
+  return static_cast<DrawCmdType>(cmd.index());
+}
 
 
 class DrawQueue {
 class DrawQueue {
 public:
 public:
   void clear() { m_items.clear(); }
   void clear() { m_items.clear(); }
+
   void submit(const MeshCmd &c) { m_items.emplace_back(c); }
   void submit(const MeshCmd &c) { m_items.emplace_back(c); }
   void submit(const GridCmd &c) { m_items.emplace_back(c); }
   void submit(const GridCmd &c) { m_items.emplace_back(c); }
   void submit(const SelectionRingCmd &c) { m_items.emplace_back(c); }
   void submit(const SelectionRingCmd &c) { m_items.emplace_back(c); }
   void submit(const SelectionSmokeCmd &c) { m_items.emplace_back(c); }
   void submit(const SelectionSmokeCmd &c) { m_items.emplace_back(c); }
+  void submit(const CylinderCmd &c) { m_items.emplace_back(c); }
+  void submit(const FogBatchCmd &c) { m_items.emplace_back(c); }
+  void submit(const GrassBatchCmd &c) { m_items.emplace_back(c); }
+  void submit(const TerrainChunkCmd &c) { m_items.emplace_back(c); }
+
   bool empty() const { return m_items.empty(); }
   bool empty() const { return m_items.empty(); }
+  std::size_t size() const { return m_items.size(); }
+
+  const DrawCmd &getSorted(std::size_t i) const {
+    return m_items[m_sortIndices[i]];
+  }
+
   const std::vector<DrawCmd> &items() const { return m_items; }
   const std::vector<DrawCmd> &items() const { return m_items; }
-  std::vector<DrawCmd> &items() { return m_items; }
+
   void sortForBatching() {
   void sortForBatching() {
+    const std::size_t count = m_items.size();
+
+    m_sortKeys.resize(count);
+    m_sortIndices.resize(count);
 
 
-    auto weight = [](const DrawCmd &c) -> int {
-      if (std::holds_alternative<GridCmd>(c))
-        return 0;
-      if (std::holds_alternative<SelectionSmokeCmd>(c))
-        return 1;
-      if (std::holds_alternative<MeshCmd>(c))
-        return 2;
-      if (std::holds_alternative<SelectionRingCmd>(c))
-        return 3;
-      return 4;
-    };
-    std::stable_sort(m_items.begin(), m_items.end(),
-                     [&](const DrawCmd &a, const DrawCmd &b) {
-                       return weight(a) < weight(b);
-                     });
+    for (std::size_t i = 0; i < count; ++i) {
+      m_sortIndices[i] = static_cast<uint32_t>(i);
+      m_sortKeys[i] = computeSortKey(m_items[i]);
+    }
+
+    if (count >= 2) {
+      radixSortTwoPass(count);
+    }
   }
   }
 
 
 private:
 private:
+  void radixSortTwoPass(std::size_t count) {
+    constexpr int BUCKETS = 256;
+
+    m_tempIndices.resize(count);
+
+    {
+      int histogram[BUCKETS] = {0};
+
+      for (std::size_t i = 0; i < count; ++i) {
+        uint8_t bucket = static_cast<uint8_t>(m_sortKeys[i] >> 56);
+        ++histogram[bucket];
+      }
+
+      int offsets[BUCKETS];
+      offsets[0] = 0;
+      for (int i = 1; i < BUCKETS; ++i) {
+        offsets[i] = offsets[i - 1] + histogram[i - 1];
+      }
+
+      for (std::size_t i = 0; i < count; ++i) {
+        uint8_t bucket =
+            static_cast<uint8_t>(m_sortKeys[m_sortIndices[i]] >> 56);
+        m_tempIndices[offsets[bucket]++] = m_sortIndices[i];
+      }
+    }
+
+    {
+      int histogram[BUCKETS] = {0};
+
+      for (std::size_t i = 0; i < count; ++i) {
+        uint8_t bucket =
+            static_cast<uint8_t>(m_sortKeys[m_tempIndices[i]] >> 48) & 0xFF;
+        ++histogram[bucket];
+      }
+
+      int offsets[BUCKETS];
+      offsets[0] = 0;
+      for (int i = 1; i < BUCKETS; ++i) {
+        offsets[i] = offsets[i - 1] + histogram[i - 1];
+      }
+
+      for (std::size_t i = 0; i < count; ++i) {
+        uint8_t bucket =
+            static_cast<uint8_t>(m_sortKeys[m_tempIndices[i]] >> 48) & 0xFF;
+        m_sortIndices[offsets[bucket]++] = m_tempIndices[i];
+      }
+    }
+  }
+
+  uint64_t computeSortKey(const DrawCmd &cmd) const {
+    static constexpr uint8_t kTypeOrder[] = {0, 1, 2, 3, 4, 7, 6, 5};
+
+    const std::size_t typeIndex = cmd.index();
+    constexpr std::size_t typeCount =
+        sizeof(kTypeOrder) / sizeof(kTypeOrder[0]);
+    const uint8_t typeOrder = typeIndex < typeCount
+                                  ? kTypeOrder[typeIndex]
+                                  : static_cast<uint8_t>(typeIndex);
+
+    uint64_t key = static_cast<uint64_t>(typeOrder) << 56;
+
+    if (cmd.index() == MeshCmdIndex) {
+      const auto &mesh = std::get<MeshCmdIndex>(cmd);
+
+      uint64_t texPtr =
+          reinterpret_cast<uintptr_t>(mesh.texture) & 0x0000FFFFFFFFFFFF;
+      key |= texPtr;
+    } else if (cmd.index() == GrassBatchCmdIndex) {
+      const auto &grass = std::get<GrassBatchCmdIndex>(cmd);
+      uint64_t bufferPtr = reinterpret_cast<uintptr_t>(grass.instanceBuffer) &
+                           0x0000FFFFFFFFFFFF;
+      key |= bufferPtr;
+    } else if (cmd.index() == TerrainChunkCmdIndex) {
+      const auto &terrain = std::get<TerrainChunkCmdIndex>(cmd);
+      uint64_t sortByte = static_cast<uint64_t>((terrain.sortKey >> 8) & 0xFFu);
+      key |= sortByte << 48;
+      uint64_t meshPtr =
+          reinterpret_cast<uintptr_t>(terrain.mesh) & 0x0000FFFFFFFFFFFFu;
+      key |= meshPtr;
+    }
+
+    return key;
+  }
+
   std::vector<DrawCmd> m_items;
   std::vector<DrawCmd> m_items;
+  std::vector<uint32_t> m_sortIndices;
+  std::vector<uint64_t> m_sortKeys;
+  std::vector<uint32_t> m_tempIndices;
 };
 };
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 77 - 54
render/entity/archer_renderer.cpp

@@ -21,6 +21,10 @@
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
+namespace {
+const QMatrix4x4 kIdentityMatrix;
+}
+
 using Render::Geom::clamp01;
 using Render::Geom::clamp01;
 using Render::Geom::clampf;
 using Render::Geom::clampf;
 using Render::Geom::clampVec01;
 using Render::Geom::clampVec01;
@@ -202,14 +206,14 @@ static inline void drawTorso(const DrawContext &p, ISubmitter &out,
   float torsoRadius = HP::TORSO_TOP_R;
   float torsoRadius = HP::TORSO_TOP_R;
 
 
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(torsoTop, torsoBot, torsoRadius), C.tunic,
+           cylinderBetween(p.model, torsoTop, torsoBot, torsoRadius), C.tunic,
            nullptr, 1.0f);
            nullptr, 1.0f);
 
 
   QVector3D waist{0.0f, HP::WAIST_Y, 0.0f};
   QVector3D waist{0.0f, HP::WAIST_Y, 0.0f};
   QVector3D hipCenter = (P.hipL + P.hipR) * 0.5f;
   QVector3D hipCenter = (P.hipL + P.hipR) * 0.5f;
 
 
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(waist, hipCenter, HP::TORSO_BOT_R),
+           coneFromTo(p.model, waist, hipCenter, HP::TORSO_BOT_R),
            C.tunic * 0.9f, nullptr, 1.0f);
            C.tunic * 0.9f, nullptr, 1.0f);
 }
 }
 
 
@@ -219,17 +223,17 @@ static inline void drawHeadAndNeck(const DrawContext &p, ISubmitter &out,
 
 
   QVector3D chinPos{0.0f, HP::CHIN_Y, 0.0f};
   QVector3D chinPos{0.0f, HP::CHIN_Y, 0.0f};
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.neckBase, chinPos, HP::NECK_RADIUS),
+           cylinderBetween(p.model, P.neckBase, chinPos, HP::NECK_RADIUS),
            C.skin * 0.9f, nullptr, 1.0f);
            C.skin * 0.9f, nullptr, 1.0f);
 
 
-  out.mesh(getUnitSphere(), p.model * sphereAt(P.headPos, P.headR), C.skin,
+  out.mesh(getUnitSphere(), sphereAt(p.model, P.headPos, P.headR), C.skin,
            nullptr, 1.0f);
            nullptr, 1.0f);
 
 
   float headTopOffset = P.headR * 0.7f;
   float headTopOffset = P.headR * 0.7f;
   QVector3D helmBase = P.headPos + QVector3D(0.0f, headTopOffset, 0.0f);
   QVector3D helmBase = P.headPos + QVector3D(0.0f, headTopOffset, 0.0f);
   QVector3D helmApex = P.headPos + QVector3D(0.0f, P.headR * 2.4f, 0.0f);
   QVector3D helmApex = P.headPos + QVector3D(0.0f, P.headR * 2.4f, 0.0f);
   float helmBaseR = P.headR * 1.45f;
   float helmBaseR = P.headR * 1.45f;
-  out.mesh(getUnitCone(), p.model * coneFromTo(helmBase, helmApex, helmBaseR),
+  out.mesh(getUnitCone(), coneFromTo(p.model, helmBase, helmApex, helmBaseR),
            C.tunic, nullptr, 1.0f);
            C.tunic, nullptr, 1.0f);
 
 
   QVector3D iris(0.06f, 0.06f, 0.07f);
   QVector3D iris(0.06f, 0.06f, 0.07f);
@@ -255,21 +259,21 @@ static inline void drawArms(const DrawContext &p, ISubmitter &out,
   const float jointR = HP::HAND_RADIUS * 1.05f;
   const float jointR = HP::HAND_RADIUS * 1.05f;
 
 
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.shoulderL, P.elbowL, upperArmR), C.tunic,
+           cylinderBetween(p.model, P.shoulderL, P.elbowL, upperArmR), C.tunic,
            nullptr, 1.0f);
            nullptr, 1.0f);
-  out.mesh(getUnitSphere(), p.model * sphereAt(P.elbowL, jointR),
+  out.mesh(getUnitSphere(), sphereAt(p.model, P.elbowL, jointR),
            C.tunic * 0.95f, nullptr, 1.0f);
            C.tunic * 0.95f, nullptr, 1.0f);
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.elbowL, P.handL, foreArmR),
+           cylinderBetween(p.model, P.elbowL, P.handL, foreArmR),
            C.skin * 0.95f, nullptr, 1.0f);
            C.skin * 0.95f, nullptr, 1.0f);
 
 
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.shoulderR, P.elbowR, upperArmR), C.tunic,
+           cylinderBetween(p.model, P.shoulderR, P.elbowR, upperArmR), C.tunic,
            nullptr, 1.0f);
            nullptr, 1.0f);
-  out.mesh(getUnitSphere(), p.model * sphereAt(P.elbowR, jointR),
+  out.mesh(getUnitSphere(), sphereAt(p.model, P.elbowR, jointR),
            C.tunic * 0.95f, nullptr, 1.0f);
            C.tunic * 0.95f, nullptr, 1.0f);
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.elbowR, P.handR, foreArmR),
+           cylinderBetween(p.model, P.elbowR, P.handR, foreArmR),
            C.skin * 0.95f, nullptr, 1.0f);
            C.skin * 0.95f, nullptr, 1.0f);
 }
 }
 
 
@@ -286,27 +290,27 @@ static inline void drawLegs(const DrawContext &p, ISubmitter &out,
   const float shinR = HP::LOWER_LEG_R;
   const float shinR = HP::LOWER_LEG_R;
   const float kneeJointR = thighR * 1.15f;
   const float kneeJointR = thighR * 1.15f;
 
 
-  out.mesh(getUnitCone(), p.model * coneFromTo(P.hipL, kneeL, thighR),
-           C.leather, nullptr, 1.0f);
-  out.mesh(getUnitCone(), p.model * coneFromTo(P.hipR, kneeR, thighR),
-           C.leather, nullptr, 1.0f);
+  out.mesh(getUnitCone(), coneFromTo(p.model, P.hipL, kneeL, thighR), C.leather,
+           nullptr, 1.0f);
+  out.mesh(getUnitCone(), coneFromTo(p.model, P.hipR, kneeR, thighR), C.leather,
+           nullptr, 1.0f);
 
 
-  out.mesh(getUnitSphere(), p.model * sphereAt(kneeL, kneeJointR),
+  out.mesh(getUnitSphere(), sphereAt(p.model, kneeL, kneeJointR),
            C.leather * 0.95f, nullptr, 1.0f);
            C.leather * 0.95f, nullptr, 1.0f);
-  out.mesh(getUnitSphere(), p.model * sphereAt(kneeR, kneeJointR),
+  out.mesh(getUnitSphere(), sphereAt(p.model, kneeR, kneeJointR),
            C.leather * 0.95f, nullptr, 1.0f);
            C.leather * 0.95f, nullptr, 1.0f);
 
 
-  out.mesh(getUnitCone(), p.model * coneFromTo(kneeL, P.footL, shinR),
+  out.mesh(getUnitCone(), coneFromTo(p.model, kneeL, P.footL, shinR),
            C.leatherDark, nullptr, 1.0f);
            C.leatherDark, nullptr, 1.0f);
-  out.mesh(getUnitCone(), p.model * coneFromTo(kneeR, P.footR, shinR),
+  out.mesh(getUnitCone(), coneFromTo(p.model, kneeR, P.footR, shinR),
            C.leatherDark, nullptr, 1.0f);
            C.leatherDark, nullptr, 1.0f);
 
 
   QVector3D down(0.0f, -0.02f, 0.0f);
   QVector3D down(0.0f, -0.02f, 0.0f);
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.footL, P.footL + down, shinR * 1.1f),
+           cylinderBetween(p.model, P.footL, P.footL + down, shinR * 1.1f),
            C.leatherDark, nullptr, 1.0f);
            C.leatherDark, nullptr, 1.0f);
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.footR, P.footR + down, shinR * 1.1f),
+           cylinderBetween(p.model, P.footR, P.footR + down, shinR * 1.1f),
            C.leatherDark, nullptr, 1.0f);
            C.leatherDark, nullptr, 1.0f);
 }
 }
 
 
@@ -326,24 +330,24 @@ static inline void drawQuiver(const DrawContext &p, ISubmitter &out,
   QVector3D qBase(-0.10f, HP::CHEST_Y, -0.22f);
   QVector3D qBase(-0.10f, HP::CHEST_Y, -0.22f);
 
 
   float quiverR = HP::HEAD_RADIUS * 0.45f;
   float quiverR = HP::HEAD_RADIUS * 0.45f;
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qBase, qTop, quiverR),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, qBase, qTop, quiverR),
            C.leather, nullptr, 1.0f);
            C.leather, nullptr, 1.0f);
 
 
   float j = (hash01(seed) - 0.5f) * 0.04f;
   float j = (hash01(seed) - 0.5f) * 0.04f;
   float k = (hash01(seed ^ 0x9E3779B9u) - 0.5f) * 0.04f;
   float k = (hash01(seed ^ 0x9E3779B9u) - 0.5f) * 0.04f;
 
 
   QVector3D a1 = qTop + QVector3D(0.00f + j, 0.08f, 0.00f + k);
   QVector3D a1 = qTop + QVector3D(0.00f + j, 0.08f, 0.00f + k);
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qTop, a1, 0.010f),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, qTop, a1, 0.010f),
            C.wood, nullptr, 1.0f);
            C.wood, nullptr, 1.0f);
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(a1, a1 + QVector3D(0, 0.05f, 0), 0.025f),
+           coneFromTo(p.model, a1, a1 + QVector3D(0, 0.05f, 0), 0.025f),
            C.fletch, nullptr, 1.0f);
            C.fletch, nullptr, 1.0f);
 
 
   QVector3D a2 = qTop + QVector3D(0.02f - j, 0.07f, 0.02f - k);
   QVector3D a2 = qTop + QVector3D(0.02f - j, 0.07f, 0.02f - k);
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qTop, a2, 0.010f),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, qTop, a2, 0.010f),
            C.wood, nullptr, 1.0f);
            C.wood, nullptr, 1.0f);
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(a2, a2 + QVector3D(0, 0.05f, 0), 0.025f),
+           coneFromTo(p.model, a2, a2 + QVector3D(0, 0.05f, 0), 0.025f),
            C.fletch, nullptr, 1.0f);
            C.fletch, nullptr, 1.0f);
 }
 }
 
 
@@ -371,36 +375,34 @@ static inline void drawBowAndArrow(const DrawContext &p, ISubmitter &out,
   for (int i = 1; i <= segs; ++i) {
   for (int i = 1; i <= segs; ++i) {
     float t = float(i) / float(segs);
     float t = float(i) / float(segs);
     QVector3D cur = qBezier(botEnd, ctrl, topEnd, t);
     QVector3D cur = qBezier(botEnd, ctrl, topEnd, t);
-    out.mesh(getUnitCylinder(), p.model * cylinderBetween(prev, cur, P.bowRodR),
+    out.mesh(getUnitCylinder(), cylinderBetween(p.model, prev, cur, P.bowRodR),
              C.wood, nullptr, 1.0f);
              C.wood, nullptr, 1.0f);
     prev = cur;
     prev = cur;
   }
   }
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(grip - up * 0.05f, grip + up * 0.05f,
-                                     P.bowRodR * 1.45f),
+           cylinderBetween(p.model, grip - up * 0.05f, grip + up * 0.05f,
+                           P.bowRodR * 1.45f),
            C.wood, nullptr, 1.0f);
            C.wood, nullptr, 1.0f);
 
 
-  out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(topEnd, nock, P.stringR), C.stringCol,
-           nullptr, 1.0f);
-  out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(nock, botEnd, P.stringR), C.stringCol,
-           nullptr, 1.0f);
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(P.handR, nock, 0.0045f),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, topEnd, nock, P.stringR),
+           C.stringCol, nullptr, 1.0f);
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, nock, botEnd, P.stringR),
+           C.stringCol, nullptr, 1.0f);
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, P.handR, nock, 0.0045f),
            C.stringCol * 0.9f, nullptr, 1.0f);
            C.stringCol * 0.9f, nullptr, 1.0f);
 
 
   QVector3D tail = nock - forward * 0.06f;
   QVector3D tail = nock - forward * 0.06f;
   QVector3D tip = tail + forward * 0.90f;
   QVector3D tip = tail + forward * 0.90f;
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(tail, tip, 0.018f),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, tail, tip, 0.018f),
            C.wood, nullptr, 1.0f);
            C.wood, nullptr, 1.0f);
   QVector3D headBase = tip - forward * 0.10f;
   QVector3D headBase = tip - forward * 0.10f;
-  out.mesh(getUnitCone(), p.model * coneFromTo(headBase, tip, 0.05f),
+  out.mesh(getUnitCone(), coneFromTo(p.model, headBase, tip, 0.05f),
            C.metalHead, nullptr, 1.0f);
            C.metalHead, nullptr, 1.0f);
   QVector3D f1b = tail - forward * 0.02f, f1a = f1b - forward * 0.06f;
   QVector3D f1b = tail - forward * 0.02f, f1a = f1b - forward * 0.06f;
   QVector3D f2b = tail + forward * 0.02f, f2a = f2b + forward * 0.06f;
   QVector3D f2b = tail + forward * 0.02f, f2a = f2b + forward * 0.06f;
-  out.mesh(getUnitCone(), p.model * coneFromTo(f1b, f1a, 0.04f), C.fletch,
+  out.mesh(getUnitCone(), coneFromTo(p.model, f1b, f1a, 0.04f), C.fletch,
            nullptr, 1.0f);
            nullptr, 1.0f);
-  out.mesh(getUnitCone(), p.model * coneFromTo(f2a, f2b, 0.04f), C.fletch,
+  out.mesh(getUnitCone(), coneFromTo(p.model, f2a, f2b, 0.04f), C.fletch,
            nullptr, 1.0f);
            nullptr, 1.0f);
 }
 }
 
 
@@ -459,21 +461,43 @@ void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
           p.entity->getComponent<Engine::Core::TransformComponent>();
           p.entity->getComponent<Engine::Core::TransformComponent>();
 
 
       isMoving = (movement && movement->hasTarget);
       isMoving = (movement && movement->hasTarget);
-      isAttacking = (attack && attackTarget && attackTarget->targetId > 0);
-
-      if (isAttacking && attackTarget && p.world && transform) {
-        auto *target = p.world->getEntity(attackTarget->targetId);
-        if (target) {
-          auto *targetTransform =
-              target->getComponent<Engine::Core::TransformComponent>();
-          if (targetTransform) {
 
 
-            float dx = targetTransform->position.x - transform->position.x;
-            float dz = targetTransform->position.z - transform->position.z;
-
-            targetRotationY = std::atan2(dx, dz) * 180.0f / 3.14159f;
+      if (attack && attackTarget && attackTarget->targetId > 0 && transform) {
+        bool stationary = !isMoving;
+        bool recentlyFired =
+            attack->timeSinceLast < std::min(attack->cooldown, 0.45f);
+        bool targetInRange = false;
+
+        if (p.world) {
+          auto *target = p.world->getEntity(attackTarget->targetId);
+          if (target) {
+            auto *targetTransform =
+                target->getComponent<Engine::Core::TransformComponent>();
+            if (targetTransform) {
+              float dx = targetTransform->position.x - transform->position.x;
+              float dz = targetTransform->position.z - transform->position.z;
+              float dist = std::sqrt(dx * dx + dz * dz);
+              float targetRadius = 0.0f;
+              if (target->hasComponent<Engine::Core::BuildingComponent>()) {
+                targetRadius = std::max(targetTransform->scale.x,
+                                        targetTransform->scale.z) *
+                               0.5f;
+              } else {
+                targetRadius = std::max(targetTransform->scale.x,
+                                        targetTransform->scale.z) *
+                               0.5f;
+              }
+              float effectiveRange = attack->range + targetRadius + 0.25f;
+              targetInRange = dist <= effectiveRange;
+
+              targetRotationY = std::atan2(dx, dz) * 180.0f / 3.14159f;
+            }
           }
           }
         }
         }
+
+        isAttacking = stationary && (targetInRange || recentlyFired);
+      } else {
+        isAttacking = false;
       }
       }
     }
     }
 
 
@@ -503,8 +527,7 @@ void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
       if (p.entity) {
       if (p.entity) {
         if (auto *entT =
         if (auto *entT =
                 p.entity->getComponent<Engine::Core::TransformComponent>()) {
                 p.entity->getComponent<Engine::Core::TransformComponent>()) {
-          QMatrix4x4 M;
-          M.setToIdentity();
+          QMatrix4x4 M = kIdentityMatrix;
           M.translate(entT->position.x, entT->position.y, entT->position.z);
           M.translate(entT->position.x, entT->position.y, entT->position.z);
           float baseYaw = entT->rotation.y;
           float baseYaw = entT->rotation.y;
           float appliedYaw = baseYaw + (isAttacking ? yawOffset : 0.0f);
           float appliedYaw = baseYaw + (isAttacking ? yawOffset : 0.0f);

+ 41 - 43
render/entity/arrow_vfx_renderer.cpp

@@ -148,14 +148,14 @@ static inline void drawTorso(const DrawContext &p, ISubmitter &out,
   float torsoRadius = HP::TORSO_TOP_R;
   float torsoRadius = HP::TORSO_TOP_R;
 
 
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(torsoTop, torsoBot, torsoRadius), C.tunic,
+           cylinderBetween(p.model, torsoTop, torsoBot, torsoRadius), C.tunic,
            nullptr, 1.0f);
            nullptr, 1.0f);
 
 
   QVector3D waist{0.0f, HP::WAIST_Y, 0.0f};
   QVector3D waist{0.0f, HP::WAIST_Y, 0.0f};
   QVector3D hipCenter = (P.hipL + P.hipR) * 0.5f;
   QVector3D hipCenter = (P.hipL + P.hipR) * 0.5f;
 
 
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(waist, hipCenter, HP::TORSO_BOT_R),
+           coneFromTo(p.model, waist, hipCenter, HP::TORSO_BOT_R),
            C.tunic * 0.9f, nullptr, 1.0f);
            C.tunic * 0.9f, nullptr, 1.0f);
 }
 }
 
 
@@ -165,10 +165,10 @@ static inline void drawHeadAndNeck(const DrawContext &p, ISubmitter &out,
 
 
   QVector3D chinPos{0.0f, HP::CHIN_Y, 0.0f};
   QVector3D chinPos{0.0f, HP::CHIN_Y, 0.0f};
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.neckBase, chinPos, HP::NECK_RADIUS),
+           cylinderBetween(p.model, P.neckBase, chinPos, HP::NECK_RADIUS),
            C.skin * 0.9f, nullptr, 1.0f);
            C.skin * 0.9f, nullptr, 1.0f);
 
 
-  out.mesh(getUnitSphere(), p.model * sphereAt(P.headPos, P.headR), C.skin,
+  out.mesh(getUnitSphere(), sphereAt(p.model, P.headPos, P.headR), C.skin,
            nullptr, 1.0f);
            nullptr, 1.0f);
 
 
   QVector3D iris(0.06f, 0.06f, 0.07f);
   QVector3D iris(0.06f, 0.06f, 0.07f);
@@ -186,13 +186,13 @@ static inline void drawHeadAndNeck(const DrawContext &p, ISubmitter &out,
 
 
   QVector3D domeC = P.headPos + QVector3D(0.0f, P.headR * 0.25f, 0.0f);
   QVector3D domeC = P.headPos + QVector3D(0.0f, P.headR * 0.25f, 0.0f);
   float domeR = P.headR * 1.05f;
   float domeR = P.headR * 1.05f;
-  out.mesh(getUnitSphere(), p.model * sphereAt(domeC, domeR), C.metal, nullptr,
+  out.mesh(getUnitSphere(), sphereAt(p.model, domeC, domeR), C.metal, nullptr,
            1.0f);
            1.0f);
 
 
   QVector3D visorBase(0.0f, P.headPos.y() + P.headR * 0.10f, P.headR * 0.80f);
   QVector3D visorBase(0.0f, P.headPos.y() + P.headR * 0.10f, P.headR * 0.80f);
   QVector3D visorTip = visorBase + QVector3D(0.0f, -0.015f, 0.06f);
   QVector3D visorTip = visorBase + QVector3D(0.0f, -0.015f, 0.06f);
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(visorBase, visorTip, P.headR * 0.38f),
+           coneFromTo(p.model, visorBase, visorTip, P.headR * 0.38f),
            C.metal * 0.92f, nullptr, 1.0f);
            C.metal * 0.92f, nullptr, 1.0f);
 
 
   QVector3D cheekL0(-P.headR * 0.85f, P.headPos.y() + P.headR * 0.05f, 0.02f);
   QVector3D cheekL0(-P.headR * 0.85f, P.headPos.y() + P.headR * 0.05f, 0.02f);
@@ -200,10 +200,10 @@ static inline void drawHeadAndNeck(const DrawContext &p, ISubmitter &out,
   QVector3D cheekR0(P.headR * 0.85f, P.headPos.y() + P.headR * 0.05f, -0.02f);
   QVector3D cheekR0(P.headR * 0.85f, P.headPos.y() + P.headR * 0.05f, -0.02f);
   QVector3D cheekR1(P.headR * 0.85f, P.headPos.y() - P.headR * 0.20f, -0.04f);
   QVector3D cheekR1(P.headR * 0.85f, P.headPos.y() - P.headR * 0.20f, -0.04f);
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(cheekL0, cheekL1, P.headR * 0.24f),
+           coneFromTo(p.model, cheekL0, cheekL1, P.headR * 0.24f),
            C.metal * 0.95f, nullptr, 1.0f);
            C.metal * 0.95f, nullptr, 1.0f);
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(cheekR0, cheekR1, P.headR * 0.24f),
+           coneFromTo(p.model, cheekR0, cheekR1, P.headR * 0.24f),
            C.metal * 0.95f, nullptr, 1.0f);
            C.metal * 0.95f, nullptr, 1.0f);
 }
 }
 
 
@@ -216,25 +216,25 @@ static inline void drawArms(const DrawContext &p, ISubmitter &out,
   const float jointR = upperArmR * 1.2f;
   const float jointR = upperArmR * 1.2f;
 
 
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.shoulderL, P.elbowL, upperArmR), C.tunic,
+           cylinderBetween(p.model, P.shoulderL, P.elbowL, upperArmR), C.tunic,
            nullptr, 1.0f);
            nullptr, 1.0f);
 
 
-  out.mesh(getUnitSphere(), p.model * sphereAt(P.elbowL, jointR),
+  out.mesh(getUnitSphere(), sphereAt(p.model, P.elbowL, jointR),
            C.tunic * 0.95f, nullptr, 1.0f);
            C.tunic * 0.95f, nullptr, 1.0f);
 
 
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.elbowL, P.handL, foreArmR),
+           cylinderBetween(p.model, P.elbowL, P.handL, foreArmR),
            C.skin * 0.95f, nullptr, 1.0f);
            C.skin * 0.95f, nullptr, 1.0f);
 
 
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.shoulderR, P.elbowR, upperArmR), C.tunic,
+           cylinderBetween(p.model, P.shoulderR, P.elbowR, upperArmR), C.tunic,
            nullptr, 1.0f);
            nullptr, 1.0f);
 
 
-  out.mesh(getUnitSphere(), p.model * sphereAt(P.elbowR, jointR),
+  out.mesh(getUnitSphere(), sphereAt(p.model, P.elbowR, jointR),
            C.tunic * 0.95f, nullptr, 1.0f);
            C.tunic * 0.95f, nullptr, 1.0f);
 
 
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.elbowR, P.handR, foreArmR),
+           cylinderBetween(p.model, P.elbowR, P.handR, foreArmR),
            C.skin * 0.95f, nullptr, 1.0f);
            C.skin * 0.95f, nullptr, 1.0f);
 }
 }
 
 
@@ -259,27 +259,27 @@ static inline void drawLegs(const DrawContext &p, ISubmitter &out,
   QVector3D kneeL = makeKnee(P.hipL, P.footL, -1.0f);
   QVector3D kneeL = makeKnee(P.hipL, P.footL, -1.0f);
   QVector3D kneeR = makeKnee(P.hipR, P.footR, 1.0f);
   QVector3D kneeR = makeKnee(P.hipR, P.footR, 1.0f);
 
 
-  out.mesh(getUnitCone(), p.model * coneFromTo(P.hipL, kneeL, thighR),
-           C.leather, nullptr, 1.0f);
-  out.mesh(getUnitCone(), p.model * coneFromTo(P.hipR, kneeR, thighR),
-           C.leather, nullptr, 1.0f);
+  out.mesh(getUnitCone(), coneFromTo(p.model, P.hipL, kneeL, thighR), C.leather,
+           nullptr, 1.0f);
+  out.mesh(getUnitCone(), coneFromTo(p.model, P.hipR, kneeR, thighR), C.leather,
+           nullptr, 1.0f);
 
 
-  out.mesh(getUnitSphere(), p.model * sphereAt(kneeL, kneeJointR),
+  out.mesh(getUnitSphere(), sphereAt(p.model, kneeL, kneeJointR),
            C.leather * 0.95f, nullptr, 1.0f);
            C.leather * 0.95f, nullptr, 1.0f);
-  out.mesh(getUnitSphere(), p.model * sphereAt(kneeR, kneeJointR),
+  out.mesh(getUnitSphere(), sphereAt(p.model, kneeR, kneeJointR),
            C.leather * 0.95f, nullptr, 1.0f);
            C.leather * 0.95f, nullptr, 1.0f);
 
 
-  out.mesh(getUnitCone(), p.model * coneFromTo(kneeL, P.footL, shinR),
+  out.mesh(getUnitCone(), coneFromTo(p.model, kneeL, P.footL, shinR),
            C.leatherDark, nullptr, 1.0f);
            C.leatherDark, nullptr, 1.0f);
-  out.mesh(getUnitCone(), p.model * coneFromTo(kneeR, P.footR, shinR),
+  out.mesh(getUnitCone(), coneFromTo(p.model, kneeR, P.footR, shinR),
            C.leatherDark, nullptr, 1.0f);
            C.leatherDark, nullptr, 1.0f);
 
 
   QVector3D down(0.0f, -0.02f, 0.0f);
   QVector3D down(0.0f, -0.02f, 0.0f);
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.footL, P.footL + down, shinR * 1.1f),
+           cylinderBetween(p.model, P.footL, P.footL + down, shinR * 1.1f),
            C.leatherDark, nullptr, 1.0f);
            C.leatherDark, nullptr, 1.0f);
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(P.footR, P.footR + down, shinR * 1.1f),
+           cylinderBetween(p.model, P.footR, P.footR + down, shinR * 1.1f),
            C.leatherDark, nullptr, 1.0f);
            C.leatherDark, nullptr, 1.0f);
 }
 }
 
 
@@ -299,24 +299,24 @@ static inline void drawQuiver(const DrawContext &p, ISubmitter &out,
   QVector3D qBase(-0.10f, HP::CHEST_Y, -0.22f);
   QVector3D qBase(-0.10f, HP::CHEST_Y, -0.22f);
 
 
   float quiverR = HP::HEAD_RADIUS * 0.45f;
   float quiverR = HP::HEAD_RADIUS * 0.45f;
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qBase, qTop, quiverR),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, qBase, qTop, quiverR),
            C.leather, nullptr, 1.0f);
            C.leather, nullptr, 1.0f);
 
 
   float j = (hash01(seed) - 0.5f) * 0.04f;
   float j = (hash01(seed) - 0.5f) * 0.04f;
   float k = (hash01(seed ^ 0x9E3779B9u) - 0.5f) * 0.04f;
   float k = (hash01(seed ^ 0x9E3779B9u) - 0.5f) * 0.04f;
 
 
   QVector3D a1 = qTop + QVector3D(0.00f + j, 0.08f, 0.00f + k);
   QVector3D a1 = qTop + QVector3D(0.00f + j, 0.08f, 0.00f + k);
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qTop, a1, 0.010f),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, qTop, a1, 0.010f),
            C.wood, nullptr, 1.0f);
            C.wood, nullptr, 1.0f);
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(a1, a1 + QVector3D(0, 0.05f, 0), 0.025f),
+           coneFromTo(p.model, a1, a1 + QVector3D(0, 0.05f, 0), 0.025f),
            C.fletch, nullptr, 1.0f);
            C.fletch, nullptr, 1.0f);
 
 
   QVector3D a2 = qTop + QVector3D(0.02f - j, 0.07f, 0.02f - k);
   QVector3D a2 = qTop + QVector3D(0.02f - j, 0.07f, 0.02f - k);
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qTop, a2, 0.010f),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, qTop, a2, 0.010f),
            C.wood, nullptr, 1.0f);
            C.wood, nullptr, 1.0f);
   out.mesh(getUnitCone(),
   out.mesh(getUnitCone(),
-           p.model * coneFromTo(a2, a2 + QVector3D(0, 0.05f, 0), 0.025f),
+           coneFromTo(p.model, a2, a2 + QVector3D(0, 0.05f, 0), 0.025f),
            C.fletch, nullptr, 1.0f);
            C.fletch, nullptr, 1.0f);
 }
 }
 
 
@@ -344,36 +344,34 @@ static inline void drawBowAndArrow(const DrawContext &p, ISubmitter &out,
   for (int i = 1; i <= segs; ++i) {
   for (int i = 1; i <= segs; ++i) {
     float t = float(i) / float(segs);
     float t = float(i) / float(segs);
     QVector3D cur = qBezier(botEnd, ctrl, topEnd, t);
     QVector3D cur = qBezier(botEnd, ctrl, topEnd, t);
-    out.mesh(getUnitCylinder(), p.model * cylinderBetween(prev, cur, P.bowRodR),
+    out.mesh(getUnitCylinder(), cylinderBetween(p.model, prev, cur, P.bowRodR),
              C.wood, nullptr, 1.0f);
              C.wood, nullptr, 1.0f);
     prev = cur;
     prev = cur;
   }
   }
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(grip - up * 0.05f, grip + up * 0.05f,
-                                     P.bowRodR * 1.45f),
+           cylinderBetween(p.model, grip - up * 0.05f, grip + up * 0.05f,
+                           P.bowRodR * 1.45f),
            C.wood, nullptr, 1.0f);
            C.wood, nullptr, 1.0f);
 
 
-  out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(topEnd, nock, P.stringR), C.stringCol,
-           nullptr, 1.0f);
-  out.mesh(getUnitCylinder(),
-           p.model * cylinderBetween(nock, botEnd, P.stringR), C.stringCol,
-           nullptr, 1.0f);
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(P.handR, nock, 0.0045f),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, topEnd, nock, P.stringR),
+           C.stringCol, nullptr, 1.0f);
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, nock, botEnd, P.stringR),
+           C.stringCol, nullptr, 1.0f);
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, P.handR, nock, 0.0045f),
            C.stringCol * 0.9f, nullptr, 1.0f);
            C.stringCol * 0.9f, nullptr, 1.0f);
 
 
   QVector3D tail = nock - forward * 0.06f;
   QVector3D tail = nock - forward * 0.06f;
   QVector3D tip = tail + forward * 0.90f;
   QVector3D tip = tail + forward * 0.90f;
-  out.mesh(getUnitCylinder(), p.model * cylinderBetween(tail, tip, 0.035f),
+  out.mesh(getUnitCylinder(), cylinderBetween(p.model, tail, tip, 0.035f),
            C.wood, nullptr, 1.0f);
            C.wood, nullptr, 1.0f);
   QVector3D headBase = tip - forward * 0.10f;
   QVector3D headBase = tip - forward * 0.10f;
-  out.mesh(getUnitCone(), p.model * coneFromTo(headBase, tip, 0.05f),
+  out.mesh(getUnitCone(), coneFromTo(p.model, headBase, tip, 0.05f),
            C.metalHead, nullptr, 1.0f);
            C.metalHead, nullptr, 1.0f);
   QVector3D f1b = tail - forward * 0.02f, f1a = f1b - forward * 0.06f;
   QVector3D f1b = tail - forward * 0.02f, f1a = f1b - forward * 0.06f;
   QVector3D f2b = tail + forward * 0.02f, f2a = f2b + forward * 0.06f;
   QVector3D f2b = tail + forward * 0.02f, f2a = f2b + forward * 0.06f;
-  out.mesh(getUnitCone(), p.model * coneFromTo(f1b, f1a, 0.04f), C.fletch,
+  out.mesh(getUnitCone(), coneFromTo(p.model, f1b, f1a, 0.04f), C.fletch,
            nullptr, 1.0f);
            nullptr, 1.0f);
-  out.mesh(getUnitCone(), p.model * coneFromTo(f2a, f2b, 0.04f), C.fletch,
+  out.mesh(getUnitCone(), coneFromTo(p.model, f2a, f2b, 0.04f), C.fletch,
            nullptr, 1.0f);
            nullptr, 1.0f);
 }
 }
 
 

+ 7 - 4
render/geom/flag.cpp

@@ -3,6 +3,10 @@
 namespace Render {
 namespace Render {
 namespace Geom {
 namespace Geom {
 
 
+namespace {
+const QMatrix4x4 kIdentityMatrix;
+}
+
 Flag::FlagMatrices Flag::create(float worldX, float worldZ,
 Flag::FlagMatrices Flag::create(float worldX, float worldZ,
                                 const QVector3D &flagColor,
                                 const QVector3D &flagColor,
                                 const QVector3D &poleColor, float scale) {
                                 const QVector3D &poleColor, float scale) {
@@ -10,17 +14,16 @@ Flag::FlagMatrices Flag::create(float worldX, float worldZ,
   result.pennantColor = flagColor;
   result.pennantColor = flagColor;
   result.poleColor = poleColor;
   result.poleColor = poleColor;
 
 
-  result.pole.setToIdentity();
-
+  result.pole = kIdentityMatrix;
   result.pole.translate(worldX, (0.15f + 0.02f) * scale, worldZ);
   result.pole.translate(worldX, (0.15f + 0.02f) * scale, worldZ);
   result.pole.scale(0.03f * scale, 0.30f * scale, 0.03f * scale);
   result.pole.scale(0.03f * scale, 0.30f * scale, 0.03f * scale);
 
 
-  result.pennant.setToIdentity();
+  result.pennant = kIdentityMatrix;
   result.pennant.translate(worldX + 0.10f * scale, (0.25f + 0.02f) * scale,
   result.pennant.translate(worldX + 0.10f * scale, (0.25f + 0.02f) * scale,
                            worldZ);
                            worldZ);
   result.pennant.scale(0.18f * scale, 0.12f * scale, 0.02f * scale);
   result.pennant.scale(0.18f * scale, 0.12f * scale, 0.02f * scale);
 
 
-  result.finial.setToIdentity();
+  result.finial = kIdentityMatrix;
   result.finial.translate(worldX, (0.32f + 0.02f) * scale, worldZ);
   result.finial.translate(worldX, (0.32f + 0.02f) * scale, worldZ);
   result.finial.scale(0.05f * scale, 0.05f * scale, 0.05f * scale);
   result.finial.scale(0.05f * scale, 0.05f * scale, 0.05f * scale);
 
 

+ 92 - 14
render/geom/transforms.cpp

@@ -4,29 +4,50 @@
 
 
 namespace Render::Geom {
 namespace Render::Geom {
 
 
+namespace {
+const QVector3D kYAxis(0, 1, 0);
+const float kRadToDeg = 57.2957795131f;
+const float kEpsilon = 1e-6f;
+const float kEpsilonSq = kEpsilon * kEpsilon;
+} // namespace
+
 QMatrix4x4 cylinderBetween(const QVector3D &a, const QVector3D &b,
 QMatrix4x4 cylinderBetween(const QVector3D &a, const QVector3D &b,
                            float radius) {
                            float radius) {
-  QVector3D mid = (a + b) * 0.5f;
-  QVector3D dir = b - a;
-  float len = dir.length();
+
+  const float dx = b.x() - a.x();
+  const float dy = b.y() - a.y();
+  const float dz = b.z() - a.z();
+  const float lenSq = dx * dx + dy * dy + dz * dz;
 
 
   QMatrix4x4 M;
   QMatrix4x4 M;
-  M.translate(mid);
 
 
-  if (len > 1e-6f) {
-    QVector3D yAxis(0, 1, 0);
-    QVector3D d = dir / len;
-    float dot = std::clamp(QVector3D::dotProduct(yAxis, d), -1.0f, 1.0f);
-    float angleDeg = std::acos(dot) * 57.2957795131f;
-    QVector3D axis = QVector3D::crossProduct(yAxis, d);
-    if (axis.lengthSquared() < 1e-6f) {
+  M.translate((a.x() + b.x()) * 0.5f, (a.y() + b.y()) * 0.5f,
+              (a.z() + b.z()) * 0.5f);
+
+  if (lenSq > kEpsilonSq) {
+    const float len = std::sqrt(lenSq);
+
+    const float invLen = 1.0f / len;
+    const float ndx = dx * invLen;
+    const float ndy = dy * invLen;
+    const float ndz = dz * invLen;
+
+    const float dot = std::clamp(ndy, -1.0f, 1.0f);
+    const float angleDeg = std::acos(dot) * kRadToDeg;
+
+    const float axisX = ndz;
+    const float axisZ = -ndx;
+    const float axisLenSq = axisX * axisX + axisZ * axisZ;
+
+    if (axisLenSq < kEpsilonSq) {
 
 
       if (dot < 0.0f) {
       if (dot < 0.0f) {
         M.rotate(180.0f, 1.0f, 0.0f, 0.0f);
         M.rotate(180.0f, 1.0f, 0.0f, 0.0f);
       }
       }
     } else {
     } else {
-      axis.normalize();
-      M.rotate(angleDeg, axis);
+
+      const float axisInvLen = 1.0f / std::sqrt(axisLenSq);
+      M.rotate(angleDeg, axisX * axisInvLen, 0.0f, axisZ * axisInvLen);
     }
     }
     M.scale(radius, len, radius);
     M.scale(radius, len, radius);
   } else {
   } else {
@@ -42,10 +63,67 @@ QMatrix4x4 sphereAt(const QVector3D &pos, float radius) {
   return M;
   return M;
 }
 }
 
 
+QMatrix4x4 sphereAt(const QMatrix4x4 &parent, const QVector3D &pos,
+                    float radius) {
+  QMatrix4x4 M = parent;
+  M.translate(pos);
+  M.scale(radius, radius, radius);
+  return M;
+}
+
+QMatrix4x4 cylinderBetween(const QMatrix4x4 &parent, const QVector3D &a,
+                           const QVector3D &b, float radius) {
+
+  const float dx = b.x() - a.x();
+  const float dy = b.y() - a.y();
+  const float dz = b.z() - a.z();
+  const float lenSq = dx * dx + dy * dy + dz * dz;
+
+  QMatrix4x4 M = parent;
+
+  M.translate((a.x() + b.x()) * 0.5f, (a.y() + b.y()) * 0.5f,
+              (a.z() + b.z()) * 0.5f);
+
+  if (lenSq > kEpsilonSq) {
+    const float len = std::sqrt(lenSq);
+
+    const float invLen = 1.0f / len;
+    const float ndx = dx * invLen;
+    const float ndy = dy * invLen;
+    const float ndz = dz * invLen;
+
+    const float dot = std::clamp(ndy, -1.0f, 1.0f);
+    const float angleDeg = std::acos(dot) * kRadToDeg;
+
+    const float axisX = ndz;
+    const float axisZ = -ndx;
+    const float axisLenSq = axisX * axisX + axisZ * axisZ;
+
+    if (axisLenSq < kEpsilonSq) {
+
+      if (dot < 0.0f) {
+        M.rotate(180.0f, 1.0f, 0.0f, 0.0f);
+      }
+    } else {
+
+      const float axisInvLen = 1.0f / std::sqrt(axisLenSq);
+      M.rotate(angleDeg, axisX * axisInvLen, 0.0f, axisZ * axisInvLen);
+    }
+    M.scale(radius, len, radius);
+  } else {
+    M.scale(radius, 1.0f, radius);
+  }
+  return M;
+}
+
 QMatrix4x4 coneFromTo(const QVector3D &baseCenter, const QVector3D &apex,
 QMatrix4x4 coneFromTo(const QVector3D &baseCenter, const QVector3D &apex,
                       float baseRadius) {
                       float baseRadius) {
-
   return cylinderBetween(baseCenter, apex, baseRadius);
   return cylinderBetween(baseCenter, apex, baseRadius);
 }
 }
 
 
+QMatrix4x4 coneFromTo(const QMatrix4x4 &parent, const QVector3D &baseCenter,
+                      const QVector3D &apex, float baseRadius) {
+  return cylinderBetween(parent, baseCenter, apex, baseRadius);
+}
+
 } // namespace Render::Geom
 } // namespace Render::Geom

+ 7 - 0
render/geom/transforms.h

@@ -8,9 +8,16 @@ namespace Render::Geom {
 QMatrix4x4 cylinderBetween(const QVector3D &a, const QVector3D &b,
 QMatrix4x4 cylinderBetween(const QVector3D &a, const QVector3D &b,
                            float radius);
                            float radius);
 
 
+QMatrix4x4 cylinderBetween(const QMatrix4x4 &parent, const QVector3D &a,
+                           const QVector3D &b, float radius);
+
 QMatrix4x4 sphereAt(const QVector3D &pos, float radius);
 QMatrix4x4 sphereAt(const QVector3D &pos, float radius);
+QMatrix4x4 sphereAt(const QMatrix4x4 &parent, const QVector3D &pos,
+                    float radius);
 
 
 QMatrix4x4 coneFromTo(const QVector3D &baseCenter, const QVector3D &apex,
 QMatrix4x4 coneFromTo(const QVector3D &baseCenter, const QVector3D &apex,
                       float baseRadius);
                       float baseRadius);
+QMatrix4x4 coneFromTo(const QMatrix4x4 &parent, const QVector3D &baseCenter,
+                      const QVector3D &apex, float baseRadius);
 
 
 } // namespace Render::Geom
 } // namespace Render::Geom

+ 763 - 60
render/gl/backend.cpp

@@ -2,14 +2,29 @@
 #include "../draw_queue.h"
 #include "../draw_queue.h"
 #include "../geom/selection_disc.h"
 #include "../geom/selection_disc.h"
 #include "../geom/selection_ring.h"
 #include "../geom/selection_ring.h"
+#include "buffer.h"
 #include "mesh.h"
 #include "mesh.h"
+#include "primitives.h"
 #include "shader.h"
 #include "shader.h"
 #include "state_scopes.h"
 #include "state_scopes.h"
 #include "texture.h"
 #include "texture.h"
 #include <QDebug>
 #include <QDebug>
+#include <algorithm>
+#include <cstddef>
+#include <memory>
 
 
 namespace Render::GL {
 namespace Render::GL {
-Backend::~Backend() = default;
+
+namespace {
+
+const QVector3D kGridLineColor(0.22f, 0.25f, 0.22f);
+}
+
+Backend::~Backend() {
+  shutdownCylinderPipeline();
+  shutdownFogPipeline();
+  shutdownGrassPipeline();
+}
 
 
 void Backend::initialize() {
 void Backend::initialize() {
   initializeOpenGLFunctions();
   initializeOpenGLFunctions();
@@ -30,10 +45,32 @@ void Backend::initialize() {
   m_shaderCache->initializeDefaults();
   m_shaderCache->initializeDefaults();
   m_basicShader = m_shaderCache->get(QStringLiteral("basic"));
   m_basicShader = m_shaderCache->get(QStringLiteral("basic"));
   m_gridShader = m_shaderCache->get(QStringLiteral("grid"));
   m_gridShader = m_shaderCache->get(QStringLiteral("grid"));
+  m_cylinderShader = m_shaderCache->get(QStringLiteral("cylinder_instanced"));
+  m_fogShader = m_shaderCache->get(QStringLiteral("fog_instanced"));
+  m_grassShader = m_shaderCache->get(QStringLiteral("grass_instanced"));
+  m_terrainShader = m_shaderCache->get(QStringLiteral("terrain_chunk"));
   if (!m_basicShader)
   if (!m_basicShader)
     qWarning() << "Backend: basic shader missing";
     qWarning() << "Backend: basic shader missing";
   if (!m_gridShader)
   if (!m_gridShader)
     qWarning() << "Backend: grid shader missing";
     qWarning() << "Backend: grid shader missing";
+  if (!m_cylinderShader)
+    qWarning() << "Backend: cylinder shader missing";
+  if (!m_fogShader)
+    qWarning() << "Backend: fog shader missing";
+  if (!m_grassShader)
+    qWarning() << "Backend: grass shader missing";
+  if (!m_terrainShader)
+    qWarning() << "Backend: terrain shader missing";
+
+  cacheBasicUniforms();
+  cacheGridUniforms();
+  cacheCylinderUniforms();
+  cacheFogUniforms();
+  cacheGrassUniforms();
+  cacheTerrainUniforms();
+  initializeCylinderPipeline();
+  initializeFogPipeline();
+  initializeGrassPipeline();
 }
 }
 
 
 void Backend::beginFrame() {
 void Backend::beginFrame() {
@@ -67,70 +104,305 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
   if (!m_basicShader)
   if (!m_basicShader)
     return;
     return;
 
 
-  m_basicShader->use();
-  m_basicShader->setUniform("u_view", cam.getViewMatrix());
-  m_basicShader->setUniform("u_projection", cam.getProjectionMatrix());
-  for (const auto &cmd : queue.items()) {
-    if (std::holds_alternative<MeshCmd>(cmd)) {
-      const auto &it = std::get<MeshCmd>(cmd);
+  const QMatrix4x4 viewProj = cam.getProjectionMatrix() * cam.getViewMatrix();
+
+  m_lastBoundShader = nullptr;
+  m_lastBoundTexture = nullptr;
+
+  const std::size_t count = queue.size();
+  std::size_t i = 0;
+  while (i < count) {
+    const auto &cmd = queue.getSorted(i);
+    switch (cmd.index()) {
+    case CylinderCmdIndex: {
+      m_cylinderScratch.clear();
+      do {
+        const auto &cy = std::get<CylinderCmdIndex>(queue.getSorted(i));
+        CylinderInstanceGpu gpu{};
+        gpu.start = cy.start;
+        gpu.end = cy.end;
+        gpu.radius = cy.radius;
+        gpu.alpha = cy.alpha;
+        gpu.color = cy.color;
+        m_cylinderScratch.emplace_back(gpu);
+        ++i;
+      } while (i < count && queue.getSorted(i).index() == CylinderCmdIndex);
+
+      const std::size_t instanceCount = m_cylinderScratch.size();
+      if (instanceCount > 0 && m_cylinderShader && m_cylinderVao) {
+        glDepthMask(GL_TRUE);
+        if (glIsEnabled(GL_POLYGON_OFFSET_FILL))
+          glDisable(GL_POLYGON_OFFSET_FILL);
+        if (m_lastBoundShader != m_cylinderShader) {
+          m_cylinderShader->use();
+          m_lastBoundShader = m_cylinderShader;
+          m_lastBoundTexture = nullptr;
+        }
+        if (m_cylinderUniforms.viewProj != Shader::InvalidUniform) {
+          m_cylinderShader->setUniform(m_cylinderUniforms.viewProj, viewProj);
+        }
+        uploadCylinderInstances(instanceCount);
+        drawCylinders(instanceCount);
+      }
+      continue;
+    }
+    case FogBatchCmdIndex: {
+      const auto &batch = std::get<FogBatchCmdIndex>(cmd);
+      const FogInstanceData *instances = batch.instances;
+      const std::size_t instanceCount = batch.count;
+      if (instances && instanceCount > 0 && m_fogShader && m_fogVao) {
+        m_fogScratch.resize(instanceCount);
+        for (std::size_t idx = 0; idx < instanceCount; ++idx) {
+          FogInstanceGpu gpu{};
+          gpu.center = instances[idx].center;
+          gpu.size = instances[idx].size;
+          gpu.color = instances[idx].color;
+          gpu.alpha = instances[idx].alpha;
+          m_fogScratch[idx] = gpu;
+        }
+        glDepthMask(GL_TRUE);
+        if (glIsEnabled(GL_POLYGON_OFFSET_FILL))
+          glDisable(GL_POLYGON_OFFSET_FILL);
+        if (m_lastBoundShader != m_fogShader) {
+          m_fogShader->use();
+          m_lastBoundShader = m_fogShader;
+          m_lastBoundTexture = nullptr;
+        }
+        if (m_fogUniforms.viewProj != Shader::InvalidUniform) {
+          m_fogShader->setUniform(m_fogUniforms.viewProj, viewProj);
+        }
+        uploadFogInstances(instanceCount);
+        drawFog(instanceCount);
+      }
+      ++i;
+      continue;
+    }
+    case GrassBatchCmdIndex: {
+      const auto &grass = std::get<GrassBatchCmdIndex>(cmd);
+      if (!grass.instanceBuffer || grass.instanceCount == 0 || !m_grassShader ||
+          !m_grassVao || m_grassVertexCount == 0)
+        break;
+
+      DepthMaskScope depthMask(false);
+      BlendScope blend(true);
+      glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+      GLboolean prevCull = glIsEnabled(GL_CULL_FACE);
+      if (prevCull)
+        glDisable(GL_CULL_FACE);
+
+      if (m_lastBoundShader != m_grassShader) {
+        m_grassShader->use();
+        m_lastBoundShader = m_grassShader;
+        m_lastBoundTexture = nullptr;
+      }
+
+      if (m_grassUniforms.viewProj != Shader::InvalidUniform) {
+        m_grassShader->setUniform(m_grassUniforms.viewProj, viewProj);
+      }
+      if (m_grassUniforms.time != Shader::InvalidUniform) {
+        m_grassShader->setUniform(m_grassUniforms.time, grass.params.time);
+      }
+      if (m_grassUniforms.windStrength != Shader::InvalidUniform) {
+        m_grassShader->setUniform(m_grassUniforms.windStrength,
+                                  grass.params.windStrength);
+      }
+      if (m_grassUniforms.windSpeed != Shader::InvalidUniform) {
+        m_grassShader->setUniform(m_grassUniforms.windSpeed,
+                                  grass.params.windSpeed);
+      }
+      if (m_grassUniforms.soilColor != Shader::InvalidUniform) {
+        m_grassShader->setUniform(m_grassUniforms.soilColor,
+                                  grass.params.soilColor);
+      }
+      if (m_grassUniforms.lightDir != Shader::InvalidUniform) {
+        QVector3D lightDir = grass.params.lightDirection;
+        if (!lightDir.isNull())
+          lightDir.normalize();
+        m_grassShader->setUniform(m_grassUniforms.lightDir, lightDir);
+      }
+
+      glBindVertexArray(m_grassVao);
+      grass.instanceBuffer->bind();
+      const GLsizei stride = static_cast<GLsizei>(sizeof(GrassInstanceGpu));
+      glVertexAttribPointer(
+          2, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(GrassInstanceGpu, posHeight)));
+      glVertexAttribPointer(
+          3, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(GrassInstanceGpu, colorWidth)));
+      glVertexAttribPointer(
+          4, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(GrassInstanceGpu, swayParams)));
+      grass.instanceBuffer->unbind();
+
+      glDrawArraysInstanced(GL_TRIANGLES, 0, m_grassVertexCount,
+                            static_cast<GLsizei>(grass.instanceCount));
+      glBindVertexArray(0);
+
+      if (prevCull)
+        glEnable(GL_CULL_FACE);
+
+      break;
+    }
+    case TerrainChunkCmdIndex: {
+      const auto &terrain = std::get<TerrainChunkCmdIndex>(cmd);
+      if (!terrain.mesh || !m_terrainShader)
+        break;
+
+      if (m_lastBoundShader != m_terrainShader) {
+        m_terrainShader->use();
+        m_lastBoundShader = m_terrainShader;
+        m_lastBoundTexture = nullptr;
+      }
+
+      const QMatrix4x4 mvp = viewProj * terrain.model;
+      if (m_terrainUniforms.mvp != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.mvp, mvp);
+      if (m_terrainUniforms.model != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.model, terrain.model);
+      if (m_terrainUniforms.grassPrimary != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.grassPrimary,
+                                    terrain.params.grassPrimary);
+      if (m_terrainUniforms.grassSecondary != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.grassSecondary,
+                                    terrain.params.grassSecondary);
+      if (m_terrainUniforms.grassDry != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.grassDry,
+                                    terrain.params.grassDry);
+      if (m_terrainUniforms.soilColor != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.soilColor,
+                                    terrain.params.soilColor);
+      if (m_terrainUniforms.rockLow != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.rockLow,
+                                    terrain.params.rockLow);
+      if (m_terrainUniforms.rockHigh != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.rockHigh,
+                                    terrain.params.rockHigh);
+      if (m_terrainUniforms.tint != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.tint,
+                                    terrain.params.tint);
+      if (m_terrainUniforms.noiseOffset != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.noiseOffset,
+                                    terrain.params.noiseOffset);
+      if (m_terrainUniforms.tileSize != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.tileSize,
+                                    terrain.params.tileSize);
+      if (m_terrainUniforms.macroNoiseScale != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.macroNoiseScale,
+                                    terrain.params.macroNoiseScale);
+      if (m_terrainUniforms.detailNoiseScale != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.detailNoiseScale,
+                                    terrain.params.detailNoiseScale);
+      if (m_terrainUniforms.slopeRockThreshold != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.slopeRockThreshold,
+                                    terrain.params.slopeRockThreshold);
+      if (m_terrainUniforms.slopeRockSharpness != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.slopeRockSharpness,
+                                    terrain.params.slopeRockSharpness);
+      if (m_terrainUniforms.soilBlendHeight != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.soilBlendHeight,
+                                    terrain.params.soilBlendHeight);
+      if (m_terrainUniforms.soilBlendSharpness != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.soilBlendSharpness,
+                                    terrain.params.soilBlendSharpness);
+      if (m_terrainUniforms.heightNoiseStrength != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.heightNoiseStrength,
+                                    terrain.params.heightNoiseStrength);
+      if (m_terrainUniforms.heightNoiseFrequency != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.heightNoiseFrequency,
+                                    terrain.params.heightNoiseFrequency);
+      if (m_terrainUniforms.ambientBoost != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.ambientBoost,
+                                    terrain.params.ambientBoost);
+      if (m_terrainUniforms.rockDetailStrength != Shader::InvalidUniform)
+        m_terrainShader->setUniform(m_terrainUniforms.rockDetailStrength,
+                                    terrain.params.rockDetailStrength);
+      if (m_terrainUniforms.lightDir != Shader::InvalidUniform) {
+        QVector3D lightDir = terrain.params.lightDirection;
+        if (!lightDir.isNull())
+          lightDir.normalize();
+        m_terrainShader->setUniform(m_terrainUniforms.lightDir, lightDir);
+      }
+
+      DepthMaskScope depthMask(terrain.depthWrite);
+      std::unique_ptr<PolygonOffsetScope> polyScope;
+      if (terrain.depthBias != 0.0f)
+        polyScope = std::make_unique<PolygonOffsetScope>(terrain.depthBias,
+                                                         terrain.depthBias);
+
+      terrain.mesh->draw();
+      break;
+    }
+    case MeshCmdIndex: {
+      const auto &it = std::get<MeshCmdIndex>(cmd);
       if (!it.mesh)
       if (!it.mesh)
-        continue;
+        break;
 
 
       glDepthMask(GL_TRUE);
       glDepthMask(GL_TRUE);
       if (glIsEnabled(GL_POLYGON_OFFSET_FILL))
       if (glIsEnabled(GL_POLYGON_OFFSET_FILL))
         glDisable(GL_POLYGON_OFFSET_FILL);
         glDisable(GL_POLYGON_OFFSET_FILL);
 
 
-      m_basicShader->use();
-      m_basicShader->setUniform("u_view", cam.getViewMatrix());
-      m_basicShader->setUniform("u_projection", cam.getProjectionMatrix());
-      m_basicShader->setUniform("u_model", it.model);
-      if (it.texture) {
-        it.texture->bind(0);
-        m_basicShader->setUniform("u_texture", 0);
-        m_basicShader->setUniform("u_useTexture", true);
-      } else {
-        if (m_resources && m_resources->white()) {
-          m_resources->white()->bind(0);
-          m_basicShader->setUniform("u_texture", 0);
-        }
-        m_basicShader->setUniform("u_useTexture", false);
+      if (m_lastBoundShader != m_basicShader) {
+        m_basicShader->use();
+        m_lastBoundShader = m_basicShader;
       }
       }
-      m_basicShader->setUniform("u_color", it.color);
-      m_basicShader->setUniform("u_alpha", it.alpha);
+
+      m_basicShader->setUniform(m_basicUniforms.mvp, it.mvp);
+      m_basicShader->setUniform(m_basicUniforms.model, it.model);
+
+      Texture *texToUse = it.texture
+                              ? it.texture
+                              : (m_resources ? m_resources->white() : nullptr);
+      if (texToUse && texToUse != m_lastBoundTexture) {
+        texToUse->bind(0);
+        m_lastBoundTexture = texToUse;
+        m_basicShader->setUniform(m_basicUniforms.texture, 0);
+      }
+
+      m_basicShader->setUniform(m_basicUniforms.useTexture,
+                                it.texture != nullptr);
+      m_basicShader->setUniform(m_basicUniforms.color, it.color);
+      m_basicShader->setUniform(m_basicUniforms.alpha, it.alpha);
       it.mesh->draw();
       it.mesh->draw();
-    } else if (std::holds_alternative<GridCmd>(cmd)) {
+      break;
+    }
+    case GridCmdIndex: {
       if (!m_gridShader)
       if (!m_gridShader)
-        continue;
-      const auto &gc = std::get<GridCmd>(cmd);
-      m_gridShader->use();
-      m_gridShader->setUniform("u_view", cam.getViewMatrix());
-      m_gridShader->setUniform("u_projection", cam.getProjectionMatrix());
+        break;
+      const auto &gc = std::get<GridCmdIndex>(cmd);
 
 
-      QMatrix4x4 model = gc.model;
+      if (m_lastBoundShader != m_gridShader) {
+        m_gridShader->use();
+        m_lastBoundShader = m_gridShader;
+      }
 
 
-      m_gridShader->setUniform("u_model", model);
-      m_gridShader->setUniform("u_gridColor", gc.color);
-      m_gridShader->setUniform("u_lineColor", QVector3D(0.22f, 0.25f, 0.22f));
-      m_gridShader->setUniform("u_cellSize", gc.cellSize);
-      m_gridShader->setUniform("u_thickness", gc.thickness);
+      m_gridShader->setUniform(m_gridUniforms.mvp, gc.mvp);
+      m_gridShader->setUniform(m_gridUniforms.model, gc.model);
+      m_gridShader->setUniform(m_gridUniforms.gridColor, gc.color);
+      m_gridShader->setUniform(m_gridUniforms.lineColor, kGridLineColor);
+      m_gridShader->setUniform(m_gridUniforms.cellSize, gc.cellSize);
+      m_gridShader->setUniform(m_gridUniforms.thickness, gc.thickness);
 
 
       if (m_resources) {
       if (m_resources) {
         if (auto *plane = m_resources->ground())
         if (auto *plane = m_resources->ground())
           plane->draw();
           plane->draw();
       }
       }
-
-    } else if (std::holds_alternative<SelectionRingCmd>(cmd)) {
-      const auto &sc = std::get<SelectionRingCmd>(cmd);
+      break;
+    }
+    case SelectionRingCmdIndex: {
+      const auto &sc = std::get<SelectionRingCmdIndex>(cmd);
       Mesh *ring = Render::Geom::SelectionRing::get();
       Mesh *ring = Render::Geom::SelectionRing::get();
       if (!ring)
       if (!ring)
-        continue;
+        break;
 
 
-      m_basicShader->use();
-      m_basicShader->setUniform("u_view", cam.getViewMatrix());
-      m_basicShader->setUniform("u_projection", cam.getProjectionMatrix());
+      if (m_lastBoundShader != m_basicShader) {
+        m_basicShader->use();
+        m_lastBoundShader = m_basicShader;
+      }
 
 
-      m_basicShader->setUniform("u_useTexture", false);
-      m_basicShader->setUniform("u_color", sc.color);
+      m_basicShader->use();
+      m_basicShader->setUniform(m_basicUniforms.useTexture, false);
+      m_basicShader->setUniform(m_basicUniforms.color, sc.color);
 
 
       DepthMaskScope depthMask(false);
       DepthMaskScope depthMask(false);
       PolygonOffsetScope poly(-1.0f, -1.0f);
       PolygonOffsetScope poly(-1.0f, -1.0f);
@@ -139,26 +411,34 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
       {
       {
         QMatrix4x4 m = sc.model;
         QMatrix4x4 m = sc.model;
         m.scale(1.08f, 1.0f, 1.08f);
         m.scale(1.08f, 1.0f, 1.08f);
-        m_basicShader->setUniform("u_model", m);
-        m_basicShader->setUniform("u_alpha", sc.alphaOuter);
+        const QMatrix4x4 mvp = viewProj * m;
+        m_basicShader->setUniform(m_basicUniforms.mvp, mvp);
+        m_basicShader->setUniform(m_basicUniforms.model, m);
+        m_basicShader->setUniform(m_basicUniforms.alpha, sc.alphaOuter);
         ring->draw();
         ring->draw();
       }
       }
 
 
       {
       {
-        m_basicShader->setUniform("u_model", sc.model);
-        m_basicShader->setUniform("u_alpha", sc.alphaInner);
+        const QMatrix4x4 mvp = viewProj * sc.model;
+        m_basicShader->setUniform(m_basicUniforms.mvp, mvp);
+        m_basicShader->setUniform(m_basicUniforms.model, sc.model);
+        m_basicShader->setUniform(m_basicUniforms.alpha, sc.alphaInner);
         ring->draw();
         ring->draw();
       }
       }
-    } else if (std::holds_alternative<SelectionSmokeCmd>(cmd)) {
-      const auto &sm = std::get<SelectionSmokeCmd>(cmd);
+      break;
+    }
+    case SelectionSmokeCmdIndex: {
+      const auto &sm = std::get<SelectionSmokeCmdIndex>(cmd);
       Mesh *disc = Render::Geom::SelectionDisc::get();
       Mesh *disc = Render::Geom::SelectionDisc::get();
       if (!disc)
       if (!disc)
-        continue;
-      m_basicShader->use();
-      m_basicShader->setUniform("u_view", cam.getViewMatrix());
-      m_basicShader->setUniform("u_projection", cam.getProjectionMatrix());
-      m_basicShader->setUniform("u_useTexture", false);
-      m_basicShader->setUniform("u_color", sm.color);
+        break;
+
+      if (m_lastBoundShader != m_basicShader) {
+        m_basicShader->use();
+        m_lastBoundShader = m_basicShader;
+      }
+      m_basicShader->setUniform(m_basicUniforms.useTexture, false);
+      m_basicShader->setUniform(m_basicUniforms.color, sm.color);
       DepthMaskScope depthMask(false);
       DepthMaskScope depthMask(false);
       DepthTestScope depthTest(false);
       DepthTestScope depthTest(false);
 
 
@@ -170,13 +450,436 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
         QMatrix4x4 m = sm.model;
         QMatrix4x4 m = sm.model;
         m.translate(0.0f, 0.02f, 0.0f);
         m.translate(0.0f, 0.02f, 0.0f);
         m.scale(scale, 1.0f, scale);
         m.scale(scale, 1.0f, scale);
-        m_basicShader->setUniform("u_model", m);
-        m_basicShader->setUniform("u_alpha", a);
+        const QMatrix4x4 mvp = viewProj * m;
+        m_basicShader->setUniform(m_basicUniforms.mvp, mvp);
+        m_basicShader->setUniform(m_basicUniforms.model, m);
+        m_basicShader->setUniform(m_basicUniforms.alpha, a);
         disc->draw();
         disc->draw();
       }
       }
+      break;
+    }
+    default:
+      break;
     }
     }
+    ++i;
+  }
+  if (m_lastBoundShader) {
+    m_lastBoundShader->release();
+    m_lastBoundShader = nullptr;
+  }
+}
+
+void Backend::cacheBasicUniforms() {
+  if (!m_basicShader)
+    return;
+
+  m_basicUniforms.mvp = m_basicShader->uniformHandle("u_mvp");
+  m_basicUniforms.model = m_basicShader->uniformHandle("u_model");
+  m_basicUniforms.texture = m_basicShader->uniformHandle("u_texture");
+  m_basicUniforms.useTexture = m_basicShader->uniformHandle("u_useTexture");
+  m_basicUniforms.color = m_basicShader->uniformHandle("u_color");
+  m_basicUniforms.alpha = m_basicShader->uniformHandle("u_alpha");
+}
+
+void Backend::cacheGridUniforms() {
+  if (!m_gridShader)
+    return;
+
+  m_gridUniforms.mvp = m_gridShader->uniformHandle("u_mvp");
+  m_gridUniforms.model = m_gridShader->uniformHandle("u_model");
+  m_gridUniforms.gridColor = m_gridShader->uniformHandle("u_gridColor");
+  m_gridUniforms.lineColor = m_gridShader->uniformHandle("u_lineColor");
+  m_gridUniforms.cellSize = m_gridShader->uniformHandle("u_cellSize");
+  m_gridUniforms.thickness = m_gridShader->uniformHandle("u_thickness");
+}
+
+void Backend::cacheCylinderUniforms() {
+  if (!m_cylinderShader)
+    return;
+
+  m_cylinderUniforms.viewProj = m_cylinderShader->uniformHandle("u_viewProj");
+}
+
+void Backend::cacheFogUniforms() {
+  if (!m_fogShader)
+    return;
+
+  m_fogUniforms.viewProj = m_fogShader->uniformHandle("u_viewProj");
+}
+
+void Backend::initializeCylinderPipeline() {
+  initializeOpenGLFunctions();
+  shutdownCylinderPipeline();
+
+  Mesh *unit = getUnitCylinder();
+  if (!unit)
+    return;
+
+  const auto &vertices = unit->getVertices();
+  const auto &indices = unit->getIndices();
+  if (vertices.empty() || indices.empty())
+    return;
+
+  glGenVertexArrays(1, &m_cylinderVao);
+  glBindVertexArray(m_cylinderVao);
+
+  glGenBuffers(1, &m_cylinderVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_cylinderVertexBuffer);
+  glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex),
+               vertices.data(), GL_STATIC_DRAW);
+
+  glGenBuffers(1, &m_cylinderIndexBuffer);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_cylinderIndexBuffer);
+  glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
+               indices.data(), GL_STATIC_DRAW);
+  m_cylinderIndexCount = static_cast<GLsizei>(indices.size());
+
+  glEnableVertexAttribArray(0);
+  glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, position)));
+  glEnableVertexAttribArray(1);
+  glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, normal)));
+  glEnableVertexAttribArray(2);
+  glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, texCoord)));
+
+  glGenBuffers(1, &m_cylinderInstanceBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_cylinderInstanceBuffer);
+  m_cylinderInstanceCapacity = 256;
+  glBufferData(GL_ARRAY_BUFFER,
+               m_cylinderInstanceCapacity * sizeof(CylinderInstanceGpu),
+               nullptr, GL_DYNAMIC_DRAW);
+
+  const GLsizei stride = static_cast<GLsizei>(sizeof(CylinderInstanceGpu));
+  glEnableVertexAttribArray(3);
+  glVertexAttribPointer(
+      3, 3, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(CylinderInstanceGpu, start)));
+  glVertexAttribDivisor(3, 1);
+
+  glEnableVertexAttribArray(4);
+  glVertexAttribPointer(
+      4, 3, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(CylinderInstanceGpu, end)));
+  glVertexAttribDivisor(4, 1);
+
+  glEnableVertexAttribArray(5);
+  glVertexAttribPointer(
+      5, 1, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(CylinderInstanceGpu, radius)));
+  glVertexAttribDivisor(5, 1);
+
+  glEnableVertexAttribArray(6);
+  glVertexAttribPointer(
+      6, 1, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(CylinderInstanceGpu, alpha)));
+  glVertexAttribDivisor(6, 1);
+
+  glEnableVertexAttribArray(7);
+  glVertexAttribPointer(
+      7, 3, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(CylinderInstanceGpu, color)));
+  glVertexAttribDivisor(7, 1);
+
+  glBindVertexArray(0);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
+
+  m_cylinderScratch.reserve(m_cylinderInstanceCapacity);
+}
+
+void Backend::shutdownCylinderPipeline() {
+  initializeOpenGLFunctions();
+  if (m_cylinderInstanceBuffer) {
+    glDeleteBuffers(1, &m_cylinderInstanceBuffer);
+    m_cylinderInstanceBuffer = 0;
+  }
+  if (m_cylinderVertexBuffer) {
+    glDeleteBuffers(1, &m_cylinderVertexBuffer);
+    m_cylinderVertexBuffer = 0;
+  }
+  if (m_cylinderIndexBuffer) {
+    glDeleteBuffers(1, &m_cylinderIndexBuffer);
+    m_cylinderIndexBuffer = 0;
+  }
+  if (m_cylinderVao) {
+    glDeleteVertexArrays(1, &m_cylinderVao);
+    m_cylinderVao = 0;
+  }
+  m_cylinderIndexCount = 0;
+  m_cylinderInstanceCapacity = 0;
+  m_cylinderScratch.clear();
+}
+
+void Backend::uploadCylinderInstances(std::size_t count) {
+  if (!m_cylinderInstanceBuffer || count == 0)
+    return;
+
+  initializeOpenGLFunctions();
+  glBindBuffer(GL_ARRAY_BUFFER, m_cylinderInstanceBuffer);
+  if (count > m_cylinderInstanceCapacity) {
+    m_cylinderInstanceCapacity = std::max<std::size_t>(
+        count,
+        m_cylinderInstanceCapacity ? m_cylinderInstanceCapacity * 2 : count);
+    glBufferData(GL_ARRAY_BUFFER,
+                 m_cylinderInstanceCapacity * sizeof(CylinderInstanceGpu),
+                 nullptr, GL_DYNAMIC_DRAW);
+    m_cylinderScratch.reserve(m_cylinderInstanceCapacity);
+  }
+  glBufferSubData(GL_ARRAY_BUFFER, 0, count * sizeof(CylinderInstanceGpu),
+                  m_cylinderScratch.data());
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+}
+
+void Backend::drawCylinders(std::size_t count) {
+  if (!m_cylinderVao || m_cylinderIndexCount == 0 || count == 0)
+    return;
+
+  initializeOpenGLFunctions();
+  glBindVertexArray(m_cylinderVao);
+  glDrawElementsInstanced(GL_TRIANGLES, m_cylinderIndexCount, GL_UNSIGNED_INT,
+                          nullptr, static_cast<GLsizei>(count));
+  glBindVertexArray(0);
+}
+
+void Backend::initializeFogPipeline() {
+  initializeOpenGLFunctions();
+  shutdownFogPipeline();
+
+  const Vertex vertices[4] = {
+      {{-0.5f, 0.0f, -0.5f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}},
+      {{0.5f, 0.0f, -0.5f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
+      {{-0.5f, 0.0f, 0.5f}, {0.0f, 1.0f, 0.0f}, {0.0f, 1.0f}},
+      {{0.5f, 0.0f, 0.5f}, {0.0f, 1.0f, 0.0f}, {1.0f, 1.0f}},
+  };
+
+  const unsigned int indices[6] = {0, 1, 2, 2, 1, 3};
+
+  glGenVertexArrays(1, &m_fogVao);
+  glBindVertexArray(m_fogVao);
+
+  glGenBuffers(1, &m_fogVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_fogVertexBuffer);
+  glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
+
+  glGenBuffers(1, &m_fogIndexBuffer);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_fogIndexBuffer);
+  glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices,
+               GL_STATIC_DRAW);
+  m_fogIndexCount = 6;
+
+  glEnableVertexAttribArray(0);
+  glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, position)));
+  glEnableVertexAttribArray(1);
+  glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, normal)));
+  glEnableVertexAttribArray(2);
+  glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, texCoord)));
+
+  glGenBuffers(1, &m_fogInstanceBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_fogInstanceBuffer);
+  m_fogInstanceCapacity = 512;
+  glBufferData(GL_ARRAY_BUFFER, m_fogInstanceCapacity * sizeof(FogInstanceGpu),
+               nullptr, GL_DYNAMIC_DRAW);
+
+  const GLsizei stride = static_cast<GLsizei>(sizeof(FogInstanceGpu));
+  glEnableVertexAttribArray(3);
+  glVertexAttribPointer(
+      3, 3, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(FogInstanceGpu, center)));
+  glVertexAttribDivisor(3, 1);
+
+  glEnableVertexAttribArray(4);
+  glVertexAttribPointer(
+      4, 1, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(FogInstanceGpu, size)));
+  glVertexAttribDivisor(4, 1);
+
+  glEnableVertexAttribArray(5);
+  glVertexAttribPointer(
+      5, 3, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(FogInstanceGpu, color)));
+  glVertexAttribDivisor(5, 1);
+
+  glEnableVertexAttribArray(6);
+  glVertexAttribPointer(
+      6, 1, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(FogInstanceGpu, alpha)));
+  glVertexAttribDivisor(6, 1);
+
+  glBindVertexArray(0);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
+
+  m_fogScratch.reserve(m_fogInstanceCapacity);
+}
+
+void Backend::shutdownFogPipeline() {
+  initializeOpenGLFunctions();
+  if (m_fogInstanceBuffer) {
+    glDeleteBuffers(1, &m_fogInstanceBuffer);
+    m_fogInstanceBuffer = 0;
+  }
+  if (m_fogVertexBuffer) {
+    glDeleteBuffers(1, &m_fogVertexBuffer);
+    m_fogVertexBuffer = 0;
+  }
+  if (m_fogIndexBuffer) {
+    glDeleteBuffers(1, &m_fogIndexBuffer);
+    m_fogIndexBuffer = 0;
+  }
+  if (m_fogVao) {
+    glDeleteVertexArrays(1, &m_fogVao);
+    m_fogVao = 0;
+  }
+  m_fogIndexCount = 0;
+  m_fogInstanceCapacity = 0;
+  m_fogScratch.clear();
+}
+
+void Backend::uploadFogInstances(std::size_t count) {
+  if (!m_fogInstanceBuffer || count == 0)
+    return;
+
+  initializeOpenGLFunctions();
+  glBindBuffer(GL_ARRAY_BUFFER, m_fogInstanceBuffer);
+  if (count > m_fogInstanceCapacity) {
+    m_fogInstanceCapacity = std::max<std::size_t>(
+        count, m_fogInstanceCapacity ? m_fogInstanceCapacity * 2 : count);
+    glBufferData(GL_ARRAY_BUFFER,
+                 m_fogInstanceCapacity * sizeof(FogInstanceGpu), nullptr,
+                 GL_DYNAMIC_DRAW);
+    m_fogScratch.reserve(m_fogInstanceCapacity);
+  }
+  glBufferSubData(GL_ARRAY_BUFFER, 0, count * sizeof(FogInstanceGpu),
+                  m_fogScratch.data());
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+}
+
+void Backend::drawFog(std::size_t count) {
+  if (!m_fogVao || m_fogIndexCount == 0 || count == 0)
+    return;
+
+  initializeOpenGLFunctions();
+  glBindVertexArray(m_fogVao);
+  glDrawElementsInstanced(GL_TRIANGLES, m_fogIndexCount, GL_UNSIGNED_INT,
+                          nullptr, static_cast<GLsizei>(count));
+  glBindVertexArray(0);
+}
+
+void Backend::cacheGrassUniforms() {
+  if (!m_grassShader)
+    return;
+
+  m_grassUniforms.viewProj = m_grassShader->uniformHandle("u_viewProj");
+  m_grassUniforms.time = m_grassShader->uniformHandle("u_time");
+  m_grassUniforms.windStrength = m_grassShader->uniformHandle("u_windStrength");
+  m_grassUniforms.windSpeed = m_grassShader->uniformHandle("u_windSpeed");
+  m_grassUniforms.soilColor = m_grassShader->uniformHandle("u_soilColor");
+  m_grassUniforms.lightDir = m_grassShader->uniformHandle("u_lightDir");
+}
+
+void Backend::cacheTerrainUniforms() {
+  if (!m_terrainShader)
+    return;
+
+  m_terrainUniforms.mvp = m_terrainShader->uniformHandle("u_mvp");
+  m_terrainUniforms.model = m_terrainShader->uniformHandle("u_model");
+  m_terrainUniforms.grassPrimary =
+      m_terrainShader->uniformHandle("u_grassPrimary");
+  m_terrainUniforms.grassSecondary =
+      m_terrainShader->uniformHandle("u_grassSecondary");
+  m_terrainUniforms.grassDry = m_terrainShader->uniformHandle("u_grassDry");
+  m_terrainUniforms.soilColor = m_terrainShader->uniformHandle("u_soilColor");
+  m_terrainUniforms.rockLow = m_terrainShader->uniformHandle("u_rockLow");
+  m_terrainUniforms.rockHigh = m_terrainShader->uniformHandle("u_rockHigh");
+  m_terrainUniforms.tint = m_terrainShader->uniformHandle("u_tint");
+  m_terrainUniforms.noiseOffset =
+      m_terrainShader->uniformHandle("u_noiseOffset");
+  m_terrainUniforms.tileSize = m_terrainShader->uniformHandle("u_tileSize");
+  m_terrainUniforms.macroNoiseScale =
+      m_terrainShader->uniformHandle("u_macroNoiseScale");
+  m_terrainUniforms.detailNoiseScale =
+      m_terrainShader->uniformHandle("u_detailNoiseScale");
+  m_terrainUniforms.slopeRockThreshold =
+      m_terrainShader->uniformHandle("u_slopeRockThreshold");
+  m_terrainUniforms.slopeRockSharpness =
+      m_terrainShader->uniformHandle("u_slopeRockSharpness");
+  m_terrainUniforms.soilBlendHeight =
+      m_terrainShader->uniformHandle("u_soilBlendHeight");
+  m_terrainUniforms.soilBlendSharpness =
+      m_terrainShader->uniformHandle("u_soilBlendSharpness");
+  m_terrainUniforms.heightNoiseStrength =
+      m_terrainShader->uniformHandle("u_heightNoiseStrength");
+  m_terrainUniforms.heightNoiseFrequency =
+      m_terrainShader->uniformHandle("u_heightNoiseFrequency");
+  m_terrainUniforms.ambientBoost =
+      m_terrainShader->uniformHandle("u_ambientBoost");
+  m_terrainUniforms.rockDetailStrength =
+      m_terrainShader->uniformHandle("u_rockDetailStrength");
+  m_terrainUniforms.lightDir = m_terrainShader->uniformHandle("u_lightDir");
+}
+
+void Backend::initializeGrassPipeline() {
+  initializeOpenGLFunctions();
+  shutdownGrassPipeline();
+
+  struct GrassVertex {
+    QVector3D position;
+    QVector2D uv;
+  };
+
+  const GrassVertex bladeVertices[6] = {
+      {{-0.5f, 0.0f, 0.0f}, {0.0f, 0.0f}},
+      {{0.5f, 0.0f, 0.0f}, {1.0f, 0.0f}},
+      {{-0.35f, 1.0f, 0.0f}, {0.1f, 1.0f}},
+      {{-0.35f, 1.0f, 0.0f}, {0.1f, 1.0f}},
+      {{0.5f, 0.0f, 0.0f}, {1.0f, 0.0f}},
+      {{0.35f, 1.0f, 0.0f}, {0.9f, 1.0f}},
+  };
+
+  glGenVertexArrays(1, &m_grassVao);
+  glBindVertexArray(m_grassVao);
+
+  glGenBuffers(1, &m_grassVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_grassVertexBuffer);
+  glBufferData(GL_ARRAY_BUFFER, sizeof(bladeVertices), bladeVertices,
+               GL_STATIC_DRAW);
+  m_grassVertexCount = 6;
+
+  glEnableVertexAttribArray(0);
+  glVertexAttribPointer(
+      0, 3, GL_FLOAT, GL_FALSE, sizeof(GrassVertex),
+      reinterpret_cast<void *>(offsetof(GrassVertex, position)));
+  glEnableVertexAttribArray(1);
+  glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(GrassVertex),
+                        reinterpret_cast<void *>(offsetof(GrassVertex, uv)));
+
+  glEnableVertexAttribArray(2);
+  glVertexAttribDivisor(2, 1);
+  glEnableVertexAttribArray(3);
+  glVertexAttribDivisor(3, 1);
+  glEnableVertexAttribArray(4);
+  glVertexAttribDivisor(4, 1);
+
+  glBindVertexArray(0);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+}
+
+void Backend::shutdownGrassPipeline() {
+  initializeOpenGLFunctions();
+  if (m_grassVertexBuffer) {
+    glDeleteBuffers(1, &m_grassVertexBuffer);
+    m_grassVertexBuffer = 0;
+  }
+  if (m_grassVao) {
+    glDeleteVertexArrays(1, &m_grassVao);
+    m_grassVao = 0;
   }
   }
-  m_basicShader->release();
+  m_grassVertexCount = 0;
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 127 - 0
render/gl/backend.h

@@ -1,13 +1,18 @@
 #pragma once
 #pragma once
 
 
 #include "../draw_queue.h"
 #include "../draw_queue.h"
+#include "../ground/grass_gpu.h"
+#include "../ground/terrain_gpu.h"
 #include "camera.h"
 #include "camera.h"
 #include "resources.h"
 #include "resources.h"
 #include "shader.h"
 #include "shader.h"
 #include "shader_cache.h"
 #include "shader_cache.h"
 #include <QOpenGLFunctions_3_3_Core>
 #include <QOpenGLFunctions_3_3_Core>
+#include <QVector2D>
+#include <QVector3D>
 #include <array>
 #include <array>
 #include <memory>
 #include <memory>
+#include <vector>
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
@@ -69,6 +74,128 @@ private:
 
 
   Shader *m_basicShader = nullptr;
   Shader *m_basicShader = nullptr;
   Shader *m_gridShader = nullptr;
   Shader *m_gridShader = nullptr;
+  Shader *m_cylinderShader = nullptr;
+  Shader *m_fogShader = nullptr;
+  Shader *m_grassShader = nullptr;
+  Shader *m_terrainShader = nullptr;
+
+  struct BasicUniforms {
+    Shader::UniformHandle mvp{Shader::InvalidUniform};
+    Shader::UniformHandle model{Shader::InvalidUniform};
+    Shader::UniformHandle texture{Shader::InvalidUniform};
+    Shader::UniformHandle useTexture{Shader::InvalidUniform};
+    Shader::UniformHandle color{Shader::InvalidUniform};
+    Shader::UniformHandle alpha{Shader::InvalidUniform};
+  } m_basicUniforms;
+
+  struct GridUniforms {
+    Shader::UniformHandle mvp{Shader::InvalidUniform};
+    Shader::UniformHandle model{Shader::InvalidUniform};
+    Shader::UniformHandle gridColor{Shader::InvalidUniform};
+    Shader::UniformHandle lineColor{Shader::InvalidUniform};
+    Shader::UniformHandle cellSize{Shader::InvalidUniform};
+    Shader::UniformHandle thickness{Shader::InvalidUniform};
+  } m_gridUniforms;
+
+  struct CylinderUniforms {
+    Shader::UniformHandle viewProj{Shader::InvalidUniform};
+  } m_cylinderUniforms;
+
+  struct FogUniforms {
+    Shader::UniformHandle viewProj{Shader::InvalidUniform};
+  } m_fogUniforms;
+
+  struct GrassUniforms {
+    Shader::UniformHandle viewProj{Shader::InvalidUniform};
+    Shader::UniformHandle time{Shader::InvalidUniform};
+    Shader::UniformHandle windStrength{Shader::InvalidUniform};
+    Shader::UniformHandle windSpeed{Shader::InvalidUniform};
+    Shader::UniformHandle soilColor{Shader::InvalidUniform};
+    Shader::UniformHandle lightDir{Shader::InvalidUniform};
+  } m_grassUniforms;
+
+  struct TerrainUniforms {
+    Shader::UniformHandle mvp{Shader::InvalidUniform};
+    Shader::UniformHandle model{Shader::InvalidUniform};
+    Shader::UniformHandle grassPrimary{Shader::InvalidUniform};
+    Shader::UniformHandle grassSecondary{Shader::InvalidUniform};
+    Shader::UniformHandle grassDry{Shader::InvalidUniform};
+    Shader::UniformHandle soilColor{Shader::InvalidUniform};
+    Shader::UniformHandle rockLow{Shader::InvalidUniform};
+    Shader::UniformHandle rockHigh{Shader::InvalidUniform};
+    Shader::UniformHandle tint{Shader::InvalidUniform};
+    Shader::UniformHandle noiseOffset{Shader::InvalidUniform};
+    Shader::UniformHandle tileSize{Shader::InvalidUniform};
+    Shader::UniformHandle macroNoiseScale{Shader::InvalidUniform};
+    Shader::UniformHandle detailNoiseScale{Shader::InvalidUniform};
+    Shader::UniformHandle slopeRockThreshold{Shader::InvalidUniform};
+    Shader::UniformHandle slopeRockSharpness{Shader::InvalidUniform};
+    Shader::UniformHandle soilBlendHeight{Shader::InvalidUniform};
+    Shader::UniformHandle soilBlendSharpness{Shader::InvalidUniform};
+    Shader::UniformHandle heightNoiseStrength{Shader::InvalidUniform};
+    Shader::UniformHandle heightNoiseFrequency{Shader::InvalidUniform};
+    Shader::UniformHandle ambientBoost{Shader::InvalidUniform};
+    Shader::UniformHandle rockDetailStrength{Shader::InvalidUniform};
+    Shader::UniformHandle lightDir{Shader::InvalidUniform};
+  } m_terrainUniforms;
+
+  struct CylinderInstanceGpu {
+    QVector3D start;
+    float radius{0.0f};
+    QVector3D end;
+    float alpha{1.0f};
+    QVector3D color;
+    float padding{0.0f};
+  };
+
+  GLuint m_cylinderVao = 0;
+  GLuint m_cylinderVertexBuffer = 0;
+  GLuint m_cylinderIndexBuffer = 0;
+  GLuint m_cylinderInstanceBuffer = 0;
+  GLsizei m_cylinderIndexCount = 0;
+  std::size_t m_cylinderInstanceCapacity = 0;
+  std::vector<CylinderInstanceGpu> m_cylinderScratch;
+
+  struct FogInstanceGpu {
+    QVector3D center;
+    float size{1.0f};
+    QVector3D color;
+    float alpha{1.0f};
+  };
+
+  GLuint m_fogVao = 0;
+  GLuint m_fogVertexBuffer = 0;
+  GLuint m_fogIndexBuffer = 0;
+  GLuint m_fogInstanceBuffer = 0;
+  GLsizei m_fogIndexCount = 0;
+  std::size_t m_fogInstanceCapacity = 0;
+  std::vector<FogInstanceGpu> m_fogScratch;
+
+  GLuint m_grassVao = 0;
+  GLuint m_grassVertexBuffer = 0;
+  GLsizei m_grassVertexCount = 0;
+
+  void cacheBasicUniforms();
+  void cacheGridUniforms();
+  void cacheCylinderUniforms();
+  void initializeCylinderPipeline();
+  void shutdownCylinderPipeline();
+  void uploadCylinderInstances(std::size_t count);
+  void drawCylinders(std::size_t count);
+  void cacheFogUniforms();
+  void initializeFogPipeline();
+  void shutdownFogPipeline();
+  void uploadFogInstances(std::size_t count);
+  void drawFog(std::size_t count);
+  void cacheGrassUniforms();
+  void initializeGrassPipeline();
+  void shutdownGrassPipeline();
+  void cacheTerrainUniforms();
+
+  Shader *m_lastBoundShader = nullptr;
+  Texture *m_lastBoundTexture = nullptr;
+  bool m_depthTestEnabled = true;
+  bool m_blendEnabled = false;
 };
 };
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 176 - 135
render/gl/camera.cpp

@@ -1,93 +1,93 @@
 #include "camera.h"
 #include "camera.h"
 #include "../../game/map/visibility_service.h"
 #include "../../game/map/visibility_service.h"
 #include <QtMath>
 #include <QtMath>
-#include <cmath>
 #include <algorithm>
 #include <algorithm>
+#include <cmath>
 #include <limits>
 #include <limits>
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
-// -------- internal helpers, do not change public API --------
 namespace {
 namespace {
-constexpr float kEps      = 1e-6f;
-constexpr float kTiny     = 1e-4f;
-constexpr float kMinDist  = 1.0f;    // zoomDistance floor
-constexpr float kMaxDist  = 500.0f;  // zoomDistance ceiling
-constexpr float kMinFov   = 1.0f;    // perspective clamp (deg)
-constexpr float kMaxFov   = 89.0f;
-
-inline bool finite(const QVector3D& v) {
+constexpr float kEps = 1e-6f;
+constexpr float kTiny = 1e-4f;
+constexpr float kMinDist = 1.0f;
+constexpr float kMaxDist = 500.0f;
+constexpr float kMinFov = 1.0f;
+constexpr float kMaxFov = 89.0f;
+
+inline bool finite(const QVector3D &v) {
   return qIsFinite(v.x()) && qIsFinite(v.y()) && qIsFinite(v.z());
   return qIsFinite(v.x()) && qIsFinite(v.y()) && qIsFinite(v.z());
 }
 }
 inline bool finite(float v) { return qIsFinite(v); }
 inline bool finite(float v) { return qIsFinite(v); }
 
 
-inline QVector3D safeNormalize(const QVector3D& v,
-                               const QVector3D& fallback,
+inline QVector3D safeNormalize(const QVector3D &v, const QVector3D &fallback,
                                float eps = kEps) {
                                float eps = kEps) {
-  if (!finite(v)) return fallback;
+  if (!finite(v))
+    return fallback;
   float len2 = v.lengthSquared();
   float len2 = v.lengthSquared();
-  if (len2 < eps) return fallback;
+  if (len2 < eps)
+    return fallback;
   return v / std::sqrt(len2);
   return v / std::sqrt(len2);
 }
 }
 
 
-// Keep up-vector not collinear with front; pick a sane orthonormal basis.
-inline void orthonormalize(const QVector3D& frontIn,
-                           QVector3D& frontOut,
-                           QVector3D& rightOut,
-                           QVector3D& upOut) {
+inline void orthonormalize(const QVector3D &frontIn, QVector3D &frontOut,
+                           QVector3D &rightOut, QVector3D &upOut) {
   QVector3D worldUp(0.f, 1.f, 0.f);
   QVector3D worldUp(0.f, 1.f, 0.f);
   QVector3D f = safeNormalize(frontIn, QVector3D(0, 0, -1));
   QVector3D f = safeNormalize(frontIn, QVector3D(0, 0, -1));
-  // If front is near-collinear with worldUp, choose another temp up.
+
   QVector3D u = (std::abs(QVector3D::dotProduct(f, worldUp)) > 1.f - 1e-3f)
   QVector3D u = (std::abs(QVector3D::dotProduct(f, worldUp)) > 1.f - 1e-3f)
-                  ? QVector3D(0, 0, 1)
-                  : worldUp;
+                    ? QVector3D(0, 0, 1)
+                    : worldUp;
   QVector3D r = QVector3D::crossProduct(f, u);
   QVector3D r = QVector3D::crossProduct(f, u);
-  if (r.lengthSquared() < kEps) r = QVector3D(1, 0, 0);
+  if (r.lengthSquared() < kEps)
+    r = QVector3D(1, 0, 0);
   r = r.normalized();
   r = r.normalized();
   u = QVector3D::crossProduct(r, f).normalized();
   u = QVector3D::crossProduct(r, f).normalized();
 
 
   frontOut = f;
   frontOut = f;
   rightOut = r;
   rightOut = r;
-  upOut    = u;
+  upOut = u;
 }
 }
 
 
-inline void clampOrthoBox(float& left, float& right,
-                          float& bottom, float& top) {
+inline void clampOrthoBox(float &left, float &right, float &bottom,
+                          float &top) {
   if (left == right) {
   if (left == right) {
-    left -= 0.5f; right += 0.5f;
+    left -= 0.5f;
+    right += 0.5f;
   } else if (left > right) {
   } else if (left > right) {
     std::swap(left, right);
     std::swap(left, right);
   }
   }
   if (bottom == top) {
   if (bottom == top) {
-    bottom -= 0.5f; top += 0.5f;
+    bottom -= 0.5f;
+    top += 0.5f;
   } else if (bottom > top) {
   } else if (bottom > top) {
     std::swap(bottom, top);
     std::swap(bottom, top);
   }
   }
 }
 }
-} // anonymous namespace
-// ------------------------------------------------------------
+} // namespace
 
 
 Camera::Camera() { updateVectors(); }
 Camera::Camera() { updateVectors(); }
 
 
 void Camera::setPosition(const QVector3D &position) {
 void Camera::setPosition(const QVector3D &position) {
-  if (!finite(position)) return; // ignore invalid input
+  if (!finite(position))
+    return;
   m_position = position;
   m_position = position;
   clampAboveGround();
   clampAboveGround();
-  // keep looking at current target; repair front/up basis if needed
+
   QVector3D newFront = (m_target - m_position);
   QVector3D newFront = (m_target - m_position);
   orthonormalize(newFront, m_front, m_right, m_up);
   orthonormalize(newFront, m_front, m_right, m_up);
 }
 }
 
 
 void Camera::setTarget(const QVector3D &target) {
 void Camera::setTarget(const QVector3D &target) {
-  if (!finite(target)) return;
+  if (!finite(target))
+    return;
   m_target = target;
   m_target = target;
 
 
   QVector3D dir = (m_target - m_position);
   QVector3D dir = (m_target - m_position);
   if (dir.lengthSquared() < kEps) {
   if (dir.lengthSquared() < kEps) {
-    // Nudge target forward to avoid zero-length front
-    m_target = m_position + (m_front.lengthSquared() < kEps
-                              ? QVector3D(0, 0, -1)
-                              : m_front);
+
+    m_target = m_position +
+               (m_front.lengthSquared() < kEps ? QVector3D(0, 0, -1) : m_front);
     dir = (m_target - m_position);
     dir = (m_target - m_position);
   }
   }
   orthonormalize(dir, m_front, m_right, m_up);
   orthonormalize(dir, m_front, m_right, m_up);
@@ -95,20 +95,21 @@ void Camera::setTarget(const QVector3D &target) {
 }
 }
 
 
 void Camera::setUp(const QVector3D &up) {
 void Camera::setUp(const QVector3D &up) {
-  if (!finite(up)) return;
+  if (!finite(up))
+    return;
   QVector3D upN = up;
   QVector3D upN = up;
-  if (upN.lengthSquared() < kEps) upN = QVector3D(0, 1, 0);
-  // Ensure up is not collinear with front; re-orthonormalize
+  if (upN.lengthSquared() < kEps)
+    upN = QVector3D(0, 1, 0);
+
   orthonormalize(m_target - m_position, m_front, m_right, m_up);
   orthonormalize(m_target - m_position, m_front, m_right, m_up);
 }
 }
 
 
 void Camera::lookAt(const QVector3D &position, const QVector3D &target,
 void Camera::lookAt(const QVector3D &position, const QVector3D &target,
                     const QVector3D &up) {
                     const QVector3D &up) {
-  if (!finite(position) || !finite(target) || !finite(up)) return;
+  if (!finite(position) || !finite(target) || !finite(up))
+    return;
   m_position = position;
   m_position = position;
-  m_target   = (position == target)
-                 ? position + QVector3D(0, 0, -1)
-                 : target;
+  m_target = (position == target) ? position + QVector3D(0, 0, -1) : target;
 
 
   QVector3D f = (m_target - m_position);
   QVector3D f = (m_target - m_position);
   m_up = up.lengthSquared() < kEps ? QVector3D(0, 1, 0) : up.normalized();
   m_up = up.lengthSquared() < kEps ? QVector3D(0, 1, 0) : up.normalized();
@@ -118,15 +119,16 @@ void Camera::lookAt(const QVector3D &position, const QVector3D &target,
 
 
 void Camera::setPerspective(float fov, float aspect, float nearPlane,
 void Camera::setPerspective(float fov, float aspect, float nearPlane,
                             float farPlane) {
                             float farPlane) {
-  if (!finite(fov) || !finite(aspect) || !finite(nearPlane) || !finite(farPlane))
+  if (!finite(fov) || !finite(aspect) || !finite(nearPlane) ||
+      !finite(farPlane))
     return;
     return;
 
 
   m_isPerspective = true;
   m_isPerspective = true;
-  // Robust clamps
-  m_fov       = std::clamp(fov, kMinFov, kMaxFov);
-  m_aspect    = std::max(aspect, 1e-6f);
+
+  m_fov = std::clamp(fov, kMinFov, kMaxFov);
+  m_aspect = std::max(aspect, 1e-6f);
   m_nearPlane = std::max(nearPlane, 1e-4f);
   m_nearPlane = std::max(nearPlane, 1e-4f);
-  m_farPlane  = std::max(farPlane, m_nearPlane + 1e-3f);
+  m_farPlane = std::max(farPlane, m_nearPlane + 1e-3f);
 }
 }
 
 
 void Camera::setOrthographic(float left, float right, float bottom, float top,
 void Camera::setOrthographic(float left, float right, float bottom, float top,
@@ -137,66 +139,75 @@ void Camera::setOrthographic(float left, float right, float bottom, float top,
 
 
   m_isPerspective = false;
   m_isPerspective = false;
   clampOrthoBox(left, right, bottom, top);
   clampOrthoBox(left, right, bottom, top);
-  m_orthoLeft   = left;
-  m_orthoRight  = right;
+  m_orthoLeft = left;
+  m_orthoRight = right;
   m_orthoBottom = bottom;
   m_orthoBottom = bottom;
-  m_orthoTop    = top;
-  m_nearPlane   = std::min(nearPlane, farPlane - 1e-3f);
-  m_farPlane    = std::max(farPlane, m_nearPlane + 1e-3f);
+  m_orthoTop = top;
+  m_nearPlane = std::min(nearPlane, farPlane - 1e-3f);
+  m_farPlane = std::max(farPlane, m_nearPlane + 1e-3f);
 }
 }
 
 
 void Camera::moveForward(float distance) {
 void Camera::moveForward(float distance) {
-  if (!finite(distance)) return;
+  if (!finite(distance))
+    return;
   m_position += m_front * distance;
   m_position += m_front * distance;
-  m_target    = m_position + m_front;
+  m_target = m_position + m_front;
   clampAboveGround();
   clampAboveGround();
 }
 }
 
 
 void Camera::moveRight(float distance) {
 void Camera::moveRight(float distance) {
-  if (!finite(distance)) return;
+  if (!finite(distance))
+    return;
   m_position += m_right * distance;
   m_position += m_right * distance;
-  m_target    = m_position + m_front;
+  m_target = m_position + m_front;
   clampAboveGround();
   clampAboveGround();
 }
 }
 
 
 void Camera::moveUp(float distance) {
 void Camera::moveUp(float distance) {
-  if (!finite(distance)) return;
+  if (!finite(distance))
+    return;
   m_position += QVector3D(0, 1, 0) * distance;
   m_position += QVector3D(0, 1, 0) * distance;
   clampAboveGround();
   clampAboveGround();
   m_target = m_position + m_front;
   m_target = m_position + m_front;
 }
 }
 
 
 void Camera::zoom(float delta) {
 void Camera::zoom(float delta) {
-  if (!finite(delta)) return;
+  if (!finite(delta))
+    return;
   if (m_isPerspective) {
   if (m_isPerspective) {
     m_fov = qBound(kMinFov, m_fov - delta, kMaxFov);
     m_fov = qBound(kMinFov, m_fov - delta, kMaxFov);
   } else {
   } else {
-    // Keep scale positive and bounded
+
     float scale = 1.0f + delta * 0.1f;
     float scale = 1.0f + delta * 0.1f;
-    if (!finite(scale) || scale <= 0.05f) scale = 0.05f;
-    if (scale > 20.0f) scale = 20.0f;
-    m_orthoLeft   *= scale;
-    m_orthoRight  *= scale;
+    if (!finite(scale) || scale <= 0.05f)
+      scale = 0.05f;
+    if (scale > 20.0f)
+      scale = 20.0f;
+    m_orthoLeft *= scale;
+    m_orthoRight *= scale;
     m_orthoBottom *= scale;
     m_orthoBottom *= scale;
-    m_orthoTop    *= scale;
+    m_orthoTop *= scale;
     clampOrthoBox(m_orthoLeft, m_orthoRight, m_orthoBottom, m_orthoTop);
     clampOrthoBox(m_orthoLeft, m_orthoRight, m_orthoBottom, m_orthoTop);
   }
   }
 }
 }
 
 
 void Camera::zoomDistance(float delta) {
 void Camera::zoomDistance(float delta) {
-  if (!finite(delta)) return;
+  if (!finite(delta))
+    return;
 
 
   QVector3D offset = m_position - m_target;
   QVector3D offset = m_position - m_target;
   float r = offset.length();
   float r = offset.length();
-  if (r < kTiny) r = kTiny;
+  if (r < kTiny)
+    r = kTiny;
 
 
   float factor = 1.0f - delta * 0.15f;
   float factor = 1.0f - delta * 0.15f;
-  if (!finite(factor)) factor = 1.0f;
+  if (!finite(factor))
+    factor = 1.0f;
   factor = std::clamp(factor, 0.1f, 10.0f);
   factor = std::clamp(factor, 0.1f, 10.0f);
 
 
   float newR = std::clamp(r * factor, kMinDist, kMaxDist);
   float newR = std::clamp(r * factor, kMinDist, kMaxDist);
 
 
-  QVector3D dir = safeNormalize(offset, QVector3D(0,0,1));
+  QVector3D dir = safeNormalize(offset, QVector3D(0, 0, 1));
   m_position = m_target + dir * newR;
   m_position = m_target + dir * newR;
   clampAboveGround();
   clampAboveGround();
   QVector3D f = (m_target - m_position);
   QVector3D f = (m_target - m_position);
@@ -206,72 +217,80 @@ void Camera::zoomDistance(float delta) {
 void Camera::rotate(float yaw, float pitch) { orbit(yaw, pitch); }
 void Camera::rotate(float yaw, float pitch) { orbit(yaw, pitch); }
 
 
 void Camera::pan(float rightDist, float forwardDist) {
 void Camera::pan(float rightDist, float forwardDist) {
-  if (!finite(rightDist) || !finite(forwardDist)) return;
+  if (!finite(rightDist) || !finite(forwardDist))
+    return;
 
 
   QVector3D right = m_right;
   QVector3D right = m_right;
   QVector3D front = m_front;
   QVector3D front = m_front;
   front.setY(0.0f);
   front.setY(0.0f);
-  if (front.lengthSquared() > 0) front.normalize();
+  if (front.lengthSquared() > 0)
+    front.normalize();
 
 
   QVector3D delta = right * rightDist + front * forwardDist;
   QVector3D delta = right * rightDist + front * forwardDist;
-  if (!finite(delta)) return;
+  if (!finite(delta))
+    return;
 
 
   m_position += delta;
   m_position += delta;
-  m_target   += delta;
+  m_target += delta;
   clampAboveGround();
   clampAboveGround();
 }
 }
 
 
 void Camera::elevate(float dy) {
 void Camera::elevate(float dy) {
-  if (!finite(dy)) return;
+  if (!finite(dy))
+    return;
   m_position.setY(m_position.y() + dy);
   m_position.setY(m_position.y() + dy);
   clampAboveGround();
   clampAboveGround();
 }
 }
 
 
 void Camera::yaw(float degrees) {
 void Camera::yaw(float degrees) {
-  if (!finite(degrees)) return;
-  // computeYawPitchFromOffset already guards degeneracy
+  if (!finite(degrees))
+    return;
+
   orbit(degrees, 0.0f);
   orbit(degrees, 0.0f);
 }
 }
 
 
 void Camera::orbit(float yawDeg, float pitchDeg) {
 void Camera::orbit(float yawDeg, float pitchDeg) {
-  if (!finite(yawDeg) || !finite(pitchDeg)) return;
+  if (!finite(yawDeg) || !finite(pitchDeg))
+    return;
 
 
   QVector3D offset = m_position - m_target;
   QVector3D offset = m_position - m_target;
   float curYaw = 0.f, curPitch = 0.f;
   float curYaw = 0.f, curPitch = 0.f;
   computeYawPitchFromOffset(offset, curYaw, curPitch);
   computeYawPitchFromOffset(offset, curYaw, curPitch);
 
 
-  m_orbitStartYaw   = curYaw;
+  m_orbitStartYaw = curYaw;
   m_orbitStartPitch = curPitch;
   m_orbitStartPitch = curPitch;
-  m_orbitTargetYaw  = curYaw + yawDeg;
-  m_orbitTargetPitch = qBound(m_pitchMinDeg, curPitch + pitchDeg, m_pitchMaxDeg);
-  m_orbitTime    = 0.0f;
+  m_orbitTargetYaw = curYaw + yawDeg;
+  m_orbitTargetPitch =
+      qBound(m_pitchMinDeg, curPitch + pitchDeg, m_pitchMaxDeg);
+  m_orbitTime = 0.0f;
   m_orbitPending = true;
   m_orbitPending = true;
 }
 }
 
 
 void Camera::update(float dt) {
 void Camera::update(float dt) {
-  if (!m_orbitPending) return;
-  if (!finite(dt)) return;
+  if (!m_orbitPending)
+    return;
+  if (!finite(dt))
+    return;
 
 
   m_orbitTime += std::max(0.0f, dt);
   m_orbitTime += std::max(0.0f, dt);
   float t = (m_orbitDuration <= 0.0f)
   float t = (m_orbitDuration <= 0.0f)
-              ? 1.0f
-              : std::clamp(m_orbitTime / m_orbitDuration, 0.0f, 1.0f);
+                ? 1.0f
+                : std::clamp(m_orbitTime / m_orbitDuration, 0.0f, 1.0f);
 
 
-  // Smoothstep
   float s = t * t * (3.0f - 2.0f * t);
   float s = t * t * (3.0f - 2.0f * t);
 
 
-  // Interpolate yaw/pitch
-  float newYaw   = m_orbitStartYaw   + (m_orbitTargetYaw   - m_orbitStartYaw)   * s;
-  float newPitch = m_orbitStartPitch + (m_orbitTargetPitch - m_orbitStartPitch) * s;
+  float newYaw = m_orbitStartYaw + (m_orbitTargetYaw - m_orbitStartYaw) * s;
+  float newPitch =
+      m_orbitStartPitch + (m_orbitTargetPitch - m_orbitStartPitch) * s;
 
 
   QVector3D offset = m_position - m_target;
   QVector3D offset = m_position - m_target;
   float r = offset.length();
   float r = offset.length();
-  if (r < kTiny) r = kTiny;
+  if (r < kTiny)
+    r = kTiny;
 
 
-  float yawRad   = qDegreesToRadians(newYaw);
+  float yawRad = qDegreesToRadians(newYaw);
   float pitchRad = qDegreesToRadians(newPitch);
   float pitchRad = qDegreesToRadians(newPitch);
-  QVector3D newDir(std::sin(yawRad) * std::cos(pitchRad),
-                   std::sin(pitchRad),
+  QVector3D newDir(std::sin(yawRad) * std::cos(pitchRad), std::sin(pitchRad),
                    std::cos(yawRad) * std::cos(pitchRad));
                    std::cos(yawRad) * std::cos(pitchRad));
 
 
   QVector3D fwd = safeNormalize(newDir, m_front);
   QVector3D fwd = safeNormalize(newDir, m_front);
@@ -286,32 +305,39 @@ void Camera::update(float dt) {
 
 
 bool Camera::screenToGround(float sx, float sy, float screenW, float screenH,
 bool Camera::screenToGround(float sx, float sy, float screenW, float screenH,
                             QVector3D &outWorld) const {
                             QVector3D &outWorld) const {
-  if (screenW <= 0 || screenH <= 0) return false;
-  if (!finite(sx) || !finite(sy))   return false;
+  if (screenW <= 0 || screenH <= 0)
+    return false;
+  if (!finite(sx) || !finite(sy))
+    return false;
 
 
   float x = (2.0f * sx / screenW) - 1.0f;
   float x = (2.0f * sx / screenW) - 1.0f;
   float y = 1.0f - (2.0f * sy / screenH);
   float y = 1.0f - (2.0f * sy / screenH);
 
 
   bool ok = false;
   bool ok = false;
   QMatrix4x4 invVP = (getProjectionMatrix() * getViewMatrix()).inverted(&ok);
   QMatrix4x4 invVP = (getProjectionMatrix() * getViewMatrix()).inverted(&ok);
-  if (!ok) return false;
+  if (!ok)
+    return false;
 
 
   QVector4D nearClip(x, y, 0.0f, 1.0f);
   QVector4D nearClip(x, y, 0.0f, 1.0f);
-  QVector4D farClip (x, y, 1.0f, 1.0f);
+  QVector4D farClip(x, y, 1.0f, 1.0f);
   QVector4D nearWorld4 = invVP * nearClip;
   QVector4D nearWorld4 = invVP * nearClip;
-  QVector4D farWorld4  = invVP * farClip;
+  QVector4D farWorld4 = invVP * farClip;
 
 
-  if (std::abs(nearWorld4.w()) < kEps || std::abs(farWorld4.w()) < kEps) return false;
+  if (std::abs(nearWorld4.w()) < kEps || std::abs(farWorld4.w()) < kEps)
+    return false;
 
 
   QVector3D rayOrigin = (nearWorld4 / nearWorld4.w()).toVector3D();
   QVector3D rayOrigin = (nearWorld4 / nearWorld4.w()).toVector3D();
-  QVector3D rayEnd    = (farWorld4  / farWorld4.w()).toVector3D();
-  if (!finite(rayOrigin) || !finite(rayEnd)) return false;
+  QVector3D rayEnd = (farWorld4 / farWorld4.w()).toVector3D();
+  if (!finite(rayOrigin) || !finite(rayEnd))
+    return false;
 
 
   QVector3D rayDir = safeNormalize(rayEnd - rayOrigin, QVector3D(0, -1, 0));
   QVector3D rayDir = safeNormalize(rayEnd - rayOrigin, QVector3D(0, -1, 0));
-  if (std::abs(rayDir.y()) < kEps) return false;
+  if (std::abs(rayDir.y()) < kEps)
+    return false;
 
 
   float t = (m_groundY - rayOrigin.y()) / rayDir.y();
   float t = (m_groundY - rayOrigin.y()) / rayDir.y();
-  if (!finite(t) || t < 0.0f) return false;
+  if (!finite(t) || t < 0.0f)
+    return false;
 
 
   outWorld = rayOrigin + rayDir * t;
   outWorld = rayOrigin + rayDir * t;
   return finite(outWorld);
   return finite(outWorld);
@@ -319,15 +345,21 @@ bool Camera::screenToGround(float sx, float sy, float screenW, float screenH,
 
 
 bool Camera::worldToScreen(const QVector3D &world, int screenW, int screenH,
 bool Camera::worldToScreen(const QVector3D &world, int screenW, int screenH,
                            QPointF &outScreen) const {
                            QPointF &outScreen) const {
-  if (screenW <= 0 || screenH <= 0) return false;
-  if (!finite(world)) return false;
+  if (screenW <= 0 || screenH <= 0)
+    return false;
+  if (!finite(world))
+    return false;
 
 
-  QVector4D clip = getProjectionMatrix() * getViewMatrix() * QVector4D(world, 1.0f);
-  if (std::abs(clip.w()) < kEps) return false;
+  QVector4D clip =
+      getProjectionMatrix() * getViewMatrix() * QVector4D(world, 1.0f);
+  if (std::abs(clip.w()) < kEps)
+    return false;
 
 
   QVector3D ndc = (clip / clip.w()).toVector3D();
   QVector3D ndc = (clip / clip.w()).toVector3D();
-  if (!qIsFinite(ndc.x()) || !qIsFinite(ndc.y()) || !qIsFinite(ndc.z())) return false;
-  if (ndc.z() < -1.0f || ndc.z() > 1.0f) return false;
+  if (!qIsFinite(ndc.x()) || !qIsFinite(ndc.y()) || !qIsFinite(ndc.z()))
+    return false;
+  if (ndc.z() < -1.0f || ndc.z() > 1.0f)
+    return false;
 
 
   float sx = (ndc.x() * 0.5f + 0.5f) * float(screenW);
   float sx = (ndc.x() * 0.5f + 0.5f) * float(screenW);
   float sy = (1.0f - (ndc.y() * 0.5f + 0.5f)) * float(screenH);
   float sy = (1.0f - (ndc.y() * 0.5f + 0.5f)) * float(screenH);
@@ -336,21 +368,25 @@ bool Camera::worldToScreen(const QVector3D &world, int screenW, int screenH,
 }
 }
 
 
 void Camera::updateFollow(const QVector3D &targetCenter) {
 void Camera::updateFollow(const QVector3D &targetCenter) {
-  if (!m_followEnabled) return;
-  if (!finite(targetCenter)) return;
+  if (!m_followEnabled)
+    return;
+  if (!finite(targetCenter))
+    return;
 
 
   if (m_followOffset.lengthSquared() < 1e-5f) {
   if (m_followOffset.lengthSquared() < 1e-5f) {
-    m_followOffset = m_position - m_target; // initialize lazily
+    m_followOffset = m_position - m_target;
   }
   }
   QVector3D desiredPos = targetCenter + m_followOffset;
   QVector3D desiredPos = targetCenter + m_followOffset;
   QVector3D newPos =
   QVector3D newPos =
       (m_followLerp >= 0.999f)
       (m_followLerp >= 0.999f)
           ? desiredPos
           ? desiredPos
-          : (m_position + (desiredPos - m_position) * std::clamp(m_followLerp, 0.0f, 1.0f));
+          : (m_position +
+             (desiredPos - m_position) * std::clamp(m_followLerp, 0.0f, 1.0f));
 
 
-  if (!finite(newPos)) return;
+  if (!finite(newPos))
+    return;
 
 
-  m_target   = targetCenter;
+  m_target = targetCenter;
   m_position = newPos;
   m_position = newPos;
   clampAboveGround();
   clampAboveGround();
   orthonormalize((m_target - m_position), m_front, m_right, m_up);
   orthonormalize((m_target - m_position), m_front, m_right, m_up);
@@ -358,16 +394,17 @@ void Camera::updateFollow(const QVector3D &targetCenter) {
 
 
 void Camera::setRTSView(const QVector3D &center, float distance, float angle,
 void Camera::setRTSView(const QVector3D &center, float distance, float angle,
                         float yawDeg) {
                         float yawDeg) {
-  if (!finite(center) || !finite(distance) || !finite(angle) || !finite(yawDeg)) return;
+  if (!finite(center) || !finite(distance) || !finite(angle) || !finite(yawDeg))
+    return;
 
 
   m_target = center;
   m_target = center;
 
 
   distance = std::max(distance, 0.01f);
   distance = std::max(distance, 0.01f);
   float pitchRad = qDegreesToRadians(angle);
   float pitchRad = qDegreesToRadians(angle);
-  float yawRad   = qDegreesToRadians(yawDeg);
+  float yawRad = qDegreesToRadians(yawDeg);
 
 
-  float y    = distance * qSin(pitchRad);
-  float horiz= distance * qCos(pitchRad);
+  float y = distance * qSin(pitchRad);
+  float horiz = distance * qCos(pitchRad);
 
 
   float x = std::sin(yawRad) * horiz;
   float x = std::sin(yawRad) * horiz;
   float z = std::cos(yawRad) * horiz;
   float z = std::cos(yawRad) * horiz;
@@ -380,12 +417,13 @@ void Camera::setRTSView(const QVector3D &center, float distance, float angle,
 }
 }
 
 
 void Camera::setTopDownView(const QVector3D &center, float distance) {
 void Camera::setTopDownView(const QVector3D &center, float distance) {
-  if (!finite(center) || !finite(distance)) return;
+  if (!finite(center) || !finite(distance))
+    return;
 
 
-  m_target   = center;
+  m_target = center;
   m_position = center + QVector3D(0, std::max(distance, 0.01f), 0);
   m_position = center + QVector3D(0, std::max(distance, 0.01f), 0);
-  m_up       = QVector3D(0, 0, -1);
-  m_front    = safeNormalize((m_target - m_position), QVector3D(0,0,1));
+  m_up = QVector3D(0, 0, -1);
+  m_front = safeNormalize((m_target - m_position), QVector3D(0, 0, 1));
   updateVectors();
   updateVectors();
   clampAboveGround();
   clampAboveGround();
 }
 }
@@ -399,11 +437,10 @@ QMatrix4x4 Camera::getViewMatrix() const {
 QMatrix4x4 Camera::getProjectionMatrix() const {
 QMatrix4x4 Camera::getProjectionMatrix() const {
   QMatrix4x4 projection;
   QMatrix4x4 projection;
   if (m_isPerspective) {
   if (m_isPerspective) {
-    // perspective() assumes sane inputs — we enforce those in setters
+
     projection.perspective(m_fov, m_aspect, m_nearPlane, m_farPlane);
     projection.perspective(m_fov, m_aspect, m_nearPlane, m_farPlane);
   } else {
   } else {
-    // get local copies because this method is const and clampOrthoBox
-    // expects non-const references
+
     float left = m_orthoLeft;
     float left = m_orthoLeft;
     float right = m_orthoRight;
     float right = m_orthoRight;
     float bottom = m_orthoBottom;
     float bottom = m_orthoBottom;
@@ -423,7 +460,8 @@ float Camera::getDistance() const { return (m_position - m_target).length(); }
 float Camera::getPitchDeg() const {
 float Camera::getPitchDeg() const {
   QVector3D off = m_position - m_target;
   QVector3D off = m_position - m_target;
   QVector3D dir = -off;
   QVector3D dir = -off;
-  if (dir.lengthSquared() < 1e-6f) return 0.0f;
+  if (dir.lengthSquared() < 1e-6f)
+    return 0.0f;
   float lenXZ = std::sqrt(dir.x() * dir.x() + dir.z() * dir.z());
   float lenXZ = std::sqrt(dir.x() * dir.x() + dir.z() * dir.z());
   float pitchRad = std::atan2(dir.y(), lenXZ);
   float pitchRad = std::atan2(dir.y(), lenXZ);
   return qRadiansToDegrees(pitchRad);
   return qRadiansToDegrees(pitchRad);
@@ -435,7 +473,8 @@ void Camera::updateVectors() {
 }
 }
 
 
 void Camera::clampAboveGround() {
 void Camera::clampAboveGround() {
-  if (!qIsFinite(m_position.y())) return;
+  if (!qIsFinite(m_position.y()))
+    return;
 
 
   if (m_position.y() < m_groundY + m_minHeight) {
   if (m_position.y() < m_groundY + m_minHeight) {
     m_position.setY(m_groundY + m_minHeight);
     m_position.setY(m_groundY + m_minHeight);
@@ -443,15 +482,15 @@ void Camera::clampAboveGround() {
 
 
   auto &vis = Game::Map::VisibilityService::instance();
   auto &vis = Game::Map::VisibilityService::instance();
   if (vis.isInitialized()) {
   if (vis.isInitialized()) {
-    const float tile  = vis.getTileSize();
-    const float halfW = vis.getWidth()  * 0.5f - 0.5f;
+    const float tile = vis.getTileSize();
+    const float halfW = vis.getWidth() * 0.5f - 0.5f;
     const float halfH = vis.getHeight() * 0.5f - 0.5f;
     const float halfH = vis.getHeight() * 0.5f - 0.5f;
 
 
     if (tile > 0.0f && halfW >= 0.0f && halfH >= 0.0f) {
     if (tile > 0.0f && halfW >= 0.0f && halfH >= 0.0f) {
       const float minX = -halfW * tile;
       const float minX = -halfW * tile;
-      const float maxX =  halfW * tile;
+      const float maxX = halfW * tile;
       const float minZ = -halfH * tile;
       const float minZ = -halfH * tile;
-      const float maxZ =  halfH * tile;
+      const float maxZ = halfH * tile;
 
 
       m_position.setX(std::clamp(m_position.x(), minX, maxX));
       m_position.setX(std::clamp(m_position.x(), minX, maxX));
       m_position.setZ(std::clamp(m_position.z(), minZ, maxZ));
       m_position.setZ(std::clamp(m_position.z(), minZ, maxZ));
@@ -466,9 +505,11 @@ void Camera::computeYawPitchFromOffset(const QVector3D &off, float &yawDeg,
                                        float &pitchDeg) const {
                                        float &pitchDeg) const {
   QVector3D dir = -off;
   QVector3D dir = -off;
   if (dir.lengthSquared() < 1e-6f) {
   if (dir.lengthSquared() < 1e-6f) {
-    yawDeg = 0.f; pitchDeg = 0.f; return;
+    yawDeg = 0.f;
+    pitchDeg = 0.f;
+    return;
   }
   }
-  float yaw   = qRadiansToDegrees(std::atan2(dir.x(), dir.z()));
+  float yaw = qRadiansToDegrees(std::atan2(dir.x(), dir.z()));
   float lenXZ = std::sqrt(dir.x() * dir.x() + dir.z() * dir.z());
   float lenXZ = std::sqrt(dir.x() * dir.x() + dir.z() * dir.z());
   float pitch = qRadiansToDegrees(std::atan2(dir.y(), lenXZ));
   float pitch = qRadiansToDegrees(std::atan2(dir.y(), lenXZ));
   yawDeg = yaw;
   yawDeg = yaw;

+ 94 - 16
render/gl/shader.cpp

@@ -1,4 +1,5 @@
 #include "shader.h"
 #include "shader.h"
+#include <QByteArray>
 #include <QDebug>
 #include <QDebug>
 #include <QFile>
 #include <QFile>
 #include <QTextStream>
 #include <QTextStream>
@@ -36,6 +37,7 @@ bool Shader::loadFromFiles(const QString &vertexPath,
 bool Shader::loadFromSource(const QString &vertexSource,
 bool Shader::loadFromSource(const QString &vertexSource,
                             const QString &fragmentSource) {
                             const QString &fragmentSource) {
   initializeOpenGLFunctions();
   initializeOpenGLFunctions();
+  m_uniformCache.clear();
   GLuint vertexShader = compileShader(vertexSource, GL_VERTEX_SHADER);
   GLuint vertexShader = compileShader(vertexSource, GL_VERTEX_SHADER);
   GLuint fragmentShader = compileShader(fragmentSource, GL_FRAGMENT_SHADER);
   GLuint fragmentShader = compileShader(fragmentSource, GL_FRAGMENT_SHADER);
 
 
@@ -61,34 +63,110 @@ void Shader::release() {
   glUseProgram(0);
   glUseProgram(0);
 }
 }
 
 
-void Shader::setUniform(const QString &name, float value) {
-  GLint location = glGetUniformLocation(m_program, name.toUtf8().constData());
-  if (location != -1) {
-    glUniform1f(location, value);
+Shader::UniformHandle Shader::uniformHandle(const char *name) {
+  if (!name || *name == '\0' || m_program == 0) {
+    return InvalidUniform;
+  }
+
+  auto it = m_uniformCache.find(name);
+  if (it != m_uniformCache.end()) {
+    return it->second;
   }
   }
+
+  initializeOpenGLFunctions();
+  UniformHandle location = glGetUniformLocation(m_program, name);
+  m_uniformCache.emplace(name, location);
+  return location;
 }
 }
 
 
-void Shader::setUniform(const QString &name, const QVector3D &value) {
-  GLint location = glGetUniformLocation(m_program, name.toUtf8().constData());
-  if (location != -1) {
-    glUniform3f(location, value.x(), value.y(), value.z());
+void Shader::setUniform(UniformHandle handle, float value) {
+  initializeOpenGLFunctions();
+  if (handle != InvalidUniform) {
+    glUniform1f(handle, value);
   }
   }
 }
 }
 
 
-void Shader::setUniform(const QString &name, const QMatrix4x4 &value) {
-  GLint location = glGetUniformLocation(m_program, name.toUtf8().constData());
-  if (location != -1) {
-    glUniformMatrix4fv(location, 1, GL_FALSE, value.constData());
+void Shader::setUniform(UniformHandle handle, const QVector3D &value) {
+  initializeOpenGLFunctions();
+  if (handle != InvalidUniform) {
+    glUniform3f(handle, value.x(), value.y(), value.z());
   }
   }
 }
 }
 
 
-void Shader::setUniform(const QString &name, int value) {
-  GLint location = glGetUniformLocation(m_program, name.toUtf8().constData());
-  if (location != -1) {
-    glUniform1i(location, value);
+void Shader::setUniform(UniformHandle handle, const QVector2D &value) {
+  initializeOpenGLFunctions();
+  if (handle != InvalidUniform) {
+    glUniform2f(handle, value.x(), value.y());
+  }
+}
+
+void Shader::setUniform(UniformHandle handle, const QMatrix4x4 &value) {
+  initializeOpenGLFunctions();
+  if (handle != InvalidUniform) {
+    glUniformMatrix4fv(handle, 1, GL_FALSE, value.constData());
+  }
+}
+
+void Shader::setUniform(UniformHandle handle, int value) {
+  initializeOpenGLFunctions();
+  if (handle != InvalidUniform) {
+    glUniform1i(handle, value);
   }
   }
 }
 }
 
 
+void Shader::setUniform(UniformHandle handle, bool value) {
+  setUniform(handle, static_cast<int>(value));
+}
+
+void Shader::setUniform(const char *name, float value) {
+  setUniform(uniformHandle(name), value);
+}
+
+void Shader::setUniform(const char *name, const QVector3D &value) {
+  setUniform(uniformHandle(name), value);
+}
+
+void Shader::setUniform(const char *name, const QVector2D &value) {
+  setUniform(uniformHandle(name), value);
+}
+
+void Shader::setUniform(const char *name, const QMatrix4x4 &value) {
+  setUniform(uniformHandle(name), value);
+}
+
+void Shader::setUniform(const char *name, int value) {
+  setUniform(uniformHandle(name), value);
+}
+
+void Shader::setUniform(const char *name, bool value) {
+  setUniform(uniformHandle(name), value);
+}
+
+void Shader::setUniform(const QString &name, float value) {
+  const QByteArray utf8 = name.toUtf8();
+  setUniform(utf8.constData(), value);
+}
+
+void Shader::setUniform(const QString &name, const QVector3D &value) {
+  const QByteArray utf8 = name.toUtf8();
+  setUniform(utf8.constData(), value);
+}
+
+void Shader::setUniform(const QString &name, const QVector2D &value) {
+  const QByteArray utf8 = name.toUtf8();
+  setUniform(utf8.constData(), value);
+}
+
+void Shader::setUniform(const QString &name, const QMatrix4x4 &value) {
+  const QByteArray utf8 = name.toUtf8();
+  setUniform(utf8.constData(), value);
+}
+
+void Shader::setUniform(const QString &name, int value) {
+  const QByteArray utf8 = name.toUtf8();
+  setUniform(utf8.constData(), value);
+}
+
 void Shader::setUniform(const QString &name, bool value) {
 void Shader::setUniform(const QString &name, bool value) {
   setUniform(name, static_cast<int>(value));
   setUniform(name, static_cast<int>(value));
 }
 }

+ 25 - 0
render/gl/shader.h

@@ -3,11 +3,17 @@
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QOpenGLFunctions_3_3_Core>
 #include <QOpenGLFunctions_3_3_Core>
 #include <QString>
 #include <QString>
+#include <QVector2D>
+#include <string>
+#include <unordered_map>
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
 class Shader : protected QOpenGLFunctions_3_3_Core {
 class Shader : protected QOpenGLFunctions_3_3_Core {
 public:
 public:
+  using UniformHandle = GLint;
+  static constexpr UniformHandle InvalidUniform = -1;
+
   Shader();
   Shader();
   ~Shader();
   ~Shader();
 
 
@@ -18,8 +24,25 @@ public:
   void use();
   void use();
   void release();
   void release();
 
 
+  UniformHandle uniformHandle(const char *name);
+
+  void setUniform(UniformHandle handle, float value);
+  void setUniform(UniformHandle handle, const QVector3D &value);
+  void setUniform(UniformHandle handle, const QVector2D &value);
+  void setUniform(UniformHandle handle, const QMatrix4x4 &value);
+  void setUniform(UniformHandle handle, int value);
+  void setUniform(UniformHandle handle, bool value);
+
+  void setUniform(const char *name, float value);
+  void setUniform(const char *name, const QVector3D &value);
+  void setUniform(const char *name, const QVector2D &value);
+  void setUniform(const char *name, const QMatrix4x4 &value);
+  void setUniform(const char *name, int value);
+  void setUniform(const char *name, bool value);
+
   void setUniform(const QString &name, float value);
   void setUniform(const QString &name, float value);
   void setUniform(const QString &name, const QVector3D &value);
   void setUniform(const QString &name, const QVector3D &value);
+  void setUniform(const QString &name, const QVector2D &value);
   void setUniform(const QString &name, const QMatrix4x4 &value);
   void setUniform(const QString &name, const QMatrix4x4 &value);
   void setUniform(const QString &name, int value);
   void setUniform(const QString &name, int value);
   void setUniform(const QString &name, bool value);
   void setUniform(const QString &name, bool value);
@@ -28,6 +51,8 @@ private:
   GLuint m_program = 0;
   GLuint m_program = 0;
   GLuint compileShader(const QString &source, GLenum type);
   GLuint compileShader(const QString &source, GLenum type);
   bool linkProgram(GLuint vertexShader, GLuint fragmentShader);
   bool linkProgram(GLuint vertexShader, GLuint fragmentShader);
+
+  std::unordered_map<std::string, UniformHandle> m_uniformCache;
 };
 };
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 19 - 0
render/gl/shader_cache.h

@@ -47,6 +47,25 @@ public:
     const QString gridFrag = kShaderBase + QStringLiteral("grid.frag");
     const QString gridFrag = kShaderBase + QStringLiteral("grid.frag");
     load(QStringLiteral("basic"), basicVert, basicFrag);
     load(QStringLiteral("basic"), basicVert, basicFrag);
     load(QStringLiteral("grid"), basicVert, gridFrag);
     load(QStringLiteral("grid"), basicVert, gridFrag);
+    const QString cylVert =
+        kShaderBase + QStringLiteral("cylinder_instanced.vert");
+    const QString cylFrag =
+        kShaderBase + QStringLiteral("cylinder_instanced.frag");
+    load(QStringLiteral("cylinder_instanced"), cylVert, cylFrag);
+    const QString fogVert = kShaderBase + QStringLiteral("fog_instanced.vert");
+    const QString fogFrag = kShaderBase + QStringLiteral("fog_instanced.frag");
+    load(QStringLiteral("fog_instanced"), fogVert, fogFrag);
+    const QString grassVert =
+        kShaderBase + QStringLiteral("grass_instanced.vert");
+    const QString grassFrag =
+        kShaderBase + QStringLiteral("grass_instanced.frag");
+    load(QStringLiteral("grass_instanced"), grassVert, grassFrag);
+
+    const QString terrainVert =
+        kShaderBase + QStringLiteral("terrain_chunk.vert");
+    const QString terrainFrag =
+        kShaderBase + QStringLiteral("terrain_chunk.frag");
+    load(QStringLiteral("terrain_chunk"), terrainVert, terrainFrag);
   }
   }
 
 
   void clear() {
   void clear() {

+ 417 - 0
render/ground/biome_renderer.cpp

@@ -0,0 +1,417 @@
+#include "biome_renderer.h"
+#include "../gl/buffer.h"
+#include "../scene_renderer.h"
+#include <QDebug>
+#include <QElapsedTimer>
+#include <QVector2D>
+#include <algorithm>
+#include <array>
+#include <cmath>
+#include <optional>
+
+namespace {
+
+using std::uint32_t;
+
+inline uint32_t hashCoords(int x, int z, uint32_t salt = 0u) {
+  uint32_t ux = static_cast<uint32_t>(x * 73856093);
+  uint32_t uz = static_cast<uint32_t>(z * 19349663);
+  return ux ^ uz ^ (salt * 83492791u);
+}
+
+inline float rand01(uint32_t &state) {
+  state = state * 1664525u + 1013904223u;
+  return static_cast<float>((state >> 8) & 0xFFFFFF) /
+         static_cast<float>(0xFFFFFF);
+}
+
+inline float remap(float value, float minOut, float maxOut) {
+  return minOut + (maxOut - minOut) * value;
+}
+
+inline float hashTo01(uint32_t h) {
+  h ^= h >> 17;
+  h *= 0xed5ad4bbu;
+  h ^= h >> 11;
+  h *= 0xac4c1b51u;
+  h ^= h >> 15;
+  h *= 0x31848babu;
+  h ^= h >> 14;
+  return (h & 0x00FFFFFFu) / float(0x01000000);
+}
+
+inline float valueNoise(float x, float z, uint32_t salt = 0u) {
+  int x0 = int(std::floor(x)), z0 = int(std::floor(z));
+  int x1 = x0 + 1, z1 = z0 + 1;
+  float tx = x - float(x0), tz = z - float(z0);
+  float n00 = hashTo01(hashCoords(x0, z0, salt));
+  float n10 = hashTo01(hashCoords(x1, z0, salt));
+  float n01 = hashTo01(hashCoords(x0, z1, salt));
+  float n11 = hashTo01(hashCoords(x1, z1, salt));
+  float nx0 = n00 * (1 - tx) + n10 * tx;
+  float nx1 = n01 * (1 - tx) + n11 * tx;
+  return nx0 * (1 - tz) + nx1 * tz;
+}
+
+inline int sectionFor(Game::Map::TerrainType type) {
+  switch (type) {
+  case Game::Map::TerrainType::Mountain:
+    return 2;
+  case Game::Map::TerrainType::Hill:
+    return 1;
+  case Game::Map::TerrainType::Flat:
+  default:
+    return 0;
+  }
+}
+
+} // namespace
+
+namespace Render::GL {
+
+BiomeRenderer::BiomeRenderer() = default;
+BiomeRenderer::~BiomeRenderer() = default;
+
+void BiomeRenderer::configure(const Game::Map::TerrainHeightMap &heightMap,
+                              const Game::Map::BiomeSettings &biomeSettings) {
+  m_width = heightMap.getWidth();
+  m_height = heightMap.getHeight();
+  m_tileSize = heightMap.getTileSize();
+  m_heightData = heightMap.getHeightData();
+  m_terrainTypes = heightMap.getTerrainTypes();
+  m_biomeSettings = biomeSettings;
+  m_noiseSeed = biomeSettings.seed;
+
+  m_grassInstances.clear();
+  m_grassInstanceBuffer.reset();
+  m_grassInstanceCount = 0;
+  m_grassInstancesDirty = false;
+
+  m_grassParams.soilColor = m_biomeSettings.soilColor;
+  m_grassParams.windStrength = m_biomeSettings.swayStrength;
+  m_grassParams.windSpeed = m_biomeSettings.swaySpeed;
+  m_grassParams.lightDirection = QVector3D(0.35f, 0.8f, 0.45f);
+  m_grassParams.time = 0.0f;
+
+  generateGrassInstances();
+
+  qDebug() << "BiomeRenderer configured: generated" << m_grassInstances.size()
+           << "grass instances";
+}
+
+void BiomeRenderer::submit(Renderer &renderer) {
+  if (m_grassInstanceCount > 0) {
+    if (!m_grassInstanceBuffer) {
+      m_grassInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
+    }
+    if (m_grassInstancesDirty && m_grassInstanceBuffer) {
+      m_grassInstanceBuffer->setData(m_grassInstances, Buffer::Usage::Static);
+      m_grassInstancesDirty = false;
+    }
+  } else {
+    m_grassInstanceBuffer.reset();
+    return;
+  }
+
+  if (m_grassInstanceBuffer && m_grassInstanceCount > 0) {
+    GrassBatchParams params = m_grassParams;
+    params.time = renderer.getAnimationTime();
+    renderer.grassBatch(m_grassInstanceBuffer.get(), m_grassInstanceCount,
+                        params);
+  }
+}
+
+void BiomeRenderer::clear() {
+  m_grassInstances.clear();
+  m_grassInstanceBuffer.reset();
+  m_grassInstanceCount = 0;
+  m_grassInstancesDirty = false;
+}
+
+void BiomeRenderer::generateGrassInstances() {
+  QElapsedTimer timer;
+  timer.start();
+
+  m_grassInstances.clear();
+
+  if (m_width < 2 || m_height < 2 || m_heightData.empty()) {
+    m_grassInstanceCount = 0;
+    m_grassInstancesDirty = false;
+    return;
+  }
+
+  if (m_biomeSettings.patchDensity < 0.01f) {
+    m_grassInstanceCount = 0;
+    m_grassInstancesDirty = false;
+    return;
+  }
+
+  const float halfWidth = m_width * 0.5f - 0.5f;
+  const float halfHeight = m_height * 0.5f - 0.5f;
+  const float tileSafe = std::max(0.001f, m_tileSize);
+
+  std::vector<QVector3D> normals(m_width * m_height,
+                                 QVector3D(0.0f, 1.0f, 0.0f));
+
+  auto sampleHeightAt = [&](float gx, float gz) -> float {
+    gx = std::clamp(gx, 0.0f, float(m_width - 1));
+    gz = std::clamp(gz, 0.0f, float(m_height - 1));
+    int x0 = int(std::floor(gx));
+    int z0 = int(std::floor(gz));
+    int x1 = std::min(x0 + 1, m_width - 1);
+    int z1 = std::min(z0 + 1, m_height - 1);
+    float tx = gx - float(x0);
+    float tz = gz - float(z0);
+    float h00 = m_heightData[z0 * m_width + x0];
+    float h10 = m_heightData[z0 * m_width + x1];
+    float h01 = m_heightData[z1 * m_width + x0];
+    float h11 = m_heightData[z1 * m_width + x1];
+    float h0 = h00 * (1.0f - tx) + h10 * tx;
+    float h1 = h01 * (1.0f - tx) + h11 * tx;
+    return h0 * (1.0f - tz) + h1 * tz;
+  };
+
+  for (int z = 0; z < m_height; ++z) {
+    for (int x = 0; x < m_width; ++x) {
+      int idx = z * m_width + x;
+      float gx0 = std::clamp(float(x) - 1.0f, 0.0f, float(m_width - 1));
+      float gx1 = std::clamp(float(x) + 1.0f, 0.0f, float(m_width - 1));
+      float gz0 = std::clamp(float(z) - 1.0f, 0.0f, float(m_height - 1));
+      float gz1 = std::clamp(float(z) + 1.0f, 0.0f, float(m_height - 1));
+
+      float hL = sampleHeightAt(gx0, float(z));
+      float hR = sampleHeightAt(gx1, float(z));
+      float hD = sampleHeightAt(float(x), gz0);
+      float hU = sampleHeightAt(float(x), gz1);
+
+      QVector3D dx(2.0f * m_tileSize, hR - hL, 0.0f);
+      QVector3D dz(0.0f, hU - hD, 2.0f * m_tileSize);
+      QVector3D n = QVector3D::crossProduct(dz, dx);
+      if (n.lengthSquared() > 0.0f) {
+        n.normalize();
+      } else {
+        n = QVector3D(0, 1, 0);
+      }
+      normals[idx] = n;
+    }
+  }
+
+  auto addGrassBlade = [&](float gx, float gz, uint32_t &state) {
+    float sgx = std::clamp(gx, 0.0f, float(m_width - 1));
+    float sgz = std::clamp(gz, 0.0f, float(m_height - 1));
+
+    int ix = std::clamp(int(std::floor(sgx + 0.5f)), 0, m_width - 1);
+    int iz = std::clamp(int(std::floor(sgz + 0.5f)), 0, m_height - 1);
+    int normalIdx = iz * m_width + ix;
+
+    if (m_terrainTypes[normalIdx] == Game::Map::TerrainType::Mountain)
+      return false;
+
+    QVector3D normal = normals[normalIdx];
+    float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);
+    if (slope > 0.92f)
+      return false;
+
+    float worldX = (gx - halfWidth) * m_tileSize;
+    float worldZ = (gz - halfHeight) * m_tileSize;
+    float worldY = sampleHeightAt(sgx, sgz);
+
+    float lushNoise =
+        valueNoise(worldX * 0.06f, worldZ * 0.06f, m_noiseSeed ^ 0x9235u);
+    float drynessNoise =
+        valueNoise(worldX * 0.12f, worldZ * 0.12f, m_noiseSeed ^ 0x47d2u);
+    float dryness = std::clamp(drynessNoise * 0.6f + slope * 0.4f, 0.0f, 1.0f);
+    QVector3D lushMix = m_biomeSettings.grassPrimary * (1.0f - lushNoise) +
+                        m_biomeSettings.grassSecondary * lushNoise;
+    QVector3D color =
+        lushMix * (1.0f - dryness) + m_biomeSettings.grassDry * dryness;
+
+    float height = remap(rand01(state), m_biomeSettings.bladeHeightMin,
+                         m_biomeSettings.bladeHeightMax) *
+                   tileSafe;
+    float width = remap(rand01(state), m_biomeSettings.bladeWidthMin,
+                        m_biomeSettings.bladeWidthMax) *
+                  tileSafe;
+
+    float swayStrength = remap(rand01(state), 0.75f, 1.25f);
+    float swaySpeed = remap(rand01(state), 0.85f, 1.15f);
+    float swayPhase = rand01(state) * 6.2831853f;
+    float orientation = rand01(state) * 6.2831853f;
+
+    GrassInstanceGpu instance;
+    instance.posHeight = QVector4D(worldX, worldY, worldZ, height);
+    instance.colorWidth = QVector4D(color.x(), color.y(), color.z(), width);
+    instance.swayParams =
+        QVector4D(swayStrength, swaySpeed, swayPhase, orientation);
+    m_grassInstances.push_back(instance);
+    return true;
+  };
+
+  auto quadSection = [&](Game::Map::TerrainType a, Game::Map::TerrainType b,
+                         Game::Map::TerrainType c, Game::Map::TerrainType d) {
+    int priorityA = sectionFor(a);
+    int priorityB = sectionFor(b);
+    int priorityC = sectionFor(c);
+    int priorityD = sectionFor(d);
+    int result = priorityA;
+    result = std::max(result, priorityB);
+    result = std::max(result, priorityC);
+    result = std::max(result, priorityD);
+    return result;
+  };
+
+  const int chunkSize = 16;
+
+  for (int chunkZ = 0; chunkZ < m_height - 1; chunkZ += chunkSize) {
+    int chunkMaxZ = std::min(chunkZ + chunkSize, m_height - 1);
+    for (int chunkX = 0; chunkX < m_width - 1; chunkX += chunkSize) {
+      int chunkMaxX = std::min(chunkX + chunkSize, m_width - 1);
+
+      std::array<int, 3> typeCounts = {0, 0, 0};
+      float chunkHeightSum = 0.0f;
+      float chunkSlopeSum = 0.0f;
+      int sampleCount = 0;
+
+      for (int z = chunkZ; z < chunkMaxZ && z < m_height - 1; ++z) {
+        for (int x = chunkX; x < chunkMaxX && x < m_width - 1; ++x) {
+          int idx0 = z * m_width + x;
+          int idx1 = idx0 + 1;
+          int idx2 = (z + 1) * m_width + x;
+          int idx3 = idx2 + 1;
+
+          int sectionIdx =
+              quadSection(m_terrainTypes[idx0], m_terrainTypes[idx1],
+                          m_terrainTypes[idx2], m_terrainTypes[idx3]);
+          typeCounts[sectionIdx]++;
+
+          float quadHeight = (m_heightData[idx0] + m_heightData[idx1] +
+                              m_heightData[idx2] + m_heightData[idx3]) *
+                             0.25f;
+          chunkHeightSum += quadHeight;
+
+          float nY = (normals[idx0].y() + normals[idx1].y() +
+                      normals[idx2].y() + normals[idx3].y()) *
+                     0.25f;
+          chunkSlopeSum += 1.0f - std::clamp(nY, 0.0f, 1.0f);
+          sampleCount++;
+        }
+      }
+
+      if (sampleCount == 0)
+        continue;
+
+      const float usableCoverage =
+          sampleCount > 0
+              ? float(typeCounts[0] + typeCounts[1]) / float(sampleCount)
+              : 0.0f;
+      if (usableCoverage < 0.05f)
+        continue;
+
+      int dominantType = (typeCounts[1] > typeCounts[0]) ? 1 : 0;
+
+      float avgSlope = chunkSlopeSum / float(sampleCount);
+
+      uint32_t state = hashCoords(chunkX, chunkZ, m_noiseSeed ^ 0xC915872Bu);
+      float slopePenalty = 1.0f - std::clamp(avgSlope * 1.35f, 0.0f, 0.75f);
+      float typeBias = (dominantType == 1) ? 0.85f : 1.0f;
+      constexpr float kClusterBoost = 1.35f;
+      float expectedClusters =
+          std::max(0.0f, m_biomeSettings.patchDensity * kClusterBoost *
+                             slopePenalty * typeBias * usableCoverage);
+      int clusterCount = static_cast<int>(std::floor(expectedClusters));
+      float frac = expectedClusters - float(clusterCount);
+      if (rand01(state) < frac)
+        clusterCount += 1;
+
+      if (clusterCount > 0) {
+        float chunkSpanX = float(chunkMaxX - chunkX + 1);
+        float chunkSpanZ = float(chunkMaxZ - chunkZ + 1);
+        float scatterBase = std::max(0.25f, m_biomeSettings.patchJitter);
+
+        auto pickClusterCenter =
+            [&](uint32_t &rng) -> std::optional<QVector2D> {
+          constexpr int kMaxAttempts = 8;
+          for (int attempt = 0; attempt < kMaxAttempts; ++attempt) {
+            float candidateGX = float(chunkX) + rand01(rng) * chunkSpanX;
+            float candidateGZ = float(chunkZ) + rand01(rng) * chunkSpanZ;
+
+            int cx = std::clamp(int(std::round(candidateGX)), 0, m_width - 1);
+            int cz = std::clamp(int(std::round(candidateGZ)), 0, m_height - 1);
+            int centerIdx = cz * m_width + cx;
+            if (m_terrainTypes[centerIdx] == Game::Map::TerrainType::Mountain)
+              continue;
+
+            QVector3D centerNormal = normals[centerIdx];
+            float centerSlope = 1.0f - std::clamp(centerNormal.y(), 0.0f, 1.0f);
+            if (centerSlope > 0.92f)
+              continue;
+
+            return QVector2D(candidateGX, candidateGZ);
+          }
+          return std::nullopt;
+        };
+
+        for (int cluster = 0; cluster < clusterCount; ++cluster) {
+          auto center = pickClusterCenter(state);
+          if (!center)
+            continue;
+
+          float centerGX = center->x();
+          float centerGZ = center->y();
+
+          int blades = 6 + static_cast<int>(rand01(state) * 6.0f);
+          blades = std::max(
+              4, int(std::round(blades * (0.85f + 0.3f * rand01(state)))));
+          float scatterRadius =
+              (0.45f + 0.55f * rand01(state)) * scatterBase * tileSafe;
+
+          for (int blade = 0; blade < blades; ++blade) {
+            float angle = rand01(state) * 6.2831853f;
+            float radius = scatterRadius * std::sqrt(rand01(state));
+            float gx = centerGX + std::cos(angle) * radius / tileSafe;
+            float gz = centerGZ + std::sin(angle) * radius / tileSafe;
+            addGrassBlade(gx, gz, state);
+          }
+        }
+      }
+    }
+  }
+
+  const float backgroundDensity =
+      std::max(0.0f, m_biomeSettings.backgroundBladeDensity);
+  if (backgroundDensity > 0.0f) {
+    for (int z = 0; z < m_height; ++z) {
+      for (int x = 0; x < m_width; ++x) {
+        int idx = z * m_width + x;
+        if (m_terrainTypes[idx] == Game::Map::TerrainType::Mountain)
+          continue;
+
+        QVector3D normal = normals[idx];
+        float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);
+        if (slope > 0.95f)
+          continue;
+
+        uint32_t state = hashCoords(
+            x, z, m_noiseSeed ^ 0x51bda7u ^ static_cast<uint32_t>(idx));
+        int baseCount = static_cast<int>(std::floor(backgroundDensity));
+        float frac = backgroundDensity - float(baseCount);
+        if (rand01(state) < frac)
+          baseCount += 1;
+
+        for (int i = 0; i < baseCount; ++i) {
+          float gx = float(x) + rand01(state);
+          float gz = float(z) + rand01(state);
+          addGrassBlade(gx, gz, state);
+        }
+      }
+    }
+  }
+
+  m_grassInstanceCount = m_grassInstances.size();
+  m_grassInstancesDirty = m_grassInstanceCount > 0;
+
+  qDebug() << "BiomeRenderer: generated" << m_grassInstanceCount
+           << "grass instances in" << timer.elapsed() << "ms";
+}
+
+} // namespace Render::GL

+ 47 - 0
render/ground/biome_renderer.h

@@ -0,0 +1,47 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include "grass_gpu.h"
+#include <QVector3D>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace Render {
+namespace GL {
+class Buffer;
+class Renderer;
+
+class BiomeRenderer {
+public:
+  BiomeRenderer();
+  ~BiomeRenderer();
+
+  void configure(const Game::Map::TerrainHeightMap &heightMap,
+                 const Game::Map::BiomeSettings &biomeSettings);
+
+  void submit(Renderer &renderer);
+
+  void clear();
+
+private:
+  void generateGrassInstances();
+
+  int m_width = 0;
+  int m_height = 0;
+  float m_tileSize = 1.0f;
+
+  std::vector<float> m_heightData;
+  std::vector<Game::Map::TerrainType> m_terrainTypes;
+  Game::Map::BiomeSettings m_biomeSettings;
+  std::uint32_t m_noiseSeed = 0u;
+
+  std::vector<GrassInstanceGpu> m_grassInstances;
+  std::unique_ptr<Buffer> m_grassInstanceBuffer;
+  std::size_t m_grassInstanceCount = 0;
+  GrassBatchParams m_grassParams;
+  bool m_grassInstancesDirty = false;
+};
+
+} // namespace GL
+} // namespace Render

+ 29 - 107
render/ground/fog_renderer.cpp

@@ -1,13 +1,15 @@
 #include "fog_renderer.h"
 #include "fog_renderer.h"
 
 
-#include "../gl/mesh.h"
-#include "../gl/resources.h"
 #include "../scene_renderer.h"
 #include "../scene_renderer.h"
 #include <QElapsedTimer>
 #include <QElapsedTimer>
 #include <algorithm>
 #include <algorithm>
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
+namespace {
+const QMatrix4x4 kIdentityMatrix;
+}
+
 void FogRenderer::updateMask(int width, int height, float tileSize,
 void FogRenderer::updateMask(int width, int height, float tileSize,
                              const std::vector<std::uint8_t> &cells) {
                              const std::vector<std::uint8_t> &cells) {
   m_width = std::max(0, width);
   m_width = std::max(0, width);
@@ -27,21 +29,15 @@ void FogRenderer::submit(Renderer &renderer, ResourceManager &resources) {
   if (static_cast<int>(m_cells.size()) != m_width * m_height)
   if (static_cast<int>(m_cells.size()) != m_width * m_height)
     return;
     return;
 
 
-  Texture *white = resources.white();
-  if (!white)
-    return;
+  (void)resources;
 
 
-  QMatrix4x4 model;
-
-  for (const auto &chunk : m_chunks) {
-    if (!chunk.mesh)
-      continue;
-    renderer.mesh(chunk.mesh.get(), model, chunk.color, white, chunk.alpha);
+  if (!m_instances.empty()) {
+    renderer.fogBatch(m_instances.data(), m_instances.size());
   }
   }
 }
 }
 
 
 void FogRenderer::buildChunks() {
 void FogRenderer::buildChunks() {
-  m_chunks.clear();
+  m_instances.clear();
 
 
   if (m_width <= 0 || m_height <= 0)
   if (m_width <= 0 || m_height <= 0)
     return;
     return;
@@ -52,106 +48,32 @@ void FogRenderer::buildChunks() {
   timer.start();
   timer.start();
 
 
   const float halfTile = m_tileSize * 0.5f;
   const float halfTile = m_tileSize * 0.5f;
-  const int chunkSize = 16;
+  m_instances.reserve(static_cast<std::size_t>(m_width) * m_height);
+
   std::size_t totalQuads = 0;
   std::size_t totalQuads = 0;
 
 
-  for (int chunkZ = 0; chunkZ < m_height; chunkZ += chunkSize) {
-    int chunkMaxZ = std::min(chunkZ + chunkSize, m_height);
-    for (int chunkX = 0; chunkX < m_width; chunkX += chunkSize) {
-      int chunkMaxX = std::min(chunkX + chunkSize, m_width);
-
-      struct SectionData {
-        std::vector<Vertex> vertices;
-        std::vector<unsigned int> indices;
-      };
-
-      SectionData sections[2];
-
-      auto appendQuad = [&](SectionData &section, float centerX,
-                            float centerZ) {
-        Vertex v0{};
-        Vertex v1{};
-        Vertex v2{};
-        Vertex v3{};
-
-        v0.position[0] = centerX - halfTile;
-        v0.position[1] = 0.25f;
-        v0.position[2] = centerZ - halfTile;
-        v1.position[0] = centerX + halfTile;
-        v1.position[1] = 0.25f;
-        v1.position[2] = centerZ - halfTile;
-        v2.position[0] = centerX - halfTile;
-        v2.position[1] = 0.25f;
-        v2.position[2] = centerZ + halfTile;
-        v3.position[0] = centerX + halfTile;
-        v3.position[1] = 0.25f;
-        v3.position[2] = centerZ + halfTile;
-
-        v0.normal[0] = v1.normal[0] = v2.normal[0] = v3.normal[0] = 0.0f;
-        v0.normal[1] = v1.normal[1] = v2.normal[1] = v3.normal[1] = 1.0f;
-        v0.normal[2] = v1.normal[2] = v2.normal[2] = v3.normal[2] = 0.0f;
-
-        v0.texCoord[0] = 0.0f;
-        v0.texCoord[1] = 0.0f;
-        v1.texCoord[0] = 1.0f;
-        v1.texCoord[1] = 0.0f;
-        v2.texCoord[0] = 0.0f;
-        v2.texCoord[1] = 1.0f;
-        v3.texCoord[0] = 1.0f;
-        v3.texCoord[1] = 1.0f;
-
-        const unsigned int base =
-            static_cast<unsigned int>(section.vertices.size());
-        section.vertices.push_back(v0);
-        section.vertices.push_back(v1);
-        section.vertices.push_back(v2);
-        section.vertices.push_back(v3);
-
-        section.indices.push_back(base + 0);
-        section.indices.push_back(base + 1);
-        section.indices.push_back(base + 2);
-        section.indices.push_back(base + 2);
-        section.indices.push_back(base + 1);
-        section.indices.push_back(base + 3);
-      };
-
-      for (int z = chunkZ; z < chunkMaxZ; ++z) {
-        for (int x = chunkX; x < chunkMaxX; ++x) {
-          const std::uint8_t state = m_cells[z * m_width + x];
-          if (state >= 2)
-            continue;
-
-          const float worldX = (x - m_halfWidth) * m_tileSize;
-          const float worldZ = (z - m_halfHeight) * m_tileSize;
-
-          SectionData &section = sections[std::min<int>(state, 1)];
-          appendQuad(section, worldX, worldZ);
-        }
-      }
-
-      if (!sections[0].indices.empty()) {
-        FogChunk chunk;
-        chunk.mesh =
-            std::make_unique<Mesh>(sections[0].vertices, sections[0].indices);
-        chunk.color = QVector3D(0.02f, 0.02f, 0.05f);
-        chunk.alpha = 0.9f;
-        totalQuads += sections[0].indices.size() / 6;
-        m_chunks.push_back(std::move(chunk));
-      }
-      if (!sections[1].indices.empty()) {
-        FogChunk chunk;
-        chunk.mesh =
-            std::make_unique<Mesh>(sections[1].vertices, sections[1].indices);
-        chunk.color = QVector3D(0.05f, 0.05f, 0.05f);
-        chunk.alpha = 0.45f;
-        totalQuads += sections[1].indices.size() / 6;
-        m_chunks.push_back(std::move(chunk));
-      }
+  for (int z = 0; z < m_height; ++z) {
+    for (int x = 0; x < m_width; ++x) {
+      const std::uint8_t state = m_cells[z * m_width + x];
+      if (state >= 2)
+        continue;
+
+      FogInstance instance;
+      const float worldX = (x - m_halfWidth) * m_tileSize;
+      const float worldZ = (z - m_halfHeight) * m_tileSize;
+      instance.center = QVector3D(worldX, 0.25f, worldZ);
+      instance.color = (state == 0) ? QVector3D(0.02f, 0.02f, 0.05f)
+                                    : QVector3D(0.05f, 0.05f, 0.05f);
+      instance.alpha = (state == 0) ? 0.9f : 0.45f;
+      instance.size = m_tileSize;
+
+      m_instances.push_back(instance);
+      ++totalQuads;
     }
     }
   }
   }
 
 
-  qDebug() << "FogRenderer: built" << m_chunks.size() << "chunks in"
-           << timer.elapsed() << "ms" << "quads:" << totalQuads;
+  qDebug() << "FogRenderer: built" << m_instances.size() << "instances in"
+           << timer.elapsed() << "ms" << "tile half-size:" << halfTile;
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 3 - 8
render/ground/fog_renderer.h

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include "../draw_queue.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
 #include <cstdint>
 #include <cstdint>
@@ -10,8 +11,6 @@ namespace Render {
 namespace GL {
 namespace GL {
 class Renderer;
 class Renderer;
 class ResourceManager;
 class ResourceManager;
-class Mesh;
-class Texture;
 
 
 class FogRenderer {
 class FogRenderer {
 public:
 public:
@@ -29,11 +28,7 @@ public:
 private:
 private:
   void buildChunks();
   void buildChunks();
 
 
-  struct FogChunk {
-    std::unique_ptr<Mesh> mesh;
-    QVector3D color{0.05f, 0.05f, 0.05f};
-    float alpha = 0.45f;
-  };
+  using FogInstance = FogInstanceData;
 
 
   bool m_enabled = true;
   bool m_enabled = true;
   int m_width = 0;
   int m_width = 0;
@@ -42,7 +37,7 @@ private:
   float m_halfWidth = 0.0f;
   float m_halfWidth = 0.0f;
   float m_halfHeight = 0.0f;
   float m_halfHeight = 0.0f;
   std::vector<std::uint8_t> m_cells;
   std::vector<std::uint8_t> m_cells;
-  std::vector<FogChunk> m_chunks;
+  std::vector<FogInstance> m_instances;
 };
 };
 
 
 } // namespace GL
 } // namespace GL

+ 25 - 0
render/ground/grass_gpu.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <QVector3D>
+#include <QVector4D>
+
+namespace Render::GL {
+
+struct GrassInstanceGpu {
+  QVector4D posHeight{0.0f, 0.0f, 0.0f, 0.0f};
+  QVector4D colorWidth{0.0f, 0.0f, 0.0f, 0.0f};
+  QVector4D swayParams{0.0f, 0.0f, 0.0f, 0.0f};
+};
+
+struct GrassBatchParams {
+  QVector3D soilColor{0.28f, 0.24f, 0.18f};
+  float windStrength{0.25f};
+  QVector3D lightDirection{0.35f, 0.8f, 0.45f};
+  float windSpeed{1.4f};
+  float time{0.0f};
+  float pad0{0.0f};
+  float pad1{0.0f};
+  float pad2{0.0f};
+};
+
+} // namespace Render::GL

+ 112 - 11
render/ground/ground_renderer.cpp

@@ -2,30 +2,131 @@
 #include "../draw_queue.h"
 #include "../draw_queue.h"
 #include "../gl/resources.h"
 #include "../gl/resources.h"
 #include "../scene_renderer.h"
 #include "../scene_renderer.h"
+#include <algorithm>
+#include <cmath>
 
 
 namespace Render {
 namespace Render {
 namespace GL {
 namespace GL {
 
 
+namespace {
+const QMatrix4x4 kIdentityMatrix;
+
+inline QVector3D saturate(const QVector3D &c) {
+  return QVector3D(std::clamp(c.x(), 0.0f, 1.0f), std::clamp(c.y(), 0.0f, 1.0f),
+                   std::clamp(c.z(), 0.0f, 1.0f));
+}
+} // namespace
+
+static QVector3D clamp01(const QVector3D &c) { return saturate(c); }
+
 void GroundRenderer::recomputeModel() {
 void GroundRenderer::recomputeModel() {
-  m_model.setToIdentity();
+  QMatrix4x4 newModel = kIdentityMatrix;
+  newModel.translate(0.0f, -0.5f, 0.0f);
 
 
-  m_model.translate(0.0f, -0.02f, 0.0f);
   if (m_width > 0 && m_height > 0) {
   if (m_width > 0 && m_height > 0) {
-
-    float scaleX = float(m_width) * m_tileSize;
-    float scaleZ = float(m_height) * m_tileSize;
-    m_model.scale(scaleX, 1.0f, scaleZ);
+    const float scaleX = float(m_width) * m_tileSize;
+    const float scaleZ = float(m_height) * m_tileSize;
+    newModel.scale(scaleX, 1.0f, scaleZ);
   } else {
   } else {
-    m_model.scale(m_extent, 1.0f, m_extent);
+    newModel.scale(m_extent, 1.0f, m_extent);
   }
   }
+
+  if (newModel != m_model) {
+    m_model = newModel;
+    m_modelDirty = true;
+  }
+}
+
+void GroundRenderer::updateNoiseOffset() {
+  const float spanX = (m_width > 0 ? float(m_width) * m_tileSize : m_extent);
+  const float spanZ = (m_height > 0 ? float(m_height) * m_tileSize : m_extent);
+  const float seed = static_cast<float>(m_biomeSettings.seed % 1024u);
+
+  QVector2D newOffset;
+  newOffset.setX(spanX * 0.37f + seed * 0.21f);
+  newOffset.setY(spanZ * 0.43f + seed * 0.17f);
+
+  if (newOffset != m_noiseOffset) {
+    m_noiseOffset = newOffset;
+    invalidateParamsCache();
+  }
+}
+
+TerrainChunkParams GroundRenderer::buildParams() const {
+  if (m_cachedParamsValid) {
+    return m_cachedParams;
+  }
+
+  TerrainChunkParams params;
+
+  const QVector3D primary = m_biomeSettings.grassPrimary * 0.97f;
+  const QVector3D secondary = m_biomeSettings.grassSecondary * 0.93f;
+  const QVector3D dry = m_biomeSettings.grassDry * 0.90f;
+  const QVector3D soil = m_biomeSettings.soilColor * 0.68f;
+
+  params.grassPrimary = saturate(primary);
+  params.grassSecondary = saturate(secondary);
+  params.grassDry = saturate(dry);
+  params.soilColor = saturate(soil);
+  params.rockLow = saturate(m_biomeSettings.rockLow);
+  params.rockHigh = saturate(m_biomeSettings.rockHigh);
+
+  params.tint = QVector3D(0.96f, 0.98f, 0.96f);
+
+  params.tileSize = std::max(0.25f, m_tileSize);
+
+  params.macroNoiseScale =
+      std::max(0.012f, m_biomeSettings.terrainMacroNoiseScale * 0.60f);
+  params.detailNoiseScale =
+      std::max(0.045f, m_biomeSettings.terrainDetailNoiseScale * 0.75f);
+
+  params.slopeRockThreshold = 0.72f;
+  params.slopeRockSharpness = 4.5f;
+
+  params.soilBlendHeight = -0.38f;
+  params.soilBlendSharpness = 2.6f;
+
+  params.noiseOffset = m_noiseOffset;
+
+  params.heightNoiseStrength = m_biomeSettings.heightNoiseAmplitude * 0.22f;
+  params.heightNoiseFrequency = m_biomeSettings.heightNoiseFrequency * 1.05f;
+
+  params.ambientBoost = m_biomeSettings.terrainAmbientBoost * 0.92f;
+
+  params.rockDetailStrength = m_biomeSettings.terrainRockDetailStrength * 0.18f;
+
+  QVector3D L(0.35f, 0.85f, 0.42f);
+  params.lightDirection = L.normalized();
+
+  m_cachedParams = params;
+  m_cachedParamsValid = true;
+  return params;
 }
 }
 
 
 void GroundRenderer::submit(Renderer &renderer, ResourceManager &resources) {
 void GroundRenderer::submit(Renderer &renderer, ResourceManager &resources) {
 
 
-  float cell = m_tileSize > 0.0f ? m_tileSize : 1.0f;
-  float extent = (m_width > 0 && m_height > 0)
-                     ? std::max(m_width, m_height) * m_tileSize * 0.5f
-                     : m_extent;
+  if (m_hasBiome) {
+    Mesh *plane = resources.ground();
+    if (plane) {
+      const TerrainChunkParams params = buildParams();
+
+      const bool modelChanged =
+          m_modelDirty || (m_lastSubmittedModel != m_model);
+      const bool stateChanged = (m_lastSubmittedStateVersion != m_stateVersion);
+
+      renderer.terrainChunk(plane, m_model, params, 0x0100u, false, 0.0f);
+
+      m_lastSubmittedModel = m_model;
+      m_modelDirty = false;
+      m_lastSubmittedStateVersion = m_stateVersion;
+      return;
+    }
+  }
+
+  const float cell = (m_tileSize > 0.0f ? m_tileSize : 1.0f);
+  const float extent = (m_width > 0 && m_height > 0)
+                           ? std::max(m_width, m_height) * m_tileSize * 0.5f
+                           : m_extent;
   renderer.grid(m_model, m_color, cell, 0.06f, extent);
   renderer.grid(m_model, m_color, cell, 0.06f, extent);
 }
 }
 
 

+ 43 - 0
render/ground/ground_renderer.h

@@ -1,6 +1,11 @@
 #pragma once
 #pragma once
 #include <QMatrix4x4>
 #include <QMatrix4x4>
+#include <QVector2D>
 #include <QVector3D>
 #include <QVector3D>
+#include <cstdint>
+
+#include "../../game/map/terrain.h"
+#include "terrain_gpu.h"
 
 
 namespace Render {
 namespace Render {
 namespace GL {
 namespace GL {
@@ -16,22 +21,60 @@ public:
     m_width = width;
     m_width = width;
     m_height = height;
     m_height = height;
     recomputeModel();
     recomputeModel();
+    updateNoiseOffset();
+
+    invalidateParamsCache();
   }
   }
+
   void configureExtent(float extent) {
   void configureExtent(float extent) {
     m_extent = extent;
     m_extent = extent;
     recomputeModel();
     recomputeModel();
+    updateNoiseOffset();
+
+    invalidateParamsCache();
   }
   }
+
   void setColor(const QVector3D &c) { m_color = c; }
   void setColor(const QVector3D &c) { m_color = c; }
+
+  void setBiome(const Game::Map::BiomeSettings &settings) {
+    m_biomeSettings = settings;
+    m_hasBiome = true;
+    updateNoiseOffset();
+
+    invalidateParamsCache();
+  }
+
   void submit(Renderer &renderer, ResourceManager &resources);
   void submit(Renderer &renderer, ResourceManager &resources);
 
 
 private:
 private:
   void recomputeModel();
   void recomputeModel();
+  void updateNoiseOffset();
+  Render::GL::TerrainChunkParams buildParams() const;
+
   float m_tileSize = 1.0f;
   float m_tileSize = 1.0f;
   int m_width = 50;
   int m_width = 50;
   int m_height = 50;
   int m_height = 50;
   float m_extent = 50.0f;
   float m_extent = 50.0f;
+
   QVector3D m_color{0.15f, 0.18f, 0.15f};
   QVector3D m_color{0.15f, 0.18f, 0.15f};
   QMatrix4x4 m_model;
   QMatrix4x4 m_model;
+  Game::Map::BiomeSettings m_biomeSettings;
+  bool m_hasBiome = false;
+  QVector2D m_noiseOffset{0.0f, 0.0f};
+
+  mutable Render::GL::TerrainChunkParams m_cachedParams{};
+  mutable bool m_cachedParamsValid = false;
+
+  QMatrix4x4 m_lastSubmittedModel;
+  bool m_modelDirty = true;
+
+  std::uint64_t m_stateVersion = 1;
+  std::uint64_t m_lastSubmittedStateVersion = 0;
+
+  inline void invalidateParamsCache() {
+    m_cachedParamsValid = false;
+    ++m_stateVersion;
+  }
 };
 };
 
 
 } // namespace GL
 } // namespace GL

+ 31 - 0
render/ground/terrain_gpu.h

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <QVector2D>
+#include <QVector3D>
+
+namespace Render::GL {
+
+struct TerrainChunkParams {
+  QVector3D grassPrimary{0.30f, 0.60f, 0.28f};
+  float tileSize = 1.0f;
+  QVector3D grassSecondary{0.44f, 0.70f, 0.32f};
+  float macroNoiseScale = 0.035f;
+  QVector3D grassDry{0.72f, 0.66f, 0.48f};
+  float detailNoiseScale = 0.16f;
+  QVector3D soilColor{0.30f, 0.26f, 0.20f};
+  float slopeRockThreshold = 0.45f;
+  QVector3D rockLow{0.48f, 0.46f, 0.44f};
+  float slopeRockSharpness = 3.0f;
+  QVector3D rockHigh{0.70f, 0.71f, 0.75f};
+  float soilBlendHeight = 0.6f;
+  QVector3D tint{1.0f, 1.0f, 1.0f};
+  float soilBlendSharpness = 3.5f;
+  QVector2D noiseOffset{0.0f, 0.0f};
+  float heightNoiseStrength = 0.05f;
+  float heightNoiseFrequency = 0.1f;
+  float ambientBoost = 1.05f;
+  float rockDetailStrength = 0.35f;
+  QVector3D lightDirection{0.35f, 0.8f, 0.45f};
+};
+
+} // namespace Render::GL

+ 171 - 312
render/ground/terrain_renderer.cpp

@@ -1,11 +1,14 @@
 #include "terrain_renderer.h"
 #include "terrain_renderer.h"
 #include "../../game/map/visibility_service.h"
 #include "../../game/map/visibility_service.h"
+#include "../gl/buffer.h"
 #include "../gl/mesh.h"
 #include "../gl/mesh.h"
 #include "../gl/resources.h"
 #include "../gl/resources.h"
 #include "../scene_renderer.h"
 #include "../scene_renderer.h"
 #include <QDebug>
 #include <QDebug>
 #include <QElapsedTimer>
 #include <QElapsedTimer>
 #include <QQuaternion>
 #include <QQuaternion>
+#include <QVector2D>
+#include <QtGlobal>
 #include <algorithm>
 #include <algorithm>
 #include <cmath>
 #include <cmath>
 #include <unordered_map>
 #include <unordered_map>
@@ -14,6 +17,8 @@ namespace {
 
 
 using std::uint32_t;
 using std::uint32_t;
 
 
+const QMatrix4x4 kIdentityMatrix;
+
 inline uint32_t hashCoords(int x, int z, uint32_t salt = 0u) {
 inline uint32_t hashCoords(int x, int z, uint32_t salt = 0u) {
   uint32_t ux = static_cast<uint32_t>(x * 73856093);
   uint32_t ux = static_cast<uint32_t>(x * 73856093);
   uint32_t uz = static_cast<uint32_t>(z * 19349663);
   uint32_t uz = static_cast<uint32_t>(z * 19349663);
@@ -72,13 +77,16 @@ namespace Render::GL {
 TerrainRenderer::TerrainRenderer() = default;
 TerrainRenderer::TerrainRenderer() = default;
 TerrainRenderer::~TerrainRenderer() = default;
 TerrainRenderer::~TerrainRenderer() = default;
 
 
-void TerrainRenderer::configure(const Game::Map::TerrainHeightMap &heightMap) {
+void TerrainRenderer::configure(const Game::Map::TerrainHeightMap &heightMap,
+                                const Game::Map::BiomeSettings &biomeSettings) {
   m_width = heightMap.getWidth();
   m_width = heightMap.getWidth();
   m_height = heightMap.getHeight();
   m_height = heightMap.getHeight();
   m_tileSize = heightMap.getTileSize();
   m_tileSize = heightMap.getTileSize();
 
 
   m_heightData = heightMap.getHeightData();
   m_heightData = heightMap.getHeightData();
   m_terrainTypes = heightMap.getTerrainTypes();
   m_terrainTypes = heightMap.getTerrainTypes();
+  m_biomeSettings = biomeSettings;
+  m_noiseSeed = biomeSettings.seed;
   buildMeshes();
   buildMeshes();
 
 
   qDebug() << "TerrainRenderer configured:" << m_width << "x" << m_height
   qDebug() << "TerrainRenderer configured:" << m_width << "x" << m_height
@@ -90,53 +98,11 @@ void TerrainRenderer::submit(Renderer &renderer, ResourceManager &resources) {
     return;
     return;
   }
   }
 
 
-  Texture *white = resources.white();
-  if (!white)
-    return;
+  Q_UNUSED(resources);
 
 
   auto &visibility = Game::Map::VisibilityService::instance();
   auto &visibility = Game::Map::VisibilityService::instance();
   const bool useVisibility = visibility.isInitialized();
   const bool useVisibility = visibility.isInitialized();
 
 
-  Mesh *unitMesh = resources.unit();
-  Mesh *quadMesh = resources.quad();
-  const float halfWidth = m_width * 0.5f - 0.5f;
-  const float halfHeight = m_height * 0.5f - 0.5f;
-
-  auto sampleHeightAt = [&](float gx, float gz) {
-    gx = std::clamp(gx, 0.0f, float(m_width - 1));
-    gz = std::clamp(gz, 0.0f, float(m_height - 1));
-    int x0 = int(std::floor(gx));
-    int z0 = int(std::floor(gz));
-    int x1 = std::min(x0 + 1, m_width - 1);
-    int z1 = std::min(z0 + 1, m_height - 1);
-    float tx = gx - float(x0);
-    float tz = gz - float(z0);
-    float h00 = m_heightData[z0 * m_width + x0];
-    float h10 = m_heightData[z0 * m_width + x1];
-    float h01 = m_heightData[z1 * m_width + x0];
-    float h11 = m_heightData[z1 * m_width + x1];
-    float h0 = h00 * (1.0f - tx) + h10 * tx;
-    float h1 = h01 * (1.0f - tx) + h11 * tx;
-    return h0 * (1.0f - tz) + h1 * tz;
-  };
-
-  auto normalFromHeights = [&](float gx, float gz) {
-    float gx0 = std::clamp(gx - 1.0f, 0.0f, float(m_width - 1));
-    float gx1 = std::clamp(gx + 1.0f, 0.0f, float(m_width - 1));
-    float gz0 = std::clamp(gz - 1.0f, 0.0f, float(m_height - 1));
-    float gz1 = std::clamp(gz + 1.0f, 0.0f, float(m_height - 1));
-    float hL = sampleHeightAt(gx0, gz);
-    float hR = sampleHeightAt(gx1, gz);
-    float hD = sampleHeightAt(gx, gz0);
-    float hU = sampleHeightAt(gx, gz1);
-    QVector3D dx(2.0f * m_tileSize, hR - hL, 0.0f);
-    QVector3D dz(0.0f, hU - hD, 2.0f * m_tileSize);
-    QVector3D n = QVector3D::crossProduct(dz, dx);
-    if (n.lengthSquared() > 0.0f)
-      n.normalize();
-    return n.isNull() ? QVector3D(0, 1, 0) : n;
-  };
-
   for (const auto &chunk : m_chunks) {
   for (const auto &chunk : m_chunks) {
     if (!chunk.mesh)
     if (!chunk.mesh)
       continue;
       continue;
@@ -156,80 +122,8 @@ void TerrainRenderer::submit(Renderer &renderer, ResourceManager &resources) {
         continue;
         continue;
     }
     }
 
 
-    QMatrix4x4 model;
-    renderer.mesh(chunk.mesh.get(), model, chunk.color, white, 1.0f);
-  }
-
-  for (const auto &prop : m_props) {
-    if (useVisibility) {
-      int gridX = static_cast<int>(
-          std::floor(prop.position.x() / m_tileSize + halfWidth + 0.5f));
-      int gridZ = static_cast<int>(
-          std::floor(prop.position.z() / m_tileSize + halfHeight + 0.5f));
-      gridX = std::clamp(gridX, 0, m_width - 1);
-      gridZ = std::clamp(gridZ, 0, m_height - 1);
-      if (visibility.stateAt(gridX, gridZ) !=
-          Game::Map::VisibilityState::Visible)
-        continue;
-    }
-
-    Mesh *mesh = nullptr;
-    switch (prop.type) {
-    case PropType::Pebble:
-      mesh = unitMesh;
-      break;
-    case PropType::Tuft:
-      mesh = unitMesh;
-      break;
-    case PropType::Stick:
-      mesh = unitMesh;
-      break;
-    }
-    if (!mesh)
-      continue;
-
-    float gx = prop.position.x() / m_tileSize + halfWidth;
-    float gz = prop.position.z() / m_tileSize + halfHeight;
-    QVector3D n = normalFromHeights(gx, gz);
-    float slope = 1.0f - std::clamp(n.y(), 0.0f, 1.0f);
-
-    QQuaternion tilt = QQuaternion::rotationTo(QVector3D(0, 1, 0), n);
-
-    QMatrix4x4 model;
-    model.translate(prop.position);
-    model.rotate(tilt);
-    model.rotate(prop.rotationDeg, 0.0f, 1.0f, 0.0f);
-
-    QVector3D scale = prop.scale;
-    float along = 1.0f + 0.25f * (1.0f - slope);
-    float across = 1.0f - 0.15f * (1.0f - slope);
-    scale.setX(scale.x() * across);
-    scale.setZ(scale.z() * along);
-    model.scale(scale);
-
-    QVector3D propColor = prop.color;
-    float shade = 0.9f + 0.2f * (1.0f - slope);
-    propColor *= shade;
-    renderer.mesh(mesh, model, clamp01(propColor), white, prop.alpha);
-
-    if (quadMesh) {
-      QMatrix4x4 decal;
-      decal.translate(prop.position.x(), prop.position.y() + 0.01f,
-                      prop.position.z());
-      decal.rotate(-90.0f, 1.0f, 0.0f, 0.0f);
-      decal.rotate(tilt);
-      float scaleBoost = (prop.type == PropType::Tuft)
-                             ? 1.25f
-                             : (prop.type == PropType::Pebble ? 1.6f : 1.4f);
-      decal.scale(prop.scale.x() * scaleBoost, prop.scale.z() * scaleBoost,
-                  1.0f);
-      float ao = (prop.type == PropType::Tuft)
-                     ? 0.22f
-                     : (prop.type == PropType::Pebble ? 0.42f : 0.35f);
-      ao = std::clamp(ao + 0.15f * slope, 0.18f, 0.55f);
-      QVector3D aoColor(0.05f, 0.05f, 0.048f);
-      renderer.mesh(quadMesh, decal, aoColor, white, ao);
-    }
+    renderer.terrainChunk(chunk.mesh.get(), kIdentityMatrix, chunk.params,
+                          0x0080u);
   }
   }
 }
 }
 
 
@@ -250,7 +144,6 @@ void TerrainRenderer::buildMeshes() {
   timer.start();
   timer.start();
 
 
   m_chunks.clear();
   m_chunks.clear();
-  m_props.clear();
 
 
   if (m_width < 2 || m_height < 2 || m_heightData.empty()) {
   if (m_width < 2 || m_height < 2 || m_heightData.empty()) {
     return;
     return;
@@ -282,6 +175,41 @@ void TerrainRenderer::buildMeshes() {
     normals[i2] += normal;
     normals[i2] += normal;
   };
   };
 
 
+  auto sampleHeightAt = [&](float gx, float gz) {
+    gx = std::clamp(gx, 0.0f, float(m_width - 1));
+    gz = std::clamp(gz, 0.0f, float(m_height - 1));
+    int x0 = int(std::floor(gx));
+    int z0 = int(std::floor(gz));
+    int x1 = std::min(x0 + 1, m_width - 1);
+    int z1 = std::min(z0 + 1, m_height - 1);
+    float tx = gx - float(x0);
+    float tz = gz - float(z0);
+    float h00 = m_heightData[z0 * m_width + x0];
+    float h10 = m_heightData[z0 * m_width + x1];
+    float h01 = m_heightData[z1 * m_width + x0];
+    float h11 = m_heightData[z1 * m_width + x1];
+    float h0 = h00 * (1.0f - tx) + h10 * tx;
+    float h1 = h01 * (1.0f - tx) + h11 * tx;
+    return h0 * (1.0f - tz) + h1 * tz;
+  };
+
+  auto normalFromHeightsAt = [&](float gx, float gz) {
+    float gx0 = std::clamp(gx - 1.0f, 0.0f, float(m_width - 1));
+    float gx1 = std::clamp(gx + 1.0f, 0.0f, float(m_width - 1));
+    float gz0 = std::clamp(gz - 1.0f, 0.0f, float(m_height - 1));
+    float gz1 = std::clamp(gz + 1.0f, 0.0f, float(m_height - 1));
+    float hL = sampleHeightAt(gx0, gz);
+    float hR = sampleHeightAt(gx1, gz);
+    float hD = sampleHeightAt(gx, gz0);
+    float hU = sampleHeightAt(gx, gz1);
+    QVector3D dx(2.0f * m_tileSize, hR - hL, 0.0f);
+    QVector3D dz(0.0f, hU - hD, 2.0f * m_tileSize);
+    QVector3D n = QVector3D::crossProduct(dz, dx);
+    if (n.lengthSquared() > 0.0f)
+      n.normalize();
+    return n.isNull() ? QVector3D(0, 1, 0) : n;
+  };
+
   for (int z = 0; z < m_height - 1; ++z) {
   for (int z = 0; z < m_height - 1; ++z) {
     for (int x = 0; x < m_width - 1; ++x) {
     for (int x = 0; x < m_width - 1; ++x) {
       int idx0 = z * m_width + x;
       int idx0 = z * m_width + x;
@@ -359,7 +287,7 @@ void TerrainRenderer::buildMeshes() {
 
 
       SectionData sections[3];
       SectionData sections[3];
 
 
-      uint32_t chunkSeed = hashCoords(chunkX, chunkZ);
+      uint32_t chunkSeed = hashCoords(chunkX, chunkZ, m_noiseSeed);
       uint32_t variantSeed = chunkSeed ^ 0x9e3779b9u;
       uint32_t variantSeed = chunkSeed ^ 0x9e3779b9u;
       float rotationStep = static_cast<float>((variantSeed >> 5) & 3) * 90.0f;
       float rotationStep = static_cast<float>((variantSeed >> 5) & 3) * 90.0f;
       bool flip = ((variantSeed >> 7) & 1u) != 0u;
       bool flip = ((variantSeed >> 7) & 1u) != 0u;
@@ -437,62 +365,61 @@ void TerrainRenderer::buildMeshes() {
           float maxHeight =
           float maxHeight =
               std::max(std::max(m_heightData[idx0], m_heightData[idx1]),
               std::max(std::max(m_heightData[idx0], m_heightData[idx1]),
                        std::max(m_heightData[idx2], m_heightData[idx3]));
                        std::max(m_heightData[idx2], m_heightData[idx3]));
-          if (maxHeight <= 0.05f) {
-            continue;
-          }
 
 
           int sectionIndex =
           int sectionIndex =
               quadSection(m_terrainTypes[idx0], m_terrainTypes[idx1],
               quadSection(m_terrainTypes[idx0], m_terrainTypes[idx1],
                           m_terrainTypes[idx2], m_terrainTypes[idx3]);
                           m_terrainTypes[idx2], m_terrainTypes[idx3]);
 
 
-          SectionData &section = sections[sectionIndex];
-          unsigned int v0 = ensureVertex(section, idx0);
-          unsigned int v1 = ensureVertex(section, idx1);
-          unsigned int v2 = ensureVertex(section, idx2);
-          unsigned int v3 = ensureVertex(section, idx3);
-          section.indices.push_back(v0);
-          section.indices.push_back(v1);
-          section.indices.push_back(v2);
-          section.indices.push_back(v2);
-          section.indices.push_back(v1);
-          section.indices.push_back(v3);
-
-          float quadHeight = (m_heightData[idx0] + m_heightData[idx1] +
-                              m_heightData[idx2] + m_heightData[idx3]) *
-                             0.25f;
-          section.heightSum += quadHeight;
-          section.heightCount += 1;
-
-          float nY = (normals[idx0].y() + normals[idx1].y() +
-                      normals[idx2].y() + normals[idx3].y()) *
-                     0.25f;
-          float slope = 1.0f - std::clamp(nY, 0.0f, 1.0f);
-          section.slopeSum += slope;
-
-          float hmin =
-              std::min(std::min(m_heightData[idx0], m_heightData[idx1]),
-                       std::min(m_heightData[idx2], m_heightData[idx3]));
-          float hmax =
-              std::max(std::max(m_heightData[idx0], m_heightData[idx1]),
-                       std::max(m_heightData[idx2], m_heightData[idx3]));
-          section.heightVarSum += (hmax - hmin);
-          section.statCount += 1;
-
-          auto H = [&](int gx, int gz) {
-            gx = std::clamp(gx, 0, m_width - 1);
-            gz = std::clamp(gz, 0, m_height - 1);
-            return m_heightData[gz * m_width + gx];
-          };
-          int cx = x, cz = z;
-          float hC = quadHeight;
-          float ao = 0.0f;
-          ao += std::max(0.0f, H(cx - 1, cz) - hC);
-          ao += std::max(0.0f, H(cx + 1, cz) - hC);
-          ao += std::max(0.0f, H(cx, cz - 1) - hC);
-          ao += std::max(0.0f, H(cx, cz + 1) - hC);
-          ao = std::clamp(ao * 0.15f, 0.0f, 1.0f);
-          section.aoSum += ao;
-          section.aoCount += 1;
+          if (sectionIndex > 0) {
+            SectionData &section = sections[sectionIndex];
+            unsigned int v0 = ensureVertex(section, idx0);
+            unsigned int v1 = ensureVertex(section, idx1);
+            unsigned int v2 = ensureVertex(section, idx2);
+            unsigned int v3 = ensureVertex(section, idx3);
+            section.indices.push_back(v0);
+            section.indices.push_back(v1);
+            section.indices.push_back(v2);
+            section.indices.push_back(v2);
+            section.indices.push_back(v1);
+            section.indices.push_back(v3);
+
+            float quadHeight = (m_heightData[idx0] + m_heightData[idx1] +
+                                m_heightData[idx2] + m_heightData[idx3]) *
+                               0.25f;
+            section.heightSum += quadHeight;
+            section.heightCount += 1;
+
+            float nY = (normals[idx0].y() + normals[idx1].y() +
+                        normals[idx2].y() + normals[idx3].y()) *
+                       0.25f;
+            float slope = 1.0f - std::clamp(nY, 0.0f, 1.0f);
+            section.slopeSum += slope;
+
+            float hmin =
+                std::min(std::min(m_heightData[idx0], m_heightData[idx1]),
+                         std::min(m_heightData[idx2], m_heightData[idx3]));
+            float hmax =
+                std::max(std::max(m_heightData[idx0], m_heightData[idx1]),
+                         std::max(m_heightData[idx2], m_heightData[idx3]));
+            section.heightVarSum += (hmax - hmin);
+            section.statCount += 1;
+
+            auto H = [&](int gx, int gz) {
+              gx = std::clamp(gx, 0, m_width - 1);
+              gz = std::clamp(gz, 0, m_height - 1);
+              return m_heightData[gz * m_width + gx];
+            };
+            int cx = x, cz = z;
+            float hC = quadHeight;
+            float ao = 0.0f;
+            ao += std::max(0.0f, H(cx - 1, cz) - hC);
+            ao += std::max(0.0f, H(cx + 1, cz) - hC);
+            ao += std::max(0.0f, H(cx, cz - 1) - hC);
+            ao += std::max(0.0f, H(cx, cz + 1) - hC);
+            ao = std::clamp(ao * 0.15f, 0.0f, 1.0f);
+            section.aoSum += ao;
+            section.aoCount += 1;
+          }
         }
         }
       }
       }
 
 
@@ -528,7 +455,7 @@ void TerrainRenderer::buildMeshes() {
             (section.statCount > 0)
             (section.statCount > 0)
                 ? (section.heightVarSum / float(section.statCount))
                 ? (section.heightVarSum / float(section.statCount))
                 : 0.0f;
                 : 0.0f;
-        QVector3D rockTint(0.52f, 0.49f, 0.47f);
+        QVector3D rockTint = m_biomeSettings.rockLow;
         float slopeMix = std::clamp(
         float slopeMix = std::clamp(
             avgSlope *
             avgSlope *
                 ((chunk.type == Game::Map::TerrainType::Flat)
                 ((chunk.type == Game::Map::TerrainType::Flat)
@@ -563,7 +490,8 @@ void TerrainRenderer::buildMeshes() {
         float centerGZ = 0.5f * (chunk.minZ + chunk.maxZ);
         float centerGZ = 0.5f * (chunk.minZ + chunk.maxZ);
         float centerWX = (centerGX - halfWidth) * m_tileSize;
         float centerWX = (centerGX - halfWidth) * m_tileSize;
         float centerWZ = (centerGZ - halfHeight) * m_tileSize;
         float centerWZ = (centerGZ - halfHeight) * m_tileSize;
-        float macro = valueNoise(centerWX * 0.02f, centerWZ * 0.02f, 1337u);
+        float macro = valueNoise(centerWX * 0.02f, centerWZ * 0.02f,
+                                 m_noiseSeed ^ 0x51C3u);
         float macroShade = 0.9f + 0.2f * macro;
         float macroShade = 0.9f + 0.2f * macro;
 
 
         float aoAvg = (section.aoCount > 0)
         float aoAvg = (section.aoCount > 0)
@@ -582,137 +510,61 @@ void TerrainRenderer::buildMeshes() {
         color = color * 0.96f + QVector3D(0.04f, 0.04f, 0.04f);
         color = color * 0.96f + QVector3D(0.04f, 0.04f, 0.04f);
         chunk.color = clamp01(color);
         chunk.color = clamp01(color);
 
 
-        if (chunk.type != Game::Map::TerrainType::Mountain) {
-          uint32_t propSeed = hashCoords(chunk.minX, chunk.minZ,
-                                         static_cast<uint32_t>(chunk.type));
-          uint32_t state = propSeed ^ 0x6d2b79f5u;
-          float spawnChance = rand01(state);
-          int clusterCount = 0;
-          if (spawnChance > 0.58f) {
-            clusterCount = 1;
-            if (rand01(state) > 0.7f)
-              clusterCount += 1;
-            if (rand01(state) > 0.9f)
-              clusterCount += 1;
-          }
-
-          for (int cluster = 0; cluster < clusterCount; ++cluster) {
-            float gridSpanX = float(chunk.maxX - chunk.minX + 1);
-            float gridSpanZ = float(chunk.maxZ - chunk.minZ + 1);
-            float centerGX2 = float(chunk.minX) + rand01(state) * gridSpanX;
-            float centerGZ2 = float(chunk.minZ) + rand01(state) * gridSpanZ;
-
-            int propsPerCluster = 2 + static_cast<int>(rand01(state) * 4.0f);
-            float scatterRadius =
-                remap(rand01(state), 0.25f, 0.85f) * m_tileSize;
-
-            for (int p = 0; p < propsPerCluster; ++p) {
-              float angle = rand01(state) * 6.2831853f;
-              float radius = scatterRadius * std::sqrt(rand01(state));
-              float gx = centerGX2 + std::cos(angle) * radius / m_tileSize;
-              float gz = centerGZ2 + std::sin(angle) * radius / m_tileSize;
-
-              float worldX = (gx - halfWidth) * m_tileSize;
-              float worldZ = (gz - halfHeight) * m_tileSize;
-              float worldY = 0.0f;
-              {
-                float sgx = std::clamp(gx, 0.0f, float(m_width - 1));
-                float sgz = std::clamp(gz, 0.0f, float(m_height - 1));
-                int x0 = int(std::floor(sgx));
-                int z0 = int(std::floor(sgz));
-                int x1 = std::min(x0 + 1, m_width - 1);
-                int z1 = std::min(z0 + 1, m_height - 1);
-                float tx = sgx - float(x0);
-                float tz = sgz - float(z0);
-                float h00 = m_heightData[z0 * m_width + x0];
-                float h10 = m_heightData[z0 * m_width + x1];
-                float h01 = m_heightData[z1 * m_width + x0];
-                float h11 = m_heightData[z1 * m_width + x1];
-                float h0 = h00 * (1.0f - tx) + h10 * tx;
-                float h1 = h01 * (1.0f - tx) + h11 * tx;
-                worldY = h0 * (1.0f - tz) + h1 * tz;
-              }
-
-              QVector3D n;
-              {
-                float sgx = std::clamp(gx, 0.0f, float(m_width - 1));
-                float sgz = std::clamp(gz, 0.0f, float(m_height - 1));
-                float gx0 = std::clamp(sgx - 1.0f, 0.0f, float(m_width - 1));
-                float gx1 = std::clamp(sgx + 1.0f, 0.0f, float(m_width - 1));
-                float gz0 = std::clamp(sgz - 1.0f, 0.0f, float(m_height - 1));
-                float gz1 = std::clamp(sgz + 1.0f, 0.0f, float(m_height - 1));
-                auto h = [&](float x, float z) {
-                  int xi = int(std::round(x));
-                  int zi = int(std::round(z));
-                  return m_heightData[zi * m_width + xi];
-                };
-                QVector3D dx(2.0f * m_tileSize, h(gx1, sgz) - h(gx0, sgz),
-                             0.0f);
-                QVector3D dz(0.0f, h(sgx, gz1) - h(sgx, gz0),
-                             2.0f * m_tileSize);
-                n = QVector3D::crossProduct(dz, dx);
-                if (n.lengthSquared() > 0.0f)
-                  n.normalize();
-                if (n.isNull())
-                  n = QVector3D(0, 1, 0);
-              }
-              float slope = 1.0f - std::clamp(n.y(), 0.0f, 1.0f);
-
-              float northnessP = std::clamp(
-                  QVector3D::dotProduct(n, QVector3D(0, 0, 1)) * 0.5f + 0.5f,
-                  0.0f, 1.0f);
-              auto Hs = [&](int ix, int iz) {
-                ix = std::clamp(ix, 0, m_width - 1);
-                iz = std::clamp(iz, 0, m_height - 1);
-                return m_heightData[iz * m_width + ix];
-              };
-              int ix = int(std::round(gx)), iz = int(std::round(gz));
-              float concav = std::max(0.0f, (Hs(ix - 1, iz) + Hs(ix + 1, iz) +
-                                             Hs(ix, iz - 1) + Hs(ix, iz + 1)) *
-                                                    0.25f -
-                                                Hs(ix, iz));
-              concav = std::clamp(concav * 0.15f, 0.0f, 1.0f);
-
-              float tuftAffinity = (1.0f - slope) * (0.6f + 0.4f * northnessP) *
-                                   (0.7f + 0.3f * concav);
-              float pebbleAffinity =
-                  (0.3f + 0.7f * slope) * (0.6f + 0.4f * concav);
-
-              float r = rand01(state);
-              PropInstance instance;
-              if (r < 0.55f * tuftAffinity && slope < 0.6f) {
-                instance.type = PropType::Tuft;
-                instance.color = applyTint(QVector3D(0.28f, 0.62f, 0.24f),
-                                           remap(rand01(state), 0.9f, 1.15f));
-                instance.scale = QVector3D(remap(rand01(state), 0.20f, 0.32f),
-                                           remap(rand01(state), 0.45f, 0.7f),
-                                           remap(rand01(state), 0.20f, 0.32f));
-                instance.alpha = 1.0f;
-              } else if (r < 0.85f * pebbleAffinity) {
-                instance.type = PropType::Pebble;
-                instance.color = applyTint(QVector3D(0.44f, 0.42f, 0.40f),
-                                           remap(rand01(state), 0.85f, 1.08f));
-                instance.scale = QVector3D(remap(rand01(state), 0.12f, 0.26f),
-                                           remap(rand01(state), 0.06f, 0.12f),
-                                           remap(rand01(state), 0.12f, 0.26f));
-                instance.alpha = 1.0f;
-              } else {
-                instance.type = PropType::Stick;
-                instance.color = applyTint(QVector3D(0.36f, 0.25f, 0.13f),
-                                           remap(rand01(state), 0.95f, 1.12f));
-                instance.scale = QVector3D(remap(rand01(state), 0.06f, 0.1f),
-                                           remap(rand01(state), 0.35f, 0.6f),
-                                           remap(rand01(state), 0.06f, 0.1f));
-                instance.alpha = 1.0f;
-              }
-              instance.rotationDeg = rand01(state) * 360.0f;
-              instance.position = QVector3D(worldX, worldY, worldZ);
-
-              if (slope < 0.95f)
-                m_props.push_back(instance);
-            }
-          }
-        }
+        TerrainChunkParams params;
+        auto tintColor = [&](const QVector3D &base) {
+          return clamp01(applyTint(base, chunk.tint));
+        };
+        params.grassPrimary = tintColor(m_biomeSettings.grassPrimary);
+        params.grassSecondary = tintColor(m_biomeSettings.grassSecondary);
+        params.grassDry = tintColor(m_biomeSettings.grassDry);
+        params.soilColor = tintColor(m_biomeSettings.soilColor);
+        params.rockLow = tintColor(m_biomeSettings.rockLow);
+        params.rockHigh = tintColor(m_biomeSettings.rockHigh);
+        params.tileSize = std::max(0.001f, m_tileSize);
+        params.macroNoiseScale = m_biomeSettings.terrainMacroNoiseScale;
+        params.detailNoiseScale = m_biomeSettings.terrainDetailNoiseScale;
+
+        float slopeThreshold = m_biomeSettings.terrainRockThreshold;
+        if (chunk.type == Game::Map::TerrainType::Hill)
+          slopeThreshold -= 0.05f;
+        else if (chunk.type == Game::Map::TerrainType::Mountain)
+          slopeThreshold -= 0.12f;
+        slopeThreshold -= std::clamp(avgSlope * 0.25f, 0.0f, 0.15f);
+        params.slopeRockThreshold = std::clamp(slopeThreshold, 0.05f, 0.9f);
+        params.slopeRockSharpness =
+            std::max(1.0f, m_biomeSettings.terrainRockSharpness);
+
+        float soilHeight = m_biomeSettings.terrainSoilHeight;
+        if (chunk.type == Game::Map::TerrainType::Hill)
+          soilHeight -= 0.08f;
+        else if (chunk.type == Game::Map::TerrainType::Mountain)
+          soilHeight -= 0.18f;
+        soilHeight += std::clamp((0.35f - avgSlope) * 0.18f, -0.12f, 0.12f);
+        params.soilBlendHeight = soilHeight;
+        params.soilBlendSharpness =
+            std::max(0.75f, m_biomeSettings.terrainSoilSharpness);
+
+        const uint32_t noiseKeyA =
+            hashCoords(chunk.minX, chunk.minZ, m_noiseSeed ^ 0xB5297A4Du);
+        const uint32_t noiseKeyB =
+            hashCoords(chunk.minX, chunk.minZ, m_noiseSeed ^ 0x68E31DA4u);
+        params.noiseOffset = QVector2D(hashTo01(noiseKeyA) * 256.0f,
+                                       hashTo01(noiseKeyB) * 256.0f);
+
+        params.heightNoiseStrength =
+            m_biomeSettings.heightNoiseAmplitude *
+            (0.7f + 0.3f * std::clamp(roughness * 0.6f, 0.0f, 1.0f));
+        params.heightNoiseFrequency = m_biomeSettings.heightNoiseFrequency;
+        params.ambientBoost =
+            m_biomeSettings.terrainAmbientBoost *
+            (0.9f + 0.1f * (1.0f - std::clamp(aoAvg * 1.6f, 0.0f, 1.0f)));
+        params.rockDetailStrength =
+            m_biomeSettings.terrainRockDetailStrength *
+            (0.75f + 0.25f * std::clamp(avgSlope * 1.4f, 0.0f, 1.0f));
+        params.tint = clamp01(QVector3D(chunk.tint, chunk.tint, chunk.tint));
+        params.lightDirection = QVector3D(0.35f, 0.8f, 0.45f);
+
+        chunk.params = params;
 
 
         totalTriangles += chunk.mesh->getIndices().size() / 3;
         totalTriangles += chunk.mesh->getIndices().size() / 3;
         m_chunks.push_back(std::move(chunk));
         m_chunks.push_back(std::move(chunk));
@@ -729,19 +581,26 @@ QVector3D TerrainRenderer::getTerrainColor(Game::Map::TerrainType type,
   switch (type) {
   switch (type) {
   case Game::Map::TerrainType::Mountain:
   case Game::Map::TerrainType::Mountain:
     if (height > 4.0f) {
     if (height > 4.0f) {
-      return QVector3D(0.66f, 0.68f, 0.72f);
-    } else {
-      return QVector3D(0.50f, 0.48f, 0.46f);
+      return m_biomeSettings.rockHigh;
     }
     }
+    return m_biomeSettings.rockLow;
   case Game::Map::TerrainType::Hill: {
   case Game::Map::TerrainType::Hill: {
     float t = std::clamp(height / 3.0f, 0.0f, 1.0f);
     float t = std::clamp(height / 3.0f, 0.0f, 1.0f);
-    QVector3D lushGrass(0.34f, 0.66f, 0.30f);
-    QVector3D sunKissed(0.58f, 0.49f, 0.35f);
-    return lushGrass * (1.0f - t) + sunKissed * t;
+    QVector3D grass = m_biomeSettings.grassSecondary * (1.0f - t) +
+                      m_biomeSettings.grassDry * t;
+    QVector3D rock =
+        m_biomeSettings.rockLow * (1.0f - t) + m_biomeSettings.rockHigh * t;
+    float rockBlend = std::clamp(0.25f + 0.5f * t, 0.0f, 0.75f);
+    return grass * (1.0f - rockBlend) + rock * rockBlend;
   }
   }
   case Game::Map::TerrainType::Flat:
   case Game::Map::TerrainType::Flat:
-  default:
-    return QVector3D(0.27f, 0.58f, 0.30f);
+  default: {
+    float moisture = std::clamp((height - 0.5f) * 0.2f, 0.0f, 0.4f);
+    QVector3D base = m_biomeSettings.grassPrimary * (1.0f - moisture) +
+                     m_biomeSettings.grassSecondary * moisture;
+    float dryBlend = std::clamp((height - 2.0f) * 0.12f, 0.0f, 0.3f);
+    return base * (1.0f - dryBlend) + m_biomeSettings.grassDry * dryBlend;
+  }
   }
   }
 }
 }
 
 

+ 7 - 13
render/ground/terrain_renderer.h

@@ -1,6 +1,7 @@
 #pragma once
 #pragma once
 
 
 #include "../../game/map/terrain.h"
 #include "../../game/map/terrain.h"
+#include "terrain_gpu.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
 #include <cmath>
 #include <cmath>
@@ -10,6 +11,7 @@
 
 
 namespace Render {
 namespace Render {
 namespace GL {
 namespace GL {
+class Buffer;
 class Renderer;
 class Renderer;
 class ResourceManager;
 class ResourceManager;
 class Mesh;
 class Mesh;
@@ -20,7 +22,8 @@ public:
   TerrainRenderer();
   TerrainRenderer();
   ~TerrainRenderer();
   ~TerrainRenderer();
 
 
-  void configure(const Game::Map::TerrainHeightMap &heightMap);
+  void configure(const Game::Map::TerrainHeightMap &heightMap,
+                 const Game::Map::BiomeSettings &biomeSettings);
 
 
   void submit(Renderer &renderer, ResourceManager &resources);
   void submit(Renderer &renderer, ResourceManager &resources);
 
 
@@ -41,17 +44,7 @@ private:
     QVector3D color{0.3f, 0.5f, 0.3f};
     QVector3D color{0.3f, 0.5f, 0.3f};
     float averageHeight = 0.0f;
     float averageHeight = 0.0f;
     float tint = 1.0f;
     float tint = 1.0f;
-  };
-
-  enum class PropType { Pebble, Tuft, Stick };
-
-  struct PropInstance {
-    PropType type = PropType::Pebble;
-    QVector3D position{0.0f, 0.0f, 0.0f};
-    QVector3D scale{1.0f, 1.0f, 1.0f};
-    QVector3D color{0.4f, 0.4f, 0.4f};
-    float alpha = 1.0f;
-    float rotationDeg = 0.0f;
+    TerrainChunkParams params;
   };
   };
 
 
   int m_width = 0;
   int m_width = 0;
@@ -62,7 +55,8 @@ private:
   std::vector<float> m_heightData;
   std::vector<float> m_heightData;
   std::vector<Game::Map::TerrainType> m_terrainTypes;
   std::vector<Game::Map::TerrainType> m_terrainTypes;
   std::vector<ChunkMesh> m_chunks;
   std::vector<ChunkMesh> m_chunks;
-  std::vector<PropInstance> m_props;
+  Game::Map::BiomeSettings m_biomeSettings;
+  std::uint32_t m_noiseSeed = 0u;
 };
 };
 
 
 } // namespace GL
 } // namespace GL

+ 92 - 13
render/scene_renderer.cpp

@@ -5,6 +5,7 @@
 #include "game/core/world.h"
 #include "game/core/world.h"
 #include "gl/backend.h"
 #include "gl/backend.h"
 #include "gl/camera.h"
 #include "gl/camera.h"
+#include "gl/primitives.h"
 #include "gl/resources.h"
 #include "gl/resources.h"
 #include <QDebug>
 #include <QDebug>
 #include <algorithm>
 #include <algorithm>
@@ -12,7 +13,13 @@
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
-Renderer::Renderer() {}
+namespace {
+const QVector3D kAxisX(1.0f, 0.0f, 0.0f);
+const QVector3D kAxisY(0.0f, 1.0f, 0.0f);
+const QVector3D kAxisZ(0.0f, 0.0f, 1.0f);
+} // namespace
+
+Renderer::Renderer() { m_activeQueue = &m_queues[m_fillQueueIndex]; }
 
 
 Renderer::~Renderer() { shutdown(); }
 Renderer::~Renderer() { shutdown(); }
 
 
@@ -28,19 +35,25 @@ bool Renderer::initialize() {
 void Renderer::shutdown() { m_backend.reset(); }
 void Renderer::shutdown() { m_backend.reset(); }
 
 
 void Renderer::beginFrame() {
 void Renderer::beginFrame() {
-  if (m_paused.load())
-    return;
+  m_activeQueue = &m_queues[m_fillQueueIndex];
+  m_activeQueue->clear();
+
+  if (m_camera) {
+    m_viewProj = m_camera->getProjectionMatrix() * m_camera->getViewMatrix();
+  }
+
   if (m_backend)
   if (m_backend)
     m_backend->beginFrame();
     m_backend->beginFrame();
-  m_queue.clear();
 }
 }
 
 
 void Renderer::endFrame() {
 void Renderer::endFrame() {
   if (m_paused.load())
   if (m_paused.load())
     return;
     return;
   if (m_backend && m_camera) {
   if (m_backend && m_camera) {
-    m_queue.sortForBatching();
-    m_backend->execute(m_queue, *m_camera);
+    std::swap(m_fillQueueIndex, m_renderQueueIndex);
+    DrawQueue &renderQueue = m_queues[m_renderQueueIndex];
+    renderQueue.sortForBatching();
+    m_backend->execute(renderQueue, *m_camera);
   }
   }
 }
 }
 
 
@@ -66,43 +79,109 @@ void Renderer::mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
                     Texture *texture, float alpha) {
                     Texture *texture, float alpha) {
   if (!mesh)
   if (!mesh)
     return;
     return;
+
+  if (mesh == getUnitCylinder() && (!texture)) {
+    QVector3D start, end;
+    float radius = 0.0f;
+    if (detail::decomposeUnitCylinder(model, start, end, radius)) {
+      cylinder(start, end, radius, color, alpha);
+      return;
+    }
+  }
   MeshCmd cmd;
   MeshCmd cmd;
   cmd.mesh = mesh;
   cmd.mesh = mesh;
   cmd.texture = texture;
   cmd.texture = texture;
   cmd.model = model;
   cmd.model = model;
+  cmd.mvp = m_viewProj * model;
+  cmd.color = color;
+  cmd.alpha = alpha;
+  if (m_activeQueue)
+    m_activeQueue->submit(cmd);
+}
+
+void Renderer::cylinder(const QVector3D &start, const QVector3D &end,
+                        float radius, const QVector3D &color, float alpha) {
+  CylinderCmd cmd;
+  cmd.start = start;
+  cmd.end = end;
+  cmd.radius = radius;
   cmd.color = color;
   cmd.color = color;
   cmd.alpha = alpha;
   cmd.alpha = alpha;
-  m_queue.submit(cmd);
+  if (m_activeQueue)
+    m_activeQueue->submit(cmd);
+}
+
+void Renderer::fogBatch(const FogInstanceData *instances, std::size_t count) {
+  if (!instances || count == 0 || !m_activeQueue)
+    return;
+  FogBatchCmd cmd;
+  cmd.instances = instances;
+  cmd.count = count;
+  m_activeQueue->submit(cmd);
+}
+
+void Renderer::grassBatch(Buffer *instanceBuffer, std::size_t instanceCount,
+                          const GrassBatchParams &params) {
+  if (!instanceBuffer || instanceCount == 0 || !m_activeQueue)
+    return;
+  GrassBatchCmd cmd;
+  cmd.instanceBuffer = instanceBuffer;
+  cmd.instanceCount = instanceCount;
+  cmd.params = params;
+  cmd.params.time = m_accumulatedTime;
+  m_activeQueue->submit(cmd);
+}
+
+void Renderer::terrainChunk(Mesh *mesh, const QMatrix4x4 &model,
+                            const TerrainChunkParams &params,
+                            std::uint16_t sortKey, bool depthWrite,
+                            float depthBias) {
+  if (!mesh || !m_activeQueue)
+    return;
+  TerrainChunkCmd cmd;
+  cmd.mesh = mesh;
+  cmd.model = model;
+  cmd.params = params;
+  cmd.sortKey = sortKey;
+  cmd.depthWrite = depthWrite;
+  cmd.depthBias = depthBias;
+  m_activeQueue->submit(cmd);
 }
 }
 
 
 void Renderer::selectionRing(const QMatrix4x4 &model, float alphaInner,
 void Renderer::selectionRing(const QMatrix4x4 &model, float alphaInner,
                              float alphaOuter, const QVector3D &color) {
                              float alphaOuter, const QVector3D &color) {
   SelectionRingCmd cmd;
   SelectionRingCmd cmd;
   cmd.model = model;
   cmd.model = model;
+  cmd.mvp = m_viewProj * model;
   cmd.alphaInner = alphaInner;
   cmd.alphaInner = alphaInner;
   cmd.alphaOuter = alphaOuter;
   cmd.alphaOuter = alphaOuter;
   cmd.color = color;
   cmd.color = color;
-  m_queue.submit(cmd);
+  if (m_activeQueue)
+    m_activeQueue->submit(cmd);
 }
 }
 
 
 void Renderer::grid(const QMatrix4x4 &model, const QVector3D &color,
 void Renderer::grid(const QMatrix4x4 &model, const QVector3D &color,
                     float cellSize, float thickness, float extent) {
                     float cellSize, float thickness, float extent) {
   GridCmd cmd;
   GridCmd cmd;
   cmd.model = model;
   cmd.model = model;
+  cmd.mvp = m_viewProj * model;
   cmd.color = color;
   cmd.color = color;
   cmd.cellSize = cellSize;
   cmd.cellSize = cellSize;
   cmd.thickness = thickness;
   cmd.thickness = thickness;
   cmd.extent = extent;
   cmd.extent = extent;
-  m_queue.submit(cmd);
+  if (m_activeQueue)
+    m_activeQueue->submit(cmd);
 }
 }
 
 
 void Renderer::selectionSmoke(const QMatrix4x4 &model, const QVector3D &color,
 void Renderer::selectionSmoke(const QMatrix4x4 &model, const QVector3D &color,
                               float baseAlpha) {
                               float baseAlpha) {
   SelectionSmokeCmd cmd;
   SelectionSmokeCmd cmd;
   cmd.model = model;
   cmd.model = model;
+  cmd.mvp = m_viewProj * model;
   cmd.color = color;
   cmd.color = color;
   cmd.baseAlpha = baseAlpha;
   cmd.baseAlpha = baseAlpha;
-  m_queue.submit(cmd);
+  if (m_activeQueue)
+    m_activeQueue->submit(cmd);
 }
 }
 
 
 void Renderer::renderWorld(Engine::Core::World *world) {
 void Renderer::renderWorld(Engine::Core::World *world) {
@@ -137,9 +216,9 @@ void Renderer::renderWorld(Engine::Core::World *world) {
     QMatrix4x4 modelMatrix;
     QMatrix4x4 modelMatrix;
     modelMatrix.translate(transform->position.x, transform->position.y,
     modelMatrix.translate(transform->position.x, transform->position.y,
                           transform->position.z);
                           transform->position.z);
-    modelMatrix.rotate(transform->rotation.x, QVector3D(1, 0, 0));
-    modelMatrix.rotate(transform->rotation.y, QVector3D(0, 1, 0));
-    modelMatrix.rotate(transform->rotation.z, QVector3D(0, 0, 1));
+    modelMatrix.rotate(transform->rotation.x, kAxisX);
+    modelMatrix.rotate(transform->rotation.y, kAxisY);
+    modelMatrix.rotate(transform->rotation.z, kAxisZ);
     modelMatrix.scale(transform->scale.x, transform->scale.y,
     modelMatrix.scale(transform->scale.x, transform->scale.y,
                       transform->scale.z);
                       transform->scale.z);
 
 

+ 17 - 1
render/scene_renderer.h

@@ -8,6 +8,7 @@
 #include "gl/texture.h"
 #include "gl/texture.h"
 #include "submitter.h"
 #include "submitter.h"
 #include <atomic>
 #include <atomic>
+#include <cstdint>
 #include <memory>
 #include <memory>
 #include <mutex>
 #include <mutex>
 #include <optional>
 #include <optional>
@@ -105,6 +106,8 @@ public:
 
 
   void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
   void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
             Texture *texture = nullptr, float alpha = 1.0f) override;
             Texture *texture = nullptr, float alpha = 1.0f) override;
+  void cylinder(const QVector3D &start, const QVector3D &end, float radius,
+                const QVector3D &color, float alpha = 1.0f) override;
   void selectionRing(const QMatrix4x4 &model, float alphaInner,
   void selectionRing(const QMatrix4x4 &model, float alphaInner,
                      float alphaOuter, const QVector3D &color) override;
                      float alphaOuter, const QVector3D &color) override;
 
 
@@ -113,16 +116,27 @@ public:
 
 
   void selectionSmoke(const QMatrix4x4 &model, const QVector3D &color,
   void selectionSmoke(const QMatrix4x4 &model, const QVector3D &color,
                       float baseAlpha = 0.15f) override;
                       float baseAlpha = 0.15f) override;
+  void terrainChunk(Mesh *mesh, const QMatrix4x4 &model,
+                    const TerrainChunkParams &params,
+                    std::uint16_t sortKey = 0x8000u, bool depthWrite = true,
+                    float depthBias = 0.0f);
 
 
   void renderWorld(Engine::Core::World *world);
   void renderWorld(Engine::Core::World *world);
 
 
   void lockWorldForModification() { m_worldMutex.lock(); }
   void lockWorldForModification() { m_worldMutex.lock(); }
   void unlockWorldForModification() { m_worldMutex.unlock(); }
   void unlockWorldForModification() { m_worldMutex.unlock(); }
 
 
+  void fogBatch(const FogInstanceData *instances, std::size_t count);
+  void grassBatch(Buffer *instanceBuffer, std::size_t instanceCount,
+                  const GrassBatchParams &params);
+
 private:
 private:
   Camera *m_camera = nullptr;
   Camera *m_camera = nullptr;
   std::shared_ptr<Backend> m_backend;
   std::shared_ptr<Backend> m_backend;
-  DrawQueue m_queue;
+  DrawQueue m_queues[2];
+  DrawQueue *m_activeQueue = nullptr;
+  int m_fillQueueIndex = 0;
+  int m_renderQueueIndex = 1;
 
 
   std::unique_ptr<EntityRendererRegistry> m_entityRegistry;
   std::unique_ptr<EntityRendererRegistry> m_entityRegistry;
   unsigned int m_hoveredEntityId = 0;
   unsigned int m_hoveredEntityId = 0;
@@ -136,6 +150,8 @@ private:
 
 
   std::mutex m_worldMutex;
   std::mutex m_worldMutex;
   int m_localOwnerId = 1;
   int m_localOwnerId = 1;
+
+  QMatrix4x4 m_viewProj;
 };
 };
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 43 - 0
render/submitter.h

@@ -1,6 +1,7 @@
 #pragma once
 #pragma once
 
 
 #include "draw_queue.h"
 #include "draw_queue.h"
+#include "gl/primitives.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
 
 
@@ -16,6 +17,9 @@ public:
   virtual ~ISubmitter() = default;
   virtual ~ISubmitter() = default;
   virtual void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
   virtual void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
                     Texture *tex = nullptr, float alpha = 1.0f) = 0;
                     Texture *tex = nullptr, float alpha = 1.0f) = 0;
+  virtual void cylinder(const QVector3D &start, const QVector3D &end,
+                        float radius, const QVector3D &color,
+                        float alpha = 1.0f) = 0;
   virtual void selectionRing(const QMatrix4x4 &model, float alphaInner,
   virtual void selectionRing(const QMatrix4x4 &model, float alphaInner,
                              float alphaOuter, const QVector3D &color) = 0;
                              float alphaOuter, const QVector3D &color) = 0;
   virtual void grid(const QMatrix4x4 &model, const QVector3D &color,
   virtual void grid(const QMatrix4x4 &model, const QVector3D &color,
@@ -24,6 +28,18 @@ public:
                               float baseAlpha = 0.15f) = 0;
                               float baseAlpha = 0.15f) = 0;
 };
 };
 
 
+namespace detail {
+inline bool decomposeUnitCylinder(const QMatrix4x4 &model, QVector3D &start,
+                                  QVector3D &end, float &radius) {
+  start = model.map(QVector3D(0.0f, -0.5f, 0.0f));
+  end = model.map(QVector3D(0.0f, 0.5f, 0.0f));
+  QVector3D sx = model.mapVector(QVector3D(1.0f, 0.0f, 0.0f));
+  QVector3D sz = model.mapVector(QVector3D(0.0f, 0.0f, 1.0f));
+  radius = 0.5f * (sx.length() + sz.length());
+  return radius > 0.0f;
+}
+} // namespace detail
+
 class QueueSubmitter : public ISubmitter {
 class QueueSubmitter : public ISubmitter {
 public:
 public:
   explicit QueueSubmitter(DrawQueue *queue) : m_queue(queue) {}
   explicit QueueSubmitter(DrawQueue *queue) : m_queue(queue) {}
@@ -31,6 +47,21 @@ public:
             Texture *tex = nullptr, float alpha = 1.0f) override {
             Texture *tex = nullptr, float alpha = 1.0f) override {
     if (!m_queue || !mesh)
     if (!m_queue || !mesh)
       return;
       return;
+
+    if (mesh == getUnitCylinder() && (!tex)) {
+      QVector3D start, end;
+      float radius = 0.0f;
+      if (detail::decomposeUnitCylinder(model, start, end, radius)) {
+        CylinderCmd cyl;
+        cyl.start = start;
+        cyl.end = end;
+        cyl.radius = radius;
+        cyl.color = color;
+        cyl.alpha = alpha;
+        m_queue->submit(cyl);
+        return;
+      }
+    }
     MeshCmd cmd;
     MeshCmd cmd;
     cmd.mesh = mesh;
     cmd.mesh = mesh;
     cmd.texture = tex;
     cmd.texture = tex;
@@ -39,6 +70,18 @@ public:
     cmd.alpha = alpha;
     cmd.alpha = alpha;
     m_queue->submit(cmd);
     m_queue->submit(cmd);
   }
   }
+  void cylinder(const QVector3D &start, const QVector3D &end, float radius,
+                const QVector3D &color, float alpha = 1.0f) override {
+    if (!m_queue)
+      return;
+    CylinderCmd cmd;
+    cmd.start = start;
+    cmd.end = end;
+    cmd.radius = radius;
+    cmd.color = color;
+    cmd.alpha = alpha;
+    m_queue->submit(cmd);
+  }
   void selectionRing(const QMatrix4x4 &model, float alphaInner,
   void selectionRing(const QMatrix4x4 &model, float alphaInner,
                      float alphaOuter, const QVector3D &color) override {
                      float alphaOuter, const QVector3D &color) override {
     if (!m_queue)
     if (!m_queue)

+ 17 - 17
ui/qml/HUDTop.qml

@@ -9,7 +9,7 @@ Item {
     signal pauseToggled()
     signal pauseToggled()
     signal speedChanged(real speed)
     signal speedChanged(real speed)
 
 
-    // --- Responsive helpers
+    
     readonly property int barMinHeight: 72
     readonly property int barMinHeight: 72
     readonly property bool compact: width < 800
     readonly property bool compact: width < 800
     readonly property bool ultraCompact: width < 560
     readonly property bool ultraCompact: width < 560
@@ -24,7 +24,7 @@ Item {
         opacity: 0.98
         opacity: 0.98
         clip: true
         clip: true
 
 
-        // Subtle gradient
+        
         Rectangle {
         Rectangle {
             anchors.fill: parent
             anchors.fill: parent
             gradient: Gradient {
             gradient: Gradient {
@@ -34,7 +34,7 @@ Item {
             opacity: 0.9
             opacity: 0.9
         }
         }
 
 
-        // Bottom accent line
+        
         Rectangle {
         Rectangle {
             anchors.left: parent.left
             anchors.left: parent.left
             anchors.right: parent.right
             anchors.right: parent.right
@@ -47,25 +47,25 @@ Item {
             }
             }
         }
         }
 
 
-        // === Flex-like layout: left group | spacer | right group
+        
         RowLayout {
         RowLayout {
             id: barRow
             id: barRow
             anchors.fill: parent
             anchors.fill: parent
             anchors.margins: 8
             anchors.margins: 8
             spacing: 12
             spacing: 12
 
 
-            // ---------- LEFT GROUP ----------
+            
             RowLayout {
             RowLayout {
                 id: leftGroup
                 id: leftGroup
                 spacing: 10
                 spacing: 10
                 Layout.alignment: Qt.AlignVCenter
                 Layout.alignment: Qt.AlignVCenter
 
 
-                // Pause/Play
+                
                 Button {
                 Button {
                     id: pauseBtn
                     id: pauseBtn
                     Layout.preferredWidth: topRoot.compact ? 48 : 56
                     Layout.preferredWidth: topRoot.compact ? 48 : 56
                     Layout.preferredHeight: Math.min(40, topPanel.height - 12)
                     Layout.preferredHeight: Math.min(40, topPanel.height - 12)
-                    text: topRoot.gameIsPaused ? "\u25B6" : "\u23F8" // ▶ / ⏸
+                    text: topRoot.gameIsPaused ? "\u25B6" : "\u23F8" 
                     font.pixelSize: 26
                     font.pixelSize: 26
                     font.bold: true
                     font.bold: true
                     focusPolicy: Qt.NoFocus
                     focusPolicy: Qt.NoFocus
@@ -86,7 +86,7 @@ Item {
                     onClicked: topRoot.pauseToggled()
                     onClicked: topRoot.pauseToggled()
                 }
                 }
 
 
-                // Separator
+                
                 Rectangle {
                 Rectangle {
                     width: 2; Layout.fillHeight: true; radius: 1
                     width: 2; Layout.fillHeight: true; radius: 1
                     visible: !topRoot.compact
                     visible: !topRoot.compact
@@ -97,7 +97,7 @@ Item {
                     }
                     }
                 }
                 }
 
 
-                // Speed controls (buttons on wide, ComboBox on compact)
+                
                 RowLayout {
                 RowLayout {
                     spacing: 8
                     spacing: 8
                     Layout.alignment: Qt.AlignVCenter
                     Layout.alignment: Qt.AlignVCenter
@@ -166,7 +166,7 @@ Item {
                     }
                     }
                 }
                 }
 
 
-                // Separator
+                
                 Rectangle {
                 Rectangle {
                     width: 2; Layout.fillHeight: true; radius: 1
                     width: 2; Layout.fillHeight: true; radius: 1
                     visible: !topRoot.compact
                     visible: !topRoot.compact
@@ -177,7 +177,7 @@ Item {
                     }
                     }
                 }
                 }
 
 
-                // Camera controls
+                
                 RowLayout {
                 RowLayout {
                     spacing: 8
                     spacing: 8
                     Layout.alignment: Qt.AlignVCenter
                     Layout.alignment: Qt.AlignVCenter
@@ -215,7 +215,7 @@ Item {
                         id: resetBtn
                         id: resetBtn
                         Layout.preferredWidth: topRoot.compact ? 44 : 80
                         Layout.preferredWidth: topRoot.compact ? 44 : 80
                         Layout.preferredHeight: Math.min(34, topPanel.height - 16)
                         Layout.preferredHeight: Math.min(34, topPanel.height - 16)
-                        text: topRoot.compact ? "\u21BA" : "Reset" // ↺
+                        text: topRoot.compact ? "\u21BA" : "Reset" 
                         font.pixelSize: 13
                         font.pixelSize: 13
                         focusPolicy: Qt.NoFocus
                         focusPolicy: Qt.NoFocus
                         background: Rectangle {
                         background: Rectangle {
@@ -231,16 +231,16 @@ Item {
                 }
                 }
             }
             }
 
 
-            // Spacer creates "space-between"
+            
             Item { Layout.fillWidth: true }
             Item { Layout.fillWidth: true }
 
 
-            // ---------- RIGHT GROUP ----------
+            
             RowLayout {
             RowLayout {
                 id: rightGroup
                 id: rightGroup
                 spacing: 12
                 spacing: 12
                 Layout.alignment: Qt.AlignVCenter
                 Layout.alignment: Qt.AlignVCenter
 
 
-                // Stats side-by-side
+                
                 Row {
                 Row {
                     id: statsRow
                     id: statsRow
                     spacing: 10
                     spacing: 10
@@ -274,7 +274,7 @@ Item {
                     }
                     }
                 }
                 }
 
 
-                // Minimap (shrinks/hides on very small widths to prevent overflow)
+                
                 Item {
                 Item {
                     id: miniWrap
                     id: miniWrap
                     visible: !topRoot.ultraCompact
                     visible: !topRoot.ultraCompact
@@ -295,7 +295,7 @@ Item {
                             radius: 6
                             radius: 6
                             color: "#0a0f14"
                             color: "#0a0f14"
 
 
-                            // placeholder content; replace with live minimap
+                            
                             Label {
                             Label {
                                 anchors.centerIn: parent
                                 anchors.centerIn: parent
                                 text: "MINIMAP"
                                 text: "MINIMAP"