Ver código fonte

Complete firecamp rendering implementation in backend

djeada 1 mês atrás
pai
commit
9c5f5c46ee

+ 7 - 1
app/core/game_engine.cpp

@@ -66,6 +66,7 @@
 #include "render/gl/resources.h"
 #include "render/ground/biome_renderer.h"
 #include "render/ground/bridge_renderer.h"
+#include "render/ground/firecamp_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
 #include "render/ground/pine_renderer.h"
@@ -104,10 +105,11 @@ GameEngine::GameEngine() {
   m_stone = std::make_unique<Render::GL::StoneRenderer>();
   m_plant = std::make_unique<Render::GL::PlantRenderer>();
   m_pine = std::make_unique<Render::GL::PineRenderer>();
+  m_firecamp = std::make_unique<Render::GL::FireCampRenderer>();
 
   m_passes = {m_ground.get(), m_terrain.get(), m_river.get(), m_riverbank.get(),
               m_bridge.get(), m_biome.get(),   m_stone.get(), m_plant.get(),
-              m_pine.get(),   m_fog.get()};
+              m_pine.get(),   m_firecamp.get(), m_fog.get()};
 
   std::unique_ptr<Engine::Core::System> arrowSys =
       std::make_unique<Game::Systems::ArrowSystem>();
@@ -909,6 +911,7 @@ void GameEngine::startSkirmish(const QString &mapPath,
     loader.setStoneRenderer(m_stone.get());
     loader.setPlantRenderer(m_plant.get());
     loader.setPineRenderer(m_pine.get());
+    loader.setFireCampRenderer(m_firecamp.get());
 
     loader.setOnOwnersUpdated([this]() { emit ownerInfoChanged(); });
 
@@ -1496,6 +1499,9 @@ void GameEngine::restoreEnvironmentFromMetadata(const QJsonObject &metadata) {
       if (m_pine) {
         m_pine->configure(*heightMap, terrainService.biomeSettings());
       }
+      if (m_firecamp) {
+        m_firecamp->configure(*heightMap, terrainService.biomeSettings());
+      }
     }
 
     Game::Systems::CommandService::initialize(gridWidth, gridHeight);

+ 2 - 0
app/core/game_engine.h

@@ -47,6 +47,7 @@ class FogRenderer;
 class StoneRenderer;
 class PlantRenderer;
 class PineRenderer;
+class FireCampRenderer;
 struct IRenderPass;
 } // namespace GL
 } // namespace Render
@@ -267,6 +268,7 @@ private:
   std::unique_ptr<Render::GL::StoneRenderer> m_stone;
   std::unique_ptr<Render::GL::PlantRenderer> m_plant;
   std::unique_ptr<Render::GL::PineRenderer> m_pine;
+  std::unique_ptr<Render::GL::FireCampRenderer> m_firecamp;
   std::vector<Render::GL::IRenderPass *> m_passes;
   std::unique_ptr<Game::Systems::PickingService> m_pickingService;
   std::unique_ptr<Game::Systems::VictoryService> m_victoryService;

+ 15 - 0
assets/maps/map_forest.json

@@ -88,6 +88,21 @@
     { "type": "mounted_knight", "x": 192, "z": 58, "playerId": 4 },
     { "type": "spearman", "x": 190, "z": 55, "playerId": 4 }
   ],
+  "firecamps": [
+    { "x": 54, "z": 65, "intensity": 1.0, "radius": 3.0 },
+    { "x": 66, "z": 54, "intensity": 1.1, "radius": 3.2 },
+    { "x": 184, "z": 195, "intensity": 0.9, "radius": 2.8 },
+    { "x": 196, "z": 184, "intensity": 1.0, "radius": 3.0 },
+    { "x": 120, "z": 130, "intensity": 1.2, "radius": 3.5 },
+    { "x": 54, "z": 195, "intensity": 1.0, "radius": 3.0 },
+    { "x": 66, "z": 184, "intensity": 0.95, "radius": 3.1 },
+    { "x": 184, "z": 54, "intensity": 1.05, "radius": 3.2 },
+    { "x": 196, "z": 66, "intensity": 1.0, "radius": 3.0 },
+    { "x": 80, "z": 80, "intensity": 0.85, "radius": 2.5 },
+    { "x": 170, "z": 170, "intensity": 0.9, "radius": 2.7 },
+    { "x": 80, "z": 170, "intensity": 0.88, "radius": 2.6 },
+    { "x": 170, "z": 80, "intensity": 0.92, "radius": 2.8 }
+  ],
   "terrain": [
     {
       "type": "hill",

+ 11 - 0
assets/maps/map_mountain.json

@@ -86,6 +86,17 @@
       "maxPopulation": 150
     }
   ],
+  "firecamps": [
+    { "x": 68, "z": 80, "intensity": 1.0, "radius": 3.0 },
+    { "x": 82, "z": 68, "intensity": 1.1, "radius": 3.2 },
+    { "x": 218, "z": 232, "intensity": 0.9, "radius": 2.8 },
+    { "x": 232, "z": 218, "intensity": 1.0, "radius": 3.0 },
+    { "x": 145, "z": 155, "intensity": 1.2, "radius": 3.5 },
+    { "x": 68, "z": 232, "intensity": 1.0, "radius": 3.0 },
+    { "x": 232, "z": 68, "intensity": 1.05, "radius": 3.2 },
+    { "x": 100, "z": 100, "intensity": 0.85, "radius": 2.5 },
+    { "x": 200, "z": 200, "intensity": 0.9, "radius": 2.7 }
+  ],
   "terrain": [
     {
       "type": "hill",

+ 11 - 1
assets/maps/map_rivers.json

@@ -44,7 +44,7 @@
     },
     { "type": "archer", "x": 28, "z": 32, "playerId": 1 },
     { "type": "archer", "x": 32, "z": 28, "playerId": 1 },
-    { "type": "knight", "x": 30, "z": 35, "playerId": 1 },
+    { "type": "mounted_knight", "x": 30, "z": 35, "playerId": 1 },
     { "type": "spearman", "x": 35, "z": 30, "playerId": 1 },
     {
       "type": "barracks",
@@ -76,6 +76,16 @@
       "maxPopulation": 150
     }
   ],
+  "firecamps": [
+    { "x": 23, "z": 28, "intensity": 1.0, "radius": 3.0 },
+    { "x": 37, "z": 23, "intensity": 1.1, "radius": 3.2 },
+    { "x": 83, "z": 97, "intensity": 0.9, "radius": 2.8 },
+    { "x": 97, "z": 83, "intensity": 1.0, "radius": 3.0 },
+    { "x": 23, "z": 97, "intensity": 1.0, "radius": 3.0 },
+    { "x": 97, "z": 23, "intensity": 1.05, "radius": 3.2 },
+    { "x": 50, "z": 50, "intensity": 0.85, "radius": 2.5 },
+    { "x": 70, "z": 70, "intensity": 0.9, "radius": 2.7 }
+  ],
   "terrain": [
     {
       "type": "hill",

+ 45 - 0
assets/shaders/firecamp.frag

@@ -0,0 +1,45 @@
+#version 330 core
+out vec4 FragColor;
+in vec2 TexCoord;
+in float Intensity;
+in float FlamePhase;
+in float FlameHeight;
+
+uniform sampler2D fireTexture;
+uniform float u_time;
+uniform float u_glowStrength;
+
+void main() {
+    float flameHeight = clamp(FlameHeight, 0.0, 1.0);
+    float intensityScale = clamp(Intensity, 0.6, 1.6);
+
+    vec2 animatedUV =
+        vec2(TexCoord.x, TexCoord.y + fract(u_time * 0.45 + FlamePhase * 0.05));
+    vec4 texColor = texture(fireTexture, animatedUV);
+
+    float noiseLow =
+        0.5 + 0.5 * sin(u_time * 2.3 + FlamePhase * 1.9 + flameHeight * 7.0);
+    float noiseHigh =
+        0.5 + 0.5 * sin(u_time * 4.8 + FlamePhase * 3.6 + TexCoord.x * 10.0);
+    float flicker = mix(noiseLow, noiseHigh, clamp(flameHeight * 1.2, 0.0, 1.0));
+
+    vec3 baseColor =
+        mix(vec3(1.18, 0.56, 0.15), vec3(0.95, 0.28, 0.08), flameHeight);
+    vec3 coreGlow = vec3(1.45, 0.92, 0.44);
+    vec3 flame =
+        mix(baseColor, coreGlow, pow(1.0 - flameHeight, 2.4) * 0.6) *
+        mix(0.85, 1.35, flicker) * intensityScale *
+        mix(vec3(1.0), vec3(1.55), clamp(texColor.rgb, 0.0, 1.0));
+
+    float edgeFade = smoothstep(0.0, 0.2, TexCoord.x) *
+                     smoothstep(0.0, 0.2, 1.0 - TexCoord.x);
+    float heightFade = smoothstep(1.05, 0.42, TexCoord.y);
+    float alpha = edgeFade * heightFade *
+                  mix(0.78, 1.05, flicker) * intensityScale * texColor.a;
+
+    float glow = pow(1.0 - flameHeight, 2.8) * u_glowStrength;
+    flame += vec3(1.26, 0.64, 0.22) * glow * intensityScale;
+
+    flame = clamp(flame, 0.0, 3.2);
+    FragColor = vec4(flame, clamp(alpha, 0.0, 1.0));
+}

+ 73 - 0
assets/shaders/firecamp.vert

@@ -0,0 +1,73 @@
+#version 330 core
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec2 aTexCoord;
+
+// Instance attributes
+layout(location = 3) in vec4 i_posIntensity;  // x, y, z, intensity
+layout(location = 4) in vec4 i_radiusPhase;   // radius, phase, duration, unused
+
+uniform mat4 u_viewProj;
+uniform float u_time;
+uniform float u_flickerSpeed;
+uniform float u_flickerAmount;
+uniform vec3 u_cameraRight;
+uniform vec3 u_cameraForward;
+
+out vec2 TexCoord;
+out float Intensity;
+out float FlamePhase;
+out float FlameHeight;
+
+void main() {
+    vec3 campPos = i_posIntensity.xyz;
+    float intensity = i_posIntensity.w;
+    float phase = i_radiusPhase.y;
+    float radius = i_radiusPhase.x;
+
+    vec3 rightVec = normalize(vec3(u_cameraRight.x, 0.0, u_cameraRight.z));
+    if (length(rightVec) < 1e-4)
+        rightVec = vec3(1.0, 0.0, 0.0);
+    vec3 forwardVec = normalize(vec3(u_cameraForward.x, 0.0, u_cameraForward.z));
+    if (length(forwardVec) < 1e-4)
+        forwardVec = normalize(vec3(-rightVec.z, 0.0, rightVec.x));
+    vec3 upVec = vec3(0.0, 1.0, 0.0);
+
+    float planeId = floor(aPos.z + 0.5);
+    float angle = planeId * 2.0943951; // 120 degrees
+    float c = cos(angle);
+    float s = sin(angle);
+    vec3 horizontalAxis = normalize(rightVec * c + forwardVec * s);
+    if (length(horizontalAxis) < 1e-4)
+        horizontalAxis = rightVec;
+
+    float intensityScale = clamp(intensity, 0.65, 1.4);
+    float heightT = clamp(aTexCoord.y, 0.0, 1.0);
+
+    float widthBase = clamp(radius * 0.18 * intensityScale, 0.55, 0.95);
+    float widthScale = mix(widthBase, widthBase * 0.35, heightT);
+    float heightScale = clamp(radius * 0.24 * intensityScale, 0.55, 1.05);
+
+    float flickerOffset =
+        sin(u_time * u_flickerSpeed + phase) * (u_flickerAmount * 0.55);
+    float sway = sin(u_time * (u_flickerSpeed * 1.05) + phase * 2.1 +
+                     heightT * 2.7);
+    vec3 wobbleOffset =
+        horizontalAxis * (sway * u_flickerAmount * radius * (0.18 + heightT * 0.35));
+
+    vec3 localOffset =
+        horizontalAxis * (aPos.x * widthScale) +
+        upVec * (aPos.y * heightScale * (0.85 + heightT * 0.25));
+
+    float taper = mix(0.0, widthBase * 0.25, heightT * heightT);
+    localOffset += horizontalAxis * (-aPos.x * taper);
+
+    float baseLift = radius * 0.02 + intensity * 0.04;
+    vec3 pos = campPos + localOffset + wobbleOffset +
+               upVec * (flickerOffset + baseLift);
+
+    gl_Position = u_viewProj * vec4(pos, 1.0);
+    TexCoord = aTexCoord;
+    Intensity = intensity;
+    FlamePhase = phase;
+    FlameHeight = heightT;
+}

+ 9 - 0
game/map/map_definition.h

@@ -34,6 +34,14 @@ struct UnitSpawn {
   int maxPopulation = 100;
 };
 
+struct FireCamp {
+  float x = 0.0f;
+  float z = 0.0f;
+  float intensity = 1.0f;
+  float radius = 3.0f;
+  bool persistent = true;
+};
+
 enum class CoordSystem { Grid, World };
 
 struct VictoryConfig {
@@ -51,6 +59,7 @@ struct MapDefinition {
   std::vector<TerrainFeature> terrain;
   std::vector<RiverSegment> rivers;
   std::vector<Bridge> bridges;
+  std::vector<FireCamp> firecamps;
   BiomeSettings biome;
   CoordSystem coordSystem = CoordSystem::Grid;
   int maxTroopsPerPlayer = 50;

+ 19 - 0
game/map/map_loader.cpp

@@ -203,6 +203,21 @@ static void readSpawns(const QJsonArray &arr, std::vector<UnitSpawn> &out) {
   }
 }
 
+static void readFireCamps(const QJsonArray &arr, std::vector<FireCamp> &out) {
+  out.clear();
+  out.reserve(arr.size());
+  for (const auto &v : arr) {
+    auto o = v.toObject();
+    FireCamp fc;
+    fc.x = float(o.value("x").toDouble(0.0));
+    fc.z = float(o.value("z").toDouble(0.0));
+    fc.intensity = float(o.value("intensity").toDouble(1.0));
+    fc.radius = float(o.value("radius").toDouble(3.0));
+    fc.persistent = o.value("persistent").toBool(true);
+    out.push_back(fc);
+  }
+}
+
 static void readTerrain(const QJsonArray &arr, std::vector<TerrainFeature> &out,
                         const GridDefinition &grid, CoordSystem coordSys) {
   out.clear();
@@ -423,6 +438,10 @@ bool MapLoader::loadFromJsonFile(const QString &path, MapDefinition &outMap,
     readSpawns(root.value("spawns").toArray(), outMap.spawns);
   }
 
+  if (root.contains("firecamps") && root.value("firecamps").isArray()) {
+    readFireCamps(root.value("firecamps").toArray(), outMap.firecamps);
+  }
+
   if (root.contains("terrain") && root.value("terrain").isArray()) {
     readTerrain(root.value("terrain").toArray(), outMap.terrain, outMap.grid,
                 outMap.coordSystem);

+ 36 - 0
game/map/skirmish_loader.cpp

@@ -14,6 +14,7 @@
 #include "game/visuals/team_colors.h"
 #include "render/ground/biome_renderer.h"
 #include "render/ground/bridge_renderer.h"
+#include "render/ground/firecamp_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
 #include "render/ground/pine_renderer.h"
@@ -282,6 +283,41 @@ SkirmishLoadResult SkirmishLoader::start(const QString &mapPath,
     }
   }
 
+  if (m_firecamp) {
+    if (terrainService.isInitialized() && terrainService.getHeightMap()) {
+      m_firecamp->configure(*terrainService.getHeightMap(),
+                            terrainService.biomeSettings());
+      
+      // Load explicit firecamps from map definition
+      const auto &fireCamps = terrainService.fireCamps();
+      if (!fireCamps.empty()) {
+        std::vector<QVector3D> positions;
+        std::vector<float> intensities;
+        std::vector<float> radii;
+        
+        const auto *heightMap = terrainService.getHeightMap();
+        const float tileSize = heightMap->getTileSize();
+        const int width = heightMap->getWidth();
+        const int height = heightMap->getHeight();
+        const float halfWidth = width * 0.5f;
+        const float halfHeight = height * 0.5f;
+        
+        for (const auto &fc : fireCamps) {
+          // Convert grid coordinates to world coordinates
+          float worldX = (fc.x - halfWidth) * tileSize;
+          float worldZ = (fc.z - halfHeight) * tileSize;
+          float worldY = terrainService.getTerrainHeight(worldX, worldZ);
+          
+          positions.push_back(QVector3D(worldX, worldY, worldZ));
+          intensities.push_back(fc.intensity);
+          radii.push_back(fc.radius);
+        }
+        
+        m_firecamp->setExplicitFireCamps(positions, intensities, radii);
+      }
+    }
+  }
+
   int mapWidth = lr.ok ? lr.gridWidth : 100;
   int mapHeight = lr.ok ? lr.gridHeight : 100;
   Game::Systems::CommandService::initialize(mapWidth, mapHeight);

+ 5 - 0
game/map/skirmish_loader.h

@@ -25,6 +25,7 @@ class FogRenderer;
 class StoneRenderer;
 class PlantRenderer;
 class PineRenderer;
+class FireCampRenderer;
 class RiverRenderer;
 class RiverbankRenderer;
 class BridgeRenderer;
@@ -77,6 +78,9 @@ public:
   void setStoneRenderer(Render::GL::StoneRenderer *stone) { m_stone = stone; }
   void setPlantRenderer(Render::GL::PlantRenderer *plant) { m_plant = plant; }
   void setPineRenderer(Render::GL::PineRenderer *pine) { m_pine = pine; }
+  void setFireCampRenderer(Render::GL::FireCampRenderer *firecamp) {
+    m_firecamp = firecamp;
+  }
 
   void setOnOwnersUpdated(OwnersUpdatedCallback callback) {
     m_onOwnersUpdated = callback;
@@ -105,6 +109,7 @@ private:
   Render::GL::StoneRenderer *m_stone = nullptr;
   Render::GL::PlantRenderer *m_plant = nullptr;
   Render::GL::PineRenderer *m_pine = nullptr;
+  Render::GL::FireCampRenderer *m_firecamp = nullptr;
   OwnersUpdatedCallback m_onOwnersUpdated;
   VisibilityMaskReadyCallback m_onVisibilityMaskReady;
 };

+ 2 - 0
game/map/terrain_service.cpp

@@ -19,11 +19,13 @@ void TerrainService::initialize(const MapDefinition &mapDef) {
   m_heightMap->addBridges(mapDef.bridges);
   m_biomeSettings = mapDef.biome;
   m_heightMap->applyBiomeVariation(m_biomeSettings);
+  m_fireCamps = mapDef.firecamps;
 }
 
 void TerrainService::clear() {
   m_heightMap.reset();
   m_biomeSettings = BiomeSettings();
+  m_fireCamps.clear();
 }
 
 float TerrainService::getTerrainHeight(float worldX, float worldZ) const {

+ 5 - 0
game/map/terrain_service.h

@@ -2,10 +2,12 @@
 
 #include "terrain.h"
 #include <memory>
+#include <vector>
 
 namespace Game::Map {
 
 struct MapDefinition;
+struct FireCamp;
 
 class TerrainService {
 public:
@@ -33,6 +35,8 @@ public:
 
   const BiomeSettings &biomeSettings() const { return m_biomeSettings; }
 
+  const std::vector<FireCamp> &fireCamps() const { return m_fireCamps; }
+
   bool isInitialized() const { return m_heightMap != nullptr; }
 
   void restoreFromSerialized(int width, int height, float tileSize,
@@ -51,6 +55,7 @@ private:
 
   std::unique_ptr<TerrainHeightMap> m_heightMap;
   BiomeSettings m_biomeSettings;
+  std::vector<FireCamp> m_fireCamps;
 };
 
 } // namespace Game::Map

+ 1 - 1
game/units/mounted_knight.cpp

@@ -49,7 +49,7 @@ void MountedKnight::init(const SpawnParams &params) {
   m_u->unitType = m_typeString;
   m_u->health = 200;
   m_u->maxHealth = 200;
-  m_u->speed = 4.0f;
+  m_u->speed = 9.5f;
   m_u->ownerId = params.playerId;
   m_u->visionRange = 16.0f;
 

+ 1 - 0
render/CMakeLists.txt

@@ -23,6 +23,7 @@ add_library(render_gl STATIC
     ground/stone_renderer.cpp
     ground/plant_renderer.cpp
     ground/pine_renderer.cpp
+    ground/firecamp_renderer.cpp
     entity/registry.cpp
     entity/archer_renderer.cpp
     entity/knight_renderer.cpp

+ 25 - 7
render/draw_queue.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include "ground/firecamp_gpu.h"
 #include "ground/grass_gpu.h"
 #include "ground/pine_gpu.h"
 #include "ground/plant_gpu.h"
@@ -76,6 +77,12 @@ struct PineBatchCmd {
   PineBatchParams params;
 };
 
+struct FireCampBatchCmd {
+  Buffer *instanceBuffer = nullptr;
+  std::size_t instanceCount = 0;
+  FireCampBatchParams params;
+};
+
 struct TerrainChunkCmd {
   Mesh *mesh = nullptr;
   QMatrix4x4 model;
@@ -113,7 +120,7 @@ struct SelectionSmokeCmd {
 using DrawCmd =
     std::variant<GridCmd, SelectionRingCmd, SelectionSmokeCmd, CylinderCmd,
                  MeshCmd, FogBatchCmd, GrassBatchCmd, StoneBatchCmd,
-                 PlantBatchCmd, PineBatchCmd, TerrainChunkCmd>;
+                 PlantBatchCmd, PineBatchCmd, FireCampBatchCmd, TerrainChunkCmd>;
 
 enum class DrawCmdType : std::uint8_t {
   Grid = 0,
@@ -126,7 +133,8 @@ enum class DrawCmdType : std::uint8_t {
   StoneBatch = 7,
   PlantBatch = 8,
   PineBatch = 9,
-  TerrainChunk = 10
+  FireCampBatch = 10,
+  TerrainChunk = 11
 };
 
 constexpr std::size_t MeshCmdIndex =
@@ -149,6 +157,8 @@ constexpr std::size_t PlantBatchCmdIndex =
     static_cast<std::size_t>(DrawCmdType::PlantBatch);
 constexpr std::size_t PineBatchCmdIndex =
     static_cast<std::size_t>(DrawCmdType::PineBatch);
+constexpr std::size_t FireCampBatchCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::FireCampBatch);
 constexpr std::size_t TerrainChunkCmdIndex =
     static_cast<std::size_t>(DrawCmdType::TerrainChunk);
 
@@ -170,6 +180,7 @@ public:
   void submit(const StoneBatchCmd &c) { m_items.emplace_back(c); }
   void submit(const PlantBatchCmd &c) { m_items.emplace_back(c); }
   void submit(const PineBatchCmd &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); }
 
   bool empty() const { return m_items.empty(); }
@@ -255,12 +266,13 @@ private:
       StoneBatch = 2,
       PlantBatch = 3,
       PineBatch = 4,
-      SelectionRing = 5,
-      FogBatch = 6,
-      Mesh = 7,
-      Cylinder = 8,
+      FireCampBatch = 5,
+      Mesh = 6,
+      Cylinder = 7,
+      FogBatch = 8,
       SelectionSmoke = 9,
-      Grid = 10
+      Grid = 10,
+      SelectionRing = 15
     };
 
     static constexpr uint8_t kTypeOrder[] = {
@@ -274,6 +286,7 @@ private:
         static_cast<uint8_t>(RenderOrder::StoneBatch),
         static_cast<uint8_t>(RenderOrder::PlantBatch),
         static_cast<uint8_t>(RenderOrder::PineBatch),
+        static_cast<uint8_t>(RenderOrder::FireCampBatch),
         static_cast<uint8_t>(RenderOrder::TerrainChunk)};
 
     const std::size_t typeIndex = cmd.index();
@@ -311,6 +324,11 @@ private:
       uint64_t bufferPtr =
           reinterpret_cast<uintptr_t>(pine.instanceBuffer) & 0x0000FFFFFFFFFFFF;
       key |= bufferPtr;
+    } else if (cmd.index() == FireCampBatchCmdIndex) {
+      const auto &firecamp = std::get<FireCampBatchCmdIndex>(cmd);
+      uint64_t bufferPtr =
+          reinterpret_cast<uintptr_t>(firecamp.instanceBuffer) & 0x0000FFFFFFFFFFFF;
+      key |= bufferPtr;
     } else if (cmd.index() == TerrainChunkCmdIndex) {
       const auto &terrain = std::get<TerrainChunkCmdIndex>(cmd);
       uint64_t sortByte = static_cast<uint64_t>((terrain.sortKey >> 8) & 0xFFu);

+ 264 - 63
render/entity/horse_renderer.cpp

@@ -58,6 +58,49 @@ inline QVector3D lighten(const QVector3D &c, float k) {
                    saturate(c.z() * k));
 }
 
+inline QVector3D coatGradient(const QVector3D &coat, float verticalFactor,
+                              float longitudinalFactor, float seed) {
+  float highlight = saturate(0.55f + verticalFactor * 0.35f -
+                             longitudinalFactor * 0.20f + seed * 0.08f);
+  QVector3D bright = lighten(coat, 1.08f);
+  QVector3D shadow = darken(coat, 0.86f);
+  return shadow * (1.0f - highlight) + bright * highlight;
+}
+
+inline QVector3D lerp3(const QVector3D &a, const QVector3D &b, float t) {
+  return QVector3D(a.x() + (b.x() - a.x()) * t,
+                   a.y() + (b.y() - a.y()) * t,
+                   a.z() + (b.z() - a.z()) * t);
+}
+
+inline QMatrix4x4 scaledSphere(const QMatrix4x4 &model, const QVector3D &center,
+                               const QVector3D &scale) {
+  QMatrix4x4 m = model;
+  m.translate(center);
+  m.scale(scale);
+  return m;
+}
+
+inline void drawCylinder(ISubmitter &out, const QMatrix4x4 &model,
+                         const QVector3D &a, const QVector3D &b, float radius,
+                         const QVector3D &color, float alpha = 1.0f) {
+  out.mesh(getUnitCylinder(), cylinderBetween(model, a, b, radius), color,
+           nullptr, alpha);
+}
+
+inline void drawCone(ISubmitter &out, const QMatrix4x4 &model,
+                     const QVector3D &tip, const QVector3D &base, float radius,
+                     const QVector3D &color, float alpha = 1.0f) {
+  out.mesh(getUnitCone(), coneFromTo(model, tip, base, radius), color, nullptr,
+           alpha);
+}
+
+inline QVector3D bezier(const QVector3D &p0, const QVector3D &p1,
+                        const QVector3D &p2, float t) {
+  float u = 1.0f - t;
+  return p0 * (u * u) + p1 * (2.0f * u * t) + p2 * (t * t);
+}
+
 inline uint32_t colorHash(const QVector3D &c) {
   uint32_t r = uint32_t(saturate(c.x()) * 255.0f);
   uint32_t g = uint32_t(saturate(c.y()) * 255.0f);
@@ -77,30 +120,30 @@ inline uint32_t colorHash(const QVector3D &c) {
 HorseDimensions makeHorseDimensions(uint32_t seed) {
   HorseDimensions d;
 
-  d.bodyLength = randBetween(seed, 0x12u, 0.74f, 0.84f);
-  d.bodyWidth = randBetween(seed, 0x34u, 0.17f, 0.20f);
-  d.bodyHeight = randBetween(seed, 0x56u, 0.33f, 0.37f);
-  d.barrelCenterY = randBetween(seed, 0x78u, 0.02f, 0.08f);
+  d.bodyLength = randBetween(seed, 0x12u, 0.88f, 0.98f);
+  d.bodyWidth = randBetween(seed, 0x34u, 0.18f, 0.22f);
+  d.bodyHeight = randBetween(seed, 0x56u, 0.40f, 0.46f);
+  d.barrelCenterY = randBetween(seed, 0x78u, 0.05f, 0.09f);
 
-  d.neckLength = randBetween(seed, 0x9Au, 0.32f, 0.37f);
-  d.neckRise = randBetween(seed, 0xBCu, 0.20f, 0.26f);
-  d.headLength = randBetween(seed, 0xDEu, 0.24f, 0.28f);
-  d.headWidth = randBetween(seed, 0xF1u, 0.13f, 0.16f);
-  d.headHeight = randBetween(seed, 0x1357u, 0.16f, 0.19f);
-  d.muzzleLength = randBetween(seed, 0x2468u, 0.11f, 0.14f);
+  d.neckLength = randBetween(seed, 0x9Au, 0.42f, 0.50f);
+  d.neckRise = randBetween(seed, 0xBCu, 0.26f, 0.32f);
+  d.headLength = randBetween(seed, 0xDEu, 0.28f, 0.34f);
+  d.headWidth = randBetween(seed, 0xF1u, 0.14f, 0.17f);
+  d.headHeight = randBetween(seed, 0x1357u, 0.18f, 0.22f);
+  d.muzzleLength = randBetween(seed, 0x2468u, 0.13f, 0.16f);
 
-  d.legLength = randBetween(seed, 0x369Cu, 0.87f, 0.99f);
-  d.hoofHeight = randBetween(seed, 0x48AEu, 0.070f, 0.080f);
+  d.legLength = randBetween(seed, 0x369Cu, 1.05f, 1.18f);
+  d.hoofHeight = randBetween(seed, 0x48AEu, 0.080f, 0.095f);
 
-  d.tailLength = randBetween(seed, 0x5ABCu, 0.30f, 0.36f);
+  d.tailLength = randBetween(seed, 0x5ABCu, 0.38f, 0.48f);
 
-  d.saddleThickness = randBetween(seed, 0x6CDEu, 0.040f, 0.052f);
-  d.seatForwardOffset = randBetween(seed, 0x7531u, 0.020f, 0.050f);
-  d.stirrupOut = d.bodyWidth * randBetween(seed, 0x8642u, 0.88f, 0.98f);
-  d.stirrupDrop = randBetween(seed, 0x9753u, 0.23f, 0.27f);
+  d.saddleThickness = randBetween(seed, 0x6CDEu, 0.035f, 0.045f);
+  d.seatForwardOffset = randBetween(seed, 0x7531u, 0.010f, 0.035f);
+  d.stirrupOut = d.bodyWidth * randBetween(seed, 0x8642u, 0.75f, 0.88f);
+  d.stirrupDrop = randBetween(seed, 0x9753u, 0.28f, 0.32f);
 
-  d.idleBobAmplitude = randBetween(seed, 0xA864u, 0.005f, 0.008f);
-  d.moveBobAmplitude = randBetween(seed, 0xB975u, 0.020f, 0.028f);
+  d.idleBobAmplitude = randBetween(seed, 0xA864u, 0.004f, 0.007f);
+  d.moveBobAmplitude = randBetween(seed, 0xB975u, 0.024f, 0.032f);
 
   d.saddleHeight = d.barrelCenterY + d.bodyHeight * 0.55f + d.saddleThickness;
 
@@ -160,13 +203,13 @@ HorseProfile makeHorseProfile(uint32_t seed, const QVector3D &leatherBase,
   profile.dims = makeHorseDimensions(seed);
   profile.variant = makeHorseVariant(seed, leatherBase, clothBase);
 
-  profile.gait.cycleTime = randBetween(seed, 0xAA12u, 0.50f, 0.58f);
-  profile.gait.frontLegPhase = randBetween(seed, 0xBB34u, 0.10f, 0.18f);
-  float diagonalLead = randBetween(seed, 0xCC56u, 0.48f, 0.56f);
+  profile.gait.cycleTime = randBetween(seed, 0xAA12u, 0.60f, 0.72f);
+  profile.gait.frontLegPhase = randBetween(seed, 0xBB34u, 0.08f, 0.16f);
+  float diagonalLead = randBetween(seed, 0xCC56u, 0.44f, 0.54f);
   profile.gait.rearLegPhase =
       std::fmod(profile.gait.frontLegPhase + diagonalLead, 1.0f);
-  profile.gait.strideSwing = randBetween(seed, 0xDD78u, 0.18f, 0.24f);
-  profile.gait.strideLift = randBetween(seed, 0xEE9Au, 0.11f, 0.15f);
+  profile.gait.strideSwing = randBetween(seed, 0xDD78u, 0.26f, 0.32f);
+  profile.gait.strideLift = randBetween(seed, 0xEE9Au, 0.10f, 0.14f);
 
   return profile;
 }
@@ -199,6 +242,11 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
   float sockChanceRR = hash01(vhash ^ 0x404u);
   bool hasBlaze = hash01(vhash ^ 0x505u) > 0.82f;
 
+  const float coatSeedA = hash01(vhash ^ 0x701u);
+  const float coatSeedB = hash01(vhash ^ 0x702u);
+  const float coatSeedC = hash01(vhash ^ 0x703u);
+  const float coatSeedD = hash01(vhash ^ 0x704u);
+
   QVector3D barrelCenter(0.0f, d.barrelCenterY + bob, 0.0f);
   QVector3D chestCenter = barrelCenter + QVector3D(0.0f, d.bodyHeight * 0.12f,
                                                    d.bodyLength * 0.34f);
@@ -212,7 +260,9 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
     chest.translate(chestCenter);
     chest.scale(d.bodyWidth * 1.12f, d.bodyHeight * 0.95f,
                 d.bodyLength * 0.36f);
-    out.mesh(getUnitSphere(), chest, v.coatColor * 1.03f, nullptr, 1.0f);
+    QVector3D chestColor =
+        coatGradient(v.coatColor, 0.75f, 0.20f, coatSeedA);
+    out.mesh(getUnitSphere(), chest, chestColor, nullptr, 1.0f);
   }
 
   {
@@ -221,7 +271,9 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
                                               -d.bodyLength * 0.03f));
     withers.scale(d.bodyWidth * 0.75f, d.bodyHeight * 0.35f,
                   d.bodyLength * 0.18f);
-    out.mesh(getUnitSphere(), withers, v.coatColor * 0.96f, nullptr, 1.0f);
+    QVector3D witherColor =
+        coatGradient(v.coatColor, 0.88f, 0.35f, coatSeedB);
+    out.mesh(getUnitSphere(), withers, witherColor, nullptr, 1.0f);
   }
 
   {
@@ -229,7 +281,9 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
     belly.translate(bellyCenter);
     belly.scale(d.bodyWidth * 0.98f, d.bodyHeight * 0.64f,
                 d.bodyLength * 0.40f);
-    out.mesh(getUnitSphere(), belly, v.coatColor * 1.06f, nullptr, 1.0f);
+    QVector3D bellyColor =
+        coatGradient(v.coatColor, 0.25f, -0.10f, coatSeedC);
+    out.mesh(getUnitSphere(), belly, bellyColor, nullptr, 1.0f);
   }
 
   for (int i = 0; i < 2; ++i) {
@@ -239,14 +293,18 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
                                             -d.bodyHeight * 0.10f,
                                             -d.bodyLength * 0.05f));
     ribs.scale(d.bodyWidth * 0.38f, d.bodyHeight * 0.42f, d.bodyLength * 0.30f);
-    out.mesh(getUnitSphere(), ribs, v.coatColor * 1.00f, nullptr, 1.0f);
+    QVector3D ribColor =
+        coatGradient(v.coatColor, 0.45f, 0.05f, coatSeedD + side * 0.05f);
+    out.mesh(getUnitSphere(), ribs, ribColor, nullptr, 1.0f);
   }
 
   {
     QMatrix4x4 rump = ctx.model;
     rump.translate(rumpCenter);
     rump.scale(d.bodyWidth * 1.18f, d.bodyHeight * 1.00f, d.bodyLength * 0.36f);
-    out.mesh(getUnitSphere(), rump, v.coatColor * 0.98f, nullptr, 1.0f);
+    QVector3D rumpColor =
+        coatGradient(v.coatColor, 0.62f, -0.28f, coatSeedA * 0.7f);
+    out.mesh(getUnitSphere(), rump, rumpColor, nullptr, 1.0f);
   }
 
   for (int i = 0; i < 2; ++i) {
@@ -256,7 +314,9 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
                                          -d.bodyHeight * 0.10f,
                                          -d.bodyLength * 0.08f));
     hip.scale(d.bodyWidth * 0.45f, d.bodyHeight * 0.42f, d.bodyLength * 0.26f);
-    out.mesh(getUnitSphere(), hip, v.coatColor * 0.99f, nullptr, 1.0f);
+    QVector3D hipColor =
+        coatGradient(v.coatColor, 0.58f, -0.18f, coatSeedB + side * 0.06f);
+    out.mesh(getUnitSphere(), hip, hipColor, nullptr, 1.0f);
   }
 
   QVector3D neckBase =
@@ -267,12 +327,28 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
   QVector3D neckMid =
       lerp(neckBase, neckTop, 0.55f) +
       QVector3D(0.0f, d.bodyHeight * 0.02f, d.bodyLength * 0.02f);
+  QVector3D neckColorBase =
+      coatGradient(v.coatColor, 0.78f, 0.12f, coatSeedC * 0.6f);
   out.mesh(getUnitCylinder(),
            cylinderBetween(ctx.model, neckBase, neckMid, neckRadius * 1.00f),
-           v.coatColor * 1.03f, nullptr, 1.0f);
+           neckColorBase, nullptr, 1.0f);
   out.mesh(getUnitCylinder(),
-           cylinderBetween(ctx.model, neckMid, neckTop, neckRadius * 0.86f),
-           v.coatColor * 1.04f, nullptr, 1.0f);
+          cylinderBetween(ctx.model, neckMid, neckTop, neckRadius * 0.86f),
+           lighten(neckColorBase, 1.03f), nullptr, 1.0f);
+
+  // Mane cards along the neck (compute after neck base/top defined)
+  const int maneSections = 8;
+  QVector3D maneColor = lerp3(v.maneColor, QVector3D(0.12f, 0.09f, 0.08f),
+                              0.35f);
+  for (int i = 0; i < maneSections; ++i) {
+    float t = static_cast<float>(i) / static_cast<float>(maneSections - 1);
+    QVector3D spine = lerp(neckBase, neckTop, t) +
+                      QVector3D(0.0f, d.bodyHeight * 0.12f, 0.0f);
+    float length = lerp(0.14f, 0.08f, t) * d.bodyHeight * 1.4f;
+    QVector3D tip = spine + QVector3D(0.0f, length * 1.2f, 0.02f * length);
+    drawCone(out, ctx.model, tip, spine, d.bodyWidth * lerp(0.25f, 0.12f, t),
+             maneColor, 1.0f);
+  }
 
   QVector3D headCenter =
       neckTop + QVector3D(0.0f, d.headHeight * (0.10f - headNod * 0.15f),
@@ -284,7 +360,9 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
                                            -d.headLength * 0.10f));
     skull.scale(d.headWidth * 0.95f, d.headHeight * 0.90f,
                 d.headLength * 0.80f);
-    out.mesh(getUnitSphere(), skull, v.coatColor * 1.05f, nullptr, 1.0f);
+    QVector3D skullColor =
+        coatGradient(v.coatColor, 0.82f, 0.30f, coatSeedD * 0.8f);
+    out.mesh(getUnitSphere(), skull, skullColor, nullptr, 1.0f);
   }
 
   for (int i = 0; i < 2; ++i) {
@@ -294,7 +372,9 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
                                            -d.headHeight * 0.15f, 0.0f));
     cheek.scale(d.headWidth * 0.45f, d.headHeight * 0.50f,
                 d.headLength * 0.60f);
-    out.mesh(getUnitSphere(), cheek, v.coatColor * 1.02f, nullptr, 1.0f);
+    QVector3D cheekColor =
+        coatGradient(v.coatColor, 0.70f, 0.18f, coatSeedA * 0.9f);
+    out.mesh(getUnitSphere(), cheek, cheekColor, nullptr, 1.0f);
   }
 
   QVector3D muzzleCenter =
@@ -402,6 +482,30 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
              1.0f);
   }
 
+  // Simple bridle straps
+  QVector3D bridleBase = muzzleCenter + QVector3D(0.0f, -d.headHeight * 0.05f,
+                                                 d.muzzleLength * 0.20f);
+  QVector3D cheekAnchorLeft = headCenter +
+                              QVector3D(d.headWidth * 0.55f,
+                                        d.headHeight * 0.05f,
+                                        -d.headLength * 0.05f);
+  QVector3D cheekAnchorRight = headCenter +
+                               QVector3D(-d.headWidth * 0.55f,
+                                         d.headHeight * 0.05f,
+                                         -d.headLength * 0.05f);
+  QVector3D brow = headCenter +
+                   QVector3D(0.0f, d.headHeight * 0.38f,
+                             -d.headLength * 0.28f);
+  QVector3D tackColor = lighten(v.tackColor, 0.9f);
+  drawCylinder(out, ctx.model, bridleBase, cheekAnchorLeft,
+               d.headWidth * 0.07f, tackColor);
+  drawCylinder(out, ctx.model, bridleBase, cheekAnchorRight,
+               d.headWidth * 0.07f, tackColor);
+  drawCylinder(out, ctx.model, cheekAnchorLeft, brow, d.headWidth * 0.05f,
+               tackColor);
+  drawCylinder(out, ctx.model, cheekAnchorRight, brow, d.headWidth * 0.05f,
+               tackColor);
+
   QVector3D maneRoot =
       neckTop + QVector3D(0.0f, d.headHeight * 0.20f, -d.headLength * 0.20f);
   for (int i = 0; i < 12; ++i) {
@@ -421,21 +525,22 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
 
   QVector3D tailBase =
       rumpCenter + QVector3D(0.0f, d.bodyHeight * 0.36f, -d.bodyLength * 0.48f);
-  for (int i = 0; i < 8; ++i) {
-    float t = i / 8.0f;
-    float swing =
-        (anim.isMoving
-             ? std::sin((phase + t * 0.10f) * 2.0f * kPi) *
-                   (0.05f + 0.02f * (1.0f - t))
-             : std::sin((phase * 0.7f + t * 0.20f) * 2.0f * kPi) * 0.04f);
-    QVector3D segStart =
-        tailBase + QVector3D(swing, -i * 0.06f, -t * d.tailLength * 0.65f);
-    QVector3D segEnd = segStart + QVector3D(swing * 0.6f, -0.08f - t * 0.05f,
-                                            -d.tailLength * 0.16f);
-    float radius = d.bodyWidth * (0.22f - 0.025f * i);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, segStart, segEnd, radius),
-             v.tailColor * (0.95f - 0.05f * i), nullptr, 1.0f);
+  QVector3D tailCtrl = tailBase + QVector3D(0.0f, -d.tailLength * 0.20f,
+                                            -d.tailLength * 0.28f);
+  QVector3D tailEnd = tailBase + QVector3D(0.0f, -d.tailLength,
+                                           -d.tailLength * 0.70f);
+  QVector3D tailColor = lerp3(v.tailColor, v.maneColor, 0.35f);
+  QVector3D prevTail = tailBase;
+  for (int i = 1; i <= 8; ++i) {
+    float t = static_cast<float>(i) / 8.0f;
+    QVector3D p = bezier(tailBase, tailCtrl, tailEnd, t);
+    float swing = (anim.isMoving ? std::sin((phase + t * 0.12f) * 2.0f * kPi)
+                                 : std::sin((phase * 0.7f + t * 0.3f) * 2.0f * kPi)) *
+                  (0.04f + 0.015f * (1.0f - t));
+    p.setX(p.x() + swing);
+    float radius = d.bodyWidth * (0.20f - 0.018f * i);
+    drawCylinder(out, ctx.model, prevTail, p, radius, tailColor);
+    prevTail = p;
   }
 
   auto drawLeg = [&](const QVector3D &anchor, float lateralSign,
@@ -446,7 +551,7 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
 
     if (anim.isMoving) {
       float angle = legPhase * 2.0f * kPi;
-      stride = std::sin(angle) * g.strideSwing + forwardBias;
+      stride = std::sin(angle) * g.strideSwing * 0.75f + forwardBias;
       float liftRaw = std::sin(angle);
       lift = liftRaw > 0.0f ? liftRaw * g.strideLift
                             : liftRaw * g.strideLift * 0.22f;
@@ -457,7 +562,8 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
     }
 
     bool tightenLegs = anim.isMoving;
-    float shoulderOut = d.bodyWidth * (tightenLegs ? 0.48f : 0.58f);
+    float shoulderOut =
+        d.bodyWidth * (tightenLegs ? 0.44f : 0.58f);
     QVector3D shoulder = anchor + QVector3D(lateralSign * shoulderOut,
                                             0.05f + lift * 0.05f, stride);
     bool isRear = (forwardBias < 0.0f);
@@ -473,8 +579,8 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
     if (tightenLegs)
       shoulder.setX(shoulder.x() - lateralSign * liftFactor * 0.05f);
 
-    float thighLength = d.legLength * 0.55f;
-    float hipPitch = hipSwing * (isRear ? 0.70f : 0.55f);
+    float thighLength = d.legLength * 0.58f;
+    float hipPitch = hipSwing * (isRear ? 0.60f : 0.48f);
     float inwardLean = tightenLegs ? (-0.06f - liftFactor * 0.04f) : -0.015f;
     QVector3D thighDir(lateralSign * inwardLean, -std::cos(hipPitch) * 0.90f,
                        (isRear ? -1.0f : 1.0f) * std::sin(hipPitch) * 0.65f);
@@ -486,9 +592,9 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
 
     float kneeFlex =
         anim.isMoving
-            ? clamp01(std::sin(gallopAngle + (isRear ? 0.65f : -0.45f)) * 0.7f +
-                      0.45f)
-            : 0.35f;
+            ? clamp01(std::sin(gallopAngle + (isRear ? 0.65f : -0.45f)) * 0.55f +
+                      0.42f)
+            : 0.32f;
 
     float forearmLength = d.legLength * 0.30f;
     float bendCos = std::cos(kneeFlex * kPi * 0.5f);
@@ -521,39 +627,46 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
 
     QVector3D thighBelly = shoulder + (knee - shoulder) * 0.62f;
 
+    QVector3D thighColor =
+        coatGradient(v.coatColor, isRear ? 0.48f : 0.58f,
+                      isRear ? -0.22f : 0.18f, coatSeedA + lateralSign * 0.07f);
     out.mesh(getUnitCone(),
              coneFromTo(ctx.model, thighBelly, shoulder, thighBellyR),
-             v.coatColor * 1.01f, nullptr, 1.0f);
+             thighColor, nullptr, 1.0f);
 
     {
       QMatrix4x4 muscle = ctx.model;
       muscle.translate(thighBelly +
                        QVector3D(0.0f, 0.0f, isRear ? -0.015f : 0.020f));
       muscle.scale(thighBellyR * QVector3D(1.05f, 0.85f, 0.92f));
-      out.mesh(getUnitSphere(), muscle, v.coatColor * 1.04f, nullptr, 1.0f);
+      out.mesh(getUnitSphere(), muscle,
+               lighten(thighColor, 1.03f), nullptr, 1.0f);
     }
 
+    QVector3D kneeColor = darken(thighColor, 0.96f);
     out.mesh(getUnitCone(), coneFromTo(ctx.model, knee, thighBelly, kneeR),
-             v.coatColor * 0.98f, nullptr, 1.0f);
+             kneeColor, nullptr, 1.0f);
 
     {
 
       QMatrix4x4 joint = ctx.model;
       joint.translate(knee + QVector3D(0.0f, 0.0f, isRear ? -0.025f : 0.030f));
       joint.scale(QVector3D(kneeR * 1.22f, kneeR * 1.08f, kneeR * 1.40f));
-      out.mesh(getUnitSphere(), joint, v.coatColor * 0.95f, nullptr, 1.0f);
+      out.mesh(getUnitSphere(), joint, darken(kneeColor, 0.92f), nullptr,
+               1.0f);
     }
 
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, knee, cannon, cannonR),
-             v.coatColor * 0.94f, nullptr, 1.0f);
+             darken(thighColor, 0.93f), nullptr, 1.0f);
 
     {
 
       QMatrix4x4 joint = ctx.model;
       joint.translate(fetlock);
       joint.scale(pasternR * 1.18f);
-      out.mesh(getUnitSphere(), joint, v.coatColor * 0.95f, nullptr, 1.0f);
+      out.mesh(getUnitSphere(), joint, darken(thighColor, 0.92f), nullptr,
+               1.0f);
     }
 
     float sock =
@@ -628,6 +741,94 @@ void HorseRenderer::render(const DrawContext &ctx, const AnimationInputs &anim,
         v.tackColor * 0.94f, nullptr, 1.0f);
   }
 
+  auto drawRider = [&]() {
+    QVector3D riderCoat(0.23f, 0.23f, 0.26f);
+    QVector3D riderCloth = lighten(v.blanketColor, 1.15f);
+    QVector3D riderSkin(1.0f, 0.86f, 0.72f);
+
+    QVector3D pelvisCenter = saddleCenter +
+                             QVector3D(0.0f, d.saddleThickness * 0.6f,
+                                       -d.bodyLength * 0.06f);
+    QVector3D torsoTop = pelvisCenter + QVector3D(0.0f, d.bodyHeight * 0.55f,
+                                                  0.02f);
+    QMatrix4x4 pelvis = ctx.model;
+    pelvis.translate(pelvisCenter);
+    pelvis.scale(QVector3D(d.bodyWidth * 0.55f, d.bodyWidth * 0.40f,
+                           d.bodyWidth * 0.45f));
+    out.mesh(getUnitSphere(), pelvis, riderCoat * 0.9f, nullptr, 1.0f);
+
+    drawCylinder(out, ctx.model, pelvisCenter + QVector3D(0.0f, 0.0f, 0.0f),
+                 torsoTop, d.bodyWidth * 0.35f, riderCoat);
+
+    QVector3D shoulderLeft = torsoTop + QVector3D(d.bodyWidth * 0.42f,
+                                                  -d.bodyWidth * 0.05f,
+                                                  0.02f);
+    QVector3D shoulderRight = torsoTop + QVector3D(-d.bodyWidth * 0.42f,
+                                                   -d.bodyWidth * 0.05f,
+                                                   0.02f);
+    QVector3D handLeft = cheekAnchorLeft + QVector3D(0.0f, -d.headHeight * 0.25f,
+                                                    d.headLength * 0.25f);
+    QVector3D handRight = cheekAnchorRight + QVector3D(0.0f, -d.headHeight * 0.25f,
+                                                      d.headLength * 0.25f);
+    drawCylinder(out, ctx.model, shoulderLeft, handLeft,
+                 d.bodyWidth * 0.11f, riderCloth);
+    drawCylinder(out, ctx.model, shoulderRight, handRight,
+                 d.bodyWidth * 0.11f, riderCloth);
+
+    drawCylinder(out, ctx.model, handLeft,
+                 handLeft + QVector3D(0.0f, -d.bodyWidth * 0.08f, 0.0f),
+                 d.bodyWidth * 0.09f, riderSkin);
+    drawCylinder(out, ctx.model, handRight,
+                 handRight + QVector3D(0.0f, -d.bodyWidth * 0.08f, 0.0f),
+                 d.bodyWidth * 0.09f, riderSkin);
+
+    QVector3D helmetTop = torsoTop + QVector3D(0.0f, d.bodyHeight * 0.35f,
+                                               0.05f);
+    QMatrix4x4 head = ctx.model;
+    head.translate(helmetTop + QVector3D(0.0f, -d.bodyWidth * 0.12f, 0.0f));
+    head.scale(d.bodyWidth * 0.32f);
+    out.mesh(getUnitSphere(), head, riderSkin * 0.95f, nullptr, 1.0f);
+
+    QMatrix4x4 helm = ctx.model;
+    helm.translate(helmetTop + QVector3D(0.0f, d.bodyWidth * 0.08f, 0.0f));
+    helm.scale(d.bodyWidth * 0.36f, d.bodyWidth * 0.20f, d.bodyWidth * 0.36f);
+    out.mesh(getUnitSphere(), helm, riderCloth * 0.85f, nullptr, 1.0f);
+
+    auto drawLegPair = [&](float sign) {
+      QVector3D hip = pelvisCenter + QVector3D(sign * d.bodyWidth * 0.32f,
+                                               -d.bodyWidth * 0.05f,
+                                               0.0f);
+      QVector3D knee = hip + QVector3D(sign * d.bodyWidth * 0.05f,
+                                       -d.stirrupDrop * 0.55f,
+                                       d.bodyLength * 0.14f);
+      QVector3D foot = saddleCenter +
+                       QVector3D(sign * d.stirrupOut,
+                                 -d.stirrupDrop,
+                                 d.bodyLength * 0.05f);
+      drawCylinder(out, ctx.model, hip, knee, d.bodyWidth * 0.12f,
+                   riderCloth * 0.95f);
+      drawCylinder(out, ctx.model, knee, foot, d.bodyWidth * 0.10f,
+                   riderCloth);
+      QVector3D bootTip = foot + QVector3D(sign * d.bodyWidth * 0.08f,
+                                          -d.bodyWidth * 0.05f,
+                                          d.bodyWidth * 0.08f);
+      drawCone(out, ctx.model, bootTip, foot, d.bodyWidth * 0.10f,
+               riderCoat * 0.7f);
+    };
+    drawLegPair(1.0f);
+    drawLegPair(-1.0f);
+
+    // Simple reins from hands to bit
+    drawCylinder(out, ctx.model, handLeft,
+                 bridleBase + QVector3D(0.0f, -d.headHeight * 0.02f, 0.0f),
+                 d.bodyWidth * 0.04f, riderCloth * 0.6f, 0.85f);
+    drawCylinder(out, ctx.model, handRight,
+                 bridleBase + QVector3D(0.0f, -d.headHeight * 0.02f, 0.0f),
+                 d.bodyWidth * 0.04f, riderCloth * 0.6f, 0.85f);
+  };
+
+  drawRider();
+
   auto drawStirrup = [&](float sideSign) {
     QVector3D stirrupAttach =
         saddleCenter + QVector3D(sideSign * d.bodyWidth * 0.92f,

+ 213 - 1
render/gl/backend.cpp

@@ -55,6 +55,7 @@ void Backend::initialize() {
   m_stoneShader = m_shaderCache->get(QStringLiteral("stone_instanced"));
   m_plantShader = m_shaderCache->get(QStringLiteral("plant_instanced"));
   m_pineShader = m_shaderCache->get(QStringLiteral("pine_instanced"));
+  m_firecampShader = m_shaderCache->get(QStringLiteral("firecamp"));
   m_groundShader = m_shaderCache->get(QStringLiteral("ground_plane"));
   m_terrainShader = m_shaderCache->get(QStringLiteral("terrain_chunk"));
   m_riverShader = m_shaderCache->get(QStringLiteral("river"));
@@ -81,6 +82,9 @@ void Backend::initialize() {
   if (!m_pineShader)
     qWarning()
         << "Backend: pine shader missing - check pine_instanced.vert/frag";
+  if (!m_firecampShader)
+    qWarning()
+        << "Backend: firecamp shader missing - check firecamp.vert/frag";
   if (!m_groundShader)
     qWarning() << "Backend: ground_plane shader missing";
   if (!m_terrainShader)
@@ -109,6 +113,7 @@ void Backend::initialize() {
   cacheStoneUniforms();
   cachePlantUniforms();
   cachePineUniforms();
+  cacheFireCampUniforms();
   cacheGroundUniforms();
   cacheTerrainUniforms();
   cacheRiverUniforms();
@@ -120,6 +125,7 @@ void Backend::initialize() {
   initializeStonePipeline();
   initializePlantPipeline();
   initializePinePipeline();
+  initializeFireCampPipeline();
 }
 
 void Backend::beginFrame() {
@@ -480,6 +486,98 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
 
       break;
     }
+    case FireCampBatchCmdIndex: {
+      const auto &firecamp = std::get<FireCampBatchCmdIndex>(cmd);
+
+      if (!firecamp.instanceBuffer || firecamp.instanceCount == 0 ||
+          !m_firecampShader || !m_firecampVao || m_firecampIndexCount == 0) {
+        break;
+      }
+
+      DepthMaskScope depthMask(true);
+      glEnable(GL_DEPTH_TEST);
+      BlendScope blend(true);
+      glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+      GLboolean prevCull = glIsEnabled(GL_CULL_FACE);
+      if (prevCull)
+        glDisable(GL_CULL_FACE);
+
+      if (m_lastBoundShader != m_firecampShader) {
+        m_firecampShader->use();
+        m_lastBoundShader = m_firecampShader;
+        m_lastBoundTexture = nullptr;
+      }
+
+      if (m_firecampUniforms.viewProj != Shader::InvalidUniform) {
+        m_firecampShader->setUniform(m_firecampUniforms.viewProj, viewProj);
+      }
+      if (m_firecampUniforms.time != Shader::InvalidUniform) {
+        m_firecampShader->setUniform(m_firecampUniforms.time,
+                                     firecamp.params.time);
+      }
+      if (m_firecampUniforms.flickerSpeed != Shader::InvalidUniform) {
+        m_firecampShader->setUniform(m_firecampUniforms.flickerSpeed,
+                                     firecamp.params.flickerSpeed);
+      }
+      if (m_firecampUniforms.flickerAmount != Shader::InvalidUniform) {
+        m_firecampShader->setUniform(m_firecampUniforms.flickerAmount,
+                                     firecamp.params.flickerAmount);
+      }
+      if (m_firecampUniforms.glowStrength != Shader::InvalidUniform) {
+        m_firecampShader->setUniform(m_firecampUniforms.glowStrength,
+                                     firecamp.params.glowStrength);
+      }
+      if (m_firecampUniforms.cameraRight != Shader::InvalidUniform) {
+        QVector3D cameraRight = cam.getRightVector();
+        if (cameraRight.lengthSquared() < 1e-6f) {
+          cameraRight = QVector3D(1.0f, 0.0f, 0.0f);
+        } else {
+          cameraRight.normalize();
+        }
+        m_firecampShader->setUniform(m_firecampUniforms.cameraRight,
+                                     cameraRight);
+      }
+      if (m_firecampUniforms.cameraForward != Shader::InvalidUniform) {
+        QVector3D cameraForward = cam.getForwardVector();
+        if (cameraForward.lengthSquared() < 1e-6f) {
+          cameraForward = QVector3D(0.0f, 0.0f, -1.0f);
+        } else {
+          cameraForward.normalize();
+        }
+        m_firecampShader->setUniform(m_firecampUniforms.cameraForward,
+                                     cameraForward);
+      }
+
+      // Bind white texture if no fire texture is available
+      if (m_firecampUniforms.fireTexture != Shader::InvalidUniform) {
+        if (m_resources && m_resources->white()) {
+          m_resources->white()->bind(0);
+          m_firecampShader->setUniform(m_firecampUniforms.fireTexture, 0);
+        }
+      }
+
+      glBindVertexArray(m_firecampVao);
+      firecamp.instanceBuffer->bind();
+      const GLsizei stride =
+          static_cast<GLsizei>(sizeof(FireCampInstanceGpu));
+      glVertexAttribPointer(
+          3, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(FireCampInstanceGpu, posIntensity)));
+      glVertexAttribPointer(
+          4, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(FireCampInstanceGpu, radiusPhase)));
+      firecamp.instanceBuffer->unbind();
+
+      glDrawElementsInstanced(GL_TRIANGLES, m_firecampIndexCount,
+                              GL_UNSIGNED_SHORT, nullptr,
+                              static_cast<GLsizei>(firecamp.instanceCount));
+      glBindVertexArray(0);
+
+      if (prevCull)
+        glEnable(GL_CULL_FACE);
+
+      break;
+    }
     case TerrainChunkCmdIndex: {
       const auto &terrain = std::get<TerrainChunkCmdIndex>(cmd);
 
@@ -755,7 +853,7 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
       m_basicShader->setUniform(m_basicUniforms.color, sc.color);
 
       DepthMaskScope depthMask(false);
-      DepthTestScope depthTest(true);
+      DepthTestScope depthTest(false);
       PolygonOffsetScope poly(-1.0f, -1.0f);
       BlendScope blend(true);
 
@@ -1489,6 +1587,25 @@ void Backend::cachePineUniforms() {
   }
 }
 
+void Backend::cacheFireCampUniforms() {
+  if (m_firecampShader) {
+    m_firecampUniforms.viewProj = m_firecampShader->uniformHandle("u_viewProj");
+    m_firecampUniforms.time = m_firecampShader->uniformHandle("u_time");
+    m_firecampUniforms.flickerSpeed =
+        m_firecampShader->uniformHandle("u_flickerSpeed");
+    m_firecampUniforms.flickerAmount =
+        m_firecampShader->uniformHandle("u_flickerAmount");
+    m_firecampUniforms.glowStrength =
+        m_firecampShader->uniformHandle("u_glowStrength");
+    m_firecampUniforms.fireTexture =
+        m_firecampShader->uniformHandle("fireTexture");
+    m_firecampUniforms.cameraRight =
+        m_firecampShader->uniformHandle("u_cameraRight");
+    m_firecampUniforms.cameraForward =
+        m_firecampShader->uniformHandle("u_cameraForward");
+  }
+}
+
 void Backend::initializePlantPipeline() {
   initializeOpenGLFunctions();
   shutdownPlantPipeline();
@@ -1745,4 +1862,99 @@ void Backend::shutdownPinePipeline() {
   m_pineIndexCount = 0;
 }
 
+void Backend::initializeFireCampPipeline() {
+  initializeOpenGLFunctions();
+  shutdownFireCampPipeline();
+
+  // Simple quad for billboard fire camp rendering
+  struct FireCampVertex {
+    QVector3D position;
+    QVector2D texCoord;
+  };
+
+  std::vector<FireCampVertex> vertices;
+  vertices.reserve(12);
+  std::vector<unsigned short> indices;
+  indices.reserve(18);
+
+  auto appendPlane = [&](float planeIndex) {
+    unsigned short base = static_cast<unsigned short>(vertices.size());
+    vertices.push_back({QVector3D(-1.0f, 0.0f, planeIndex),
+                        QVector2D(0.0f, 0.0f)}); // bottom-left
+    vertices.push_back({QVector3D(1.0f, 0.0f, planeIndex),
+                        QVector2D(1.0f, 0.0f)}); // bottom-right
+    vertices.push_back({QVector3D(1.0f, 2.0f, planeIndex),
+                        QVector2D(1.0f, 1.0f)}); // top-right
+    vertices.push_back({QVector3D(-1.0f, 2.0f, planeIndex),
+                        QVector2D(0.0f, 1.0f)}); // top-left
+
+    indices.push_back(base + 0);
+    indices.push_back(base + 1);
+    indices.push_back(base + 2);
+    indices.push_back(base + 0);
+    indices.push_back(base + 2);
+    indices.push_back(base + 3);
+  };
+
+  appendPlane(0.0f);
+  appendPlane(1.0f);
+  appendPlane(2.0f);
+
+  glGenVertexArrays(1, &m_firecampVao);
+  glBindVertexArray(m_firecampVao);
+
+  glGenBuffers(1, &m_firecampVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_firecampVertexBuffer);
+  glBufferData(
+      GL_ARRAY_BUFFER,
+      static_cast<GLsizeiptr>(vertices.size() * sizeof(FireCampVertex)),
+      vertices.data(), GL_STATIC_DRAW);
+  m_firecampVertexCount = static_cast<GLsizei>(vertices.size());
+
+  glEnableVertexAttribArray(0);
+  glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(FireCampVertex),
+                        reinterpret_cast<void *>(0));
+
+  glEnableVertexAttribArray(1);
+  glVertexAttribPointer(
+      1, 2, GL_FLOAT, GL_FALSE, sizeof(FireCampVertex),
+      reinterpret_cast<void *>(offsetof(FireCampVertex, texCoord)));
+
+  glGenBuffers(1, &m_firecampIndexBuffer);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_firecampIndexBuffer);
+  glBufferData(GL_ELEMENT_ARRAY_BUFFER,
+               static_cast<GLsizeiptr>(indices.size() * sizeof(unsigned short)),
+               indices.data(), GL_STATIC_DRAW);
+  m_firecampIndexCount = static_cast<GLsizei>(indices.size());
+
+  // Setup instance attribute pointers (will be set per-instance)
+  glEnableVertexAttribArray(3);
+  glVertexAttribDivisor(3, 1);
+
+  glEnableVertexAttribArray(4);
+  glVertexAttribDivisor(4, 1);
+
+  glBindVertexArray(0);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
+}
+
+void Backend::shutdownFireCampPipeline() {
+  initializeOpenGLFunctions();
+  if (m_firecampIndexBuffer) {
+    glDeleteBuffers(1, &m_firecampIndexBuffer);
+    m_firecampIndexBuffer = 0;
+  }
+  if (m_firecampVertexBuffer) {
+    glDeleteBuffers(1, &m_firecampVertexBuffer);
+    m_firecampVertexBuffer = 0;
+  }
+  if (m_firecampVao) {
+    glDeleteVertexArrays(1, &m_firecampVao);
+    m_firecampVao = 0;
+  }
+  m_firecampVertexCount = 0;
+  m_firecampIndexCount = 0;
+}
+
 } // namespace Render::GL

+ 21 - 0
render/gl/backend.h

@@ -84,6 +84,7 @@ private:
   Shader *m_stoneShader = nullptr;
   Shader *m_plantShader = nullptr;
   Shader *m_pineShader = nullptr;
+  Shader *m_firecampShader = nullptr;
   Shader *m_groundShader = nullptr;
   Shader *m_terrainShader = nullptr;
   Shader *m_riverShader = nullptr;
@@ -174,6 +175,17 @@ private:
     Shader::UniformHandle lightDirection{Shader::InvalidUniform};
   } m_pineUniforms;
 
+  struct FireCampUniforms {
+    Shader::UniformHandle viewProj{Shader::InvalidUniform};
+    Shader::UniformHandle time{Shader::InvalidUniform};
+    Shader::UniformHandle flickerSpeed{Shader::InvalidUniform};
+    Shader::UniformHandle flickerAmount{Shader::InvalidUniform};
+    Shader::UniformHandle glowStrength{Shader::InvalidUniform};
+    Shader::UniformHandle fireTexture{Shader::InvalidUniform};
+    Shader::UniformHandle cameraRight{Shader::InvalidUniform};
+    Shader::UniformHandle cameraForward{Shader::InvalidUniform};
+  } m_firecampUniforms;
+
   struct GroundUniforms {
     Shader::UniformHandle mvp{Shader::InvalidUniform};
     Shader::UniformHandle model{Shader::InvalidUniform};
@@ -274,6 +286,12 @@ private:
   GLsizei m_pineIndexCount = 0;
   GLsizei m_pineVertexCount = 0;
 
+  GLuint m_firecampVao = 0;
+  GLuint m_firecampVertexBuffer = 0;
+  GLuint m_firecampIndexBuffer = 0;
+  GLsizei m_firecampIndexCount = 0;
+  GLsizei m_firecampVertexCount = 0;
+
   void cacheBasicUniforms();
   void cacheArcherUniforms();
   void cacheKnightUniforms();
@@ -301,6 +319,9 @@ private:
   void cachePineUniforms();
   void initializePinePipeline();
   void shutdownPinePipeline();
+  void cacheFireCampUniforms();
+  void initializeFireCampPipeline();
+  void shutdownFireCampPipeline();
   void cacheGroundUniforms();
   void cacheTerrainUniforms();
   void cacheRiverUniforms();

+ 3 - 0
render/gl/camera.h

@@ -60,6 +60,9 @@ public:
 
   const QVector3D &getPosition() const { return m_position; }
   const QVector3D &getTarget() const { return m_target; }
+  const QVector3D &getUpVector() const { return m_up; }
+  const QVector3D &getRightVector() const { return m_right; }
+  const QVector3D &getForwardVector() const { return m_front; }
   float getDistance() const;
   float getPitchDeg() const;
   float getFOV() const { return m_fov; }

+ 4 - 0
render/gl/shader_cache.h

@@ -79,6 +79,10 @@ public:
         kShaderBase + QStringLiteral("pine_instanced.frag");
     load(QStringLiteral("pine_instanced"), pineVert, pineFrag);
 
+    const QString firecampVert = kShaderBase + QStringLiteral("firecamp.vert");
+    const QString firecampFrag = kShaderBase + QStringLiteral("firecamp.frag");
+    load(QStringLiteral("firecamp"), firecampVert, firecampFrag);
+
     const QString groundVert =
         kShaderBase + QStringLiteral("ground_plane.vert");
     const QString groundFrag =

+ 135 - 90
render/ground/bridge_renderer.cpp

@@ -6,6 +6,7 @@
 #include "terrain_gpu.h"
 #include <QVector2D>
 #include <QVector3D>
+#include <algorithm>
 #include <cmath>
 
 namespace Render::GL {
@@ -46,6 +47,37 @@ void BridgeRenderer::buildMeshes() {
     std::vector<Vertex> vertices;
     std::vector<unsigned int> indices;
 
+    const int vertsPerSegment = 12;
+    const float deckThickness =
+        std::clamp(bridge.width * 0.25f, 0.35f, 0.8f);
+    const float parapetHeight = std::clamp(bridge.width * 0.25f, 0.25f, 0.55f);
+    const float parapetOffset = halfWidth * 1.05f;
+
+    auto addVertex = [&](const QVector3D &position, const QVector3D &normal,
+                         float u, float v) {
+      Vertex vtx{};
+      vtx.position[0] = position.x();
+      vtx.position[1] = position.y();
+      vtx.position[2] = position.z();
+      QVector3D n = normal.normalized();
+      vtx.normal[0] = n.x();
+      vtx.normal[1] = n.y();
+      vtx.normal[2] = n.z();
+      vtx.texCoord[0] = u;
+      vtx.texCoord[1] = v;
+      vertices.push_back(vtx);
+    };
+
+    auto pushQuad = [&](unsigned int a, unsigned int b, unsigned int c,
+                        unsigned int d) {
+      indices.push_back(a);
+      indices.push_back(b);
+      indices.push_back(c);
+      indices.push_back(a);
+      indices.push_back(c);
+      indices.push_back(d);
+    };
+
     for (int i = 0; i <= lengthSegments; ++i) {
       float t = static_cast<float>(i) / static_cast<float>(lengthSegments);
       QVector3D centerPos = bridge.start + dir * (length * t);
@@ -58,101 +90,114 @@ void BridgeRenderer::buildMeshes() {
       float stoneNoise = std::sin(centerPos.x() * 3.0f) *
                          std::cos(centerPos.z() * 2.5f) * 0.02f;
 
-      for (int side = 0; side < 2; ++side) {
-        float sideSign = (side == 0) ? -1.0f : 1.0f;
-        QVector3D edgePos = centerPos + perpendicular * (halfWidth * sideSign);
-        edgePos.setY(deckHeight + stoneNoise);
-
-        Vertex deckVertex;
-        deckVertex.position[0] = edgePos.x();
-        deckVertex.position[1] = edgePos.y();
-        deckVertex.position[2] = edgePos.z();
-        deckVertex.normal[0] = 0.0f;
-        deckVertex.normal[1] = 1.0f;
-        deckVertex.normal[2] = 0.0f;
-        deckVertex.texCoord[0] = side * 1.0f;
-        deckVertex.texCoord[1] = t * length * 0.5f;
-        vertices.push_back(deckVertex);
-      }
-
-      float archUnderHeight = bridge.start.y() + archHeight;
-      for (int side = 0; side < 2; ++side) {
-        float sideSign = (side == 0) ? -1.0f : 1.0f;
-        float archWidth = halfWidth * 0.7f;
-        QVector3D archPos = centerPos + perpendicular * (archWidth * sideSign);
-        archPos.setY(archUnderHeight);
-
-        Vertex archVertex;
-        archVertex.position[0] = archPos.x();
-        archVertex.position[1] = archPos.y();
-        archVertex.position[2] = archPos.z();
-        archVertex.normal[0] = perpendicular.x() * sideSign;
-        archVertex.normal[1] = -0.5f;
-        archVertex.normal[2] = perpendicular.z() * sideSign;
-        archVertex.texCoord[0] = side * 1.0f;
-        archVertex.texCoord[1] = t * length * 0.5f;
-        vertices.push_back(archVertex);
-      }
-
-      float parapetHeight = deckHeight + 0.25f;
-      for (int side = 0; side < 2; ++side) {
-        float sideSign = (side == 0) ? -1.0f : 1.0f;
-        QVector3D parapetPos =
-            centerPos + perpendicular * (halfWidth * sideSign * 1.05f);
-        parapetPos.setY(parapetHeight);
-
-        Vertex parapetVertex;
-        parapetVertex.position[0] = parapetPos.x();
-        parapetVertex.position[1] = parapetPos.y();
-        parapetVertex.position[2] = parapetPos.z();
-        parapetVertex.normal[0] = perpendicular.x() * sideSign;
-        parapetVertex.normal[1] = 0.0f;
-        parapetVertex.normal[2] = perpendicular.z() * sideSign;
-        parapetVertex.texCoord[0] = side * 1.0f;
-        parapetVertex.texCoord[1] = t * length * 0.5f;
-        vertices.push_back(parapetVertex);
-      }
+      float deckY = deckHeight + stoneNoise;
+      float undersideY =
+          deckHeight - deckThickness - archCurve * bridge.height * 0.55f;
+      float railTopY = deckY + parapetHeight;
+
+      QVector3D leftNormal = (-perpendicular).normalized();
+      QVector3D rightNormal = perpendicular.normalized();
+
+      QVector3D topLeft =
+          centerPos + perpendicular * (-halfWidth);
+      topLeft.setY(deckY);
+      QVector3D topRight =
+          centerPos + perpendicular * (halfWidth);
+      topRight.setY(deckY);
+
+      QVector3D bottomLeft = topLeft;
+      bottomLeft.setY(undersideY);
+      QVector3D bottomRight = topRight;
+      bottomRight.setY(undersideY);
+
+      QVector3D sideLeftTop = topLeft;
+      QVector3D sideLeftBottom = bottomLeft;
+      QVector3D sideRightTop = topRight;
+      QVector3D sideRightBottom = bottomRight;
+
+      QVector3D parapetLeftBottom =
+          centerPos + perpendicular * (-parapetOffset);
+      parapetLeftBottom.setY(deckY);
+      QVector3D parapetLeftTop = parapetLeftBottom;
+      parapetLeftTop.setY(railTopY);
+
+      QVector3D parapetRightBottom =
+          centerPos + perpendicular * (parapetOffset);
+      parapetRightBottom.setY(deckY);
+      QVector3D parapetRightTop = parapetRightBottom;
+      parapetRightTop.setY(railTopY);
+
+      float texU0 = 0.0f;
+      float texU1 = 1.0f;
+      float texV = t * length * 0.4f;
+
+      addVertex(topLeft, QVector3D(0.0f, 1.0f, 0.0f), texU0, texV);   // 0
+      addVertex(topRight, QVector3D(0.0f, 1.0f, 0.0f), texU1, texV);  // 1
+      addVertex(bottomLeft, QVector3D(0.0f, -1.0f, 0.0f), texU0,
+                texV);                                                // 2
+      addVertex(bottomRight, QVector3D(0.0f, -1.0f, 0.0f), texU1,
+                texV);                                                // 3
+      addVertex(sideLeftTop, leftNormal, texU0, texV);                // 4
+      addVertex(sideLeftBottom, leftNormal, texU0, texV);             // 5
+      addVertex(sideRightTop, rightNormal, texU1, texV);              // 6
+      addVertex(sideRightBottom, rightNormal, texU1, texV);           // 7
+      addVertex(parapetLeftTop, leftNormal, texU0, texV);             // 8
+      addVertex(parapetLeftBottom, leftNormal, texU0, texV);          // 9
+      addVertex(parapetRightTop, rightNormal, texU1, texV);           // 10
+      addVertex(parapetRightBottom, rightNormal, texU1, texV);        // 11
 
       if (i < lengthSegments) {
-        unsigned int idx = i * 6;
-
-        indices.push_back(idx + 0);
-        indices.push_back(idx + 6);
-        indices.push_back(idx + 1);
-        indices.push_back(idx + 1);
-        indices.push_back(idx + 6);
-        indices.push_back(idx + 7);
-
-        indices.push_back(idx + 2);
-        indices.push_back(idx + 8);
-        indices.push_back(idx + 0);
-        indices.push_back(idx + 0);
-        indices.push_back(idx + 8);
-        indices.push_back(idx + 6);
-
-        indices.push_back(idx + 1);
-        indices.push_back(idx + 9);
-        indices.push_back(idx + 3);
-        indices.push_back(idx + 1);
-        indices.push_back(idx + 7);
-        indices.push_back(idx + 9);
-
-        indices.push_back(idx + 0);
-        indices.push_back(idx + 6);
-        indices.push_back(idx + 4);
-        indices.push_back(idx + 4);
-        indices.push_back(idx + 6);
-        indices.push_back(idx + 10);
-
-        indices.push_back(idx + 1);
-        indices.push_back(idx + 5);
-        indices.push_back(idx + 7);
-        indices.push_back(idx + 5);
-        indices.push_back(idx + 11);
-        indices.push_back(idx + 7);
+        unsigned int baseIdx = static_cast<unsigned int>(i * vertsPerSegment);
+        unsigned int nextIdx = baseIdx + vertsPerSegment;
+
+        // Deck top
+        pushQuad(baseIdx + 0, baseIdx + 1, nextIdx + 1, nextIdx + 0);
+        // Deck bottom
+        pushQuad(nextIdx + 3, nextIdx + 2, baseIdx + 2, baseIdx + 3);
+        // Left side wall
+        pushQuad(baseIdx + 4, baseIdx + 5, nextIdx + 5, nextIdx + 4);
+        // Right side wall
+        pushQuad(baseIdx + 6, baseIdx + 7, nextIdx + 7, nextIdx + 6);
+        // Parapet left outer face
+        pushQuad(baseIdx + 9, baseIdx + 8, nextIdx + 8, nextIdx + 9);
+        // Parapet right outer face
+        pushQuad(baseIdx + 11, baseIdx + 10, nextIdx + 10, nextIdx + 11);
       }
     }
 
+    // Add end caps to close the bridge slab
+    if (!vertices.empty()) {
+      unsigned int startIdx = 0;
+      unsigned int endIdx =
+          static_cast<unsigned int>(lengthSegments * vertsPerSegment);
+
+      QVector3D forwardNormal = dir;
+
+      auto addCap = [&](unsigned int topL, unsigned int topR,
+                        unsigned int bottomR, unsigned int bottomL,
+                        const QVector3D &normal) {
+        unsigned int capStart = static_cast<unsigned int>(vertices.size());
+        auto copyVertex = [&](unsigned int source, const QVector3D &norm) {
+          const Vertex &src = vertices[source];
+          Vertex vtx = src;
+          QVector3D n = norm.normalized();
+          vtx.normal[0] = n.x();
+          vtx.normal[1] = n.y();
+          vtx.normal[2] = n.z();
+          vertices.push_back(vtx);
+        };
+        copyVertex(topL, normal);
+        copyVertex(topR, normal);
+        copyVertex(bottomR, normal);
+        copyVertex(bottomL, normal);
+        pushQuad(capStart + 0, capStart + 1, capStart + 2, capStart + 3);
+      };
+
+      addCap(startIdx + 0, startIdx + 1, startIdx + 3, startIdx + 2,
+             -forwardNormal);
+      addCap(endIdx + 0, endIdx + 1, endIdx + 3, endIdx + 2, forwardNormal);
+    }
+
     if (!vertices.empty() && !indices.empty()) {
       m_meshes.push_back(std::make_unique<Mesh>(vertices, indices));
     } else {

+ 21 - 0
render/ground/firecamp_gpu.h

@@ -0,0 +1,21 @@
+#pragma once
+
+#include <QVector3D>
+#include <QVector4D>
+#include <cstdint>
+
+namespace Render::GL {
+
+struct FireCampInstanceGpu {
+  QVector4D posIntensity;  // x, y, z, intensity
+  QVector4D radiusPhase;   // radius, phase, duration (or 1.0 for persistent), unused
+};
+
+struct FireCampBatchParams {
+  float time = 0.0f;
+  float flickerSpeed = 5.0f;
+  float flickerAmount = 0.02f;
+  float glowStrength = 1.25f;
+};
+
+} // namespace Render::GL

+ 381 - 0
render/ground/firecamp_renderer.cpp

@@ -0,0 +1,381 @@
+#include "firecamp_renderer.h"
+#include "../../game/map/visibility_service.h"
+#include "../../game/systems/building_collision_registry.h"
+#include "../gl/buffer.h"
+#include "../scene_renderer.h"
+#include <QDebug>
+#include <QVector2D>
+#include <algorithm>
+#include <array>
+#include <cmath>
+#include <optional>
+
+namespace {
+
+using std::uint32_t;
+
+inline uint32_t hashCoords(int x, int z, uint32_t salt = 0u) {
+  uint32_t ux = static_cast<uint32_t>(x * 73856093);
+  uint32_t uz = static_cast<uint32_t>(z * 19349663);
+  return ux ^ uz ^ (salt * 83492791u);
+}
+
+inline float rand01(uint32_t &state) {
+  state = state * 1664525u + 1013904223u;
+  return static_cast<float>((state >> 8) & 0xFFFFFF) /
+         static_cast<float>(0xFFFFFF);
+}
+
+inline float remap(float value, float minOut, float maxOut) {
+  return minOut + (maxOut - minOut) * value;
+}
+
+inline float valueNoise(float x, float z, uint32_t seed) {
+  int ix = static_cast<int>(std::floor(x));
+  int iz = static_cast<int>(std::floor(z));
+  float fx = x - static_cast<float>(ix);
+  float fz = z - static_cast<float>(iz);
+
+  fx = fx * fx * (3.0f - 2.0f * fx);
+  fz = fz * fz * (3.0f - 2.0f * fz);
+
+  uint32_t s00 = hashCoords(ix, iz, seed);
+  uint32_t s10 = hashCoords(ix + 1, iz, seed);
+  uint32_t s01 = hashCoords(ix, iz + 1, seed);
+  uint32_t s11 = hashCoords(ix + 1, iz + 1, seed);
+
+  float v00 = rand01(s00);
+  float v10 = rand01(s10);
+  float v01 = rand01(s01);
+  float v11 = rand01(s11);
+
+  float v0 = v00 * (1.0f - fx) + v10 * fx;
+  float v1 = v01 * (1.0f - fx) + v11 * fx;
+  return v0 * (1.0f - fz) + v1 * fz;
+}
+
+} // namespace
+
+namespace Render::GL {
+
+FireCampRenderer::FireCampRenderer() = default;
+FireCampRenderer::~FireCampRenderer() = default;
+
+void FireCampRenderer::configure(
+    const Game::Map::TerrainHeightMap &heightMap,
+    const Game::Map::BiomeSettings &biomeSettings) {
+  m_width = heightMap.getWidth();
+  m_height = heightMap.getHeight();
+  m_tileSize = heightMap.getTileSize();
+  m_heightData = heightMap.getHeightData();
+  m_terrainTypes = heightMap.getTerrainTypes();
+  m_biomeSettings = biomeSettings;
+  m_noiseSeed = biomeSettings.seed;
+
+  m_fireCampInstances.clear();
+  m_fireCampInstanceBuffer.reset();
+  m_fireCampInstanceCount = 0;
+  m_fireCampInstancesDirty = false;
+
+  m_fireCampParams.time = 0.0f;
+  m_fireCampParams.flickerSpeed = 5.0f;
+  m_fireCampParams.flickerAmount = 0.02f;
+  m_fireCampParams.glowStrength = 1.1f;
+
+  generateFireCampInstances();
+}
+
+void FireCampRenderer::submit(Renderer &renderer, ResourceManager *resources) {
+  (void)resources;
+
+  m_fireCampInstanceCount =
+      static_cast<uint32_t>(m_fireCampInstances.size());
+
+  if (m_fireCampInstanceCount == 0) {
+    m_fireCampInstanceBuffer.reset();
+    qWarning() << "FireCampRenderer: No instances to render";
+    return;
+  }
+  
+  qDebug() << "FireCampRenderer: Submitting" << m_fireCampInstanceCount << "fire camps";
+
+  auto &visibility = Game::Map::VisibilityService::instance();
+  const bool useVisibility = visibility.isInitialized();
+
+  std::vector<FireCampInstanceGpu> visibleInstances;
+  if (useVisibility) {
+    visibleInstances.reserve(m_fireCampInstanceCount);
+    for (const auto &instance : m_fireCampInstances) {
+      float worldX = instance.posIntensity.x();
+      float worldZ = instance.posIntensity.z();
+      if (visibility.isVisibleWorld(worldX, worldZ)) {
+        visibleInstances.push_back(instance);
+      }
+    }
+  } else {
+    visibleInstances = m_fireCampInstances;
+  }
+
+  const uint32_t visibleCount =
+      static_cast<uint32_t>(visibleInstances.size());
+  if (visibleCount == 0) {
+    m_fireCampInstanceBuffer.reset();
+    return;
+  }
+
+  if (!m_fireCampInstanceBuffer) {
+    m_fireCampInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
+  }
+
+  m_fireCampInstanceBuffer->setData(visibleInstances, Buffer::Usage::Static);
+
+  FireCampBatchParams params = m_fireCampParams;
+  params.time = renderer.getAnimationTime();
+  params.flickerAmount =
+      m_fireCampParams.flickerAmount *
+      (0.9f + 0.25f * std::sin(params.time * 1.3f));
+  params.glowStrength =
+      m_fireCampParams.glowStrength *
+      (0.85f + 0.2f * std::sin(params.time * 1.7f + 1.2f));
+  renderer.firecampBatch(m_fireCampInstanceBuffer.get(), visibleCount, params);
+
+  // Add compact log arrangement directly beneath the fire
+  const QVector3D logColor(0.26f, 0.15f, 0.08f);
+  const QVector3D charColor(0.08f, 0.05f, 0.03f);
+
+  for (const auto &instance : visibleInstances) {
+    const QVector4D posIntensity = instance.posIntensity;
+    const QVector4D radiusPhase = instance.radiusPhase;
+
+    const QVector3D campPos = posIntensity.toVector3D();
+    const float intensity = std::clamp(posIntensity.w(), 0.6f, 1.6f);
+    const float baseRadius = std::max(radiusPhase.x(), 1.0f);
+
+    uint32_t state = hashCoords(static_cast<int>(std::floor(campPos.x())),
+                                static_cast<int>(std::floor(campPos.z())),
+                                static_cast<uint32_t>(radiusPhase.y() * 37.0f));
+
+    const float time = params.time;
+    const float charAmount = std::clamp(time * 0.015f + rand01(state) * 0.05f,
+                                        0.0f, 1.0f);
+
+    const QVector3D blendedLogColor = logColor * (1.0f - charAmount) +
+                                      charColor * (charAmount + 0.15f);
+
+    const float logLength = std::clamp(baseRadius * 0.85f, 0.45f, 1.1f);
+    const float logRadius = std::clamp(baseRadius * 0.08f, 0.03f, 0.08f);
+
+    const float baseYaw = (rand01(state) - 0.5f) * 0.35f;
+    const float cosBase = std::cos(baseYaw);
+    const float sinBase = std::sin(baseYaw);
+    const QVector3D axisA(cosBase, 0.0f, sinBase);
+    const QVector3D axisB(-axisA.z(), 0.0f, axisA.x());
+
+    const QVector3D baseCenter = campPos + QVector3D(0.0f, -0.02f, 0.0f);
+    const QVector3D baseHalfA = axisA * (logLength * 0.5f);
+    const QVector3D baseHalfB = axisB * (logLength * 0.45f);
+
+    renderer.cylinder(baseCenter - baseHalfA, baseCenter + baseHalfA, logRadius,
+                      blendedLogColor, 1.0f);
+    renderer.cylinder(baseCenter - baseHalfB, baseCenter + baseHalfB, logRadius,
+                      blendedLogColor, 1.0f);
+
+    if (rand01(state) > 0.25f) {
+      float topYaw = baseYaw + 0.6f + (rand01(state) - 0.5f) * 0.35f;
+      QVector3D topAxis(std::cos(topYaw), 0.0f, std::sin(topYaw));
+      QVector3D topHalf = topAxis * (logLength * 0.35f);
+      QVector3D topCenter = campPos + QVector3D(0.0f, logRadius * 1.6f, 0.0f);
+      float topRadius = logRadius * 0.85f;
+      renderer.cylinder(topCenter - topHalf, topCenter + topHalf, topRadius,
+                        blendedLogColor, 1.0f);
+    }
+  }
+}
+
+void FireCampRenderer::clear() {
+  m_fireCampInstances.clear();
+  m_fireCampInstanceBuffer.reset();
+  m_fireCampInstanceCount = 0;
+  m_fireCampInstancesDirty = false;
+  m_explicitPositions.clear();
+  m_explicitIntensities.clear();
+  m_explicitRadii.clear();
+}
+
+void FireCampRenderer::setExplicitFireCamps(
+    const std::vector<QVector3D> &positions,
+    const std::vector<float> &intensities,
+    const std::vector<float> &radii) {
+  m_explicitPositions = positions;
+  m_explicitIntensities = intensities;
+  m_explicitRadii = radii;
+  m_fireCampInstancesDirty = true;
+  if (m_width > 0 && m_height > 0 && !m_heightData.empty()) {
+    generateFireCampInstances();
+  }
+}
+
+void FireCampRenderer::addExplicitFireCamps() {
+  if (m_explicitPositions.empty()) {
+    return;
+  }
+
+  for (size_t i = 0; i < m_explicitPositions.size(); ++i) {
+    const QVector3D &pos = m_explicitPositions[i];
+    
+    float intensity = 1.0f;
+    if (i < m_explicitIntensities.size()) {
+      intensity = m_explicitIntensities[i];
+    }
+    
+    float radius = 3.0f;
+    if (i < m_explicitRadii.size()) {
+      radius = m_explicitRadii[i];
+    }
+    
+    // Random phase for animation variation
+    float phase = static_cast<float>(i) * 1.234567f;
+    
+    FireCampInstanceGpu instance;
+    instance.posIntensity = QVector4D(pos.x(), pos.y(), pos.z(), intensity);
+    instance.radiusPhase = QVector4D(radius, phase, 1.0f, 0.0f); // 1.0 = persistent
+    m_fireCampInstances.push_back(instance);
+  }
+}
+
+void FireCampRenderer::generateFireCampInstances() {
+  m_fireCampInstances.clear();
+
+  if (m_width < 2 || m_height < 2 || m_heightData.empty()) {
+    return;
+  }
+
+  const float halfWidth = static_cast<float>(m_width) * 0.5f;
+  const float halfHeight = static_cast<float>(m_height) * 0.5f;
+  const float tileSafe = std::max(0.1f, m_tileSize);
+
+  const float edgePadding =
+      std::clamp(m_biomeSettings.spawnEdgePadding, 0.0f, 0.5f);
+  const float edgeMarginX = static_cast<float>(m_width) * edgePadding;
+  const float edgeMarginZ = static_cast<float>(m_height) * edgePadding;
+
+  // Lower density for fire camps - they're decorative
+  float fireCampDensity = 0.02f;
+
+  std::vector<QVector3D> normals(m_width * m_height, QVector3D(0, 1, 0));
+  for (int z = 1; z < m_height - 1; ++z) {
+    for (int x = 1; x < m_width - 1; ++x) {
+      int idx = z * m_width + x;
+      float hL = m_heightData[(z)*m_width + (x - 1)];
+      float hR = m_heightData[(z)*m_width + (x + 1)];
+      float hD = m_heightData[(z - 1) * m_width + (x)];
+      float hU = m_heightData[(z + 1) * m_width + (x)];
+
+      QVector3D n = QVector3D(hL - hR, 2.0f * tileSafe, hD - hU);
+      if (n.lengthSquared() > 0.0f) {
+        n.normalize();
+      } else {
+        n = QVector3D(0, 1, 0);
+      }
+      normals[idx] = n;
+    }
+  }
+
+  auto addFireCamp = [&](float gx, float gz, uint32_t &state) -> bool {
+    if (gx < edgeMarginX || gx > m_width - 1 - edgeMarginX ||
+        gz < edgeMarginZ || gz > m_height - 1 - edgeMarginZ) {
+      return false;
+    }
+
+    float sgx = std::clamp(gx, 0.0f, float(m_width - 1));
+    float sgz = std::clamp(gz, 0.0f, float(m_height - 1));
+
+    int ix = std::clamp(int(std::floor(sgx + 0.5f)), 0, m_width - 1);
+    int iz = std::clamp(int(std::floor(sgz + 0.5f)), 0, m_height - 1);
+    int normalIdx = iz * m_width + ix;
+
+    QVector3D normal = normals[normalIdx];
+    float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);
+
+    // Fire camps should be on flat ground
+    if (slope > 0.3f)
+      return false;
+
+    float worldX = (gx - halfWidth) * m_tileSize;
+    float worldZ = (gz - halfHeight) * m_tileSize;
+    float worldY = m_heightData[normalIdx];
+
+    auto &buildingRegistry =
+        Game::Systems::BuildingCollisionRegistry::instance();
+    if (buildingRegistry.isPointInBuilding(worldX, worldZ)) {
+      return false;
+    }
+
+    // Intensity and radius for lighting (if added later)
+    float intensity = remap(rand01(state), 0.8f, 1.2f);
+    float radius = remap(rand01(state), 2.0f, 4.0f) * tileSafe;
+
+    // Phase for animation variation
+    float phase = rand01(state) * 6.2831853f;
+
+    // Persistent flag
+    float duration = 1.0f; // 1.0 means persistent
+
+    FireCampInstanceGpu instance;
+    instance.posIntensity = QVector4D(worldX, worldY, worldZ, intensity);
+    instance.radiusPhase = QVector4D(radius, phase, duration, 0.0f);
+    m_fireCampInstances.push_back(instance);
+    return true;
+  };
+
+  // Place fire camps near settlements (procedural generation)
+  for (int z = 0; z < m_height; z += 20) {
+    for (int x = 0; x < m_width; x += 20) {
+      int idx = z * m_width + x;
+
+      QVector3D normal = normals[idx];
+      float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);
+      if (slope > 0.3f)
+        continue;
+
+      uint32_t state = hashCoords(
+          x, z,
+          m_noiseSeed ^ 0xF12ECA3Fu ^ static_cast<uint32_t>(idx));
+
+      float worldX = (x - halfWidth) * m_tileSize;
+      float worldZ = (z - halfHeight) * m_tileSize;
+
+      float clusterNoise =
+          valueNoise(worldX * 0.02f, worldZ * 0.02f,
+                     m_noiseSeed ^ 0xCA3F12E0u);
+
+      // Only place in specific areas
+      if (clusterNoise < 0.4f)
+        continue;
+
+      float densityMult = 1.0f;
+      if (m_terrainTypes[idx] == Game::Map::TerrainType::Hill) {
+        densityMult = 0.5f;
+      } else if (m_terrainTypes[idx] == Game::Map::TerrainType::Mountain) {
+        densityMult = 0.0f; // No fire camps on mountains
+      }
+
+      float effectiveDensity = fireCampDensity * densityMult;
+      if (rand01(state) < effectiveDensity) {
+        float gx = float(x) + rand01(state) * 20.0f;
+        float gz = float(z) + rand01(state) * 20.0f;
+        addFireCamp(gx, gz, state);
+      }
+    }
+  }
+
+  // Add explicit firecamps from map definition
+  addExplicitFireCamps();
+
+  m_fireCampInstanceCount = m_fireCampInstances.size();
+  m_fireCampInstancesDirty = m_fireCampInstanceCount > 0;
+  
+  qDebug() << "FireCampRenderer: Generated" << m_fireCampInstanceCount << "total instances";
+}
+
+} // namespace Render::GL

+ 57 - 0
render/ground/firecamp_renderer.h

@@ -0,0 +1,57 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include "../i_render_pass.h"
+#include "firecamp_gpu.h"
+#include <QVector3D>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace Render {
+namespace GL {
+class Buffer;
+class Renderer;
+
+class FireCampRenderer : public IRenderPass {
+public:
+  FireCampRenderer();
+  ~FireCampRenderer();
+
+  void configure(const Game::Map::TerrainHeightMap &heightMap,
+                 const Game::Map::BiomeSettings &biomeSettings);
+
+  void setExplicitFireCamps(const std::vector<QVector3D> &positions,
+                           const std::vector<float> &intensities = {},
+                           const std::vector<float> &radii = {});
+
+  void submit(Renderer &renderer, ResourceManager *resources) override;
+
+  void clear();
+
+private:
+  void generateFireCampInstances();
+  void addExplicitFireCamps();
+
+  int m_width = 0;
+  int m_height = 0;
+  float m_tileSize = 1.0f;
+
+  std::vector<float> m_heightData;
+  std::vector<Game::Map::TerrainType> m_terrainTypes;
+  Game::Map::BiomeSettings m_biomeSettings;
+  std::uint32_t m_noiseSeed = 0u;
+
+  std::vector<FireCampInstanceGpu> m_fireCampInstances;
+  std::unique_ptr<Buffer> m_fireCampInstanceBuffer;
+  std::size_t m_fireCampInstanceCount = 0;
+  FireCampBatchParams m_fireCampParams;
+  bool m_fireCampInstancesDirty = false;
+
+  std::vector<QVector3D> m_explicitPositions;
+  std::vector<float> m_explicitIntensities;
+  std::vector<float> m_explicitRadii;
+};
+
+} // namespace GL
+} // namespace Render

+ 23 - 17
render/ground/riverbank_renderer.cpp

@@ -5,6 +5,7 @@
 #include "../scene_renderer.h"
 #include <QVector2D>
 #include <QVector3D>
+#include <algorithm>
 #include <cmath>
 
 namespace Render::GL {
@@ -25,6 +26,7 @@ void RiverbankRenderer::configure(
 
 void RiverbankRenderer::buildMeshes() {
   m_meshes.clear();
+  m_visibilitySamples.clear();
 
   if (m_riverSegments.empty()) {
     return;
@@ -88,6 +90,7 @@ void RiverbankRenderer::buildMeshes() {
     float length = dir.length();
     if (length < 0.01f) {
       m_meshes.push_back(nullptr);
+      m_visibilitySamples.emplace_back();
       continue;
     }
 
@@ -103,6 +106,7 @@ void RiverbankRenderer::buildMeshes() {
 
     std::vector<Vertex> vertices;
     std::vector<unsigned int> indices;
+    std::vector<QVector3D> samples;
 
     for (int i = 0; i < lengthSteps; ++i) {
       float t = static_cast<float>(i) / static_cast<float>(lengthSteps - 1);
@@ -133,6 +137,8 @@ void RiverbankRenderer::buildMeshes() {
           centerPos - perpendicular * (halfWidth + widthVariation);
       QVector3D innerRight =
           centerPos + perpendicular * (halfWidth + widthVariation);
+      samples.push_back(innerLeft);
+      samples.push_back(innerRight);
 
       float outerVariation =
           noise(centerPos.x() * 8.0f, centerPos.z() * 8.0f) * 0.5f;
@@ -214,8 +220,10 @@ void RiverbankRenderer::buildMeshes() {
 
     if (!vertices.empty() && !indices.empty()) {
       m_meshes.push_back(std::make_unique<Mesh>(vertices, indices));
+      m_visibilitySamples.push_back(std::move(samples));
     } else {
       m_meshes.push_back(nullptr);
+      m_visibilitySamples.emplace_back();
     }
   }
 }
@@ -253,25 +261,23 @@ void RiverbankRenderer::submit(Renderer &renderer, ResourceManager *resources) {
     }
 
     if (useVisibility) {
-      QVector3D dir = segment.end - segment.start;
-      float length = dir.length();
-
-      bool allVisible = true;
-      dir.normalize();
-
-      int samplesPerSegment = 5;
-      for (int i = 0; i < samplesPerSegment; ++i) {
-        float t =
-            static_cast<float>(i) / static_cast<float>(samplesPerSegment - 1);
-        QVector3D pos = segment.start + dir * (length * t);
-
-        if (!visibility.isVisibleWorld(pos.x(), pos.z())) {
-          allVisible = false;
-          break;
+      bool anyVisible = false;
+      if (meshIndex - 1 < m_visibilitySamples.size()) {
+        const auto &samples = m_visibilitySamples[meshIndex - 1];
+        const int minRequired =
+            std::max<int>(2, static_cast<int>(samples.size() * 0.3f));
+        int visibleCount = 0;
+        for (const auto &pos : samples) {
+          if (visibility.isVisibleWorld(pos.x(), pos.z())) {
+            ++visibleCount;
+            if (visibleCount >= minRequired) {
+              anyVisible = true;
+              break;
+            }
+          }
         }
       }
-
-      if (!allVisible) {
+      if (!anyVisible) {
         continue;
       }
     }

+ 1 - 0
render/ground/riverbank_renderer.h

@@ -31,6 +31,7 @@ private:
   int m_gridHeight = 0;
   std::vector<float> m_heights;
   std::vector<std::unique_ptr<Mesh>> m_meshes;
+  std::vector<std::vector<QVector3D>> m_visibilitySamples;
 };
 
 } // namespace GL

+ 12 - 0
render/scene_renderer.cpp

@@ -169,6 +169,18 @@ void Renderer::pineBatch(Buffer *instanceBuffer, std::size_t instanceCount,
   m_activeQueue->submit(cmd);
 }
 
+void Renderer::firecampBatch(Buffer *instanceBuffer, std::size_t instanceCount,
+                              const FireCampBatchParams &params) {
+  if (!instanceBuffer || instanceCount == 0 || !m_activeQueue)
+    return;
+  FireCampBatchCmd cmd;
+  cmd.instanceBuffer = instanceBuffer;
+  cmd.instanceCount = instanceCount;
+  cmd.params = params;
+  cmd.params.time = m_accumulatedTime;
+  m_activeQueue->submit(cmd);
+}
+
 void Renderer::terrainChunk(Mesh *mesh, const QMatrix4x4 &model,
                             const TerrainChunkParams &params,
                             std::uint16_t sortKey, bool depthWrite,

+ 2 - 0
render/scene_renderer.h

@@ -138,6 +138,8 @@ public:
                   const PlantBatchParams &params);
   void pineBatch(Buffer *instanceBuffer, std::size_t instanceCount,
                  const PineBatchParams &params);
+  void firecampBatch(Buffer *instanceBuffer, std::size_t instanceCount,
+                     const FireCampBatchParams &params);
 
 private:
   Camera *m_camera = nullptr;