Browse Source

implement more detailed lod and formations

djeada 1 week ago
parent
commit
06e7de1474

+ 2 - 0
CMakeLists.txt

@@ -126,6 +126,7 @@ if(QT_VERSION_MAJOR EQUAL 6)
         app/core/game_engine.cpp
         app/core/game_engine.cpp
         app/core/language_manager.cpp
         app/core/language_manager.cpp
         app/models/audio_system_proxy.cpp
         app/models/audio_system_proxy.cpp
+        app/models/graphics_settings_proxy.cpp
         app/models/cursor_manager.cpp
         app/models/cursor_manager.cpp
         app/models/hover_tracker.cpp
         app/models/hover_tracker.cpp
         app/models/selected_units_model.cpp
         app/models/selected_units_model.cpp
@@ -141,6 +142,7 @@ else()
         app/core/game_engine.cpp
         app/core/game_engine.cpp
         app/core/language_manager.cpp
         app/core/language_manager.cpp
         app/models/audio_system_proxy.cpp
         app/models/audio_system_proxy.cpp
+        app/models/graphics_settings_proxy.cpp
         app/models/cursor_manager.cpp
         app/models/cursor_manager.cpp
         app/models/hover_tracker.cpp
         app/models/hover_tracker.cpp
         app/models/selected_units_model.cpp
         app/models/selected_units_model.cpp

+ 3 - 0
app/core/game_engine.cpp

@@ -1206,6 +1206,9 @@ void GameEngine::start_skirmish(const QString &map_path,
     m_runtime.victoryState = "";
     m_runtime.victoryState = "";
     emit victoryStateChanged();
     emit victoryStateChanged();
   }
   }
+  if (m_victoryService) {
+    m_victoryService->reset();
+  }
   m_enemyTroopsDefeated = 0;
   m_enemyTroopsDefeated = 0;
 
 
   if (!m_runtime.initialized) {
   if (!m_runtime.initialized) {

+ 74 - 0
app/models/graphics_settings_proxy.cpp

@@ -0,0 +1,74 @@
+#include "graphics_settings_proxy.h"
+
+#include "../../render/graphics_settings.h"
+
+namespace App::Models {
+
+GraphicsSettingsProxy::GraphicsSettingsProxy(QObject *parent)
+    : QObject(parent) {}
+
+int GraphicsSettingsProxy::qualityLevel() const {
+  return static_cast<int>(Render::GraphicsSettings::instance().quality());
+}
+
+void GraphicsSettingsProxy::setQualityLevel(int level) {
+  if (level < 0 || level > 3) {
+    return;
+  }
+
+  auto newQuality = static_cast<Render::GraphicsQuality>(level);
+  if (newQuality != Render::GraphicsSettings::instance().quality()) {
+    Render::GraphicsSettings::instance().setQuality(newQuality);
+    emit qualityLevelChanged();
+  }
+}
+
+QString GraphicsSettingsProxy::qualityName() const {
+  switch (Render::GraphicsSettings::instance().quality()) {
+  case Render::GraphicsQuality::Low:
+    return tr("Low");
+  case Render::GraphicsQuality::Medium:
+    return tr("Medium");
+  case Render::GraphicsQuality::High:
+    return tr("High");
+  case Render::GraphicsQuality::Ultra:
+    return tr("Ultra");
+  }
+  return tr("Medium");
+}
+
+QStringList GraphicsSettingsProxy::qualityOptions() const {
+  return {tr("Low"), tr("Medium"), tr("High"), tr("Ultra")};
+}
+
+void GraphicsSettingsProxy::setQualityByName(const QString &name) {
+  if (name == tr("Low")) {
+    setQualityLevel(0);
+  } else if (name == tr("Medium")) {
+    setQualityLevel(1);
+  } else if (name == tr("High")) {
+    setQualityLevel(2);
+  } else if (name == tr("Ultra")) {
+    setQualityLevel(3);
+  }
+}
+
+QString GraphicsSettingsProxy::getQualityDescription() const {
+  switch (Render::GraphicsSettings::instance().quality()) {
+  case Render::GraphicsQuality::Low:
+    return tr(
+        "Maximum performance. Aggressive LOD, reduced detail at distance.");
+  case Render::GraphicsQuality::Medium:
+    return tr(
+        "Balanced performance and quality. Recommended for most systems.");
+  case Render::GraphicsQuality::High:
+    return tr("Higher quality. More detail visible at distance. Requires "
+              "better hardware.");
+  case Render::GraphicsQuality::Ultra:
+    return tr(
+        "Maximum quality. Full detail always. Best hardware recommended.");
+  }
+  return QString();
+}
+
+} // namespace App::Models

+ 34 - 0
app/models/graphics_settings_proxy.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <QStringList>
+
+namespace App::Models {
+
+class GraphicsSettingsProxy : public QObject {
+  Q_OBJECT
+  Q_PROPERTY(int qualityLevel READ qualityLevel WRITE setQualityLevel NOTIFY
+                 qualityLevelChanged)
+  Q_PROPERTY(QString qualityName READ qualityName NOTIFY qualityLevelChanged)
+  Q_PROPERTY(QStringList qualityOptions READ qualityOptions CONSTANT)
+
+public:
+  explicit GraphicsSettingsProxy(QObject *parent = nullptr);
+  ~GraphicsSettingsProxy() override = default;
+
+  [[nodiscard]] int qualityLevel() const;
+  void setQualityLevel(int level);
+
+  [[nodiscard]] QString qualityName() const;
+
+  [[nodiscard]] QStringList qualityOptions() const;
+
+  Q_INVOKABLE void setQualityByName(const QString &name);
+  Q_INVOKABLE QString getQualityDescription() const;
+
+signals:
+  void qualityLevelChanged();
+};
+
+} // namespace App::Models

+ 3 - 3
assets/data/nations/carthage.json

@@ -29,7 +29,7 @@
       },
       },
       "visuals": {
       "visuals": {
         "render_scale": 0.5,
         "render_scale": 0.5,
-        "selection_ring_size": 1.2,
+        "selection_ring_size": 1.35,
         "selection_ring_y_offset": 0.0,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
         "renderer_id": "troops/carthage/archer"
         "renderer_id": "troops/carthage/archer"
@@ -64,7 +64,7 @@
       },
       },
       "visuals": {
       "visuals": {
         "render_scale": 0.6,
         "render_scale": 0.6,
-        "selection_ring_size": 1.1,
+        "selection_ring_size": 1.4,
         "selection_ring_y_offset": 0.0,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
         "renderer_id": "troops/carthage/swordsman"
         "renderer_id": "troops/carthage/swordsman"
@@ -99,7 +99,7 @@
       },
       },
       "visuals": {
       "visuals": {
         "render_scale": 0.57,
         "render_scale": 0.57,
-        "selection_ring_size": 1.48,
+        "selection_ring_size": 2.0,
         "selection_ring_y_offset": 0.0,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
         "renderer_id": "troops/carthage/spearman"
         "renderer_id": "troops/carthage/spearman"

+ 25 - 0
assets/shaders/primitive_instanced.frag

@@ -0,0 +1,25 @@
+#version 330 core
+
+in vec3 v_worldPos;
+in vec3 v_normal;
+in vec3 v_color;
+in float v_alpha;
+
+uniform vec3 u_lightDir;
+uniform float u_ambientStrength;
+
+out vec4 FragColor;
+
+void main() {
+  vec3 normal = normalize(v_normal);
+  vec3 lightDir = normalize(u_lightDir);
+
+  // Diffuse lighting
+  float diff = max(dot(normal, lightDir), 0.0);
+
+  // Combine ambient and diffuse
+  float lighting = u_ambientStrength + (1.0 - u_ambientStrength) * diff;
+
+  vec3 color = v_color * lighting;
+  FragColor = vec4(color, v_alpha);
+}

+ 44 - 0
assets/shaders/primitive_instanced.vert

@@ -0,0 +1,44 @@
+#version 330 core
+
+// Mesh vertex attributes
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+// Per-instance attributes (model matrix as 3 columns + color/alpha)
+layout(location = 3) in vec4 i_modelCol0;  // First column of model matrix
+layout(location = 4) in vec4 i_modelCol1;  // Second column of model matrix
+layout(location = 5) in vec4 i_modelCol2;  // Third column of model matrix
+layout(location = 6) in vec4 i_colorAlpha; // RGB color + alpha
+
+uniform mat4 u_viewProj;
+uniform vec3 u_lightDir;
+
+out vec3 v_worldPos;
+out vec3 v_normal;
+out vec3 v_color;
+out float v_alpha;
+
+void main() {
+  // Reconstruct model matrix from columns
+  // The 4th column (translation) is in the w components
+  mat4 modelMatrix =
+      mat4(vec4(i_modelCol0.xyz, 0.0), vec4(i_modelCol1.xyz, 0.0),
+           vec4(i_modelCol2.xyz, 0.0),
+           vec4(i_modelCol0.w, i_modelCol1.w, i_modelCol2.w, 1.0));
+
+  // Transform position
+  vec4 worldPos4 = modelMatrix * vec4(a_position, 1.0);
+  v_worldPos = worldPos4.xyz;
+
+  // Transform normal (using transpose of inverse for non-uniform scaling)
+  mat3 normalMatrix = mat3(modelMatrix);
+  // For uniform scaling, we can simplify
+  v_normal = normalize(normalMatrix * a_normal);
+
+  // Pass through color and alpha
+  v_color = i_colorAlpha.rgb;
+  v_alpha = i_colorAlpha.a;
+
+  gl_Position = u_viewProj * worldPos4;
+}

+ 5 - 0
game/systems/nation_loader.cpp

@@ -273,6 +273,11 @@ auto NationLoader::resolve_data_path(const QString &relative) -> QString {
     }
     }
   }
   }
 
 
+  const QString resource_path = QStringLiteral(":/") + relative;
+  if (QFile::exists(resource_path)) {
+    return resource_path;
+  }
+
   return {};
   return {};
 }
 }
 
 

+ 14 - 0
game/systems/victory_service.cpp

@@ -13,6 +13,10 @@
 
 
 namespace Game::Systems {
 namespace Game::Systems {
 
 
+namespace {
+constexpr float k_startup_delay_seconds = 0.35F;
+}
+
 VictoryService::VictoryService()
 VictoryService::VictoryService()
     : m_unitDiedSubscription(
     : m_unitDiedSubscription(
           [this](const Engine::Core::UnitDiedEvent &e) { onUnitDied(e); }),
           [this](const Engine::Core::UnitDiedEvent &e) { onUnitDied(e); }),
@@ -28,8 +32,11 @@ VictoryService::~VictoryService() = default;
 void VictoryService::reset() {
 void VictoryService::reset() {
   m_victoryState = "";
   m_victoryState = "";
   m_elapsedTime = 0.0F;
   m_elapsedTime = 0.0F;
+  m_startupDelay = 0.0F;
   m_worldPtr = nullptr;
   m_worldPtr = nullptr;
   m_victoryCallback = nullptr;
   m_victoryCallback = nullptr;
+  m_keyStructures.clear();
+  m_defeatConditions.clear();
 }
 }
 
 
 void VictoryService::configure(const Game::Map::VictoryConfig &config,
 void VictoryService::configure(const Game::Map::VictoryConfig &config,
@@ -61,6 +68,8 @@ void VictoryService::configure(const Game::Map::VictoryConfig &config,
   if (m_defeatConditions.empty()) {
   if (m_defeatConditions.empty()) {
     m_defeatConditions.push_back(DefeatCondition::NoKeyStructures);
     m_defeatConditions.push_back(DefeatCondition::NoKeyStructures);
   }
   }
+
+  m_startupDelay = k_startup_delay_seconds;
 }
 }
 
 
 void VictoryService::update(Engine::Core::World &world, float deltaTime) {
 void VictoryService::update(Engine::Core::World &world, float deltaTime) {
@@ -70,6 +79,11 @@ void VictoryService::update(Engine::Core::World &world, float deltaTime) {
 
 
   m_worldPtr = &world;
   m_worldPtr = &world;
 
 
+  if (m_startupDelay > 0.0F) {
+    m_startupDelay = std::max(0.0F, m_startupDelay - deltaTime);
+    return;
+  }
+
   if (m_victoryType == VictoryType::SurviveTime) {
   if (m_victoryType == VictoryType::SurviveTime) {
     m_elapsedTime += deltaTime;
     m_elapsedTime += deltaTime;
   }
   }

+ 1 - 0
game/systems/victory_service.h

@@ -65,6 +65,7 @@ private:
 
 
   float m_surviveTimeDuration = 0.0F;
   float m_surviveTimeDuration = 0.0F;
   float m_elapsedTime = 0.0F;
   float m_elapsedTime = 0.0F;
+  float m_startupDelay = 0.0F;
 
 
   int m_localOwnerId = 1;
   int m_localOwnerId = 1;
   QString m_victoryState;
   QString m_victoryState;

+ 5 - 0
game/units/troop_catalog_loader.cpp

@@ -93,6 +93,11 @@ auto TroopCatalogLoader::resolve_data_path(const QString &relative) -> QString {
     }
     }
   }
   }
 
 
+  const QString resource_path = QStringLiteral(":/") + relative;
+  if (QFile::exists(resource_path)) {
+    return resource_path;
+  }
+
   return {};
   return {};
 }
 }
 
 

+ 9 - 0
main.cpp

@@ -37,6 +37,7 @@
 
 
 #include "app/core/game_engine.h"
 #include "app/core/game_engine.h"
 #include "app/core/language_manager.h"
 #include "app/core/language_manager.h"
+#include "app/models/graphics_settings_proxy.h"
 #include "ui/gl_view.h"
 #include "ui/gl_view.h"
 #include "ui/theme.h"
 #include "ui/theme.h"
 
 
@@ -269,6 +270,7 @@ auto main(int argc, char *argv[]) -> int {
   // This ensures proper cleanup order and prevents segfaults
   // This ensures proper cleanup order and prevents segfaults
   std::unique_ptr<LanguageManager> language_manager;
   std::unique_ptr<LanguageManager> language_manager;
   std::unique_ptr<GameEngine> game_engine;
   std::unique_ptr<GameEngine> game_engine;
+  std::unique_ptr<App::Models::GraphicsSettingsProxy> graphics_settings;
   std::unique_ptr<QQmlApplicationEngine> engine;
   std::unique_ptr<QQmlApplicationEngine> engine;
 
 
   qInfo() << "Creating LanguageManager...";
   qInfo() << "Creating LanguageManager...";
@@ -279,12 +281,19 @@ auto main(int argc, char *argv[]) -> int {
   game_engine = std::make_unique<GameEngine>(&app);
   game_engine = std::make_unique<GameEngine>(&app);
   qInfo() << "GameEngine created";
   qInfo() << "GameEngine created";
 
 
+  qInfo() << "Creating GraphicsSettingsProxy...";
+  graphics_settings =
+      std::make_unique<App::Models::GraphicsSettingsProxy>(&app);
+  qInfo() << "GraphicsSettingsProxy created";
+
   qInfo() << "Setting up QML engine...";
   qInfo() << "Setting up QML engine...";
   engine = std::make_unique<QQmlApplicationEngine>();
   engine = std::make_unique<QQmlApplicationEngine>();
   qInfo() << "Adding context properties...";
   qInfo() << "Adding context properties...";
   engine->rootContext()->setContextProperty("languageManager",
   engine->rootContext()->setContextProperty("languageManager",
                                             language_manager.get());
                                             language_manager.get());
   engine->rootContext()->setContextProperty("game", game_engine.get());
   engine->rootContext()->setContextProperty("game", game_engine.get());
+  engine->rootContext()->setContextProperty("graphicsSettings",
+                                            graphics_settings.get());
   qInfo() << "Adding import path...";
   qInfo() << "Adding import path...";
   engine->addImportPath("qrc:/StandardOfIron/ui/qml");
   engine->addImportPath("qrc:/StandardOfIron/ui/qml");
   engine->addImportPath("qrc:/");
   engine->addImportPath("qrc:/");

+ 2 - 0
render/CMakeLists.txt

@@ -15,9 +15,11 @@ add_library(render_gl STATIC
     gl/backend/character_pipeline.cpp
     gl/backend/character_pipeline.cpp
     gl/backend/water_pipeline.cpp
     gl/backend/water_pipeline.cpp
     gl/backend/effects_pipeline.cpp
     gl/backend/effects_pipeline.cpp
+    gl/backend/primitive_batch_pipeline.cpp
     gl/shader_cache.cpp
     gl/shader_cache.cpp
     gl/state_scopes.cpp
     gl/state_scopes.cpp
     draw_queue.cpp
     draw_queue.cpp
+    primitive_batch.cpp
     ground/ground_renderer.cpp
     ground/ground_renderer.cpp
     ground/fog_renderer.cpp
     ground/fog_renderer.cpp
     ground/terrain_renderer.cpp
     ground/terrain_renderer.cpp

+ 25 - 11
render/draw_queue.h

@@ -7,6 +7,7 @@
 #include "ground/plant_gpu.h"
 #include "ground/plant_gpu.h"
 #include "ground/stone_gpu.h"
 #include "ground/stone_gpu.h"
 #include "ground/terrain_gpu.h"
 #include "ground/terrain_gpu.h"
+#include "primitive_batch.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
 #include <algorithm>
 #include <algorithm>
@@ -127,10 +128,11 @@ struct SelectionSmokeCmd {
   float baseAlpha = 0.15F;
   float baseAlpha = 0.15F;
 };
 };
 
 
-using DrawCmd = std::variant<GridCmd, SelectionRingCmd, SelectionSmokeCmd,
-                             CylinderCmd, MeshCmd, FogBatchCmd, GrassBatchCmd,
-                             StoneBatchCmd, PlantBatchCmd, PineBatchCmd,
-                             OliveBatchCmd, FireCampBatchCmd, TerrainChunkCmd>;
+using DrawCmd =
+    std::variant<GridCmd, SelectionRingCmd, SelectionSmokeCmd, CylinderCmd,
+                 MeshCmd, FogBatchCmd, GrassBatchCmd, StoneBatchCmd,
+                 PlantBatchCmd, PineBatchCmd, OliveBatchCmd, FireCampBatchCmd,
+                 TerrainChunkCmd, PrimitiveBatchCmd>;
 
 
 enum class DrawCmdType : std::uint8_t {
 enum class DrawCmdType : std::uint8_t {
   Grid = 0,
   Grid = 0,
@@ -145,7 +147,8 @@ enum class DrawCmdType : std::uint8_t {
   PineBatch = 9,
   PineBatch = 9,
   OliveBatch = 10,
   OliveBatch = 10,
   FireCampBatch = 11,
   FireCampBatch = 11,
-  TerrainChunk = 12
+  TerrainChunk = 12,
+  PrimitiveBatch = 13
 };
 };
 
 
 constexpr std::size_t MeshCmdIndex =
 constexpr std::size_t MeshCmdIndex =
@@ -174,6 +177,8 @@ constexpr std::size_t FireCampBatchCmdIndex =
     static_cast<std::size_t>(DrawCmdType::FireCampBatch);
     static_cast<std::size_t>(DrawCmdType::FireCampBatch);
 constexpr std::size_t TerrainChunkCmdIndex =
 constexpr std::size_t TerrainChunkCmdIndex =
     static_cast<std::size_t>(DrawCmdType::TerrainChunk);
     static_cast<std::size_t>(DrawCmdType::TerrainChunk);
+constexpr std::size_t PrimitiveBatchCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::PrimitiveBatch);
 
 
 inline auto drawCmdType(const DrawCmd &cmd) -> DrawCmdType {
 inline auto drawCmdType(const DrawCmd &cmd) -> DrawCmdType {
   return static_cast<DrawCmdType>(cmd.index());
   return static_cast<DrawCmdType>(cmd.index());
@@ -196,6 +201,7 @@ public:
   void submit(const OliveBatchCmd &c) { m_items.emplace_back(c); }
   void submit(const OliveBatchCmd &c) { m_items.emplace_back(c); }
   void submit(const FireCampBatchCmd &c) { m_items.emplace_back(c); }
   void submit(const FireCampBatchCmd &c) { m_items.emplace_back(c); }
   void submit(const TerrainChunkCmd &c) { m_items.emplace_back(c); }
   void submit(const TerrainChunkCmd &c) { m_items.emplace_back(c); }
+  void submit(const PrimitiveBatchCmd &c) { m_items.emplace_back(c); }
 
 
   [[nodiscard]] auto empty() const -> bool { return m_items.empty(); }
   [[nodiscard]] auto empty() const -> bool { return m_items.empty(); }
   [[nodiscard]] auto size() const -> std::size_t { return m_items.size(); }
   [[nodiscard]] auto size() const -> std::size_t { return m_items.size(); }
@@ -285,11 +291,12 @@ private:
       PineBatch = 4,
       PineBatch = 4,
       OliveBatch = 5,
       OliveBatch = 5,
       FireCampBatch = 6,
       FireCampBatch = 6,
-      Mesh = 7,
-      Cylinder = 8,
-      FogBatch = 9,
-      SelectionSmoke = 10,
-      Grid = 11,
+      PrimitiveBatch = 7,
+      Mesh = 8,
+      Cylinder = 9,
+      FogBatch = 10,
+      SelectionSmoke = 11,
+      Grid = 12,
       SelectionRing = 15
       SelectionRing = 15
     };
     };
 
 
@@ -306,7 +313,8 @@ private:
         static_cast<uint8_t>(RenderOrder::PineBatch),
         static_cast<uint8_t>(RenderOrder::PineBatch),
         static_cast<uint8_t>(RenderOrder::OliveBatch),
         static_cast<uint8_t>(RenderOrder::OliveBatch),
         static_cast<uint8_t>(RenderOrder::FireCampBatch),
         static_cast<uint8_t>(RenderOrder::FireCampBatch),
-        static_cast<uint8_t>(RenderOrder::TerrainChunk)};
+        static_cast<uint8_t>(RenderOrder::TerrainChunk),
+        static_cast<uint8_t>(RenderOrder::PrimitiveBatch)};
 
 
     const std::size_t typeIndex = cmd.index();
     const std::size_t typeIndex = cmd.index();
     constexpr std::size_t typeCount =
     constexpr std::size_t typeCount =
@@ -366,6 +374,12 @@ private:
       uint64_t const meshPtr =
       uint64_t const meshPtr =
           reinterpret_cast<uintptr_t>(terrain.mesh) & 0x0000FFFFFFFFFFFFU;
           reinterpret_cast<uintptr_t>(terrain.mesh) & 0x0000FFFFFFFFFFFFU;
       key |= meshPtr;
       key |= meshPtr;
+    } else if (cmd.index() == PrimitiveBatchCmdIndex) {
+      const auto &prim = std::get<PrimitiveBatchCmdIndex>(cmd);
+
+      key |= static_cast<uint64_t>(prim.type) << 48;
+
+      key |= static_cast<uint64_t>(prim.instanceCount() & 0xFFFFFFFF);
     }
     }
 
 
     return key;
     return key;

+ 11 - 1
render/entity/mounted_humanoid_renderer_base.cpp

@@ -1,5 +1,6 @@
 #include "mounted_humanoid_renderer_base.h"
 #include "mounted_humanoid_renderer_base.h"
 
 
+#include "../gl/camera.h"
 #include "../humanoid/humanoid_math.h"
 #include "../humanoid/humanoid_math.h"
 #include "../humanoid/humanoid_specs.h"
 #include "../humanoid/humanoid_specs.h"
 #include "../palette.h"
 #include "../palette.h"
@@ -127,8 +128,17 @@ void MountedHumanoidRendererBase::addAttachments(
       (is_current_pose) ? &m_last_motion : nullptr;
       (is_current_pose) ? &m_last_motion : nullptr;
   const AnimationInputs &anim = anim_ctx.inputs;
   const AnimationInputs &anim = anim_ctx.inputs;
 
 
+  HorseLOD horse_lod = HorseLOD::Full;
+  if (ctx.camera != nullptr) {
+    QVector3D const horse_world_pos =
+        ctx.model.map(QVector3D(0.0F, 0.0F, 0.0F));
+    float const distance =
+        (horse_world_pos - ctx.camera->getPosition()).length();
+    horse_lod = calculateHorseLOD(distance);
+  }
+
   m_horseRenderer.render(ctx, anim, anim_ctx, profile, mount_ptr, rein_ptr,
   m_horseRenderer.render(ctx, anim, anim_ctx, profile, mount_ptr, rein_ptr,
-                         motion_ptr, out);
+                         motion_ptr, out, horse_lod);
 
 
   m_last_pose = nullptr;
   m_last_pose = nullptr;
   m_has_last_reins = false;
   m_has_last_reins = false;

+ 16 - 0
render/entity/registry.h

@@ -18,10 +18,25 @@ class ResourceManager;
 class Mesh;
 class Mesh;
 class Texture;
 class Texture;
 class Backend;
 class Backend;
+class Camera;
 } // namespace Render::GL
 } // namespace Render::GL
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
+enum class HumanoidLOD : uint8_t {
+  Full = 0,
+  Reduced = 1,
+  Minimal = 2,
+  Billboard = 3
+};
+
+enum class HorseLOD : uint8_t {
+  Full = 0,
+  Reduced = 1,
+  Minimal = 2,
+  Billboard = 3
+};
+
 struct DrawContext {
 struct DrawContext {
   ResourceManager *resources = nullptr;
   ResourceManager *resources = nullptr;
   Engine::Core::Entity *entity = nullptr;
   Engine::Core::Entity *entity = nullptr;
@@ -32,6 +47,7 @@ struct DrawContext {
   float animationTime = 0.0F;
   float animationTime = 0.0F;
   std::string rendererId;
   std::string rendererId;
   class Backend *backend = nullptr;
   class Backend *backend = nullptr;
+  const Camera *camera = nullptr;
 };
 };
 
 
 using RenderFunc = std::function<void(const DrawContext &, ISubmitter &out)>;
 using RenderFunc = std::function<void(const DrawContext &, ISubmitter &out)>;

+ 40 - 0
render/gl/backend.cpp

@@ -2,9 +2,11 @@
 #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 "../primitive_batch.h"
 #include "backend/character_pipeline.h"
 #include "backend/character_pipeline.h"
 #include "backend/cylinder_pipeline.h"
 #include "backend/cylinder_pipeline.h"
 #include "backend/effects_pipeline.h"
 #include "backend/effects_pipeline.h"
+#include "backend/primitive_batch_pipeline.h"
 #include "backend/terrain_pipeline.h"
 #include "backend/terrain_pipeline.h"
 #include "backend/vegetation_pipeline.h"
 #include "backend/vegetation_pipeline.h"
 #include "backend/water_pipeline.h"
 #include "backend/water_pipeline.h"
@@ -134,6 +136,13 @@ void Backend::initialize() {
   m_effectsPipeline->initialize();
   m_effectsPipeline->initialize();
   qInfo() << "Backend: EffectsPipeline initialized";
   qInfo() << "Backend: EffectsPipeline initialized";
 
 
+  qInfo() << "Backend: Creating PrimitiveBatchPipeline...";
+  m_primitiveBatchPipeline =
+      std::make_unique<BackendPipelines::PrimitiveBatchPipeline>(
+          m_shaderCache.get());
+  m_primitiveBatchPipeline->initialize();
+  qInfo() << "Backend: PrimitiveBatchPipeline initialized";
+
   qInfo() << "Backend: Loading basic shaders...";
   qInfo() << "Backend: Loading basic shaders...";
   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"));
@@ -1402,6 +1411,37 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
       }
       }
       break;
       break;
     }
     }
+    case PrimitiveBatchCmdIndex: {
+      const auto &batch = std::get<PrimitiveBatchCmdIndex>(cmd);
+      if (batch.instanceCount() == 0 || m_primitiveBatchPipeline == nullptr ||
+          !m_primitiveBatchPipeline->isInitialized()) {
+        break;
+      }
+
+      const auto *data = batch.instanceData();
+
+      switch (batch.type) {
+      case PrimitiveType::Sphere:
+        m_primitiveBatchPipeline->uploadSphereInstances(data,
+                                                        batch.instanceCount());
+        m_primitiveBatchPipeline->drawSpheres(batch.instanceCount(), view_proj);
+        break;
+      case PrimitiveType::Cylinder:
+        m_primitiveBatchPipeline->uploadCylinderInstances(
+            data, batch.instanceCount());
+        m_primitiveBatchPipeline->drawCylinders(batch.instanceCount(),
+                                                view_proj);
+        break;
+      case PrimitiveType::Cone:
+        m_primitiveBatchPipeline->uploadConeInstances(data,
+                                                      batch.instanceCount());
+        m_primitiveBatchPipeline->drawCones(batch.instanceCount(), view_proj);
+        break;
+      }
+
+      m_lastBoundShader = m_primitiveBatchPipeline->shader();
+      break;
+    }
     default:
     default:
       break;
       break;
     }
     }

+ 3 - 0
render/gl/backend.h

@@ -24,6 +24,7 @@ class TerrainPipeline;
 class CharacterPipeline;
 class CharacterPipeline;
 class WaterPipeline;
 class WaterPipeline;
 class EffectsPipeline;
 class EffectsPipeline;
+class PrimitiveBatchPipeline;
 } // namespace Render::GL::BackendPipelines
 } // namespace Render::GL::BackendPipelines
 
 
 namespace Render::GL {
 namespace Render::GL {
@@ -105,6 +106,8 @@ private:
   std::unique_ptr<BackendPipelines::CharacterPipeline> m_characterPipeline;
   std::unique_ptr<BackendPipelines::CharacterPipeline> m_characterPipeline;
   std::unique_ptr<BackendPipelines::WaterPipeline> m_waterPipeline;
   std::unique_ptr<BackendPipelines::WaterPipeline> m_waterPipeline;
   std::unique_ptr<BackendPipelines::EffectsPipeline> m_effectsPipeline;
   std::unique_ptr<BackendPipelines::EffectsPipeline> m_effectsPipeline;
+  std::unique_ptr<BackendPipelines::PrimitiveBatchPipeline>
+      m_primitiveBatchPipeline;
 
 
   Shader *m_basicShader = nullptr;
   Shader *m_basicShader = nullptr;
   Shader *m_gridShader = nullptr;
   Shader *m_gridShader = nullptr;

+ 394 - 0
render/gl/backend/primitive_batch_pipeline.cpp

@@ -0,0 +1,394 @@
+#include "primitive_batch_pipeline.h"
+#include "../backend.h"
+#include "../mesh.h"
+#include "../primitives.h"
+#include "../render_constants.h"
+#include <GL/gl.h>
+#include <QOpenGLContext>
+#include <algorithm>
+#include <cstddef>
+
+namespace Render::GL::BackendPipelines {
+
+using namespace Render::GL::VertexAttrib;
+using namespace Render::GL::ComponentCount;
+
+PrimitiveBatchPipeline::PrimitiveBatchPipeline(ShaderCache *shaderCache)
+    : m_shaderCache(shaderCache) {}
+
+PrimitiveBatchPipeline::~PrimitiveBatchPipeline() { shutdown(); }
+
+auto PrimitiveBatchPipeline::initialize() -> bool {
+  initializeOpenGLFunctions();
+
+  if (m_shaderCache == nullptr) {
+    return false;
+  }
+
+  m_shader = m_shaderCache->get(QStringLiteral("primitive_instanced"));
+  if (m_shader == nullptr) {
+    return false;
+  }
+
+  initializeSphereVao();
+  initializeCylinderVao();
+  initializeConeVao();
+  cacheUniforms();
+
+  m_initialized = true;
+  return true;
+}
+
+void PrimitiveBatchPipeline::shutdown() {
+  shutdownVaos();
+  m_initialized = false;
+}
+
+void PrimitiveBatchPipeline::cacheUniforms() {
+  if (m_shader != nullptr) {
+    m_uniforms.viewProj = m_shader->uniformHandle("u_viewProj");
+    m_uniforms.lightDir = m_shader->uniformHandle("u_lightDir");
+    m_uniforms.ambientStrength = m_shader->uniformHandle("u_ambientStrength");
+  }
+}
+
+void PrimitiveBatchPipeline::beginFrame() {}
+
+void PrimitiveBatchPipeline::setupInstanceAttributes(GLuint vao,
+                                                     GLuint instanceBuffer) {
+  glBindVertexArray(vao);
+  glBindBuffer(GL_ARRAY_BUFFER, instanceBuffer);
+
+  const auto stride = static_cast<GLsizei>(sizeof(GL::PrimitiveInstanceGpu));
+
+  glEnableVertexAttribArray(3);
+  glVertexAttribPointer(
+      3, Vec4, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(GL::PrimitiveInstanceGpu, modelCol0)));
+  glVertexAttribDivisor(3, 1);
+
+  glEnableVertexAttribArray(4);
+  glVertexAttribPointer(
+      4, Vec4, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(GL::PrimitiveInstanceGpu, modelCol1)));
+  glVertexAttribDivisor(4, 1);
+
+  glEnableVertexAttribArray(5);
+  glVertexAttribPointer(
+      5, Vec4, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(GL::PrimitiveInstanceGpu, modelCol2)));
+  glVertexAttribDivisor(5, 1);
+
+  glEnableVertexAttribArray(6);
+  glVertexAttribPointer(
+      6, Vec4, GL_FLOAT, GL_FALSE, stride,
+      reinterpret_cast<void *>(offsetof(GL::PrimitiveInstanceGpu, colorAlpha)));
+  glVertexAttribDivisor(6, 1);
+
+  glBindVertexArray(0);
+}
+
+void PrimitiveBatchPipeline::initializeSphereVao() {
+  Mesh *unit = getUnitSphere();
+  if (unit == nullptr) {
+    return;
+  }
+
+  const auto &vertices = unit->getVertices();
+  const auto &indices = unit->getIndices();
+  if (vertices.empty() || indices.empty()) {
+    return;
+  }
+
+  glGenVertexArrays(1, &m_sphereVao);
+  glBindVertexArray(m_sphereVao);
+
+  glGenBuffers(1, &m_sphereVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_sphereVertexBuffer);
+  glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex),
+               vertices.data(), GL_STATIC_DRAW);
+
+  glGenBuffers(1, &m_sphereIndexBuffer);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_sphereIndexBuffer);
+  glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
+               indices.data(), GL_STATIC_DRAW);
+  m_sphereIndexCount = static_cast<GLsizei>(indices.size());
+
+  glEnableVertexAttribArray(Position);
+  glVertexAttribPointer(Position, Vec3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, position)));
+  glEnableVertexAttribArray(Normal);
+  glVertexAttribPointer(Normal, Vec3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, normal)));
+  glEnableVertexAttribArray(TexCoord);
+  glVertexAttribPointer(TexCoord, Vec2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, tex_coord)));
+
+  glGenBuffers(1, &m_sphereInstanceBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_sphereInstanceBuffer);
+  m_sphereInstanceCapacity = kDefaultInstanceCapacity;
+  glBufferData(GL_ARRAY_BUFFER,
+               m_sphereInstanceCapacity * sizeof(GL::PrimitiveInstanceGpu),
+               nullptr, GL_DYNAMIC_DRAW);
+
+  setupInstanceAttributes(m_sphereVao, m_sphereInstanceBuffer);
+  glBindVertexArray(0);
+}
+
+void PrimitiveBatchPipeline::initializeCylinderVao() {
+  Mesh *unit = getUnitCylinder();
+  if (unit == nullptr) {
+    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(Position);
+  glVertexAttribPointer(Position, Vec3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, position)));
+  glEnableVertexAttribArray(Normal);
+  glVertexAttribPointer(Normal, Vec3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, normal)));
+  glEnableVertexAttribArray(TexCoord);
+  glVertexAttribPointer(TexCoord, Vec2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, tex_coord)));
+
+  glGenBuffers(1, &m_cylinderInstanceBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_cylinderInstanceBuffer);
+  m_cylinderInstanceCapacity = kDefaultInstanceCapacity;
+  glBufferData(GL_ARRAY_BUFFER,
+               m_cylinderInstanceCapacity * sizeof(GL::PrimitiveInstanceGpu),
+               nullptr, GL_DYNAMIC_DRAW);
+
+  setupInstanceAttributes(m_cylinderVao, m_cylinderInstanceBuffer);
+  glBindVertexArray(0);
+}
+
+void PrimitiveBatchPipeline::initializeConeVao() {
+  Mesh *unit = getUnitCone();
+  if (unit == nullptr) {
+    return;
+  }
+
+  const auto &vertices = unit->getVertices();
+  const auto &indices = unit->getIndices();
+  if (vertices.empty() || indices.empty()) {
+    return;
+  }
+
+  glGenVertexArrays(1, &m_coneVao);
+  glBindVertexArray(m_coneVao);
+
+  glGenBuffers(1, &m_coneVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_coneVertexBuffer);
+  glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex),
+               vertices.data(), GL_STATIC_DRAW);
+
+  glGenBuffers(1, &m_coneIndexBuffer);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_coneIndexBuffer);
+  glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
+               indices.data(), GL_STATIC_DRAW);
+  m_coneIndexCount = static_cast<GLsizei>(indices.size());
+
+  glEnableVertexAttribArray(Position);
+  glVertexAttribPointer(Position, Vec3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, position)));
+  glEnableVertexAttribArray(Normal);
+  glVertexAttribPointer(Normal, Vec3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, normal)));
+  glEnableVertexAttribArray(TexCoord);
+  glVertexAttribPointer(TexCoord, Vec2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
+                        reinterpret_cast<void *>(offsetof(Vertex, tex_coord)));
+
+  glGenBuffers(1, &m_coneInstanceBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_coneInstanceBuffer);
+  m_coneInstanceCapacity = kDefaultInstanceCapacity;
+  glBufferData(GL_ARRAY_BUFFER,
+               m_coneInstanceCapacity * sizeof(GL::PrimitiveInstanceGpu),
+               nullptr, GL_DYNAMIC_DRAW);
+
+  setupInstanceAttributes(m_coneVao, m_coneInstanceBuffer);
+  glBindVertexArray(0);
+}
+
+void PrimitiveBatchPipeline::shutdownVaos() {
+  if (m_sphereVao != 0) {
+    glDeleteVertexArrays(1, &m_sphereVao);
+    m_sphereVao = 0;
+  }
+  if (m_sphereVertexBuffer != 0) {
+    glDeleteBuffers(1, &m_sphereVertexBuffer);
+    m_sphereVertexBuffer = 0;
+  }
+  if (m_sphereIndexBuffer != 0) {
+    glDeleteBuffers(1, &m_sphereIndexBuffer);
+    m_sphereIndexBuffer = 0;
+  }
+  if (m_sphereInstanceBuffer != 0) {
+    glDeleteBuffers(1, &m_sphereInstanceBuffer);
+    m_sphereInstanceBuffer = 0;
+  }
+
+  if (m_cylinderVao != 0) {
+    glDeleteVertexArrays(1, &m_cylinderVao);
+    m_cylinderVao = 0;
+  }
+  if (m_cylinderVertexBuffer != 0) {
+    glDeleteBuffers(1, &m_cylinderVertexBuffer);
+    m_cylinderVertexBuffer = 0;
+  }
+  if (m_cylinderIndexBuffer != 0) {
+    glDeleteBuffers(1, &m_cylinderIndexBuffer);
+    m_cylinderIndexBuffer = 0;
+  }
+  if (m_cylinderInstanceBuffer != 0) {
+    glDeleteBuffers(1, &m_cylinderInstanceBuffer);
+    m_cylinderInstanceBuffer = 0;
+  }
+
+  if (m_coneVao != 0) {
+    glDeleteVertexArrays(1, &m_coneVao);
+    m_coneVao = 0;
+  }
+  if (m_coneVertexBuffer != 0) {
+    glDeleteBuffers(1, &m_coneVertexBuffer);
+    m_coneVertexBuffer = 0;
+  }
+  if (m_coneIndexBuffer != 0) {
+    glDeleteBuffers(1, &m_coneIndexBuffer);
+    m_coneIndexBuffer = 0;
+  }
+  if (m_coneInstanceBuffer != 0) {
+    glDeleteBuffers(1, &m_coneInstanceBuffer);
+    m_coneInstanceBuffer = 0;
+  }
+}
+
+void PrimitiveBatchPipeline::uploadSphereInstances(
+    const GL::PrimitiveInstanceGpu *data, std::size_t count) {
+  if (count == 0 || data == nullptr) {
+    return;
+  }
+
+  glBindBuffer(GL_ARRAY_BUFFER, m_sphereInstanceBuffer);
+
+  if (count > m_sphereInstanceCapacity) {
+    m_sphereInstanceCapacity = static_cast<std::size_t>(count * kGrowthFactor);
+    glBufferData(GL_ARRAY_BUFFER,
+                 m_sphereInstanceCapacity * sizeof(GL::PrimitiveInstanceGpu),
+                 nullptr, GL_DYNAMIC_DRAW);
+  }
+
+  glBufferSubData(GL_ARRAY_BUFFER, 0, count * sizeof(GL::PrimitiveInstanceGpu),
+                  data);
+}
+
+void PrimitiveBatchPipeline::uploadCylinderInstances(
+    const GL::PrimitiveInstanceGpu *data, std::size_t count) {
+  if (count == 0 || data == nullptr) {
+    return;
+  }
+
+  glBindBuffer(GL_ARRAY_BUFFER, m_cylinderInstanceBuffer);
+
+  if (count > m_cylinderInstanceCapacity) {
+    m_cylinderInstanceCapacity =
+        static_cast<std::size_t>(count * kGrowthFactor);
+    glBufferData(GL_ARRAY_BUFFER,
+                 m_cylinderInstanceCapacity * sizeof(GL::PrimitiveInstanceGpu),
+                 nullptr, GL_DYNAMIC_DRAW);
+  }
+
+  glBufferSubData(GL_ARRAY_BUFFER, 0, count * sizeof(GL::PrimitiveInstanceGpu),
+                  data);
+}
+
+void PrimitiveBatchPipeline::uploadConeInstances(
+    const GL::PrimitiveInstanceGpu *data, std::size_t count) {
+  if (count == 0 || data == nullptr) {
+    return;
+  }
+
+  glBindBuffer(GL_ARRAY_BUFFER, m_coneInstanceBuffer);
+
+  if (count > m_coneInstanceCapacity) {
+    m_coneInstanceCapacity = static_cast<std::size_t>(count * kGrowthFactor);
+    glBufferData(GL_ARRAY_BUFFER,
+                 m_coneInstanceCapacity * sizeof(GL::PrimitiveInstanceGpu),
+                 nullptr, GL_DYNAMIC_DRAW);
+  }
+
+  glBufferSubData(GL_ARRAY_BUFFER, 0, count * sizeof(GL::PrimitiveInstanceGpu),
+                  data);
+}
+
+void PrimitiveBatchPipeline::drawSpheres(std::size_t count,
+                                         const QMatrix4x4 &viewProj) {
+  if (count == 0 || m_sphereVao == 0 || m_shader == nullptr) {
+    return;
+  }
+
+  m_shader->use();
+  m_shader->setUniform(m_uniforms.viewProj, viewProj);
+  m_shader->setUniform(m_uniforms.lightDir, QVector3D(0.35F, 0.8F, 0.45F));
+  m_shader->setUniform(m_uniforms.ambientStrength, 0.3F);
+
+  glBindVertexArray(m_sphereVao);
+  glDrawElementsInstanced(GL_TRIANGLES, m_sphereIndexCount, GL_UNSIGNED_INT,
+                          nullptr, static_cast<GLsizei>(count));
+  glBindVertexArray(0);
+}
+
+void PrimitiveBatchPipeline::drawCylinders(std::size_t count,
+                                           const QMatrix4x4 &viewProj) {
+  if (count == 0 || m_cylinderVao == 0 || m_shader == nullptr) {
+    return;
+  }
+
+  m_shader->use();
+  m_shader->setUniform(m_uniforms.viewProj, viewProj);
+  m_shader->setUniform(m_uniforms.lightDir, QVector3D(0.35F, 0.8F, 0.45F));
+  m_shader->setUniform(m_uniforms.ambientStrength, 0.3F);
+
+  glBindVertexArray(m_cylinderVao);
+  glDrawElementsInstanced(GL_TRIANGLES, m_cylinderIndexCount, GL_UNSIGNED_INT,
+                          nullptr, static_cast<GLsizei>(count));
+  glBindVertexArray(0);
+}
+
+void PrimitiveBatchPipeline::drawCones(std::size_t count,
+                                       const QMatrix4x4 &viewProj) {
+  if (count == 0 || m_coneVao == 0 || m_shader == nullptr) {
+    return;
+  }
+
+  m_shader->use();
+  m_shader->setUniform(m_uniforms.viewProj, viewProj);
+  m_shader->setUniform(m_uniforms.lightDir, QVector3D(0.35F, 0.8F, 0.45F));
+  m_shader->setUniform(m_uniforms.ambientStrength, 0.3F);
+
+  glBindVertexArray(m_coneVao);
+  glDrawElementsInstanced(GL_TRIANGLES, m_coneIndexCount, GL_UNSIGNED_INT,
+                          nullptr, static_cast<GLsizei>(count));
+  glBindVertexArray(0);
+}
+
+} // namespace Render::GL::BackendPipelines

+ 87 - 0
render/gl/backend/primitive_batch_pipeline.h

@@ -0,0 +1,87 @@
+#pragma once
+
+#include "../../primitive_batch.h"
+#include "../persistent_buffer.h"
+#include "../shader_cache.h"
+#include "pipeline_interface.h"
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <memory>
+#include <vector>
+
+namespace Render::GL::BackendPipelines {
+
+class PrimitiveBatchPipeline : public IPipeline {
+public:
+  explicit PrimitiveBatchPipeline(GL::ShaderCache *shaderCache);
+  ~PrimitiveBatchPipeline() override;
+
+  auto initialize() -> bool override;
+  void shutdown() override;
+  void cacheUniforms() override;
+  [[nodiscard]] auto isInitialized() const -> bool override {
+    return m_initialized;
+  }
+
+  void beginFrame();
+
+  void uploadSphereInstances(const GL::PrimitiveInstanceGpu *data,
+                             std::size_t count);
+  void uploadCylinderInstances(const GL::PrimitiveInstanceGpu *data,
+                               std::size_t count);
+  void uploadConeInstances(const GL::PrimitiveInstanceGpu *data,
+                           std::size_t count);
+
+  void drawSpheres(std::size_t count, const QMatrix4x4 &viewProj);
+  void drawCylinders(std::size_t count, const QMatrix4x4 &viewProj);
+  void drawCones(std::size_t count, const QMatrix4x4 &viewProj);
+
+  [[nodiscard]] auto shader() const -> GL::Shader * { return m_shader; }
+
+  struct Uniforms {
+    GL::Shader::UniformHandle viewProj{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle lightDir{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle ambientStrength{GL::Shader::InvalidUniform};
+  };
+
+  Uniforms m_uniforms;
+
+private:
+  void initializeSphereVao();
+  void initializeCylinderVao();
+  void initializeConeVao();
+  void shutdownVaos();
+
+  void setupInstanceAttributes(GLuint vao, GLuint instanceBuffer);
+
+  GL::ShaderCache *m_shaderCache;
+  bool m_initialized{false};
+
+  GL::Shader *m_shader{nullptr};
+
+  GLuint m_sphereVao{0};
+  GLuint m_sphereVertexBuffer{0};
+  GLuint m_sphereIndexBuffer{0};
+  GLuint m_sphereInstanceBuffer{0};
+  GLsizei m_sphereIndexCount{0};
+  std::size_t m_sphereInstanceCapacity{0};
+
+  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};
+
+  GLuint m_coneVao{0};
+  GLuint m_coneVertexBuffer{0};
+  GLuint m_coneIndexBuffer{0};
+  GLuint m_coneInstanceBuffer{0};
+  GLsizei m_coneIndexCount{0};
+  std::size_t m_coneInstanceCapacity{0};
+
+  static constexpr std::size_t kDefaultInstanceCapacity = 4096;
+  static constexpr float kGrowthFactor = 1.5F;
+};
+
+} // namespace Render::GL::BackendPipelines

+ 3 - 3
render/gl/camera.h

@@ -73,9 +73,6 @@ public:
   [[nodiscard]] auto getProjectionMatrix() const -> QMatrix4x4;
   [[nodiscard]] auto getProjectionMatrix() const -> QMatrix4x4;
   [[nodiscard]] auto getViewProjectionMatrix() const -> QMatrix4x4;
   [[nodiscard]] auto getViewProjectionMatrix() const -> QMatrix4x4;
 
 
-  [[nodiscard]] auto getPosition() const -> const QVector3D & {
-    return m_position;
-  }
   [[nodiscard]] auto getTarget() const -> const QVector3D & { return m_target; }
   [[nodiscard]] auto getTarget() const -> const QVector3D & { return m_target; }
   [[nodiscard]] auto getUpVector() const -> const QVector3D & { return m_up; }
   [[nodiscard]] auto getUpVector() const -> const QVector3D & { return m_up; }
   [[nodiscard]] auto getRightVector() const -> const QVector3D & {
   [[nodiscard]] auto getRightVector() const -> const QVector3D & {
@@ -84,6 +81,9 @@ public:
   [[nodiscard]] auto getForwardVector() const -> const QVector3D & {
   [[nodiscard]] auto getForwardVector() const -> const QVector3D & {
     return m_front;
     return m_front;
   }
   }
+  [[nodiscard]] auto getPosition() const -> const QVector3D & {
+    return m_position;
+  }
   [[nodiscard]] auto getDistance() const -> float;
   [[nodiscard]] auto getDistance() const -> float;
   [[nodiscard]] auto getPitchDeg() const -> float;
   [[nodiscard]] auto getPitchDeg() const -> float;
   [[nodiscard]] auto getFOV() const -> float { return m_fov; }
   [[nodiscard]] auto getFOV() const -> float { return m_fov; }

+ 7 - 0
render/gl/shader_cache.h

@@ -79,6 +79,13 @@ public:
     const QString cylFrag =
     const QString cylFrag =
         resolve(kShaderBase + QStringLiteral("cylinder_instanced.frag"));
         resolve(kShaderBase + QStringLiteral("cylinder_instanced.frag"));
     load(QStringLiteral("cylinder_instanced"), cylVert, cylFrag);
     load(QStringLiteral("cylinder_instanced"), cylVert, cylFrag);
+
+    const QString primVert =
+        resolve(kShaderBase + QStringLiteral("primitive_instanced.vert"));
+    const QString primFrag =
+        resolve(kShaderBase + QStringLiteral("primitive_instanced.frag"));
+    load(QStringLiteral("primitive_instanced"), primVert, primFrag);
+
     const QString fogVert =
     const QString fogVert =
         resolve(kShaderBase + QStringLiteral("fog_instanced.vert"));
         resolve(kShaderBase + QStringLiteral("fog_instanced.vert"));
     const QString fogFrag =
     const QString fogFrag =

+ 273 - 0
render/graphics_settings.h

@@ -0,0 +1,273 @@
+#pragma once
+
+#include <cstdint>
+
+namespace Render {
+
+enum class GraphicsQuality : uint8_t {
+  Low = 0,
+  Medium = 1,
+  High = 2,
+  Ultra = 3
+};
+
+struct LODMultipliers {
+  float humanoidFull;
+  float humanoidReduced;
+  float humanoidMinimal;
+  float humanoidBillboard;
+
+  float horseFull;
+  float horseReduced;
+  float horseMinimal;
+  float horseBillboard;
+
+  float shadowDistance;
+  bool enableShadows;
+};
+
+struct GraphicsFeatures {
+  bool enableFacialHair;
+  bool enableManeDetail;
+  bool enableTailDetail;
+  bool enableArmorDetail;
+  bool enableEquipmentDetail;
+  bool enableGroundShadows;
+  bool enablePoseCache;
+};
+
+struct BatchingConfig {
+  bool forceBatching;
+  bool neverBatch;
+  int batchingUnitThreshold;
+  float batchingZoomStart;
+  float batchingZoomFull;
+};
+
+class GraphicsSettings {
+public:
+  static auto instance() noexcept -> GraphicsSettings & {
+    static GraphicsSettings inst;
+    return inst;
+  }
+
+  [[nodiscard]] auto quality() const noexcept -> GraphicsQuality {
+    return m_quality;
+  }
+
+  void setQuality(GraphicsQuality q) noexcept {
+    m_quality = q;
+    applyPreset(q);
+  }
+
+  [[nodiscard]] auto lodMultipliers() const noexcept -> const LODMultipliers & {
+    return m_lodMultipliers;
+  }
+
+  [[nodiscard]] auto features() const noexcept -> const GraphicsFeatures & {
+    return m_features;
+  }
+
+  [[nodiscard]] auto batchingConfig() const noexcept -> const BatchingConfig & {
+    return m_batchingConfig;
+  }
+
+  [[nodiscard]] auto
+  calculateBatchingRatio(int visibleUnits,
+                         float cameraHeight) const noexcept -> float {
+    if (m_batchingConfig.neverBatch) {
+      return 0.0F;
+    }
+    if (m_batchingConfig.forceBatching) {
+      return 1.0F;
+    }
+
+    float unitFactor = 0.0F;
+    if (visibleUnits > m_batchingConfig.batchingUnitThreshold) {
+
+      int excess = visibleUnits - m_batchingConfig.batchingUnitThreshold;
+      int range = m_batchingConfig.batchingUnitThreshold * 3;
+      unitFactor = static_cast<float>(excess) / static_cast<float>(range);
+      unitFactor =
+          unitFactor < 0.0F ? 0.0F : (unitFactor > 1.0F ? 1.0F : unitFactor);
+    }
+
+    float zoomFactor = 0.0F;
+    if (cameraHeight > m_batchingConfig.batchingZoomStart) {
+      float range = m_batchingConfig.batchingZoomFull -
+                    m_batchingConfig.batchingZoomStart;
+      if (range > 0.0F) {
+        zoomFactor =
+            (cameraHeight - m_batchingConfig.batchingZoomStart) / range;
+        zoomFactor =
+            zoomFactor < 0.0F ? 0.0F : (zoomFactor > 1.0F ? 1.0F : zoomFactor);
+      }
+    }
+
+    return unitFactor > zoomFactor ? unitFactor : zoomFactor;
+  }
+
+  [[nodiscard]] auto humanoidFullDetailDistance() const noexcept -> float {
+    return kBaseHumanoidFull * m_lodMultipliers.humanoidFull;
+  }
+  [[nodiscard]] auto humanoidReducedDetailDistance() const noexcept -> float {
+    return kBaseHumanoidReduced * m_lodMultipliers.humanoidReduced;
+  }
+  [[nodiscard]] auto humanoidMinimalDetailDistance() const noexcept -> float {
+    return kBaseHumanoidMinimal * m_lodMultipliers.humanoidMinimal;
+  }
+  [[nodiscard]] auto humanoidBillboardDistance() const noexcept -> float {
+    return kBaseHumanoidBillboard * m_lodMultipliers.humanoidBillboard;
+  }
+
+  [[nodiscard]] auto horseFullDetailDistance() const noexcept -> float {
+    return kBaseHorseFull * m_lodMultipliers.horseFull;
+  }
+  [[nodiscard]] auto horseReducedDetailDistance() const noexcept -> float {
+    return kBaseHorseReduced * m_lodMultipliers.horseReduced;
+  }
+  [[nodiscard]] auto horseMinimalDetailDistance() const noexcept -> float {
+    return kBaseHorseMinimal * m_lodMultipliers.horseMinimal;
+  }
+  [[nodiscard]] auto horseBillboardDistance() const noexcept -> float {
+    return kBaseHorseBillboard * m_lodMultipliers.horseBillboard;
+  }
+
+  [[nodiscard]] auto shadowMaxDistance() const noexcept -> float {
+    return m_lodMultipliers.shadowDistance;
+  }
+  [[nodiscard]] auto shadowsEnabled() const noexcept -> bool {
+    return m_lodMultipliers.enableShadows;
+  }
+
+private:
+  GraphicsSettings() { applyPreset(GraphicsQuality::Medium); }
+
+  void applyPreset(GraphicsQuality q) noexcept {
+    switch (q) {
+    case GraphicsQuality::Low:
+
+      m_lodMultipliers = {.humanoidFull = 0.8F,
+                          .humanoidReduced = 0.8F,
+                          .humanoidMinimal = 0.8F,
+                          .humanoidBillboard = 0.8F,
+                          .horseFull = 0.8F,
+                          .horseReduced = 0.8F,
+                          .horseMinimal = 0.8F,
+                          .horseBillboard = 0.8F,
+                          .shadowDistance = 25.0F,
+                          .enableShadows = true};
+      m_features = {.enableFacialHair = false,
+                    .enableManeDetail = false,
+                    .enableTailDetail = false,
+                    .enableArmorDetail = true,
+                    .enableEquipmentDetail = true,
+                    .enableGroundShadows = true,
+                    .enablePoseCache = true};
+      m_batchingConfig = {.forceBatching = true,
+                          .neverBatch = false,
+                          .batchingUnitThreshold = 0,
+                          .batchingZoomStart = 0.0F,
+                          .batchingZoomFull = 0.0F};
+      break;
+
+    case GraphicsQuality::Medium:
+
+      m_lodMultipliers = {.humanoidFull = 1.0F,
+                          .humanoidReduced = 1.0F,
+                          .humanoidMinimal = 1.0F,
+                          .humanoidBillboard = 1.0F,
+                          .horseFull = 1.0F,
+                          .horseReduced = 1.0F,
+                          .horseMinimal = 1.0F,
+                          .horseBillboard = 1.0F,
+                          .shadowDistance = 40.0F,
+                          .enableShadows = true};
+      m_features = {.enableFacialHair = true,
+                    .enableManeDetail = true,
+                    .enableTailDetail = true,
+                    .enableArmorDetail = true,
+                    .enableEquipmentDetail = true,
+                    .enableGroundShadows = true,
+                    .enablePoseCache = true};
+
+      m_batchingConfig = {.forceBatching = false,
+                          .neverBatch = false,
+                          .batchingUnitThreshold = 30,
+                          .batchingZoomStart = 60.0F,
+                          .batchingZoomFull = 90.0F};
+      break;
+
+    case GraphicsQuality::High:
+
+      m_lodMultipliers = {.humanoidFull = 2.0F,
+                          .humanoidReduced = 2.0F,
+                          .humanoidMinimal = 2.0F,
+                          .humanoidBillboard = 2.0F,
+                          .horseFull = 2.0F,
+                          .horseReduced = 2.0F,
+                          .horseMinimal = 2.0F,
+                          .horseBillboard = 2.0F,
+                          .shadowDistance = 80.0F,
+                          .enableShadows = true};
+      m_features = {.enableFacialHair = true,
+                    .enableManeDetail = true,
+                    .enableTailDetail = true,
+                    .enableArmorDetail = true,
+                    .enableEquipmentDetail = true,
+                    .enableGroundShadows = true,
+                    .enablePoseCache = true};
+
+      m_batchingConfig = {.forceBatching = false,
+                          .neverBatch = false,
+                          .batchingUnitThreshold = 50,
+                          .batchingZoomStart = 80.0F,
+                          .batchingZoomFull = 120.0F};
+      break;
+
+    case GraphicsQuality::Ultra:
+
+      m_lodMultipliers = {.humanoidFull = 100.0F,
+                          .humanoidReduced = 100.0F,
+                          .humanoidMinimal = 100.0F,
+                          .humanoidBillboard = 100.0F,
+                          .horseFull = 100.0F,
+                          .horseReduced = 100.0F,
+                          .horseMinimal = 100.0F,
+                          .horseBillboard = 100.0F,
+                          .shadowDistance = 200.0F,
+                          .enableShadows = true};
+      m_features = {.enableFacialHair = true,
+                    .enableManeDetail = true,
+                    .enableTailDetail = true,
+                    .enableArmorDetail = true,
+                    .enableEquipmentDetail = true,
+                    .enableGroundShadows = true,
+                    .enablePoseCache = false};
+
+      m_batchingConfig = {.forceBatching = false,
+                          .neverBatch = true,
+                          .batchingUnitThreshold = 999999,
+                          .batchingZoomStart = 999999.0F,
+                          .batchingZoomFull = 999999.0F};
+      break;
+    }
+  }
+
+  static constexpr float kBaseHumanoidFull = 15.0F;
+  static constexpr float kBaseHumanoidReduced = 35.0F;
+  static constexpr float kBaseHumanoidMinimal = 60.0F;
+  static constexpr float kBaseHumanoidBillboard = 100.0F;
+
+  static constexpr float kBaseHorseFull = 20.0F;
+  static constexpr float kBaseHorseReduced = 40.0F;
+  static constexpr float kBaseHorseMinimal = 70.0F;
+  static constexpr float kBaseHorseBillboard = 100.0F;
+
+  GraphicsQuality m_quality{GraphicsQuality::Medium};
+  LODMultipliers m_lodMultipliers{};
+  GraphicsFeatures m_features{};
+  BatchingConfig m_batchingConfig{};
+};
+
+} // namespace Render

+ 5 - 0
render/ground/olive_renderer.cpp

@@ -141,6 +141,11 @@ void OliveRenderer::generate_olive_instances() {
     return;
     return;
   }
   }
 
 
+  if (m_biomeSettings.ground_type != Game::Map::GroundType::GrassDry) {
+    m_oliveInstancesDirty = false;
+    return;
+  }
+
   const float half_width = static_cast<float>(m_width) * 0.5F;
   const float half_width = static_cast<float>(m_width) * 0.5F;
   const float half_height = static_cast<float>(m_height) * 0.5F;
   const float half_height = static_cast<float>(m_height) * 0.5F;
   const float tile_safe = std::max(0.1F, m_tile_size);
   const float tile_safe = std::max(0.1F, m_tile_size);

+ 196 - 1
render/horse/rig.cpp

@@ -20,6 +20,14 @@
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
+static HorseRenderStats s_horseRenderStats;
+
+auto getHorseRenderStats() -> const HorseRenderStats & {
+  return s_horseRenderStats;
+}
+
+void resetHorseRenderStats() { s_horseRenderStats.reset(); }
+
 using Render::Geom::clamp01;
 using Render::Geom::clamp01;
 using Render::Geom::coneFromTo;
 using Render::Geom::coneFromTo;
 using Render::Geom::cylinderBetween;
 using Render::Geom::cylinderBetween;
@@ -403,7 +411,7 @@ void apply_mount_vertical_offset(MountedAttachmentFrame &frame, float bob) {
   frame.bridle_base += offset;
   frame.bridle_base += offset;
 }
 }
 
 
-void HorseRendererBase::render(
+void HorseRendererBase::renderFull(
     const DrawContext &ctx, const AnimationInputs &anim,
     const DrawContext &ctx, const AnimationInputs &anim,
     const HumanoidAnimationContext &rider_ctx, HorseProfile &profile,
     const HumanoidAnimationContext &rider_ctx, HorseProfile &profile,
     const MountedAttachmentFrame *shared_mount, const ReinState *shared_reins,
     const MountedAttachmentFrame *shared_mount, const ReinState *shared_reins,
@@ -1194,4 +1202,191 @@ void HorseRendererBase::render(
                   rein_slack, body_frames, out);
                   rein_slack, body_frames, out);
 }
 }
 
 
+void HorseRendererBase::renderSimplified(
+    const DrawContext &ctx, const AnimationInputs &anim,
+    const HumanoidAnimationContext &rider_ctx, HorseProfile &profile,
+    const MountedAttachmentFrame *shared_mount,
+    const HorseMotionSample *shared_motion, ISubmitter &out) const {
+
+  const HorseDimensions &d = profile.dims;
+  const HorseVariant &v = profile.variant;
+  const HorseGait &g = profile.gait;
+
+  HorseMotionSample const motion =
+      shared_motion ? *shared_motion
+                    : evaluate_horse_motion(profile, anim, rider_ctx);
+  float const phase = motion.phase;
+  float const bob = motion.bob;
+  const bool is_moving = motion.is_moving;
+
+  MountedAttachmentFrame mount =
+      shared_mount ? *shared_mount : compute_mount_frame(profile);
+  if (!shared_mount) {
+    apply_mount_vertical_offset(mount, bob);
+  }
+
+  DrawContext horse_ctx = ctx;
+  horse_ctx.model = ctx.model;
+  horse_ctx.model.translate(mount.ground_offset);
+
+  QVector3D const barrel_center(0.0F, d.barrel_centerY + bob, 0.0F);
+
+  {
+    QMatrix4x4 body = horse_ctx.model;
+    body.translate(barrel_center);
+    body.scale(d.bodyWidth * 1.0F, d.bodyHeight * 0.85F, d.bodyLength * 0.80F);
+    out.mesh(getUnitSphere(), body, v.coatColor, nullptr, 1.0F, 6);
+  }
+
+  QVector3D const neck_base =
+      barrel_center +
+      QVector3D(0.0F, d.bodyHeight * 0.35F, d.bodyLength * 0.35F);
+  QVector3D const neck_top =
+      neck_base + QVector3D(0.0F, d.neckRise, d.neckLength);
+  draw_cylinder(out, horse_ctx.model, neck_base, neck_top, d.bodyWidth * 0.40F,
+                v.coatColor, 1.0F);
+
+  QVector3D const head_center =
+      neck_top + QVector3D(0.0F, d.headHeight * 0.10F, d.headLength * 0.40F);
+  {
+    QMatrix4x4 head = horse_ctx.model;
+    head.translate(head_center);
+    head.scale(d.headWidth * 0.90F, d.headHeight * 0.85F, d.headLength * 0.75F);
+    out.mesh(getUnitSphere(), head, v.coatColor, nullptr, 1.0F);
+  }
+
+  QVector3D const front_anchor =
+      barrel_center +
+      QVector3D(0.0F, d.bodyHeight * 0.05F, d.bodyLength * 0.30F);
+  QVector3D const rear_anchor =
+      barrel_center +
+      QVector3D(0.0F, d.bodyHeight * 0.02F, -d.bodyLength * 0.28F);
+
+  auto draw_simple_leg = [&](const QVector3D &anchor, float lateralSign,
+                             float forwardBias, float phase_offset) {
+    float const leg_phase = std::fmod(phase + phase_offset, 1.0F);
+    float stride = 0.0F;
+    float lift = 0.0F;
+
+    if (is_moving) {
+      float const angle = leg_phase * 2.0F * k_pi;
+      stride = std::sin(angle) * g.strideSwing * 0.6F + forwardBias;
+      float const lift_raw = std::sin(angle);
+      lift = lift_raw > 0.0F ? lift_raw * g.strideLift * 0.8F : 0.0F;
+    }
+
+    float const shoulder_out = d.bodyWidth * 0.45F;
+    QVector3D shoulder =
+        anchor + QVector3D(lateralSign * shoulder_out, lift * 0.05F, stride);
+
+    float const leg_length = d.legLength * 0.85F;
+    QVector3D const foot = shoulder + QVector3D(0.0F, -leg_length + lift, 0.0F);
+
+    draw_cylinder(out, horse_ctx.model, shoulder, foot, d.bodyWidth * 0.22F,
+                  v.coatColor * 0.85F, 1.0F, 6);
+
+    QMatrix4x4 hoof = horse_ctx.model;
+    hoof.translate(foot);
+    hoof.scale(d.bodyWidth * 0.28F, d.hoofHeight, d.bodyWidth * 0.30F);
+    out.mesh(getUnitCylinder(), hoof, v.hoof_color, nullptr, 1.0F, 8);
+  };
+
+  draw_simple_leg(front_anchor, 1.0F, d.bodyLength * 0.15F, g.frontLegPhase);
+  draw_simple_leg(front_anchor, -1.0F, d.bodyLength * 0.15F,
+                  g.frontLegPhase + 0.48F);
+  draw_simple_leg(rear_anchor, 1.0F, -d.bodyLength * 0.15F, g.rearLegPhase);
+  draw_simple_leg(rear_anchor, -1.0F, -d.bodyLength * 0.15F,
+                  g.rearLegPhase + 0.52F);
+}
+
+void HorseRendererBase::renderMinimal(const DrawContext &ctx,
+                                      HorseProfile &profile,
+                                      const HorseMotionSample *shared_motion,
+                                      ISubmitter &out) const {
+
+  const HorseDimensions &d = profile.dims;
+  const HorseVariant &v = profile.variant;
+
+  float const bob = shared_motion ? shared_motion->bob : 0.0F;
+
+  MountedAttachmentFrame mount = compute_mount_frame(profile);
+  apply_mount_vertical_offset(mount, bob);
+
+  DrawContext horse_ctx = ctx;
+  horse_ctx.model = ctx.model;
+  horse_ctx.model.translate(mount.ground_offset);
+
+  QVector3D const center(0.0F, d.barrel_centerY + bob, 0.0F);
+
+  QMatrix4x4 body = horse_ctx.model;
+  body.translate(center);
+  body.scale(d.bodyWidth * 1.2F, d.bodyHeight + d.neckRise * 0.5F,
+             d.bodyLength + d.headLength * 0.5F);
+  out.mesh(getUnitSphere(), body, v.coatColor, nullptr, 1.0F, 6);
+
+  for (int i = 0; i < 4; ++i) {
+    float const x_sign = (i % 2 == 0) ? 1.0F : -1.0F;
+    float const z_offset =
+        (i < 2) ? d.bodyLength * 0.25F : -d.bodyLength * 0.25F;
+
+    QVector3D const top = center + QVector3D(x_sign * d.bodyWidth * 0.40F,
+                                             -d.bodyHeight * 0.3F, z_offset);
+    QVector3D const bottom = top + QVector3D(0.0F, -d.legLength * 0.60F, 0.0F);
+
+    draw_cylinder(out, horse_ctx.model, top, bottom, d.bodyWidth * 0.15F,
+                  v.coatColor * 0.75F, 1.0F, 6);
+  }
+}
+
+void HorseRendererBase::render(const DrawContext &ctx,
+                               const AnimationInputs &anim,
+                               const HumanoidAnimationContext &rider_ctx,
+                               HorseProfile &profile,
+                               const MountedAttachmentFrame *shared_mount,
+                               const ReinState *shared_reins,
+                               const HorseMotionSample *shared_motion,
+                               ISubmitter &out, HorseLOD lod) const {
+
+  ++s_horseRenderStats.horsesTotal;
+
+  if (lod == HorseLOD::Billboard) {
+    ++s_horseRenderStats.horsesSkippedLOD;
+    return;
+  }
+
+  ++s_horseRenderStats.horsesRendered;
+
+  switch (lod) {
+  case HorseLOD::Full:
+    ++s_horseRenderStats.lodFull;
+    renderFull(ctx, anim, rider_ctx, profile, shared_mount, shared_reins,
+               shared_motion, out);
+    break;
+
+  case HorseLOD::Reduced:
+    ++s_horseRenderStats.lodReduced;
+    renderSimplified(ctx, anim, rider_ctx, profile, shared_mount, shared_motion,
+                     out);
+    break;
+
+  case HorseLOD::Minimal:
+    ++s_horseRenderStats.lodMinimal;
+    renderMinimal(ctx, profile, shared_motion, out);
+    break;
+
+  case HorseLOD::Billboard:
+
+    break;
+  }
+}
+
+void HorseRendererBase::render(
+    const DrawContext &ctx, const AnimationInputs &anim,
+    const HumanoidAnimationContext &rider_ctx, HorseProfile &profile,
+    const MountedAttachmentFrame *shared_mount, const ReinState *shared_reins,
+    const HorseMotionSample *shared_motion, ISubmitter &out) const {
+  render(ctx, anim, rider_ctx, profile, shared_mount, shared_reins,
+         shared_motion, out, HorseLOD::Full);
+}
+
 } // namespace Render::GL
 } // namespace Render::GL

+ 67 - 1
render/horse/rig.h

@@ -1,16 +1,41 @@
 #pragma once
 #pragma once
 
 
+#include "../entity/registry.h"
+#include "../graphics_settings.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
 #include <cstdint>
 #include <cstdint>
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
-struct DrawContext;
 struct AnimationInputs;
 struct AnimationInputs;
 struct HumanoidAnimationContext;
 struct HumanoidAnimationContext;
 class ISubmitter;
 class ISubmitter;
 
 
+inline auto calculateHorseLOD(float distance) -> HorseLOD;
+
+struct HorseRenderStats {
+  uint32_t horsesTotal{0};
+  uint32_t horsesRendered{0};
+  uint32_t horsesSkippedLOD{0};
+  uint32_t lodFull{0};
+  uint32_t lodReduced{0};
+  uint32_t lodMinimal{0};
+
+  void reset() {
+    horsesTotal = 0;
+    horsesRendered = 0;
+    horsesSkippedLOD = 0;
+    lodFull = 0;
+    lodReduced = 0;
+    lodMinimal = 0;
+  }
+};
+
+auto getHorseRenderStats() -> const HorseRenderStats &;
+
+void resetHorseRenderStats();
+
 struct HorseDimensions {
 struct HorseDimensions {
   float bodyLength{};
   float bodyLength{};
   float bodyWidth{};
   float bodyWidth{};
@@ -176,18 +201,59 @@ class HorseRendererBase {
 public:
 public:
   virtual ~HorseRendererBase() = default;
   virtual ~HorseRendererBase() = default;
 
 
+  void render(const DrawContext &ctx, const AnimationInputs &anim,
+              const HumanoidAnimationContext &rider_ctx, HorseProfile &profile,
+              const MountedAttachmentFrame *shared_mount,
+              const ReinState *shared_reins,
+              const HorseMotionSample *shared_motion, ISubmitter &out,
+              HorseLOD lod) const;
+
   void render(const DrawContext &ctx, const AnimationInputs &anim,
   void render(const DrawContext &ctx, const AnimationInputs &anim,
               const HumanoidAnimationContext &rider_ctx, HorseProfile &profile,
               const HumanoidAnimationContext &rider_ctx, HorseProfile &profile,
               const MountedAttachmentFrame *shared_mount,
               const MountedAttachmentFrame *shared_mount,
               const ReinState *shared_reins,
               const ReinState *shared_reins,
               const HorseMotionSample *shared_motion, ISubmitter &out) const;
               const HorseMotionSample *shared_motion, ISubmitter &out) const;
 
 
+  void renderSimplified(const DrawContext &ctx, const AnimationInputs &anim,
+                        const HumanoidAnimationContext &rider_ctx,
+                        HorseProfile &profile,
+                        const MountedAttachmentFrame *shared_mount,
+                        const HorseMotionSample *shared_motion,
+                        ISubmitter &out) const;
+
+  void renderMinimal(const DrawContext &ctx, HorseProfile &profile,
+                     const HorseMotionSample *shared_motion,
+                     ISubmitter &out) const;
+
 protected:
 protected:
   virtual void drawAttachments(const DrawContext &, const AnimationInputs &,
   virtual void drawAttachments(const DrawContext &, const AnimationInputs &,
                                const HumanoidAnimationContext &, HorseProfile &,
                                const HumanoidAnimationContext &, HorseProfile &,
                                const MountedAttachmentFrame &, float, float,
                                const MountedAttachmentFrame &, float, float,
                                float, const HorseBodyFrames &,
                                float, const HorseBodyFrames &,
                                ISubmitter &) const {}
                                ISubmitter &) const {}
+
+private:
+  void renderFull(const DrawContext &ctx, const AnimationInputs &anim,
+                  const HumanoidAnimationContext &rider_ctx,
+                  HorseProfile &profile,
+                  const MountedAttachmentFrame *shared_mount,
+                  const ReinState *shared_reins,
+                  const HorseMotionSample *shared_motion,
+                  ISubmitter &out) const;
 };
 };
 
 
+inline auto calculateHorseLOD(float distance) -> HorseLOD {
+  const auto &settings = Render::GraphicsSettings::instance();
+  if (distance < settings.horseFullDetailDistance()) {
+    return HorseLOD::Full;
+  }
+  if (distance < settings.horseReducedDetailDistance()) {
+    return HorseLOD::Reduced;
+  }
+  if (distance < settings.horseMinimalDetailDistance()) {
+    return HorseLOD::Minimal;
+  }
+  return HorseLOD::Billboard;
+}
+
 } // namespace Render::GL
 } // namespace Render::GL

+ 27 - 34
render/humanoid/formation_calculator.cpp

@@ -30,49 +30,42 @@ auto CarthageInfantryFormation::calculateOffset(
     int idx, int row, int col, int rows, int cols, float spacing,
     int idx, int row, int col, int rows, int cols, float spacing,
     uint32_t seed) const -> FormationOffset {
     uint32_t seed) const -> FormationOffset {
 
 
-  float const base_spacing =
-      spacing *
-      (1.0F +
-       std::sin(float(row) * 0.55F + float(seed & 0xFFU) * 0.01F) * 0.10F);
+  float const row_normalized = float(row) / float(rows > 1 ? rows - 1 : 1);
+  float const col_normalized =
+      float(col - (cols - 1) * 0.5F) / float(cols > 1 ? (cols - 1) * 0.5F : 1);
 
 
-  float offset_x = (col - (cols - 1) * 0.5F) * base_spacing;
-  float offset_z = (row - (rows - 1) * 0.5F) * base_spacing;
+  float const spread_factor = 1.0F + row_normalized * 0.3F;
+  float const row_spacing = spacing * (1.0F + row_normalized * 0.15F);
 
 
-  uint32_t rng_state = seed ^ (uint32_t(idx) * 7919U);
+  float offset_x = (col - (cols - 1) * 0.5F) * spacing * spread_factor;
+  float offset_z = (row - (rows - 1) * 0.5F) * row_spacing;
 
 
-  auto fast_random = [](uint32_t &state) -> float {
-    state = state * 1664525U + 1013904223U;
-    return float(state & 0x7FFFFFU) / float(0x7FFFFFU);
-  };
-
-  auto rand_range = [&](uint32_t &state, float magnitude) -> float {
-    return (fast_random(state) - 0.5F) * magnitude;
-  };
+  if (row % 2 == 1) {
+    offset_x += spacing * 0.35F;
+  }
 
 
-  float const jitter_x = rand_range(rng_state, spacing * 0.35F);
-  float const jitter_z = rand_range(rng_state, spacing * 0.35F);
+  float const rank_wave = std::sin(col_normalized * 3.14159F) * spacing *
+                          0.12F * (1.0F + row_normalized);
+  offset_z += rank_wave;
 
 
-  int const cluster_c = (col >= 0) ? (col / 2) : ((col - 1) / 2);
-  int const cluster_r = (row >= 0) ? (row / 2) : ((row - 1) / 2);
-  uint32_t cluster_state =
-      seed ^ (uint32_t(cluster_r * 97 + cluster_c * 271) * 23U + 0xBADU);
+  float const center_push = (1.0F - std::abs(col_normalized)) * spacing * 0.2F;
+  offset_z -= center_push;
 
 
-  float const cluster_shift_x = rand_range(cluster_state, spacing * 0.50F);
-  float const cluster_shift_z = rand_range(cluster_state, spacing * 0.50F);
-  float const cluster_arc =
-      std::sin(float(cluster_r) * 0.9F + float(cluster_c) * 0.7F) * spacing *
-      0.25F;
+  uint32_t variation_seed = seed ^ (uint32_t(idx) * 2654435761U);
+  float const phase = float(variation_seed & 0xFFU) / 255.0F * 6.28318F;
 
 
-  float const slant_x = (row - (rows - 1) * 0.5F) * spacing * 0.12F;
-  float const wave_z =
-      std::sin(float(col) * 0.8F + float(row) * 0.4F) * spacing * 0.15F;
+  float const jitter_scale = spacing * 0.08F * (1.0F + row_normalized * 0.5F);
+  float const jitter_x = std::sin(phase) * jitter_scale;
+  float const jitter_z = std::cos(phase * 1.3F) * jitter_scale * 0.7F;
 
 
-  offset_x += jitter_x + cluster_shift_x + cluster_arc + slant_x;
-  offset_z += jitter_z + cluster_shift_z + wave_z;
+  offset_x += jitter_x;
+  offset_z += jitter_z;
 
 
-  if (row % 2 == 1) {
-    offset_x += spacing * 0.22F;
-  }
+  int const cluster_id = idx / 4;
+  float const cluster_phase = float(cluster_id * 137 + (seed & 0xFFU)) * 0.1F;
+  float const cluster_pull = spacing * 0.06F;
+  offset_x += std::sin(cluster_phase) * cluster_pull;
+  offset_z += std::cos(cluster_phase * 0.7F) * cluster_pull;
 
 
   return {offset_x, offset_z};
   return {offset_x, offset_z};
 }
 }

+ 239 - 7
render/humanoid/rig.cpp

@@ -12,6 +12,7 @@
 #include "../geom/math_utils.h"
 #include "../geom/math_utils.h"
 #include "../geom/transforms.h"
 #include "../geom/transforms.h"
 #include "../gl/backend.h"
 #include "../gl/backend.h"
+#include "../gl/camera.h"
 #include "../gl/humanoid/animation/animation_inputs.h"
 #include "../gl/humanoid/animation/animation_inputs.h"
 #include "../gl/humanoid/animation/gait.h"
 #include "../gl/humanoid/animation/gait.h"
 #include "../gl/humanoid/humanoid_constants.h"
 #include "../gl/humanoid/humanoid_constants.h"
@@ -33,6 +34,7 @@
 #include <functional>
 #include <functional>
 #include <limits>
 #include <limits>
 #include <numbers>
 #include <numbers>
+#include <unordered_map>
 #include <vector>
 #include <vector>
 
 
 namespace Render::GL {
 namespace Render::GL {
@@ -47,11 +49,47 @@ namespace {
 
 
 constexpr float k_shadow_size_infantry = 0.16F;
 constexpr float k_shadow_size_infantry = 0.16F;
 constexpr float k_shadow_size_mounted = 0.35F;
 constexpr float k_shadow_size_mounted = 0.35F;
+
+struct CachedPoseEntry {
+  HumanoidPose pose;
+  VariationParams variation;
+  uint32_t frameNumber{0};
+  bool wasMoving{false};
+};
+
+using PoseCacheKey = uint64_t;
+static std::unordered_map<PoseCacheKey, CachedPoseEntry> s_poseCache;
+static uint32_t s_currentFrame = 0;
+constexpr uint32_t kPoseCacheMaxAge = 300;
+
+inline auto makePoseCacheKey(uintptr_t entityPtr,
+                             int soldierIdx) -> PoseCacheKey {
+  return (static_cast<uint64_t>(entityPtr) << 16) |
+         static_cast<uint64_t>(soldierIdx & 0xFFFF);
+}
+
+static HumanoidRenderStats s_renderStats;
+
 constexpr float k_shadow_ground_offset = 0.02F;
 constexpr float k_shadow_ground_offset = 0.02F;
 constexpr float k_shadow_base_alpha = 0.24F;
 constexpr float k_shadow_base_alpha = 0.24F;
 constexpr QVector3D k_shadow_light_dir(0.4F, 1.0F, 0.25F);
 constexpr QVector3D k_shadow_light_dir(0.4F, 1.0F, 0.25F);
 } // namespace
 } // namespace
 
 
+void advancePoseCacheFrame() {
+  ++s_currentFrame;
+
+  if ((s_currentFrame & 0x1FF) == 0) {
+    auto it = s_poseCache.begin();
+    while (it != s_poseCache.end()) {
+      if (s_currentFrame - it->second.frameNumber > kPoseCacheMaxAge * 2) {
+        it = s_poseCache.erase(it);
+      } else {
+        ++it;
+      }
+    }
+  }
+}
+
 auto torso_mesh_without_bottom_cap() -> Mesh * {
 auto torso_mesh_without_bottom_cap() -> Mesh * {
   static std::unique_ptr<Mesh> s_mesh;
   static std::unique_ptr<Mesh> s_mesh;
   if (s_mesh != nullptr) {
   if (s_mesh != nullptr) {
@@ -1036,6 +1074,107 @@ void HumanoidRendererBase::drawFacialHair(const DrawContext &ctx,
   }
   }
 }
 }
 
 
+void HumanoidRendererBase::drawSimplifiedBody(const DrawContext &ctx,
+                                              const HumanoidVariant &v,
+                                              HumanoidPose &pose,
+                                              ISubmitter &out) const {
+  using HP = HumanProportions;
+
+  QVector3D const scaling = get_proportion_scaling();
+  float const width_scale = scaling.x();
+  float const height_scale = scaling.y();
+  float const torso_scale = get_torso_scale();
+
+  QVector3D right_axis = pose.shoulder_r - pose.shoulder_l;
+  if (right_axis.lengthSquared() < 1e-8F) {
+    right_axis = QVector3D(1, 0, 0);
+  }
+  right_axis.normalize();
+
+  QVector3D const up_axis(0.0F, 1.0F, 0.0F);
+  QVector3D forward_axis = QVector3D::crossProduct(right_axis, up_axis);
+  if (forward_axis.lengthSquared() < 1e-8F) {
+    forward_axis = QVector3D(0.0F, 0.0F, 1.0F);
+  }
+  forward_axis.normalize();
+
+  QVector3D const shoulder_mid = (pose.shoulder_l + pose.shoulder_r) * 0.5F;
+  const float y_shoulder = shoulder_mid.y();
+  const float y_neck = pose.neck_base.y();
+  const float shoulder_half_span =
+      0.5F * std::abs(pose.shoulder_r.x() - pose.shoulder_l.x());
+
+  const float torso_r_base =
+      std::max(HP::TORSO_TOP_R, shoulder_half_span * 0.95F);
+  const float torso_r = torso_r_base * torso_scale;
+  float const depth_scale = scaling.z();
+  const float torso_depth_factor =
+      std::clamp(0.55F + (depth_scale - 1.0F) * 0.20F, 0.40F, 0.85F);
+  float torso_depth = torso_r * torso_depth_factor;
+
+  const float y_top_cover = std::max(y_shoulder + 0.00F, y_neck - 0.03F);
+
+  const float upper_arm_r = HP::UPPER_ARM_R * width_scale;
+  const float fore_arm_r = HP::FORE_ARM_R * width_scale;
+  const float thigh_r = HP::UPPER_LEG_R * width_scale;
+  const float shin_r = HP::LOWER_LEG_R * width_scale;
+
+  QVector3D const tunic_top{shoulder_mid.x(), y_top_cover - 0.006F,
+                            shoulder_mid.z()};
+  QVector3D const tunic_bot{pose.pelvis_pos.x(), pose.pelvis_pos.y() - 0.05F,
+                            pose.pelvis_pos.z()};
+  QMatrix4x4 torso_transform =
+      cylinderBetween(ctx.model, tunic_top, tunic_bot, 1.0F);
+  torso_transform.scale(torso_r, 1.0F, torso_depth);
+
+  Mesh *torso_mesh = torso_mesh_without_bottom_cap();
+  if (torso_mesh != nullptr) {
+    out.mesh(torso_mesh, torso_transform, v.palette.cloth, nullptr, 1.0F);
+  }
+
+  float const head_r = pose.head_r;
+  QMatrix4x4 head_transform = ctx.model;
+  head_transform.translate(pose.head_pos);
+  head_transform.scale(head_r);
+  out.mesh(getUnitSphere(), head_transform, v.palette.skin, nullptr, 1.0F);
+
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, pose.shoulder_l, pose.hand_l,
+                           (upper_arm_r + fore_arm_r) * 0.5F),
+           v.palette.cloth, nullptr, 1.0F);
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, pose.shoulder_r, pose.hand_r,
+                           (upper_arm_r + fore_arm_r) * 0.5F),
+           v.palette.cloth, nullptr, 1.0F);
+
+  QVector3D const hip_l = pose.pelvis_pos + QVector3D(-0.10F, -0.02F, 0.0F);
+  QVector3D const hip_r = pose.pelvis_pos + QVector3D(0.10F, -0.02F, 0.0F);
+
+  out.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, hip_l, pose.foot_l, (thigh_r + shin_r) * 0.5F),
+      v.palette.cloth * 0.92F, nullptr, 1.0F);
+  out.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, hip_r, pose.foot_r, (thigh_r + shin_r) * 0.5F),
+      v.palette.cloth * 0.92F, nullptr, 1.0F);
+}
+
+void HumanoidRendererBase::drawMinimalBody(const DrawContext &ctx,
+                                           const HumanoidVariant &v,
+                                           const HumanoidPose &pose,
+                                           ISubmitter &out) const {
+  using HP = HumanProportions;
+
+  QVector3D const top = pose.head_pos + QVector3D(0.0F, pose.head_r, 0.0F);
+  QVector3D const bot = (pose.foot_l + pose.foot_r) * 0.5F;
+
+  float const body_radius = HP::TORSO_TOP_R * get_torso_scale();
+
+  out.mesh(getUnitCapsule(), capsuleBetween(ctx.model, top, bot, body_radius),
+           v.palette.cloth, nullptr, 1.0F);
+}
+
 void HumanoidRendererBase::render(const DrawContext &ctx,
 void HumanoidRendererBase::render(const DrawContext &ctx,
                                   ISubmitter &out) const {
                                   ISubmitter &out) const {
   FormationParams const formation = resolveFormation(ctx);
   FormationParams const formation = resolveFormation(ctx);
@@ -1123,6 +1262,8 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
     return float(state & 0x7FFFFFU) / float(0x7FFFFFU);
     return float(state & 0x7FFFFFU) / float(0x7FFFFFU);
   };
   };
 
 
+  s_renderStats.soldiersTotal += visible_count;
+
   for (int idx = 0; idx < visible_count; ++idx) {
   for (int idx = 0; idx < visible_count; ++idx) {
     int const r = idx / cols;
     int const r = idx / cols;
     int const c = idx % cols;
     int const c = idx % cols;
@@ -1169,12 +1310,38 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
       }
       }
     }
     }
 
 
+    QVector3D const soldier_world_pos =
+        inst_model.map(QVector3D(0.0F, 0.0F, 0.0F));
+
+    constexpr float kSoldierCullRadius = 0.6F;
+    if (ctx.camera != nullptr &&
+        !ctx.camera->isInFrustum(soldier_world_pos, kSoldierCullRadius)) {
+      ++s_renderStats.soldiersSkippedFrustum;
+      continue;
+    }
+
+    HumanoidLOD soldier_lod = HumanoidLOD::Full;
+    float soldier_distance = 0.0F;
+    if (ctx.camera != nullptr) {
+      soldier_distance =
+          (soldier_world_pos - ctx.camera->getPosition()).length();
+      soldier_lod = calculateHumanoidLOD(soldier_distance);
+
+      if (soldier_lod == HumanoidLOD::Billboard) {
+        ++s_renderStats.soldiersSkippedLOD;
+        continue;
+      }
+    }
+
+    ++s_renderStats.soldiersRendered;
+
     DrawContext inst_ctx{ctx.resources, ctx.entity, ctx.world, inst_model};
     DrawContext inst_ctx{ctx.resources, ctx.entity, ctx.world, inst_model};
     inst_ctx.selected = ctx.selected;
     inst_ctx.selected = ctx.selected;
     inst_ctx.hovered = ctx.hovered;
     inst_ctx.hovered = ctx.hovered;
     inst_ctx.animationTime = ctx.animationTime;
     inst_ctx.animationTime = ctx.animationTime;
     inst_ctx.rendererId = ctx.rendererId;
     inst_ctx.rendererId = ctx.rendererId;
     inst_ctx.backend = ctx.backend;
     inst_ctx.backend = ctx.backend;
+    inst_ctx.camera = ctx.camera;
 
 
     VariationParams variation = VariationParams::fromSeed(inst_seed);
     VariationParams variation = VariationParams::fromSeed(inst_seed);
     adjust_variation(inst_ctx, inst_seed, variation);
     adjust_variation(inst_ctx, inst_seed, variation);
@@ -1188,8 +1355,36 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
     }
     }
 
 
     HumanoidPose pose;
     HumanoidPose pose;
-    computeLocomotionPose(inst_seed, anim.time + phase_offset, anim.is_moving,
-                          variation, pose);
+    bool usedCachedPose = false;
+
+    PoseCacheKey cacheKey =
+        makePoseCacheKey(reinterpret_cast<uintptr_t>(ctx.entity), idx);
+
+    auto cacheIt = s_poseCache.find(cacheKey);
+    if (!anim.is_moving && cacheIt != s_poseCache.end()) {
+
+      const CachedPoseEntry &cached = cacheIt->second;
+      if (!cached.wasMoving &&
+          s_currentFrame - cached.frameNumber < kPoseCacheMaxAge) {
+
+        pose = cached.pose;
+        usedCachedPose = true;
+        ++s_renderStats.posesCached;
+      }
+    }
+
+    if (!usedCachedPose) {
+
+      computeLocomotionPose(inst_seed, anim.time + phase_offset, anim.is_moving,
+                            variation, pose);
+      ++s_renderStats.posesComputed;
+
+      CachedPoseEntry &entry = s_poseCache[cacheKey];
+      entry.pose = pose;
+      entry.variation = variation;
+      entry.frameNumber = s_currentFrame;
+      entry.wasMoving = anim.is_moving;
+    }
 
 
     HumanoidAnimationContext anim_ctx{};
     HumanoidAnimationContext anim_ctx{};
     anim_ctx.inputs = anim;
     anim_ctx.inputs = anim;
@@ -1324,7 +1519,15 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
       }
       }
     }
     }
 
 
-    if (inst_ctx.backend != nullptr && inst_ctx.resources != nullptr) {
+    const auto &gfxSettings = Render::GraphicsSettings::instance();
+    const bool shouldRenderShadow =
+        gfxSettings.shadowsEnabled() &&
+        (soldier_lod == HumanoidLOD::Full ||
+         soldier_lod == HumanoidLOD::Reduced) &&
+        soldier_distance < gfxSettings.shadowMaxDistance();
+
+    if (shouldRenderShadow && inst_ctx.backend != nullptr &&
+        inst_ctx.resources != nullptr) {
       auto *shadowShader =
       auto *shadowShader =
           inst_ctx.backend->shader(QStringLiteral("troop_shadow"));
           inst_ctx.backend->shader(QStringLiteral("troop_shadow"));
       auto *quadMesh = inst_ctx.resources->quad();
       auto *quadMesh = inst_ctx.resources->quad();
@@ -1411,12 +1614,41 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
       }
       }
     }
     }
 
 
-    drawCommonBody(inst_ctx, variant, pose, out);
-    drawFacialHair(inst_ctx, variant, pose, out);
-    draw_armor(inst_ctx, variant, pose, anim_ctx, out);
+    switch (soldier_lod) {
+    case HumanoidLOD::Full:
+
+      ++s_renderStats.lodFull;
+      drawCommonBody(inst_ctx, variant, pose, out);
+      drawFacialHair(inst_ctx, variant, pose, out);
+      draw_armor(inst_ctx, variant, pose, anim_ctx, out);
+      addAttachments(inst_ctx, variant, pose, anim_ctx, out);
+      break;
+
+    case HumanoidLOD::Reduced:
+
+      ++s_renderStats.lodReduced;
+      drawSimplifiedBody(inst_ctx, variant, pose, out);
+      draw_armor(inst_ctx, variant, pose, anim_ctx, out);
+      addAttachments(inst_ctx, variant, pose, anim_ctx, out);
+      break;
 
 
-    addAttachments(inst_ctx, variant, pose, anim_ctx, out);
+    case HumanoidLOD::Minimal:
+
+      ++s_renderStats.lodMinimal;
+      drawMinimalBody(inst_ctx, variant, pose, out);
+      break;
+
+    case HumanoidLOD::Billboard:
+
+      break;
+    }
   }
   }
 }
 }
 
 
+auto getHumanoidRenderStats() -> const HumanoidRenderStats & {
+  return s_renderStats;
+}
+
+void resetHumanoidRenderStats() { s_renderStats.reset(); }
+
 } // namespace Render::GL
 } // namespace Render::GL

+ 53 - 0
render/humanoid/rig.h

@@ -3,6 +3,7 @@
 #include "../entity/registry.h"
 #include "../entity/registry.h"
 #include "../gl/humanoid/humanoid_types.h"
 #include "../gl/humanoid/humanoid_types.h"
 #include "../gl/mesh.h"
 #include "../gl/mesh.h"
+#include "../graphics_settings.h"
 #include "humanoid_specs.h"
 #include "humanoid_specs.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
@@ -20,6 +21,22 @@ namespace Render::GL {
 
 
 auto torso_mesh_without_bottom_cap() -> Mesh *;
 auto torso_mesh_without_bottom_cap() -> Mesh *;
 
 
+void advancePoseCacheFrame();
+
+inline auto calculateHumanoidLOD(float distance) -> HumanoidLOD {
+  const auto &settings = Render::GraphicsSettings::instance();
+  if (distance < settings.humanoidFullDetailDistance()) {
+    return HumanoidLOD::Full;
+  }
+  if (distance < settings.humanoidReducedDetailDistance()) {
+    return HumanoidLOD::Reduced;
+  }
+  if (distance < settings.humanoidMinimalDetailDistance()) {
+    return HumanoidLOD::Minimal;
+  }
+  return HumanoidLOD::Billboard;
+}
+
 class HumanoidRendererBase {
 class HumanoidRendererBase {
 public:
 public:
   virtual ~HumanoidRendererBase() = default;
   virtual ~HumanoidRendererBase() = default;
@@ -80,6 +97,12 @@ public:
       const DrawContext &ctx, Engine::Core::UnitComponent *unit_comp,
       const DrawContext &ctx, Engine::Core::UnitComponent *unit_comp,
       Engine::Core::TransformComponent *transform_comp) const -> float;
       Engine::Core::TransformComponent *transform_comp) const -> float;
 
 
+  void drawSimplifiedBody(const DrawContext &ctx, const HumanoidVariant &v,
+                          HumanoidPose &pose, ISubmitter &out) const;
+
+  void drawMinimalBody(const DrawContext &ctx, const HumanoidVariant &v,
+                       const HumanoidPose &pose, ISubmitter &out) const;
+
   static auto frameLocalPosition(const AttachmentFrame &frame,
   static auto frameLocalPosition(const AttachmentFrame &frame,
                                  const QVector3D &local) -> QVector3D;
                                  const QVector3D &local) -> QVector3D;
 
 
@@ -112,4 +135,34 @@ protected:
                       HumanoidPose &pose, ISubmitter &out) const;
                       HumanoidPose &pose, ISubmitter &out) const;
 };
 };
 
 
+struct HumanoidRenderStats {
+  uint32_t soldiersTotal{0};
+  uint32_t soldiersRendered{0};
+  uint32_t soldiersSkippedFrustum{0};
+  uint32_t soldiersSkippedLOD{0};
+  uint32_t posesComputed{0};
+  uint32_t posesCached{0};
+  uint32_t lodFull{0};
+  uint32_t lodReduced{0};
+  uint32_t lodMinimal{0};
+
+  void reset() {
+    soldiersTotal = 0;
+    soldiersRendered = 0;
+    soldiersSkippedFrustum = 0;
+    soldiersSkippedLOD = 0;
+    posesComputed = 0;
+    posesCached = 0;
+    lodFull = 0;
+    lodReduced = 0;
+    lodMinimal = 0;
+  }
+};
+
+void advancePoseCacheFrame();
+
+auto getHumanoidRenderStats() -> const HumanoidRenderStats &;
+
+void resetHumanoidRenderStats();
+
 } // namespace Render::GL
 } // namespace Render::GL

+ 62 - 0
render/primitive_batch.cpp

@@ -0,0 +1,62 @@
+#include "primitive_batch.h"
+
+namespace Render::GL {
+
+static PrimitiveBatchStats s_batchStats;
+
+PrimitiveBatcher::PrimitiveBatcher() {
+
+  m_spheres.reserve(1024);
+  m_cylinders.reserve(2048);
+  m_cones.reserve(512);
+}
+
+PrimitiveBatcher::~PrimitiveBatcher() = default;
+
+void PrimitiveBatcher::addSphere(const QMatrix4x4 &transform,
+                                 const QVector3D &color, float alpha) {
+  PrimitiveInstanceGpu inst;
+  inst.setTransform(transform);
+  inst.setColor(color, alpha);
+  m_spheres.push_back(inst);
+  ++s_batchStats.spheresSubmitted;
+}
+
+void PrimitiveBatcher::addCylinder(const QMatrix4x4 &transform,
+                                   const QVector3D &color, float alpha) {
+  PrimitiveInstanceGpu inst;
+  inst.setTransform(transform);
+  inst.setColor(color, alpha);
+  m_cylinders.push_back(inst);
+  ++s_batchStats.cylindersSubmitted;
+}
+
+void PrimitiveBatcher::addCone(const QMatrix4x4 &transform,
+                               const QVector3D &color, float alpha) {
+  PrimitiveInstanceGpu inst;
+  inst.setTransform(transform);
+  inst.setColor(color, alpha);
+  m_cones.push_back(inst);
+  ++s_batchStats.conesSubmitted;
+}
+
+void PrimitiveBatcher::clear() {
+  m_spheres.clear();
+  m_cylinders.clear();
+  m_cones.clear();
+}
+
+void PrimitiveBatcher::reserve(std::size_t spheres, std::size_t cylinders,
+                               std::size_t cones) {
+  m_spheres.reserve(spheres);
+  m_cylinders.reserve(cylinders);
+  m_cones.reserve(cones);
+}
+
+auto getPrimitiveBatchStats() -> const PrimitiveBatchStats & {
+  return s_batchStats;
+}
+
+void resetPrimitiveBatchStats() { s_batchStats.reset(); }
+
+} // namespace Render::GL

+ 126 - 0
render/primitive_batch.h

@@ -0,0 +1,126 @@
+#pragma once
+
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <QVector4D>
+#include <cstddef>
+#include <cstdint>
+#include <vector>
+
+namespace Render::GL {
+
+class Buffer;
+class Mesh;
+
+struct PrimitiveInstanceGpu {
+
+  QVector4D modelCol0{1.0F, 0.0F, 0.0F, 0.0F};
+  QVector4D modelCol1{0.0F, 1.0F, 0.0F, 0.0F};
+  QVector4D modelCol2{0.0F, 0.0F, 1.0F, 0.0F};
+
+  QVector4D colorAlpha{1.0F, 1.0F, 1.0F, 1.0F};
+
+  void setTransform(const QMatrix4x4 &m) {
+
+    modelCol0 = QVector4D(m(0, 0), m(1, 0), m(2, 0), m(0, 3));
+
+    modelCol1 = QVector4D(m(0, 1), m(1, 1), m(2, 1), m(1, 3));
+
+    modelCol2 = QVector4D(m(0, 2), m(1, 2), m(2, 2), m(2, 3));
+  }
+
+  void setColor(const QVector3D &color, float alpha = 1.0F) {
+    colorAlpha = QVector4D(color.x(), color.y(), color.z(), alpha);
+  }
+};
+
+static_assert(sizeof(PrimitiveInstanceGpu) == 64,
+              "PrimitiveInstanceGpu must be 64 bytes for GPU alignment");
+
+struct PrimitiveBatchParams {
+  QMatrix4x4 viewProj;
+  QVector3D lightDirection{0.35F, 0.8F, 0.45F};
+  float ambientStrength{0.3F};
+};
+
+enum class PrimitiveType : uint8_t { Sphere = 0, Cylinder = 1, Cone = 2 };
+
+struct PrimitiveBatchCmd {
+  PrimitiveType type{PrimitiveType::Sphere};
+  std::vector<PrimitiveInstanceGpu> instances;
+  PrimitiveBatchParams params;
+
+  [[nodiscard]] auto instanceCount() const -> std::size_t {
+    return instances.size();
+  }
+  [[nodiscard]] auto instanceData() const -> const PrimitiveInstanceGpu * {
+    return instances.empty() ? nullptr : instances.data();
+  }
+};
+
+class PrimitiveBatcher {
+public:
+  PrimitiveBatcher();
+  ~PrimitiveBatcher();
+
+  void addSphere(const QMatrix4x4 &transform, const QVector3D &color,
+                 float alpha = 1.0F);
+  void addCylinder(const QMatrix4x4 &transform, const QVector3D &color,
+                   float alpha = 1.0F);
+  void addCone(const QMatrix4x4 &transform, const QVector3D &color,
+               float alpha = 1.0F);
+
+  [[nodiscard]] auto sphereCount() const -> std::size_t {
+    return m_spheres.size();
+  }
+  [[nodiscard]] auto cylinderCount() const -> std::size_t {
+    return m_cylinders.size();
+  }
+  [[nodiscard]] auto coneCount() const -> std::size_t { return m_cones.size(); }
+  [[nodiscard]] auto totalCount() const -> std::size_t {
+    return m_spheres.size() + m_cylinders.size() + m_cones.size();
+  }
+
+  [[nodiscard]] auto
+  sphereData() const -> const std::vector<PrimitiveInstanceGpu> & {
+    return m_spheres;
+  }
+  [[nodiscard]] auto
+  cylinderData() const -> const std::vector<PrimitiveInstanceGpu> & {
+    return m_cylinders;
+  }
+  [[nodiscard]] auto
+  coneData() const -> const std::vector<PrimitiveInstanceGpu> & {
+    return m_cones;
+  }
+
+  void clear();
+
+  void reserve(std::size_t spheres, std::size_t cylinders, std::size_t cones);
+
+private:
+  std::vector<PrimitiveInstanceGpu> m_spheres;
+  std::vector<PrimitiveInstanceGpu> m_cylinders;
+  std::vector<PrimitiveInstanceGpu> m_cones;
+};
+
+struct PrimitiveBatchStats {
+  uint32_t spheresSubmitted{0};
+  uint32_t cylindersSubmitted{0};
+  uint32_t conesSubmitted{0};
+  uint32_t batchesRendered{0};
+  uint32_t drawCallsSaved{0};
+
+  void reset() {
+    spheresSubmitted = 0;
+    cylindersSubmitted = 0;
+    conesSubmitted = 0;
+    batchesRendered = 0;
+    drawCallsSaved = 0;
+  }
+};
+
+auto getPrimitiveBatchStats() -> const PrimitiveBatchStats &;
+void resetPrimitiveBatchStats();
+
+} // namespace Render::GL

+ 127 - 5
render/scene_renderer.cpp

@@ -1,6 +1,8 @@
 #include "scene_renderer.h"
 #include "scene_renderer.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/nation_registry.h"
+#include "../game/systems/troop_profile_service.h"
 #include "../game/units/spawn_type.h"
 #include "../game/units/spawn_type.h"
 #include "../game/units/troop_config.h"
 #include "../game/units/troop_config.h"
 #include "draw_queue.h"
 #include "draw_queue.h"
@@ -13,12 +15,16 @@
 #include "gl/camera.h"
 #include "gl/camera.h"
 #include "gl/primitives.h"
 #include "gl/primitives.h"
 #include "gl/resources.h"
 #include "gl/resources.h"
+#include "graphics_settings.h"
 #include "ground/firecamp_gpu.h"
 #include "ground/firecamp_gpu.h"
 #include "ground/grass_gpu.h"
 #include "ground/grass_gpu.h"
 #include "ground/pine_gpu.h"
 #include "ground/pine_gpu.h"
 #include "ground/plant_gpu.h"
 #include "ground/plant_gpu.h"
 #include "ground/stone_gpu.h"
 #include "ground/stone_gpu.h"
 #include "ground/terrain_gpu.h"
 #include "ground/terrain_gpu.h"
+#include "horse/rig.h"
+#include "humanoid/rig.h"
+#include "primitive_batch.h"
 #include "submitter.h"
 #include "submitter.h"
 #include <QDebug>
 #include <QDebug>
 #include <algorithm>
 #include <algorithm>
@@ -56,6 +62,12 @@ auto Renderer::initialize() -> bool {
 void Renderer::shutdown() { m_backend.reset(); }
 void Renderer::shutdown() { m_backend.reset(); }
 
 
 void Renderer::beginFrame() {
 void Renderer::beginFrame() {
+
+  advancePoseCacheFrame();
+
+  resetHumanoidRenderStats();
+  resetHorseRenderStats();
+
   m_activeQueue = &m_queues[m_fillQueueIndex];
   m_activeQueue = &m_queues[m_fillQueueIndex];
   m_activeQueue->clear();
   m_activeQueue->clear();
 
 
@@ -307,10 +319,31 @@ void Renderer::enqueueSelectionRing(Engine::Core::Entity *,
   float scale_y = 1.0F;
   float scale_y = 1.0F;
 
 
   if (unit_comp != nullptr) {
   if (unit_comp != nullptr) {
-    auto &config = Game::Units::TroopConfig::instance();
-    ring_size = config.getSelectionRingSize(unit_comp->spawn_type);
-    ring_offset += config.getSelectionRingYOffset(unit_comp->spawn_type);
-    ground_offset = config.getSelectionRingGroundOffset(unit_comp->spawn_type);
+    auto troop_type_opt =
+        Game::Units::spawn_typeToTroopType(unit_comp->spawn_type);
+
+    if (troop_type_opt) {
+      const auto &nation_reg = Game::Systems::NationRegistry::instance();
+      const Game::Systems::Nation *nation =
+          nation_reg.getNationForPlayer(unit_comp->owner_id);
+      Game::Systems::NationID nation_id =
+          nation != nullptr ? nation->id : nation_reg.default_nation_id();
+
+      const auto profile =
+          Game::Systems::TroopProfileService::instance().get_profile(
+              nation_id, *troop_type_opt);
+
+      ring_size = profile.visuals.selection_ring_size;
+      ring_offset += profile.visuals.selection_ring_y_offset;
+      ground_offset = profile.visuals.selection_ring_ground_offset;
+    } else {
+
+      auto &config = Game::Units::TroopConfig::instance();
+      ring_size = config.getSelectionRingSize(unit_comp->spawn_type);
+      ring_offset += config.getSelectionRingYOffset(unit_comp->spawn_type);
+      ground_offset =
+          config.getSelectionRingGroundOffset(unit_comp->spawn_type);
+    }
   }
   }
   if (transform != nullptr) {
   if (transform != nullptr) {
     scale_y = transform->scale.y;
     scale_y = transform->scale.y;
@@ -354,6 +387,48 @@ void Renderer::renderWorld(Engine::Core::World *world) {
   auto renderable_entities =
   auto renderable_entities =
       world->getEntitiesWith<Engine::Core::RenderableComponent>();
       world->getEntitiesWith<Engine::Core::RenderableComponent>();
 
 
+  const auto &gfxSettings = Render::GraphicsSettings::instance();
+  const auto &batchConfig = gfxSettings.batchingConfig();
+
+  float cameraHeight = 0.0F;
+  if (m_camera != nullptr) {
+    cameraHeight = m_camera->getPosition().y();
+  }
+
+  int visibleUnitCount = 0;
+  for (auto *entity : renderable_entities) {
+    if (entity->hasComponent<Engine::Core::PendingRemovalComponent>()) {
+      continue;
+    }
+    auto *unit_comp = entity->getComponent<Engine::Core::UnitComponent>();
+    if (unit_comp != nullptr && unit_comp->health > 0) {
+      auto *transform =
+          entity->getComponent<Engine::Core::TransformComponent>();
+      if (transform != nullptr && m_camera != nullptr) {
+        QVector3D const unit_pos(transform->position.x, transform->position.y,
+                                 transform->position.z);
+        if (m_camera->isInFrustum(unit_pos, 4.0F)) {
+          ++visibleUnitCount;
+        }
+      }
+    }
+  }
+
+  float batchingRatio =
+      gfxSettings.calculateBatchingRatio(visibleUnitCount, cameraHeight);
+
+  PrimitiveBatcher batcher;
+  if (batchingRatio > 0.0F) {
+    batcher.reserve(2000, 4000, 500);
+  }
+
+  float fullShaderMaxDistance = 30.0F * (1.0F - batchingRatio * 0.7F);
+  if (batchConfig.forceBatching) {
+    fullShaderMaxDistance = 0.0F;
+  }
+
+  BatchingSubmitter batchSubmitter(this, &batcher);
+
   for (auto *entity : renderable_entities) {
   for (auto *entity : renderable_entities) {
 
 
     if (entity->hasComponent<Engine::Core::PendingRemovalComponent>()) {
     if (entity->hasComponent<Engine::Core::PendingRemovalComponent>()) {
@@ -373,6 +448,7 @@ void Renderer::renderWorld(Engine::Core::World *world) {
       continue;
       continue;
     }
     }
 
 
+    float distanceToCamera = 0.0F;
     if ((m_camera != nullptr) && (unit_comp != nullptr)) {
     if ((m_camera != nullptr) && (unit_comp != nullptr)) {
 
 
       float cull_radius = 3.0F;
       float cull_radius = 3.0F;
@@ -390,6 +466,11 @@ void Renderer::renderWorld(Engine::Core::World *world) {
       if (!m_camera->isInFrustum(unit_pos, cull_radius)) {
       if (!m_camera->isInFrustum(unit_pos, cull_radius)) {
         continue;
         continue;
       }
       }
+
+      QVector3D camPos = m_camera->getPosition();
+      float dx = unit_pos.x() - camPos.x();
+      float dz = unit_pos.z() - camPos.z();
+      distanceToCamera = std::sqrt(dx * dx + dz * dz);
     }
     }
 
 
     if ((unit_comp != nullptr) && unit_comp->owner_id != m_localOwnerId) {
     if ((unit_comp != nullptr) && unit_comp->owner_id != m_localOwnerId) {
@@ -430,7 +511,19 @@ void Renderer::renderWorld(Engine::Core::World *world) {
         ctx.animationTime = m_accumulatedTime;
         ctx.animationTime = m_accumulatedTime;
         ctx.rendererId = renderer_key;
         ctx.rendererId = renderer_key;
         ctx.backend = m_backend.get();
         ctx.backend = m_backend.get();
-        fn(ctx, *this);
+        ctx.camera = m_camera;
+
+        bool useBatching = (batchingRatio > 0.0F) &&
+                           (distanceToCamera > fullShaderMaxDistance) &&
+                           !is_selected && !is_hovered &&
+                           !batchConfig.neverBatch;
+
+        if (useBatching) {
+          fn(ctx, batchSubmitter);
+        } else {
+          fn(ctx, *this);
+        }
+
         enqueueSelectionRing(entity, transform, unit_comp, is_selected,
         enqueueSelectionRing(entity, transform, unit_comp, is_selected,
                              is_hovered);
                              is_hovered);
         drawn_by_registry = true;
         drawn_by_registry = true;
@@ -516,6 +609,35 @@ void Renderer::renderWorld(Engine::Core::World *world) {
     mesh(mesh_to_draw, model_matrix, color,
     mesh(mesh_to_draw, model_matrix, color,
          (res != nullptr) ? res->white() : nullptr, 1.0F);
          (res != nullptr) ? res->white() : nullptr, 1.0F);
   }
   }
+
+  if ((m_activeQueue != nullptr) && batcher.totalCount() > 0) {
+    PrimitiveBatchParams params;
+    params.viewProj = m_view_proj;
+
+    if (batcher.sphereCount() > 0) {
+      PrimitiveBatchCmd cmd;
+      cmd.type = PrimitiveType::Sphere;
+      cmd.instances = batcher.sphereData();
+      cmd.params = params;
+      m_activeQueue->submit(cmd);
+    }
+
+    if (batcher.cylinderCount() > 0) {
+      PrimitiveBatchCmd cmd;
+      cmd.type = PrimitiveType::Cylinder;
+      cmd.instances = batcher.cylinderData();
+      cmd.params = params;
+      m_activeQueue->submit(cmd);
+    }
+
+    if (batcher.coneCount() > 0) {
+      PrimitiveBatchCmd cmd;
+      cmd.type = PrimitiveType::Cone;
+      cmd.instances = batcher.coneData();
+      cmd.params = params;
+      m_activeQueue->submit(cmd);
+    }
+  }
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 69 - 0
render/submitter.h

@@ -2,6 +2,7 @@
 
 
 #include "draw_queue.h"
 #include "draw_queue.h"
 #include "gl/primitives.h"
 #include "gl/primitives.h"
+#include "primitive_batch.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
 
 
@@ -135,4 +136,72 @@ private:
   Shader *m_shader = nullptr;
   Shader *m_shader = nullptr;
 };
 };
 
 
+class BatchingSubmitter : public ISubmitter {
+public:
+  explicit BatchingSubmitter(ISubmitter *fallback,
+                             PrimitiveBatcher *batcher = nullptr)
+      : m_fallback(fallback), m_batcher(batcher) {}
+
+  void setBatcher(PrimitiveBatcher *batcher) { m_batcher = batcher; }
+  void setEnabled(bool enabled) { m_enabled = enabled; }
+
+  void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
+            Texture *tex = nullptr, float alpha = 1.0F,
+            int materialId = 0) override {
+
+    if (m_enabled && m_batcher != nullptr && tex == nullptr) {
+      if (mesh == getUnitSphere()) {
+        m_batcher->addSphere(model, color, alpha);
+        return;
+      }
+      if (mesh == getUnitCylinder()) {
+        m_batcher->addCylinder(model, color, alpha);
+        return;
+      }
+      if (mesh == getUnitCone()) {
+        m_batcher->addCone(model, color, alpha);
+        return;
+      }
+    }
+
+    if (m_fallback != nullptr) {
+      m_fallback->mesh(mesh, model, color, tex, alpha, materialId);
+    }
+  }
+
+  void cylinder(const QVector3D &start, const QVector3D &end, float radius,
+                const QVector3D &color, float alpha = 1.0F) override {
+
+    if (m_fallback != nullptr) {
+      m_fallback->cylinder(start, end, radius, color, alpha);
+    }
+  }
+
+  void selectionRing(const QMatrix4x4 &model, float alphaInner,
+                     float alphaOuter, const QVector3D &color) override {
+    if (m_fallback != nullptr) {
+      m_fallback->selectionRing(model, alphaInner, alphaOuter, color);
+    }
+  }
+
+  void grid(const QMatrix4x4 &model, const QVector3D &color, float cellSize,
+            float thickness, float extent) override {
+    if (m_fallback != nullptr) {
+      m_fallback->grid(model, color, cellSize, thickness, extent);
+    }
+  }
+
+  void selectionSmoke(const QMatrix4x4 &model, const QVector3D &color,
+                      float baseAlpha = 0.15F) override {
+    if (m_fallback != nullptr) {
+      m_fallback->selectionSmoke(model, color, baseAlpha);
+    }
+  }
+
+private:
+  ISubmitter *m_fallback = nullptr;
+  PrimitiveBatcher *m_batcher = nullptr;
+  bool m_enabled = true;
+};
+
 } // namespace Render::GL
 } // namespace Render::GL

+ 78 - 0
ui/qml/SettingsPanel.qml

@@ -249,6 +249,84 @@ Item {
                         color: Theme.border
                         color: Theme.border
                     }
                     }
 
 
+                    ColumnLayout {
+                        Layout.fillWidth: true
+                        spacing: Theme.spacingMedium
+
+                        Label {
+                            text: qsTr("Graphics Settings")
+                            color: Theme.textMain
+                            font.pointSize: Theme.fontSizeLarge
+                            font.bold: true
+                        }
+
+                        Rectangle {
+                            Layout.fillWidth: true
+                            Layout.preferredHeight: 2
+                            color: Theme.border
+                            opacity: 0.5
+                        }
+
+                        GridLayout {
+                            Layout.fillWidth: true
+                            columns: 2
+                            rowSpacing: Theme.spacingMedium
+                            columnSpacing: Theme.spacingMedium
+
+                            Label {
+                                text: qsTr("Graphics Quality:")
+                                color: Theme.textSub
+                                font.pointSize: Theme.fontSizeMedium
+                            }
+
+                            ComboBox {
+                                id: graphicsQualityComboBox
+
+                                Layout.fillWidth: true
+                                model: typeof graphicsSettings !== 'undefined' ? graphicsSettings.qualityOptions : ["Low", "Medium", "High", "Ultra"]
+                                currentIndex: typeof graphicsSettings !== 'undefined' ? graphicsSettings.qualityLevel : 1
+                                onActivated: function(index) {
+                                    if (typeof graphicsSettings !== 'undefined')
+                                        graphicsSettings.qualityLevel = index;
+
+                                }
+
+                                delegate: ItemDelegate {
+                                    width: graphicsQualityComboBox.width
+                                    highlighted: graphicsQualityComboBox.highlightedIndex === index
+
+                                    contentItem: Text {
+                                        text: modelData
+                                        color: Theme.textMain
+                                        font.pointSize: Theme.fontSizeMedium
+                                        elide: Text.ElideRight
+                                        verticalAlignment: Text.AlignVCenter
+                                    }
+
+                                }
+
+                            }
+
+                            Label {
+                                text: typeof graphicsSettings !== 'undefined' ? graphicsSettings.getQualityDescription() : ""
+                                color: Theme.textSub
+                                font.pointSize: Theme.fontSizeSmall
+                                opacity: 0.7
+                                wrapMode: Text.WordWrap
+                                Layout.columnSpan: 2
+                                Layout.fillWidth: true
+                            }
+
+                        }
+
+                    }
+
+                    Rectangle {
+                        Layout.fillWidth: true
+                        Layout.preferredHeight: 1
+                        color: Theme.border
+                    }
+
                     ColumnLayout {
                     ColumnLayout {
                         Layout.fillWidth: true
                         Layout.fillWidth: true
                         spacing: Theme.spacingMedium
                         spacing: Theme.spacingMedium