2
0
Эх сурвалжийг харах

Merge pull request #490 from djeada/copilot/add-olive-trees

Add olive tree rendering support and Spanish Olive Grove map
Adam Djellouli 1 долоо хоног өмнө
parent
commit
5238fde6a1

+ 9 - 1
app/core/game_engine.cpp

@@ -93,6 +93,7 @@
 #include "render/ground/firecamp_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
+#include "render/ground/olive_renderer.h"
 #include "render/ground/pine_renderer.h"
 #include "render/ground/plant_renderer.h"
 #include "render/ground/river_renderer.h"
@@ -134,12 +135,14 @@ GameEngine::GameEngine(QObject *parent)
   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_olive = std::make_unique<Render::GL::OliveRenderer>();
   m_firecamp = std::make_unique<Render::GL::FireCampRenderer>();
 
   m_passes = {m_ground.get(), m_terrain.get(),   m_river.get(),
               m_road.get(),   m_riverbank.get(), m_bridge.get(),
               m_biome.get(),  m_stone.get(),     m_plant.get(),
-              m_pine.get(),   m_firecamp.get(),  m_fog.get()};
+              m_pine.get(),   m_olive.get(),     m_firecamp.get(),
+              m_fog.get()};
 
   std::unique_ptr<Engine::Core::System> arrow_sys =
       std::make_unique<Game::Systems::ArrowSystem>();
@@ -334,6 +337,7 @@ void GameEngine::cleanupOpenGLResources() {
   m_stone.reset();
   m_plant.reset();
   m_pine.reset();
+  m_olive.reset();
   m_firecamp.reset();
 
   m_renderer.reset();
@@ -1231,6 +1235,7 @@ void GameEngine::start_skirmish(const QString &map_path,
     loader.setStoneRenderer(m_stone.get());
     loader.setPlantRenderer(m_plant.get());
     loader.setPineRenderer(m_pine.get());
+    loader.setOliveRenderer(m_olive.get());
     loader.setFireCampRenderer(m_firecamp.get());
 
     loader.setOnOwnersUpdated([this]() { emit ownerInfoChanged(); });
@@ -1843,6 +1848,9 @@ void GameEngine::restoreEnvironmentFromMetadata(const QJsonObject &metadata) {
       if (m_pine) {
         m_pine->configure(*height_map, terrain_service.biomeSettings());
       }
+      if (m_olive) {
+        m_olive->configure(*height_map, terrain_service.biomeSettings());
+      }
       if (m_firecamp) {
         m_firecamp->configure(*height_map, terrain_service.biomeSettings());
       }

+ 2 - 0
app/core/game_engine.h

@@ -46,6 +46,7 @@ class FogRenderer;
 class StoneRenderer;
 class PlantRenderer;
 class PineRenderer;
+class OliveRenderer;
 class FireCampRenderer;
 struct IRenderPass;
 } // namespace Render::GL
@@ -286,6 +287,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::OliveRenderer> m_olive;
   std::unique_ptr<Render::GL::FireCampRenderer> m_firecamp;
   std::vector<Render::GL::IRenderPass *> m_passes;
   std::unique_ptr<Game::Systems::PickingService> m_pickingService;

+ 2 - 0
assets.qrc

@@ -50,6 +50,8 @@
         <file>assets/shaders/healer_carthage.vert</file>
         <file>assets/shaders/pine_instanced.frag</file>
         <file>assets/shaders/pine_instanced.vert</file>
+        <file>assets/shaders/olive_instanced.frag</file>
+        <file>assets/shaders/olive_instanced.vert</file>
         <file>assets/shaders/plant_instanced.frag</file>
         <file>assets/shaders/plant_instanced.vert</file>
         <file>assets/shaders/river.frag</file>

+ 702 - 0
assets/maps/map_spanish_grove.json

@@ -0,0 +1,702 @@
+{
+  "name": "Spanish Olive Grove",
+  "description": "A Mediterranean battlefield featuring rolling hills covered with ancient olive trees",
+  "coordSystem": "grid",
+  "maxTroopsPerPlayer": 500,
+  "grid": {
+    "width": 280,
+    "height": 280,
+    "tileSize": 1.0
+  },
+  "biome": {
+    "groundType": "grass_dry",
+    "seed": 34567,
+    "patchDensity": 2.8,
+    "patchJitter": 0.75,
+    "bladeHeight": [
+      0.35,
+      0.85
+    ],
+    "bladeWidth": [
+      0.022,
+      0.048
+    ],
+    "swayStrength": 0.22,
+    "swaySpeed": 1.2,
+    "heightNoise": [
+      0.18,
+      0.06
+    ],
+    "grassPrimary": [
+      0.58,
+      0.54,
+      0.32
+    ],
+    "grassSecondary": [
+      0.64,
+      0.60,
+      0.38
+    ],
+    "grassDry": [
+      0.72,
+      0.65,
+      0.42
+    ],
+    "soilColor": [
+      0.52,
+      0.42,
+      0.30
+    ],
+    "rockLow": [
+      0.62,
+      0.58,
+      0.52
+    ],
+    "rockHigh": [
+      0.75,
+      0.72,
+      0.68
+    ],
+    "plantDensity": 1.2,
+    "groundIrregularityEnabled": true,
+    "irregularityScale": 0.14,
+    "irregularityAmplitude": 0.07,
+    "spawnEdgePadding": 0.08
+  },
+  "camera": {
+    "center": [
+      140,
+      0,
+      140
+    ],
+    "distance": 38.0,
+    "tiltDeg": 48.0,
+    "yaw": 220.0,
+    "fovY": 45.0,
+    "near": 1.0,
+    "far": 500.0
+  },
+  "spawns": [
+    {
+      "type": "barracks",
+      "x": 70,
+      "z": 70,
+      "playerId": 1,
+      "maxPopulation": 220,
+      "nation": "carthage"
+    },
+    {
+      "type": "archer",
+      "x": 68,
+      "z": 72,
+      "playerId": 1
+    },
+    {
+      "type": "archer",
+      "x": 72,
+      "z": 68,
+      "playerId": 1
+    },
+    {
+      "type": "swordsman",
+      "x": 65,
+      "z": 70,
+      "playerId": 1
+    },
+    {
+      "type": "swordsman",
+      "x": 70,
+      "z": 65,
+      "playerId": 1
+    },
+    {
+      "type": "spearman",
+      "x": 68,
+      "z": 68,
+      "playerId": 1
+    },
+    {
+      "type": "spearman",
+      "x": 72,
+      "z": 72,
+      "playerId": 1
+    },
+    {
+      "type": "horse_archer",
+      "x": 75,
+      "z": 70,
+      "playerId": 1
+    },
+    {
+      "type": "barracks",
+      "x": 210,
+      "z": 210,
+      "playerId": 2,
+      "maxPopulation": 220,
+      "nation": "roman_republic"
+    },
+    {
+      "type": "archer",
+      "x": 208,
+      "z": 212,
+      "playerId": 2
+    },
+    {
+      "type": "archer",
+      "x": 212,
+      "z": 208,
+      "playerId": 2
+    },
+    {
+      "type": "horse_swordsman",
+      "x": 205,
+      "z": 210,
+      "playerId": 2
+    },
+    {
+      "type": "horse_swordsman",
+      "x": 210,
+      "z": 205,
+      "playerId": 2
+    },
+    {
+      "type": "spearman",
+      "x": 208,
+      "z": 208,
+      "playerId": 2
+    },
+    {
+      "type": "spearman",
+      "x": 212,
+      "z": 212,
+      "playerId": 2
+    },
+    {
+      "type": "swordsman",
+      "x": 215,
+      "z": 210,
+      "playerId": 2
+    },
+    {
+      "type": "barracks",
+      "x": 140,
+      "z": 140,
+      "maxPopulation": 200,
+      "nation": "carthage"
+    },
+    {
+      "type": "barracks",
+      "x": 70,
+      "z": 210,
+      "playerId": 3,
+      "maxPopulation": 180,
+      "nation": "kingdom_of_iron"
+    },
+    {
+      "type": "swordsman",
+      "x": 68,
+      "z": 212,
+      "playerId": 3
+    },
+    {
+      "type": "swordsman",
+      "x": 72,
+      "z": 208,
+      "playerId": 3
+    },
+    {
+      "type": "archer",
+      "x": 65,
+      "z": 210,
+      "playerId": 3
+    },
+    {
+      "type": "spearman",
+      "x": 70,
+      "z": 205,
+      "playerId": 3
+    },
+    {
+      "type": "barracks",
+      "x": 210,
+      "z": 70,
+      "playerId": 4,
+      "maxPopulation": 180,
+      "nation": "kingdom_of_iron"
+    },
+    {
+      "type": "horse_swordsman",
+      "x": 208,
+      "z": 72,
+      "playerId": 4
+    },
+    {
+      "type": "horse_swordsman",
+      "x": 212,
+      "z": 68,
+      "playerId": 4
+    },
+    {
+      "type": "spearman",
+      "x": 210,
+      "z": 65,
+      "playerId": 4
+    },
+    {
+      "type": "archer",
+      "x": 215,
+      "z": 70,
+      "playerId": 4
+    }
+  ],
+  "firecamps": [
+    {
+      "x": 64,
+      "z": 75,
+      "intensity": 1.0,
+      "radius": 3.2
+    },
+    {
+      "x": 76,
+      "z": 64,
+      "intensity": 1.1,
+      "radius": 3.0
+    },
+    {
+      "x": 204,
+      "z": 216,
+      "intensity": 0.95,
+      "radius": 2.9
+    },
+    {
+      "x": 216,
+      "z": 204,
+      "intensity": 1.05,
+      "radius": 3.1
+    },
+    {
+      "x": 135,
+      "z": 145,
+      "intensity": 1.15,
+      "radius": 3.4
+    },
+    {
+      "x": 145,
+      "z": 135,
+      "intensity": 1.2,
+      "radius": 3.5
+    },
+    {
+      "x": 64,
+      "z": 216,
+      "intensity": 1.0,
+      "radius": 3.0
+    },
+    {
+      "x": 76,
+      "z": 204,
+      "intensity": 0.98,
+      "radius": 3.1
+    },
+    {
+      "x": 204,
+      "z": 64,
+      "intensity": 1.08,
+      "radius": 3.2
+    },
+    {
+      "x": 216,
+      "z": 76,
+      "intensity": 1.0,
+      "radius": 3.0
+    },
+    {
+      "x": 90,
+      "z": 90,
+      "intensity": 0.88,
+      "radius": 2.6
+    },
+    {
+      "x": 190,
+      "z": 190,
+      "intensity": 0.92,
+      "radius": 2.8
+    },
+    {
+      "x": 90,
+      "z": 190,
+      "intensity": 0.90,
+      "radius": 2.7
+    },
+    {
+      "x": 190,
+      "z": 90,
+      "intensity": 0.94,
+      "radius": 2.9
+    },
+    {
+      "x": 110,
+      "z": 140,
+      "intensity": 0.85,
+      "radius": 2.5
+    },
+    {
+      "x": 170,
+      "z": 140,
+      "intensity": 0.87,
+      "radius": 2.6
+    },
+    {
+      "x": 140,
+      "z": 110,
+      "intensity": 0.86,
+      "radius": 2.5
+    },
+    {
+      "x": 140,
+      "z": 170,
+      "intensity": 0.89,
+      "radius": 2.7
+    }
+  ],
+  "terrain": [
+    {
+      "type": "hill",
+      "x": 140,
+      "z": 140,
+      "radius": 18,
+      "height": 3.2,
+      "entrances": [
+        {
+          "x": 140,
+          "z": 122
+        },
+        {
+          "x": 140,
+          "z": 158
+        },
+        {
+          "x": 122,
+          "z": 140
+        },
+        {
+          "x": 158,
+          "z": 140
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 70,
+      "z": 140,
+      "width": 22,
+      "depth": 16,
+      "height": 2.8,
+      "rotation": 15,
+      "entrances": [
+        {
+          "x": 70,
+          "z": 124
+        },
+        {
+          "x": 70,
+          "z": 156
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 210,
+      "z": 140,
+      "width": 22,
+      "depth": 16,
+      "height": 2.8,
+      "rotation": 195,
+      "entrances": [
+        {
+          "x": 210,
+          "z": 124
+        },
+        {
+          "x": 210,
+          "z": 156
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 140,
+      "z": 70,
+      "width": 16,
+      "depth": 22,
+      "height": 2.6,
+      "rotation": 105,
+      "entrances": [
+        {
+          "x": 124,
+          "z": 70
+        },
+        {
+          "x": 156,
+          "z": 70
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 140,
+      "z": 210,
+      "width": 16,
+      "depth": 22,
+      "height": 2.6,
+      "rotation": 285,
+      "entrances": [
+        {
+          "x": 124,
+          "z": 210
+        },
+        {
+          "x": 156,
+          "z": 210
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 90,
+      "z": 90,
+      "radius": 14,
+      "height": 2.4,
+      "entrances": [
+        {
+          "x": 76,
+          "z": 90
+        },
+        {
+          "x": 104,
+          "z": 90
+        },
+        {
+          "x": 90,
+          "z": 76
+        },
+        {
+          "x": 90,
+          "z": 104
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 190,
+      "z": 190,
+      "radius": 14,
+      "height": 2.4,
+      "entrances": [
+        {
+          "x": 176,
+          "z": 190
+        },
+        {
+          "x": 204,
+          "z": 190
+        },
+        {
+          "x": 190,
+          "z": 176
+        },
+        {
+          "x": 190,
+          "z": 204
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 90,
+      "z": 190,
+      "radius": 12,
+      "height": 2.2,
+      "entrances": [
+        {
+          "x": 78,
+          "z": 190
+        },
+        {
+          "x": 102,
+          "z": 190
+        },
+        {
+          "x": 90,
+          "z": 178
+        },
+        {
+          "x": 90,
+          "z": 202
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 190,
+      "z": 90,
+      "radius": 12,
+      "height": 2.2,
+      "entrances": [
+        {
+          "x": 178,
+          "z": 90
+        },
+        {
+          "x": 202,
+          "z": 90
+        },
+        {
+          "x": 190,
+          "z": 78
+        },
+        {
+          "x": 190,
+          "z": 102
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 110,
+      "z": 170,
+      "width": 14,
+      "depth": 10,
+      "height": 2.0,
+      "rotation": 25
+    },
+    {
+      "type": "hill",
+      "x": 170,
+      "z": 110,
+      "width": 14,
+      "depth": 10,
+      "height": 2.0,
+      "rotation": 205
+    },
+    {
+      "type": "hill",
+      "x": 50,
+      "z": 50,
+      "radius": 10,
+      "height": 1.8,
+      "entrances": [
+        {
+          "x": 40,
+          "z": 50
+        },
+        {
+          "x": 50,
+          "z": 40
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 230,
+      "z": 230,
+      "radius": 10,
+      "height": 1.8,
+      "entrances": [
+        {
+          "x": 240,
+          "z": 230
+        },
+        {
+          "x": 230,
+          "z": 240
+        }
+      ]
+    },
+    {
+      "type": "hill",
+      "x": 50,
+      "z": 230,
+      "radius": 9,
+      "height": 1.6
+    },
+    {
+      "type": "hill",
+      "x": 230,
+      "z": 50,
+      "radius": 9,
+      "height": 1.6
+    }
+  ],
+  "roads": [
+    {
+      "start": [70, 70],
+      "end": [140, 140],
+      "width": 3.2,
+      "style": "default"
+    },
+    {
+      "start": [140, 140],
+      "end": [210, 210],
+      "width": 3.2,
+      "style": "default"
+    },
+    {
+      "start": [70, 210],
+      "end": [140, 140],
+      "width": 3.0,
+      "style": "default"
+    },
+    {
+      "start": [210, 70],
+      "end": [140, 140],
+      "width": 3.0,
+      "style": "default"
+    },
+    {
+      "start": [70, 70],
+      "end": [70, 210],
+      "width": 2.8,
+      "style": "default"
+    },
+    {
+      "start": [210, 70],
+      "end": [210, 210],
+      "width": 2.8,
+      "style": "default"
+    },
+    {
+      "start": [70, 70],
+      "end": [210, 70],
+      "width": 2.8,
+      "style": "default"
+    },
+    {
+      "start": [70, 210],
+      "end": [210, 210],
+      "width": 2.8,
+      "style": "default"
+    },
+    {
+      "start": [90, 90],
+      "end": [140, 140],
+      "width": 2.5,
+      "style": "default"
+    },
+    {
+      "start": [190, 190],
+      "end": [140, 140],
+      "width": 2.5,
+      "style": "default"
+    }
+  ],
+  "victory": {
+    "type": "elimination",
+    "key_structures": [
+      "barracks"
+    ],
+    "defeat_conditions": [
+      "no_key_structures"
+    ]
+  }
+}

+ 164 - 0
assets/shaders/olive_instanced.frag

@@ -0,0 +1,164 @@
+#version 330 core
+
+in vec3 vWorldPos;
+in vec3 vNormal;
+in vec3 vColor;
+in vec2 vTexCoord;
+in float vFoliageMask;
+in float vLeafSeed;
+in float vBarkSeed;
+in float vBranchId;
+in vec2 vLocalPosXZ;
+
+uniform vec3 uLightDirection;
+
+out vec4 FragColor;
+
+const float PI = 3.14159265359;
+const float TWO_PI = 6.28318530718;
+
+// Pseudo-random hash functions
+float hash(vec2 p) {
+  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
+}
+
+float hash3(vec3 p) {
+  return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
+}
+
+// Simple 2D noise
+float noise2D(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+void main() {
+  // --- Lighting ---
+  vec3 n = normalize(vNormal);
+  vec3 l = normalize(uLightDirection);
+  float diffuse = max(dot(n, l), 0.0);
+  float ambient = 0.45;
+  float lighting = ambient + diffuse * 0.60;
+
+  // === MANY SMALL LEAVES ===
+  // The key is high-frequency patterns that look like individual small leaves
+
+  // Screen-space position for stable leaf patterns
+  vec2 leafPos = vLocalPosXZ * 120.0 + vec2(vLeafSeed * 17.3, vBarkSeed * 23.1);
+
+  // Individual leaf shapes - small ellipses scattered across surface
+  // Use multiple layers for depth
+  float leafLayer1 = hash(floor(leafPos));
+  float leafLayer2 = hash(floor(leafPos * 1.7 + vec2(5.3, 8.7)));
+  float leafLayer3 = hash(floor(leafPos * 0.6 + vec2(13.1, 3.9)));
+
+  // Combine layers - more leaves where all layers align
+  float leafDensity = (leafLayer1 + leafLayer2 + leafLayer3) / 3.0;
+
+  // High frequency variation for leaf edges
+  float leafEdge = noise2D(leafPos * 2.5);
+  float leafFine = hash(leafPos * 0.37 + vec2(vBranchId * 7.0));
+
+  // === LEAF COLORS - Olive tree specific ===
+  // Real olive leaves: dark green on top, silvery-gray underneath
+  vec3 leafDarkGreen = vec3(0.22, 0.32, 0.20);  // Dark olive green
+  vec3 leafMidGreen = vec3(0.32, 0.42, 0.28);   // Medium green
+  vec3 leafLightGreen = vec3(0.42, 0.50, 0.38); // Light green
+  vec3 leafSilver = vec3(0.52, 0.56, 0.50);     // Silvery underside
+
+  // Base leaf color with per-leaf variation
+  float colorChoice = leafFine;
+  vec3 leafColor = mix(leafDarkGreen, leafMidGreen, colorChoice);
+
+  // Some leaves show silvery underside (facing away from light)
+  float backfacing = 1.0 - max(dot(n, l), 0.0);
+  float silverShow = smoothstep(0.4, 0.8, backfacing) * leafLayer2;
+  leafColor = mix(leafColor, leafSilver, silverShow * 0.5);
+
+  // Highlight some leaves
+  float highlight = smoothstep(0.7, 0.9, leafLayer1 * leafEdge);
+  leafColor = mix(leafColor, leafLightGreen, highlight * 0.4);
+
+  // Instance tint variation
+  leafColor = mix(leafColor, vColor, 0.15);
+
+  // Interior shadow - darker toward center of canopy
+  float canopyDepth = 1.0 - smoothstep(0.0, 0.35, length(vLocalPosXZ));
+  leafColor *= mix(0.75, 1.0, canopyDepth);
+
+  // === BARK - Gnarled olive trunk ===
+  float barkU = vTexCoord.x * TWO_PI;
+  float barkV = vTexCoord.y;
+
+  // Deep vertical furrows characteristic of old olive trees
+  float furrows = pow(abs(sin(barkU * 5.0 + vBarkSeed * TWO_PI)), 0.4);
+  float verticalGrain =
+      noise2D(vec2(barkU * 3.0, barkV * 25.0 + vBarkSeed * 7.0));
+  float barkNoise = noise2D(vec2(barkU * 8.0, barkV * 15.0)) * 0.3;
+  float barkTexture = furrows * 0.5 + verticalGrain * 0.35 + barkNoise;
+
+  // Olive bark colors - gray-brown
+  vec3 barkDark = vec3(0.18, 0.16, 0.13);
+  vec3 barkMid = vec3(0.30, 0.27, 0.23);
+  vec3 barkLight = vec3(0.42, 0.38, 0.33);
+
+  vec3 barkColor = mix(barkDark, barkMid, barkTexture);
+  float barkHighlight =
+      smoothstep(0.75, 0.95, hash(vec2(barkV * 15.0, barkU * 3.0)));
+  barkColor = mix(barkColor, barkLight, barkHighlight * 0.35);
+
+  // === FINAL COLOR ===
+  vec3 baseColor = mix(barkColor, leafColor, vFoliageMask);
+  vec3 color = baseColor * lighting;
+
+  // === ALPHA - Create many small leaf silhouettes ===
+  float alpha = 1.0;
+
+  if (vFoliageMask > 0.1) {
+    // Base: lots of small gaps between leaves
+    // Higher frequency = smaller individual leaves
+    float leafMask = leafDensity;
+
+    // Add leaf-shaped holes using noise threshold
+    float holePattern = noise2D(leafPos * 0.8);
+    float holes = smoothstep(0.20, 0.35, holePattern);
+
+    // Gaps between leaf clusters (medium scale)
+    float clusterGaps = noise2D(vLocalPosXZ * 15.0 + vec2(vLeafSeed * 3.0));
+    float gaps = smoothstep(0.15, 0.40, clusterGaps);
+
+    // Edge fade - more gaps at canopy edge
+    float edgeDist = length(vLocalPosXZ);
+    float edgeFade = 1.0 - smoothstep(0.25, 0.50, edgeDist) * 0.5;
+
+    // Top fade
+    float topFade = 1.0 - smoothstep(0.85, 1.0, vTexCoord.y) * 0.4;
+
+    // Combine all alpha factors
+    alpha = leafMask * holes * gaps * edgeFade * topFade;
+    alpha = mix(1.0, alpha, vFoliageMask);
+
+    // Random small holes for airiness
+    if (leafFine > 0.82) {
+      alpha *= 0.2;
+    }
+  }
+
+  // Ground fade
+  alpha *= smoothstep(0.0, 0.06, vTexCoord.y);
+
+  // Alpha test - discard fully transparent pixels
+  if (alpha < 0.15)
+    discard;
+
+  // Normalize alpha
+  alpha = clamp(alpha * 1.3, 0.0, 1.0);
+
+  FragColor = vec4(color, alpha);
+}

+ 105 - 0
assets/shaders/olive_instanced.vert

@@ -0,0 +1,105 @@
+#version 330 core
+
+// ─────────────────────────────────────────────────────────────
+// Vertex Attributes
+// ─────────────────────────────────────────────────────────────
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec2 aTexCoord;
+layout(location = 2) in vec3 aNormal;
+layout(location = 3) in vec4 aPosScale;  // instance: xyz = world pos, w = scale
+layout(location = 4) in vec4 aColorSway; // instance: rgb = tint, a = sway phase
+layout(location =
+           5) in vec4 aRotation; // instance: x = Y-axis rotation, yzw = seeds
+
+// ─────────────────────────────────────────────────────────────
+// Uniforms
+// ─────────────────────────────────────────────────────────────
+uniform mat4 uViewProj;
+uniform float uTime;
+uniform float uWindStrength;
+uniform float uWindSpeed;
+
+// ─────────────────────────────────────────────────────────────
+// Varyings
+// ─────────────────────────────────────────────────────────────
+out vec3 vWorldPos;
+out vec3 vNormal;
+out vec3 vColor;
+out vec2 vTexCoord;
+out float vFoliageMask;
+out float vLeafSeed;
+out float vBarkSeed;
+out float vBranchId;
+out vec2 vLocalPosXZ;
+
+// ─────────────────────────────────────────────────────────────
+// Main Shader Logic
+// ─────────────────────────────────────────────────────────────
+void main() {
+  const float TWO_PI = 6.2831853;
+
+  // Instance data unpacking
+  float scale = aPosScale.w;
+  vec3 worldPos = aPosScale.xyz;
+  float swayPhase = aColorSway.a;
+  float rotation = aRotation.x;
+  float silhouetteSeed = aRotation.y;
+  float leafSeed = aRotation.z;
+  float barkSeed = aRotation.w;
+
+  vec3 modelPos = aPos;
+
+  // ── Region masks based on texture V coordinate ──
+  // Trunk: v = 0.00-0.20, Branches: 0.18-0.50, Leaves: 0.50-1.00
+  float trunkMask = 1.0 - smoothstep(0.12, 0.20, aTexCoord.y);
+  float foliageMask = smoothstep(0.45, 0.55, aTexCoord.y);
+
+  // Branch ID from horizontal angle for variation
+  float angle = aTexCoord.x * TWO_PI;
+  float branchId = floor(angle / TWO_PI * 4.0 + silhouetteSeed * 4.0);
+
+  // ── Trunk gnarling ──
+  if (trunkMask > 0.0) {
+    float twist = sin(aTexCoord.y * 20.0 + barkSeed * TWO_PI) * 0.02;
+    modelPos.x += twist * trunkMask;
+    modelPos.z += twist * 0.7 * trunkMask;
+  }
+
+  // Scale
+  vec3 localPos = modelPos * scale;
+
+  // ── Wind sway ──
+  float heightFactor = clamp(aPos.y * 2.0, 0.0, 1.0);
+  float windTime = uTime * uWindSpeed * 0.4;
+  float sway = sin(windTime + swayPhase) * uWindStrength * 0.3;
+  float sway2 = sin(windTime * 1.7 + swayPhase * 2.3) * uWindStrength * 0.15;
+
+  float swayAmount = mix(0.02, 0.12, foliageMask) * heightFactor;
+  localPos.x += sway * swayAmount;
+  localPos.z += sway2 * swayAmount * 0.6;
+
+  // ── Instance rotation ──
+  float cosR = cos(rotation);
+  float sinR = sin(rotation);
+  mat2 rot = mat2(cosR, -sinR, sinR, cosR);
+
+  vec2 rotatedXZ = rot * localPos.xz;
+  localPos = vec3(rotatedXZ.x, localPos.y, rotatedXZ.y);
+
+  vec2 rotatedNormalXZ = rot * aNormal.xz;
+  vec3 finalNormal =
+      normalize(vec3(rotatedNormalXZ.x, aNormal.y, rotatedNormalXZ.y));
+
+  // ── Outputs ──
+  vWorldPos = localPos + worldPos;
+  vNormal = finalNormal;
+  vColor = aColorSway.rgb;
+  vTexCoord = aTexCoord;
+  vFoliageMask = foliageMask;
+  vLeafSeed = leafSeed;
+  vBarkSeed = barkSeed;
+  vBranchId = branchId;
+  vLocalPosXZ = modelPos.xz;
+
+  gl_Position = uViewProj * vec4(vWorldPos, 1.0);
+}

+ 9 - 0
game/map/skirmish_loader.cpp

@@ -20,6 +20,7 @@
 #include "render/ground/firecamp_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
+#include "render/ground/olive_renderer.h"
 #include "render/ground/pine_renderer.h"
 #include "render/ground/plant_renderer.h"
 #include "render/ground/river_renderer.h"
@@ -383,6 +384,14 @@ auto SkirmishLoader::start(const QString &map_path,
     }
   }
 
+  if (m_olive != nullptr) {
+    if (terrain_service.isInitialized() &&
+        (terrain_service.getHeightMap() != nullptr)) {
+      m_olive->configure(*terrain_service.getHeightMap(),
+                         terrain_service.biomeSettings());
+    }
+  }
+
   if (m_firecamp != nullptr) {
     if (terrain_service.isInitialized() &&
         (terrain_service.getHeightMap() != nullptr)) {

+ 3 - 0
game/map/skirmish_loader.h

@@ -23,6 +23,7 @@ class FogRenderer;
 class StoneRenderer;
 class PlantRenderer;
 class PineRenderer;
+class OliveRenderer;
 class FireCampRenderer;
 class RiverRenderer;
 class RoadRenderer;
@@ -76,6 +77,7 @@ 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 setOliveRenderer(Render::GL::OliveRenderer *olive) { m_olive = olive; }
   void setFireCampRenderer(Render::GL::FireCampRenderer *firecamp) {
     m_firecamp = firecamp;
   }
@@ -108,6 +110,7 @@ private:
   Render::GL::StoneRenderer *m_stone = nullptr;
   Render::GL::PlantRenderer *m_plant = nullptr;
   Render::GL::PineRenderer *m_pine = nullptr;
+  Render::GL::OliveRenderer *m_olive = nullptr;
   Render::GL::FireCampRenderer *m_firecamp = nullptr;
   OwnersUpdatedCallback m_onOwnersUpdated;
   VisibilityMaskReadyCallback m_onVisibilityMaskReady;

+ 1 - 0
render/CMakeLists.txt

@@ -23,6 +23,7 @@ add_library(render_gl STATIC
     ground/terrain_renderer.cpp
     ground/river_renderer.cpp
     ground/road_renderer.cpp
+    ground/olive_renderer.cpp
     ground/riverbank_renderer.cpp
     ground/riverbank_asset_renderer.cpp
     ground/bridge_renderer.cpp

+ 28 - 9
render/draw_queue.h

@@ -2,6 +2,7 @@
 
 #include "ground/firecamp_gpu.h"
 #include "ground/grass_gpu.h"
+#include "ground/olive_gpu.h"
 #include "ground/pine_gpu.h"
 #include "ground/plant_gpu.h"
 #include "ground/stone_gpu.h"
@@ -80,6 +81,12 @@ struct PineBatchCmd {
   PineBatchParams params;
 };
 
+struct OliveBatchCmd {
+  Buffer *instanceBuffer = nullptr;
+  std::size_t instance_count = 0;
+  OliveBatchParams params;
+};
+
 struct FireCampBatchCmd {
   Buffer *instanceBuffer = nullptr;
   std::size_t instance_count = 0;
@@ -123,7 +130,7 @@ struct SelectionSmokeCmd {
 using DrawCmd = std::variant<GridCmd, SelectionRingCmd, SelectionSmokeCmd,
                              CylinderCmd, MeshCmd, FogBatchCmd, GrassBatchCmd,
                              StoneBatchCmd, PlantBatchCmd, PineBatchCmd,
-                             FireCampBatchCmd, TerrainChunkCmd>;
+                             OliveBatchCmd, FireCampBatchCmd, TerrainChunkCmd>;
 
 enum class DrawCmdType : std::uint8_t {
   Grid = 0,
@@ -136,8 +143,9 @@ enum class DrawCmdType : std::uint8_t {
   StoneBatch = 7,
   PlantBatch = 8,
   PineBatch = 9,
-  FireCampBatch = 10,
-  TerrainChunk = 11
+  OliveBatch = 10,
+  FireCampBatch = 11,
+  TerrainChunk = 12
 };
 
 constexpr std::size_t MeshCmdIndex =
@@ -160,6 +168,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 OliveBatchCmdIndex =
+    static_cast<std::size_t>(DrawCmdType::OliveBatch);
 constexpr std::size_t FireCampBatchCmdIndex =
     static_cast<std::size_t>(DrawCmdType::FireCampBatch);
 constexpr std::size_t TerrainChunkCmdIndex =
@@ -183,6 +193,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 OliveBatchCmd &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); }
 
@@ -272,12 +283,13 @@ private:
       StoneBatch = 2,
       PlantBatch = 3,
       PineBatch = 4,
-      FireCampBatch = 5,
-      Mesh = 6,
-      Cylinder = 7,
-      FogBatch = 8,
-      SelectionSmoke = 9,
-      Grid = 10,
+      OliveBatch = 5,
+      FireCampBatch = 6,
+      Mesh = 7,
+      Cylinder = 8,
+      FogBatch = 9,
+      SelectionSmoke = 10,
+      Grid = 11,
       SelectionRing = 15
     };
 
@@ -292,6 +304,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::OliveBatch),
         static_cast<uint8_t>(RenderOrder::FireCampBatch),
         static_cast<uint8_t>(RenderOrder::TerrainChunk)};
 
@@ -333,6 +346,12 @@ private:
       uint64_t const bufferPtr =
           reinterpret_cast<uintptr_t>(pine.instanceBuffer) & 0x0000FFFFFFFFFFFF;
       key |= bufferPtr;
+    } else if (cmd.index() == OliveBatchCmdIndex) {
+      const auto &olive = std::get<OliveBatchCmdIndex>(cmd);
+      uint64_t const bufferPtr =
+          reinterpret_cast<uintptr_t>(olive.instanceBuffer) &
+          0x0000FFFFFFFFFFFF;
+      key |= bufferPtr;
     } else if (cmd.index() == FireCampBatchCmdIndex) {
       const auto &firecamp = std::get<FireCampBatchCmdIndex>(cmd);
       uint64_t const bufferPtr =

+ 89 - 0
render/gl/backend.cpp

@@ -13,6 +13,7 @@
 #include "gl/resources.h"
 #include "ground/firecamp_gpu.h"
 #include "ground/grass_gpu.h"
+#include "ground/olive_gpu.h"
 #include "ground/pine_gpu.h"
 #include "ground/plant_gpu.h"
 #include "ground/stone_gpu.h"
@@ -590,6 +591,94 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
 
       break;
     }
+    case OliveBatchCmdIndex: {
+      if (!m_vegetationPipeline) {
+        ++i;
+        continue;
+      }
+      const auto &olive = std::get<OliveBatchCmdIndex>(cmd);
+
+      if ((olive.instanceBuffer == nullptr) || olive.instance_count == 0 ||
+          (m_vegetationPipeline->oliveShader() == nullptr) ||
+          (m_vegetationPipeline->m_oliveVao == 0U) ||
+          m_vegetationPipeline->m_oliveIndexCount == 0) {
+        break;
+      }
+
+      DepthMaskScope const depth_mask(true);
+      glEnable(GL_DEPTH_TEST);
+      BlendScope const blend(true);
+      glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+      GLboolean const prev_cull = glIsEnabled(GL_CULL_FACE);
+      if (prev_cull != 0U) {
+        glDisable(GL_CULL_FACE);
+      }
+
+      Shader *olive_shader = m_vegetationPipeline->oliveShader();
+      if (m_lastBoundShader != olive_shader) {
+        olive_shader->use();
+        m_lastBoundShader = olive_shader;
+        m_lastBoundTexture = nullptr;
+      }
+
+      if (m_vegetationPipeline->m_oliveUniforms.view_proj !=
+          Shader::InvalidUniform) {
+        olive_shader->setUniform(
+            m_vegetationPipeline->m_oliveUniforms.view_proj, view_proj);
+      }
+      if (m_vegetationPipeline->m_oliveUniforms.time !=
+          Shader::InvalidUniform) {
+        olive_shader->setUniform(m_vegetationPipeline->m_oliveUniforms.time,
+                                 olive.params.time);
+      }
+      if (m_vegetationPipeline->m_oliveUniforms.wind_strength !=
+          Shader::InvalidUniform) {
+        olive_shader->setUniform(
+            m_vegetationPipeline->m_oliveUniforms.wind_strength,
+            olive.params.wind_strength);
+      }
+      if (m_vegetationPipeline->m_oliveUniforms.wind_speed !=
+          Shader::InvalidUniform) {
+        olive_shader->setUniform(
+            m_vegetationPipeline->m_oliveUniforms.wind_speed,
+            olive.params.wind_speed);
+      }
+      if (m_vegetationPipeline->m_oliveUniforms.light_direction !=
+          Shader::InvalidUniform) {
+        QVector3D light_dir = olive.params.light_direction;
+        if (!light_dir.isNull()) {
+          light_dir.normalize();
+        }
+        olive_shader->setUniform(
+            m_vegetationPipeline->m_oliveUniforms.light_direction, light_dir);
+      }
+
+      glBindVertexArray(m_vegetationPipeline->m_oliveVao);
+      olive.instanceBuffer->bind();
+      const auto stride = static_cast<GLsizei>(sizeof(OliveInstanceGpu));
+      glVertexAttribPointer(
+          InstancePosition, Vec4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(OliveInstanceGpu, pos_scale)));
+      glVertexAttribPointer(
+          InstanceScale, Vec4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(OliveInstanceGpu, color_sway)));
+      glVertexAttribPointer(
+          InstanceColor, Vec4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(OliveInstanceGpu, rotation)));
+      olive.instanceBuffer->unbind();
+
+      glDrawElementsInstanced(GL_TRIANGLES,
+                              m_vegetationPipeline->m_oliveIndexCount,
+                              GL_UNSIGNED_SHORT, nullptr,
+                              static_cast<GLsizei>(olive.instance_count));
+      glBindVertexArray(0);
+
+      if (prev_cull != 0U) {
+        glEnable(GL_CULL_FACE);
+      }
+
+      break;
+    }
     case FireCampBatchCmdIndex: {
       if (!m_vegetationPipeline) {
         ++i;

+ 206 - 3
render/gl/backend/vegetation_pipeline.cpp

@@ -35,6 +35,7 @@ auto VegetationPipeline::initialize() -> bool {
   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_oliveShader = m_shaderCache->get(QStringLiteral("olive_instanced"));
   m_firecampShader = m_shaderCache->get(QStringLiteral("firecamp"));
 
   if (m_stoneShader == nullptr) {
@@ -46,6 +47,9 @@ auto VegetationPipeline::initialize() -> bool {
   if (m_pineShader == nullptr) {
     qWarning() << "VegetationPipeline: pine shader missing";
   }
+  if (m_oliveShader == nullptr) {
+    qWarning() << "VegetationPipeline: olive shader missing";
+  }
   if (m_firecampShader == nullptr) {
     qWarning() << "VegetationPipeline: firecamp shader missing";
   }
@@ -53,6 +57,7 @@ auto VegetationPipeline::initialize() -> bool {
   initializeStonePipeline();
   initializePlantPipeline();
   initializePinePipeline();
+  initializeOlivePipeline();
   initializeFireCampPipeline();
   cacheUniforms();
 
@@ -64,6 +69,7 @@ void VegetationPipeline::shutdown() {
   shutdownStonePipeline();
   shutdownPlantPipeline();
   shutdownPinePipeline();
+  shutdownOlivePipeline();
   shutdownFireCampPipeline();
   m_initialized = false;
 }
@@ -94,6 +100,16 @@ void VegetationPipeline::cacheUniforms() {
         m_pineShader->uniformHandle("uLightDirection");
   }
 
+  if (m_oliveShader != nullptr) {
+    m_oliveUniforms.view_proj = m_oliveShader->uniformHandle("uViewProj");
+    m_oliveUniforms.time = m_oliveShader->uniformHandle("uTime");
+    m_oliveUniforms.wind_strength =
+        m_oliveShader->uniformHandle("uWindStrength");
+    m_oliveUniforms.wind_speed = m_oliveShader->uniformHandle("uWindSpeed");
+    m_oliveUniforms.light_direction =
+        m_oliveShader->uniformHandle("uLightDirection");
+  }
+
   if (m_firecampShader != nullptr) {
     m_firecampUniforms.view_proj =
         m_firecampShader->uniformHandle("u_viewProj");
@@ -339,8 +355,9 @@ void VegetationPipeline::initializePinePipeline() {
   std::vector<unsigned short> indices;
   indices.reserve(k_segments * 6 * 4 + k_segments * 3);
 
-  auto add_ring = [&](float radius, float y, float normalUp,
-                      float v_coord) -> int {
+  auto add_ring = [&](float radius, float y, float normalUp, float v_coord,
+                      const QVector2D &center_offset =
+                          QVector2D(0.0F, 0.0F)) -> int {
     const int start = static_cast<int>(vertices.size());
     for (int i = 0; i < k_segments; ++i) {
       const float t = static_cast<float>(i) / static_cast<float>(k_segments);
@@ -349,7 +366,8 @@ void VegetationPipeline::initializePinePipeline() {
       const float nz = std::sin(angle);
       QVector3D normal(nx, normalUp, nz);
       normal.normalize();
-      QVector3D const position(radius * nx, y, radius * nz);
+      QVector3D const position(radius * nx + center_offset.x(), y,
+                               radius * nz + center_offset.y());
       QVector2D const tex_coord(t, v_coord);
       vertices.push_back({position, tex_coord, normal});
     }
@@ -479,6 +497,191 @@ void VegetationPipeline::shutdownPinePipeline() {
   m_pineIndexCount = 0;
 }
 
+void VegetationPipeline::initializeOlivePipeline() {
+  initializeOpenGLFunctions();
+  shutdownOlivePipeline();
+
+  struct OliveVertex {
+    QVector3D position;
+    QVector2D tex_coord;
+    QVector3D normal;
+  };
+
+  constexpr int k_segments = OliveTreeSegments;
+  constexpr float k_two_pi = 6.28318530718F;
+
+  std::vector<OliveVertex> vertices;
+  vertices.reserve(k_segments * 40);
+  std::vector<unsigned short> indices;
+  indices.reserve(k_segments * 6 * 40);
+
+  auto add_ring = [&](float radius, float y, float normalUp, float v_coord,
+                      const QVector2D &offset = QVector2D(0.0F, 0.0F)) -> int {
+    const int start = static_cast<int>(vertices.size());
+    for (int i = 0; i < k_segments; ++i) {
+      const float t = static_cast<float>(i) / static_cast<float>(k_segments);
+      const float angle = t * k_two_pi;
+      const float nx = std::cos(angle);
+      const float nz = std::sin(angle);
+      QVector3D normal(nx, normalUp, nz);
+      normal.normalize();
+      QVector3D const position(radius * nx + offset.x(), y,
+                               radius * nz + offset.y());
+      vertices.push_back({position, QVector2D(t, v_coord), normal});
+    }
+    return start;
+  };
+
+  auto connect_rings = [&](int lower, int upper) {
+    for (int i = 0; i < k_segments; ++i) {
+      const int next = (i + 1) % k_segments;
+      indices.push_back(static_cast<unsigned short>(lower + i));
+      indices.push_back(static_cast<unsigned short>(lower + next));
+      indices.push_back(static_cast<unsigned short>(upper + next));
+      indices.push_back(static_cast<unsigned short>(lower + i));
+      indices.push_back(static_cast<unsigned short>(upper + next));
+      indices.push_back(static_cast<unsigned short>(upper + i));
+    }
+  };
+
+  auto add_cap = [&](int ring, float capY, const QVector2D &offset, float v) {
+    const int topIdx = static_cast<int>(vertices.size());
+    vertices.push_back({QVector3D(offset.x(), capY, offset.y()),
+                        QVector2D(0.5F, v), QVector3D(0.0F, 1.0F, 0.0F)});
+    for (int i = 0; i < k_segments; ++i) {
+      const int next = (i + 1) % k_segments;
+      indices.push_back(static_cast<unsigned short>(ring + i));
+      indices.push_back(static_cast<unsigned short>(ring + next));
+      indices.push_back(static_cast<unsigned short>(topIdx));
+    }
+  };
+
+  int t0 = add_ring(0.14F, 0.00F, -0.2F, 0.00F);
+  int t1 = add_ring(0.12F, 0.08F, 0.0F, 0.06F);
+  int t2 = add_ring(0.09F, 0.15F, 0.1F, 0.12F);
+  connect_rings(t0, t1);
+  connect_rings(t1, t2);
+
+  auto add_branch = [&](float dir_x, float dir_z, float base_y, float length,
+                        float branch_r, float leaf_r, float v_start) {
+    float len = std::sqrt(dir_x * dir_x + dir_z * dir_z);
+    dir_x /= len;
+    dir_z /= len;
+
+    float rise = 0.5F;
+    float dx = dir_x * std::cos(0.5F);
+    float dz = dir_z * std::cos(0.5F);
+    float dy = std::sin(0.4F);
+
+    int b0 = add_ring(branch_r, base_y, 0.0F, v_start);
+
+    float mid_dist = length * 0.5F;
+    QVector2D mid_offset(dx * mid_dist, dz * mid_dist);
+    int b1 = add_ring(branch_r * 0.6F, base_y + dy * mid_dist, 0.3F,
+                      v_start + 0.1F, mid_offset);
+
+    float tip_dist = length;
+    QVector2D tip_offset(dx * tip_dist, dz * tip_dist);
+    float tip_y = base_y + dy * tip_dist;
+    int b2 = add_ring(branch_r * 0.3F, tip_y, 0.5F, v_start + 0.2F, tip_offset);
+
+    connect_rings(b0, b1);
+    connect_rings(b1, b2);
+
+    float ly = tip_y - leaf_r * 0.2F;
+    int l0 = add_ring(leaf_r * 0.6F, ly, -0.4F, 0.50F, tip_offset);
+    int l1 =
+        add_ring(leaf_r * 0.9F, ly + leaf_r * 0.35F, 0.0F, 0.65F, tip_offset);
+    int l2 =
+        add_ring(leaf_r * 0.85F, ly + leaf_r * 0.65F, 0.2F, 0.80F, tip_offset);
+    int l3 =
+        add_ring(leaf_r * 0.5F, ly + leaf_r * 0.90F, 0.6F, 0.92F, tip_offset);
+
+    connect_rings(b2, l0);
+    connect_rings(l0, l1);
+    connect_rings(l1, l2);
+    connect_rings(l2, l3);
+    add_cap(l3, ly + leaf_r * 1.0F, tip_offset, 1.0F);
+  };
+
+  add_branch(0.8F, 0.3F, 0.14F, 0.30F, 0.025F, 0.18F, 0.18F);
+  add_branch(-0.7F, 0.5F, 0.15F, 0.32F, 0.022F, 0.20F, 0.20F);
+  add_branch(0.4F, -0.9F, 0.16F, 0.28F, 0.020F, 0.16F, 0.22F);
+  add_branch(-0.5F, -0.7F, 0.14F, 0.34F, 0.024F, 0.19F, 0.19F);
+
+  m_oliveVertexCount = static_cast<GLsizei>(vertices.size());
+  m_oliveIndexCount = static_cast<GLsizei>(indices.size());
+
+  glGenVertexArrays(1, &m_oliveVao);
+  glBindVertexArray(m_oliveVao);
+
+  glGenBuffers(1, &m_oliveVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_oliveVertexBuffer);
+  glBufferData(GL_ARRAY_BUFFER,
+               static_cast<GLsizeiptr>(vertices.size() * sizeof(OliveVertex)),
+               vertices.data(), GL_STATIC_DRAW);
+
+  glGenBuffers(1, &m_oliveIndexBuffer);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_oliveIndexBuffer);
+  glBufferData(GL_ELEMENT_ARRAY_BUFFER,
+               static_cast<GLsizeiptr>(indices.size() * sizeof(unsigned short)),
+               indices.data(), GL_STATIC_DRAW);
+
+  glEnableVertexAttribArray(0);
+  glVertexAttribPointer(
+      0, 3, GL_FLOAT, GL_FALSE, sizeof(OliveVertex),
+      reinterpret_cast<void *>(offsetof(OliveVertex, position)));
+
+  glEnableVertexAttribArray(1);
+  glVertexAttribPointer(
+      1, 2, GL_FLOAT, GL_FALSE, sizeof(OliveVertex),
+      reinterpret_cast<void *>(offsetof(OliveVertex, tex_coord)));
+
+  glEnableVertexAttribArray(2);
+  glVertexAttribPointer(
+      2, 3, GL_FLOAT, GL_FALSE, sizeof(OliveVertex),
+      reinterpret_cast<void *>(offsetof(OliveVertex, normal)));
+
+  glEnableVertexAttribArray(InstancePosition);
+  glVertexAttribDivisor(InstancePosition, 1);
+  glEnableVertexAttribArray(InstanceScale);
+  glVertexAttribDivisor(InstanceScale, 1);
+  glEnableVertexAttribArray(InstanceColor);
+  glVertexAttribDivisor(InstanceColor, 1);
+
+  glBindVertexArray(0);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
+}
+
+void VegetationPipeline::shutdownOlivePipeline() {
+
+  if (QOpenGLContext::currentContext() == nullptr) {
+    m_oliveVao = 0;
+    m_oliveVertexBuffer = 0;
+    m_oliveIndexBuffer = 0;
+    m_oliveVertexCount = 0;
+    m_oliveIndexCount = 0;
+    return;
+  }
+
+  initializeOpenGLFunctions();
+  if (m_oliveIndexBuffer != 0U) {
+    glDeleteBuffers(1, &m_oliveIndexBuffer);
+    m_oliveIndexBuffer = 0;
+  }
+  if (m_oliveVertexBuffer != 0U) {
+    glDeleteBuffers(1, &m_oliveVertexBuffer);
+    m_oliveVertexBuffer = 0;
+  }
+  if (m_oliveVao != 0U) {
+    glDeleteVertexArrays(1, &m_oliveVao);
+    m_oliveVao = 0;
+  }
+  m_oliveVertexCount = 0;
+  m_oliveIndexCount = 0;
+}
+
 void VegetationPipeline::initializeFireCampPipeline() {
   initializeOpenGLFunctions();
   shutdownFireCampPipeline();

+ 20 - 0
render/gl/backend/vegetation_pipeline.h

@@ -27,6 +27,9 @@ public:
     return m_plantShader;
   }
   [[nodiscard]] auto pineShader() const -> GL::Shader * { return m_pineShader; }
+  [[nodiscard]] auto oliveShader() const -> GL::Shader * {
+    return m_oliveShader;
+  }
   [[nodiscard]] auto firecampShader() const -> GL::Shader * {
     return m_firecampShader;
   }
@@ -52,6 +55,14 @@ public:
     GL::Shader::UniformHandle light_direction{GL::Shader::InvalidUniform};
   } m_pineUniforms;
 
+  struct OliveUniforms {
+    GL::Shader::UniformHandle view_proj{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle time{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle wind_strength{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle wind_speed{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle light_direction{GL::Shader::InvalidUniform};
+  } m_oliveUniforms;
+
   struct FireCampUniforms {
     GL::Shader::UniformHandle view_proj{GL::Shader::InvalidUniform};
     GL::Shader::UniformHandle time{GL::Shader::InvalidUniform};
@@ -81,6 +92,12 @@ public:
   GLsizei m_pineIndexCount{0};
   GLsizei m_pineVertexCount{0};
 
+  GLuint m_oliveVao{0};
+  GLuint m_oliveVertexBuffer{0};
+  GLuint m_oliveIndexBuffer{0};
+  GLsizei m_oliveIndexCount{0};
+  GLsizei m_oliveVertexCount{0};
+
   GLuint m_firecampVao{0};
   GLuint m_firecampVertexBuffer{0};
   GLuint m_firecampIndexBuffer{0};
@@ -94,6 +111,8 @@ private:
   void shutdownPlantPipeline();
   void initializePinePipeline();
   void shutdownPinePipeline();
+  void initializeOlivePipeline();
+  void shutdownOlivePipeline();
   void initializeFireCampPipeline();
   void shutdownFireCampPipeline();
 
@@ -103,6 +122,7 @@ private:
   GL::Shader *m_stoneShader{nullptr};
   GL::Shader *m_plantShader{nullptr};
   GL::Shader *m_pineShader{nullptr};
+  GL::Shader *m_oliveShader{nullptr};
   GL::Shader *m_firecampShader{nullptr};
 };
 

+ 1 - 0
render/gl/render_constants.h

@@ -41,6 +41,7 @@ inline constexpr int CubeIndexCount = 36;
 inline constexpr int PlantCrossQuadVertexCount = 16;
 inline constexpr int PlantCrossQuadIndexCount = 24;
 inline constexpr int PineTreeSegments = 6;
+inline constexpr int OliveTreeSegments = 6;
 inline constexpr int GroundPlaneSubdivisions = 64;
 inline constexpr int DefaultCapsuleSegments = 8;
 inline constexpr int DefaultTorsoHeightSegments = 8;

+ 5 - 0
render/gl/shader_cache.h

@@ -107,6 +107,11 @@ public:
     const QString pineFrag =
         resolve(kShaderBase + QStringLiteral("pine_instanced.frag"));
     load(QStringLiteral("pine_instanced"), pineVert, pineFrag);
+    const QString oliveVert =
+        resolve(kShaderBase + QStringLiteral("olive_instanced.vert"));
+    const QString oliveFrag =
+        resolve(kShaderBase + QStringLiteral("olive_instanced.frag"));
+    load(QStringLiteral("olive_instanced"), oliveVert, oliveFrag);
 
     const QString firecampVert =
         resolve(kShaderBase + QStringLiteral("firecamp.vert"));

+ 37 - 20
render/ground/firecamp_renderer.cpp

@@ -87,38 +87,52 @@ void FireCampRenderer::submit(Renderer &renderer, ResourceManager *resources) {
 
   if (m_fireCampInstanceCount == 0) {
     m_fireCampInstanceBuffer.reset();
+    m_visibleInstances.clear();
     return;
   }
 
   auto &visibility = Game::Map::VisibilityService::instance();
   const bool use_visibility = visibility.isInitialized();
+  const std::uint64_t current_version =
+      use_visibility ? visibility.version() : 0;
+
+  const bool needs_visibility_update =
+      m_visibilityDirty || (current_version != m_cachedVisibilityVersion);
+
+  if (needs_visibility_update) {
+    m_visibleInstances.clear();
+
+    if (use_visibility) {
+      m_visibleInstances.reserve(m_fireCampInstanceCount);
+      for (const auto &instance : m_fireCampInstances) {
+        float const world_x = instance.pos_intensity.x();
+        float const world_z = instance.pos_intensity.z();
+        if (visibility.isVisibleWorld(world_x, world_z)) {
+          m_visibleInstances.push_back(instance);
+        }
+      }
+    } else {
+      m_visibleInstances = m_fireCampInstances;
+    }
 
-  std::vector<FireCampInstanceGpu> visible_instances;
-  if (use_visibility) {
-    visible_instances.reserve(m_fireCampInstanceCount);
-    for (const auto &instance : m_fireCampInstances) {
-      float const world_x = instance.pos_intensity.x();
-      float const world_z = instance.pos_intensity.z();
-      if (visibility.isVisibleWorld(world_x, world_z)) {
-        visible_instances.push_back(instance);
+    m_cachedVisibilityVersion = current_version;
+    m_visibilityDirty = false;
+
+    if (!m_visibleInstances.empty()) {
+      if (!m_fireCampInstanceBuffer) {
+        m_fireCampInstanceBuffer =
+            std::make_unique<Buffer>(Buffer::Type::Vertex);
       }
+      m_fireCampInstanceBuffer->setData(m_visibleInstances,
+                                        Buffer::Usage::Static);
     }
-  } else {
-    visible_instances = m_fireCampInstances;
   }
 
-  const auto visible_count = static_cast<uint32_t>(visible_instances.size());
-  if (visible_count == 0) {
-    m_fireCampInstanceBuffer.reset();
+  const auto visible_count = static_cast<uint32_t>(m_visibleInstances.size());
+  if (visible_count == 0 || !m_fireCampInstanceBuffer) {
     return;
   }
 
-  if (!m_fireCampInstanceBuffer) {
-    m_fireCampInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
-  }
-
-  m_fireCampInstanceBuffer->setData(visible_instances, Buffer::Usage::Static);
-
   FireCampBatchParams params = m_fireCampParams;
   params.time = renderer.getAnimationTime();
   params.flickerAmount = m_fireCampParams.flickerAmount *
@@ -130,7 +144,7 @@ void FireCampRenderer::submit(Renderer &renderer, ResourceManager *resources) {
   const QVector3D log_color(0.26F, 0.15F, 0.08F);
   const QVector3D char_color(0.08F, 0.05F, 0.03F);
 
-  for (const auto &instance : visible_instances) {
+  for (const auto &instance : m_visibleInstances) {
     const QVector4D pos_intensity = instance.pos_intensity;
     const QVector4D radius_phase = instance.radius_phase;
 
@@ -184,9 +198,12 @@ void FireCampRenderer::submit(Renderer &renderer, ResourceManager *resources) {
 
 void FireCampRenderer::clear() {
   m_fireCampInstances.clear();
+  m_visibleInstances.clear();
   m_fireCampInstanceBuffer.reset();
   m_fireCampInstanceCount = 0;
   m_fireCampInstancesDirty = false;
+  m_visibilityDirty = true;
+  m_cachedVisibilityVersion = 0;
   m_explicitPositions.clear();
   m_explicitIntensities.clear();
   m_explicitRadii.clear();

+ 4 - 0
render/ground/firecamp_renderer.h

@@ -47,6 +47,10 @@ private:
   FireCampBatchParams m_fireCampParams;
   bool m_fireCampInstancesDirty = false;
 
+  std::vector<FireCampInstanceGpu> m_visibleInstances;
+  std::uint64_t m_cachedVisibilityVersion = 0;
+  bool m_visibilityDirty = true;
+
   std::vector<QVector3D> m_explicitPositions;
   std::vector<float> m_explicitIntensities;
   std::vector<float> m_explicitRadii;

+ 32 - 0
render/ground/olive_gpu.h

@@ -0,0 +1,32 @@
+#pragma once
+
+#include <QVector3D>
+#include <QVector4D>
+#include <cstdint>
+
+namespace Render::GL {
+
+struct OliveInstanceGpu {
+  QVector4D pos_scale;
+  QVector4D color_sway;
+  QVector4D rotation;
+};
+
+struct OliveBatchParams {
+  static constexpr float kDefaultLightDirX = 0.35F;
+  static constexpr float kDefaultLightDirY = 0.8F;
+  static constexpr float kDefaultLightDirZ = 0.45F;
+  static constexpr float kDefaultWindStrength = 0.3F;
+  static constexpr float kDefaultWindSpeed = 0.5F;
+
+  static auto default_light_direction() -> QVector3D {
+    return {kDefaultLightDirX, kDefaultLightDirY, kDefaultLightDirZ};
+  }
+
+  QVector3D light_direction = default_light_direction();
+  float time = 0.0F;
+  float wind_strength = kDefaultWindStrength;
+  float wind_speed = kDefaultWindSpeed;
+};
+
+} // namespace Render::GL

+ 289 - 0
render/ground/olive_renderer.cpp

@@ -0,0 +1,289 @@
+#include "olive_renderer.h"
+#include "../../game/map/terrain_service.h"
+#include "../../game/map/visibility_service.h"
+#include "../../game/systems/building_collision_registry.h"
+#include "../gl/buffer.h"
+#include "../scene_renderer.h"
+#include "gl/render_constants.h"
+#include "gl/resources.h"
+#include "ground/olive_gpu.h"
+#include "ground_utils.h"
+#include "map/terrain.h"
+#include <QVector2D>
+#include <algorithm>
+#include <cmath>
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace {
+
+using std::uint32_t;
+using namespace Render::Ground;
+
+inline auto valueNoise(float x, float z, uint32_t salt = 0U) -> float {
+  int const x0 = int(std::floor(x));
+  int const z0 = int(std::floor(z));
+  int const x1 = x0 + 1;
+  int const z1 = z0 + 1;
+  float const tx = x - float(x0);
+  float const tz = z - float(z0);
+  float const n00 = hash_to_01(hash_coords(x0, z0, salt));
+  float const n10 = hash_to_01(hash_coords(x1, z0, salt));
+  float const n01 = hash_to_01(hash_coords(x0, z1, salt));
+  float const n11 = hash_to_01(hash_coords(x1, z1, salt));
+  float const nx0 = n00 * (1 - tx) + n10 * tx;
+  float const nx1 = n01 * (1 - tx) + n11 * tx;
+  return nx0 * (1 - tz) + nx1 * tz;
+}
+
+} // namespace
+
+namespace Render::GL {
+
+OliveRenderer::OliveRenderer() = default;
+OliveRenderer::~OliveRenderer() = default;
+
+void OliveRenderer::configure(const Game::Map::TerrainHeightMap &height_map,
+                              const Game::Map::BiomeSettings &biomeSettings) {
+  m_width = height_map.getWidth();
+  m_height = height_map.getHeight();
+  m_tile_size = height_map.getTileSize();
+  m_heightData = height_map.getHeightData();
+  m_terrain_types = height_map.getTerrainTypes();
+  m_biomeSettings = biomeSettings;
+  m_noiseSeed = biomeSettings.seed;
+
+  m_oliveInstances.clear();
+  m_oliveInstanceBuffer.reset();
+  m_oliveInstanceCount = 0;
+  m_oliveInstancesDirty = false;
+
+  m_oliveParams.light_direction = QVector3D(0.35F, 0.8F, 0.45F);
+  m_oliveParams.time = 0.0F;
+  m_oliveParams.wind_strength = 0.3F;
+  m_oliveParams.wind_speed = 0.5F;
+
+  generate_olive_instances();
+}
+
+void OliveRenderer::submit(Renderer &renderer, ResourceManager *resources) {
+  (void)resources;
+
+  m_oliveInstanceCount = static_cast<uint32_t>(m_oliveInstances.size());
+
+  if (m_oliveInstanceCount == 0) {
+    m_oliveInstanceBuffer.reset();
+    m_visibleInstances.clear();
+    return;
+  }
+
+  auto &visibility = Game::Map::VisibilityService::instance();
+  const bool use_visibility = visibility.isInitialized();
+  const std::uint64_t current_version =
+      use_visibility ? visibility.version() : 0;
+
+  const bool needs_visibility_update =
+      m_visibilityDirty || (current_version != m_cachedVisibilityVersion);
+
+  if (needs_visibility_update) {
+    m_visibleInstances.clear();
+
+    if (use_visibility) {
+      m_visibleInstances.reserve(m_oliveInstanceCount);
+      for (const auto &instance : m_oliveInstances) {
+        float const world_x = instance.pos_scale.x();
+        float const world_z = instance.pos_scale.z();
+        if (visibility.isVisibleWorld(world_x, world_z)) {
+          m_visibleInstances.push_back(instance);
+        }
+      }
+    } else {
+      m_visibleInstances = m_oliveInstances;
+    }
+
+    m_cachedVisibilityVersion = current_version;
+    m_visibilityDirty = false;
+
+    if (!m_visibleInstances.empty()) {
+      if (!m_oliveInstanceBuffer) {
+        m_oliveInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
+      }
+      m_oliveInstanceBuffer->setData(m_visibleInstances, Buffer::Usage::Static);
+    }
+  }
+
+  const auto visible_count = static_cast<uint32_t>(m_visibleInstances.size());
+  if (visible_count == 0 || !m_oliveInstanceBuffer) {
+    return;
+  }
+
+  OliveBatchParams params = m_oliveParams;
+  params.time = renderer.getAnimationTime();
+  renderer.oliveBatch(m_oliveInstanceBuffer.get(), visible_count, params);
+}
+
+void OliveRenderer::clear() {
+  m_oliveInstances.clear();
+  m_visibleInstances.clear();
+  m_oliveInstanceBuffer.reset();
+  m_oliveInstanceCount = 0;
+  m_oliveInstancesDirty = false;
+  m_visibilityDirty = true;
+  m_cachedVisibilityVersion = 0;
+}
+
+void OliveRenderer::generate_olive_instances() {
+  m_oliveInstances.clear();
+
+  if (m_width < 2 || m_height < 2 || m_heightData.empty()) {
+    return;
+  }
+
+  const float half_width = static_cast<float>(m_width) * 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 edge_padding =
+      std::clamp(m_biomeSettings.spawn_edge_padding, 0.0F, 0.5F);
+  const float edge_margin_x = static_cast<float>(m_width) * edge_padding;
+  const float edge_margin_z = static_cast<float>(m_height) * edge_padding;
+
+  float olive_density =
+      (m_biomeSettings.ground_type == Game::Map::GroundType::GrassDry) ? 0.12F
+                                                                       : 0.05F;
+  if (m_biomeSettings.plant_density > 0.0F) {
+    float const density_mult =
+        (m_biomeSettings.ground_type == Game::Map::GroundType::GrassDry)
+            ? 0.15F
+            : 0.08F;
+    olive_density = m_biomeSettings.plant_density * density_mult;
+  }
+
+  std::vector<QVector3D> normals(static_cast<qsizetype>(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 const idx = z * m_width + x;
+      float const h_l = m_heightData[(z)*m_width + (x - 1)];
+      float const h_r = m_heightData[(z)*m_width + (x + 1)];
+      float const h_d = m_heightData[(z - 1) * m_width + (x)];
+      float const h_u = m_heightData[(z + 1) * m_width + (x)];
+
+      QVector3D n = QVector3D(h_l - h_r, 2.0F * tile_safe, h_d - h_u);
+      if (n.lengthSquared() > 0.0F) {
+        n.normalize();
+      } else {
+        n = QVector3D(0, 1, 0);
+      }
+      normals[idx] = n;
+    }
+  }
+
+  auto add_olive = [&](float gx, float gz, uint32_t &state) -> bool {
+    if (gx < edge_margin_x || gx > m_width - 1 - edge_margin_x ||
+        gz < edge_margin_z || gz > m_height - 1 - edge_margin_z) {
+      return false;
+    }
+
+    float const sgx = std::clamp(gx, 0.0F, float(m_width - 1));
+    float const sgz = std::clamp(gz, 0.0F, float(m_height - 1));
+
+    int const ix = std::clamp(int(std::floor(sgx + 0.5F)), 0, m_width - 1);
+    int const iz = std::clamp(int(std::floor(sgz + 0.5F)), 0, m_height - 1);
+    int const normal_idx = iz * m_width + ix;
+
+    QVector3D const normal = normals[normal_idx];
+    float const slope = 1.0F - std::clamp(normal.y(), 0.0F, 1.0F);
+
+    float const world_x = (gx - half_width) * m_tile_size;
+    float const world_z = (gz - half_height) * m_tile_size;
+    float const world_y = m_heightData[normal_idx];
+
+    auto &building_registry =
+        Game::Systems::BuildingCollisionRegistry::instance();
+    if (building_registry.isPointInBuilding(world_x, world_z)) {
+      return false;
+    }
+
+    auto &terrain_service = Game::Map::TerrainService::instance();
+    if (terrain_service.is_point_on_road(world_x, world_z)) {
+      return false;
+    }
+
+    float const color_var = remap(rand_01(state), 0.0F, 1.0F);
+    QVector3D const base_color(0.35F, 0.42F, 0.28F);
+    QVector3D const var_color(0.38F, 0.45F, 0.32F);
+    QVector3D tint_color =
+        base_color * (1.0F - color_var) + var_color * color_var;
+
+    float const gray_mix = remap(rand_01(state), 0.08F, 0.18F);
+    QVector3D const gray_tint(0.45F, 0.48F, 0.42F);
+    tint_color = tint_color * (1.0F - gray_mix) + gray_tint * gray_mix;
+
+    float const sway_phase = rand_01(state) * MathConstants::k_two_pi;
+
+    float const rotation = rand_01(state) * MathConstants::k_two_pi;
+
+    float const silhouette_seed = rand_01(state);
+    float const leaf_seed = rand_01(state);
+    float const bark_seed = rand_01(state);
+
+    OliveInstanceGpu instance;
+
+    float const base_scale = remap(rand_01(state), 2.8F, 5.5F) * tile_safe;
+    float const dry_scale = remap(rand_01(state), 3.2F, 6.5F) * tile_safe;
+    float const chosen_scale =
+        (m_biomeSettings.ground_type == Game::Map::GroundType::GrassDry)
+            ? dry_scale
+            : base_scale;
+
+    instance.pos_scale = QVector4D(world_x, world_y, world_z, chosen_scale);
+    instance.color_sway =
+        QVector4D(tint_color.x(), tint_color.y(), tint_color.z(), sway_phase);
+    instance.rotation =
+        QVector4D(rotation, silhouette_seed, leaf_seed, bark_seed);
+    m_oliveInstances.push_back(instance);
+    return true;
+  };
+
+  for (int z = 0; z < m_height; z += 6) {
+    for (int x = 0; x < m_width; x += 6) {
+      int const idx = z * m_width + x;
+
+      QVector3D const normal = normals[idx];
+      float const slope = 1.0F - std::clamp(normal.y(), 0.0F, 1.0F);
+      if (slope > 0.65F) {
+        continue;
+      }
+
+      uint32_t state = hash_coords(
+          x, z, m_noiseSeed ^ 0xCD34EF56U ^ static_cast<uint32_t>(idx));
+
+      float const world_x = (x - half_width) * m_tile_size;
+      float const world_z = (z - half_height) * m_tile_size;
+
+      float density_mult = 1.0F;
+      if (m_terrain_types[idx] == Game::Map::TerrainType::Hill) {
+        density_mult = 1.15F;
+      } else if (m_terrain_types[idx] == Game::Map::TerrainType::Mountain) {
+        density_mult = 0.5F;
+      }
+
+      float const effective_density = olive_density * density_mult;
+      int olive_count = static_cast<int>(std::ceil(effective_density));
+
+      for (int i = 0; i < olive_count; ++i) {
+        float const gx = float(x) + rand_01(state) * 6.0F;
+        float const gz = float(z) + rand_01(state) * 6.0F;
+        add_olive(gx, gz, state);
+      }
+    }
+  }
+
+  m_oliveInstanceCount = m_oliveInstances.size();
+  m_oliveInstancesDirty = m_oliveInstanceCount > 0;
+}
+
+} // namespace Render::GL

+ 50 - 0
render/ground/olive_renderer.h

@@ -0,0 +1,50 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include "../i_render_pass.h"
+#include "olive_gpu.h"
+#include <QVector3D>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace Render::GL {
+class Buffer;
+class Renderer;
+
+class OliveRenderer : public IRenderPass {
+public:
+  OliveRenderer();
+  ~OliveRenderer() override;
+
+  void configure(const Game::Map::TerrainHeightMap &height_map,
+                 const Game::Map::BiomeSettings &biomeSettings);
+
+  void submit(Renderer &renderer, ResourceManager *resources) override;
+
+  void clear();
+
+private:
+  void generate_olive_instances();
+
+  int m_width = 0;
+  int m_height = 0;
+  float m_tile_size = 1.0F;
+
+  std::vector<float> m_heightData;
+  std::vector<Game::Map::TerrainType> m_terrain_types;
+  Game::Map::BiomeSettings m_biomeSettings;
+  std::uint32_t m_noiseSeed = 0U;
+
+  std::vector<OliveInstanceGpu> m_oliveInstances;
+  std::unique_ptr<Buffer> m_oliveInstanceBuffer;
+  std::size_t m_oliveInstanceCount = 0;
+  OliveBatchParams m_oliveParams;
+  bool m_oliveInstancesDirty = false;
+
+  std::vector<OliveInstanceGpu> m_visibleInstances;
+  std::uint64_t m_cachedVisibilityVersion = 0;
+  bool m_visibilityDirty = true;
+};
+
+} // namespace Render::GL

+ 39 - 19
render/ground/pine_renderer.cpp

@@ -75,38 +75,50 @@ void PineRenderer::submit(Renderer &renderer, ResourceManager *resources) {
 
   if (m_pineInstanceCount == 0) {
     m_pineInstanceBuffer.reset();
+    m_visibleInstances.clear();
     return;
   }
 
   auto &visibility = Game::Map::VisibilityService::instance();
   const bool use_visibility = visibility.isInitialized();
+  const std::uint64_t current_version =
+      use_visibility ? visibility.version() : 0;
+
+  const bool needs_visibility_update =
+      m_visibilityDirty || (current_version != m_cachedVisibilityVersion);
+
+  if (needs_visibility_update) {
+    m_visibleInstances.clear();
+
+    if (use_visibility) {
+      m_visibleInstances.reserve(m_pineInstanceCount);
+      for (const auto &instance : m_pineInstances) {
+        float const world_x = instance.posScale.x();
+        float const world_z = instance.posScale.z();
+        if (visibility.isVisibleWorld(world_x, world_z)) {
+          m_visibleInstances.push_back(instance);
+        }
+      }
+    } else {
+      m_visibleInstances = m_pineInstances;
+    }
+
+    m_cachedVisibilityVersion = current_version;
+    m_visibilityDirty = false;
 
-  std::vector<PineInstanceGpu> visible_instances;
-  if (use_visibility) {
-    visible_instances.reserve(m_pineInstanceCount);
-    for (const auto &instance : m_pineInstances) {
-      float const world_x = instance.posScale.x();
-      float const world_z = instance.posScale.z();
-      if (visibility.isVisibleWorld(world_x, world_z)) {
-        visible_instances.push_back(instance);
+    if (!m_visibleInstances.empty()) {
+      if (!m_pineInstanceBuffer) {
+        m_pineInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
       }
+      m_pineInstanceBuffer->setData(m_visibleInstances, Buffer::Usage::Static);
     }
-  } else {
-    visible_instances = m_pineInstances;
   }
 
-  const auto visible_count = static_cast<uint32_t>(visible_instances.size());
-  if (visible_count == 0) {
-    m_pineInstanceBuffer.reset();
+  const auto visible_count = static_cast<uint32_t>(m_visibleInstances.size());
+  if (visible_count == 0 || !m_pineInstanceBuffer) {
     return;
   }
 
-  if (!m_pineInstanceBuffer) {
-    m_pineInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
-  }
-
-  m_pineInstanceBuffer->setData(visible_instances, Buffer::Usage::Static);
-
   PineBatchParams params = m_pineParams;
   params.time = renderer.getAnimationTime();
   renderer.pineBatch(m_pineInstanceBuffer.get(), visible_count, params);
@@ -114,9 +126,12 @@ void PineRenderer::submit(Renderer &renderer, ResourceManager *resources) {
 
 void PineRenderer::clear() {
   m_pineInstances.clear();
+  m_visibleInstances.clear();
   m_pineInstanceBuffer.reset();
   m_pineInstanceCount = 0;
   m_pineInstancesDirty = false;
+  m_visibilityDirty = true;
+  m_cachedVisibilityVersion = 0;
 }
 
 void PineRenderer::generatePineInstances() {
@@ -126,6 +141,11 @@ void PineRenderer::generatePineInstances() {
     return;
   }
 
+  if (m_biomeSettings.ground_type == Game::Map::GroundType::GrassDry) {
+    m_pineInstancesDirty = false;
+    return;
+  }
+
   const float half_width = static_cast<float>(m_width) * 0.5F;
   const float half_height = static_cast<float>(m_height) * 0.5F;
   const float tile_safe = std::max(0.1F, m_tile_size);

+ 4 - 0
render/ground/pine_renderer.h

@@ -41,6 +41,10 @@ private:
   std::size_t m_pineInstanceCount = 0;
   PineBatchParams m_pineParams;
   bool m_pineInstancesDirty = false;
+
+  std::vector<PineInstanceGpu> m_visibleInstances;
+  std::uint64_t m_cachedVisibilityVersion = 0;
+  bool m_visibilityDirty = true;
 };
 
 } // namespace Render::GL

+ 43 - 42
render/ground/plant_renderer.cpp

@@ -73,67 +73,68 @@ void PlantRenderer::submit(Renderer &renderer, ResourceManager *resources) {
 
   m_plantInstanceCount = static_cast<uint32_t>(m_plantInstances.size());
 
-  if (m_plantInstanceCount > 0) {
-    if (!m_plantInstanceBuffer) {
-      m_plantInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
-    }
-    if (m_plantInstancesDirty && m_plantInstanceBuffer) {
-      m_plantInstanceBuffer->setData(m_plantInstances, Buffer::Usage::Static);
-      m_plantInstancesDirty = false;
-    }
-  } else {
+  if (m_plantInstanceCount == 0) {
     m_plantInstanceBuffer.reset();
+    m_visibleInstances.clear();
     return;
   }
 
   auto &visibility = Game::Map::VisibilityService::instance();
   const bool use_visibility = visibility.isInitialized();
-
-  if (use_visibility) {
-
-    std::vector<PlantInstanceGpu> visible_instances;
-    visible_instances.reserve(m_plantInstances.size());
-
-    for (const auto &instance : m_plantInstances) {
-      float const world_x = instance.posScale.x();
-      float const world_z = instance.posScale.z();
-
-      if (visibility.isVisibleWorld(world_x, world_z)) {
-        visible_instances.push_back(instance);
+  const std::uint64_t current_version =
+      use_visibility ? visibility.version() : 0;
+
+  const bool needs_visibility_update =
+      m_visibilityDirty || (current_version != m_cachedVisibilityVersion);
+
+  if (needs_visibility_update) {
+    m_visibleInstances.clear();
+
+    if (use_visibility) {
+      m_visibleInstances.reserve(m_plantInstanceCount);
+      for (const auto &instance : m_plantInstances) {
+        float const world_x = instance.posScale.x();
+        float const world_z = instance.posScale.z();
+        if (visibility.isVisibleWorld(world_x, world_z)) {
+          m_visibleInstances.push_back(instance);
+        }
       }
+    } else {
+      m_visibleInstances = m_plantInstances;
     }
 
-    if (visible_instances.empty()) {
-      return;
-    }
+    m_cachedVisibilityVersion = current_version;
+    m_visibilityDirty = false;
 
-    if (!m_visibleInstanceBuffer) {
-      m_visibleInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
-    }
-    m_visibleInstanceBuffer->setData(visible_instances, Buffer::Usage::Stream);
-
-    PlantBatchParams params = m_plantParams;
-    params.time = renderer.getAnimationTime();
-    renderer.plantBatch(m_visibleInstanceBuffer.get(),
-                        static_cast<uint32_t>(visible_instances.size()),
-                        params);
-  } else {
-
-    if (m_plantInstanceBuffer && m_plantInstanceCount > 0) {
-      PlantBatchParams params = m_plantParams;
-      params.time = renderer.getAnimationTime();
-      renderer.plantBatch(m_plantInstanceBuffer.get(), m_plantInstanceCount,
-                          params);
+    if (!m_visibleInstances.empty()) {
+      if (!m_visibleInstanceBuffer) {
+        m_visibleInstanceBuffer =
+            std::make_unique<Buffer>(Buffer::Type::Vertex);
+      }
+      m_visibleInstanceBuffer->setData(m_visibleInstances,
+                                       Buffer::Usage::Static);
     }
   }
+
+  const auto visible_count = static_cast<uint32_t>(m_visibleInstances.size());
+  if (visible_count == 0 || !m_visibleInstanceBuffer) {
+    return;
+  }
+
+  PlantBatchParams params = m_plantParams;
+  params.time = renderer.getAnimationTime();
+  renderer.plantBatch(m_visibleInstanceBuffer.get(), visible_count, params);
 }
 
 void PlantRenderer::clear() {
   m_plantInstances.clear();
+  m_visibleInstances.clear();
   m_plantInstanceBuffer.reset();
   m_visibleInstanceBuffer.reset();
   m_plantInstanceCount = 0;
   m_plantInstancesDirty = false;
+  m_visibilityDirty = true;
+  m_cachedVisibilityVersion = 0;
 }
 
 void PlantRenderer::generatePlantInstances() {
@@ -340,7 +341,7 @@ void PlantRenderer::generatePlantInstances() {
         density_mult = 0.6F;
       }
 
-      float const effective_density = plant_density * density_mult * 2.0F;
+      float const effective_density = plant_density * density_mult * 0.8F;
       int plant_count = static_cast<int>(std::floor(effective_density));
       float const frac = effective_density - float(plant_count);
       if (rand_01(state) < frac) {

+ 4 - 0
render/ground/plant_renderer.h

@@ -42,6 +42,10 @@ private:
   std::size_t m_plantInstanceCount = 0;
   PlantBatchParams m_plantParams;
   bool m_plantInstancesDirty = false;
+
+  std::vector<PlantInstanceGpu> m_visibleInstances;
+  std::uint64_t m_cachedVisibilityVersion = 0;
+  bool m_visibilityDirty = true;
 };
 
 } // namespace Render::GL

+ 36 - 21
render/ground/riverbank_asset_renderer.cpp

@@ -75,40 +75,52 @@ void RiverbankAssetRenderer::submit(Renderer &, ResourceManager *resources) {
     return;
   }
 
-  if (!m_assetInstanceBuffer) {
-    m_assetInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
-  }
-  if (m_assetInstancesDirty && m_assetInstanceBuffer) {
-    m_assetInstanceBuffer->setData(m_assetInstances, Buffer::Usage::Static);
-    m_assetInstancesDirty = false;
-  }
-
   auto &visibility = Game::Map::VisibilityService::instance();
   const bool use_visibility = visibility.isInitialized();
+  const std::uint64_t current_version =
+      use_visibility ? visibility.version() : 0;
+
+  const bool needs_visibility_update =
+      m_visibilityDirty || m_assetInstancesDirty ||
+      (use_visibility && current_version != m_cachedVisibilityVersion);
+
+  if (needs_visibility_update) {
+    m_visibleInstances.clear();
+    m_visibleInstances.reserve(m_assetInstances.size());
 
-  std::vector<RiverbankAssetInstanceGpu> visible_instances;
+    for (const auto &instance : m_assetInstances) {
+      bool should_render = true;
 
-  for (const auto &instance : m_assetInstances) {
-    bool should_render = true;
+      if (use_visibility) {
+        float const world_x = instance.position[0];
+        float const world_z = instance.position[2];
 
-    if (use_visibility) {
-      float const world_x = instance.position[0];
-      float const world_z = instance.position[2];
+        if (!visibility.isVisibleWorld(world_x, world_z)) {
+          should_render = false;
+        }
+      }
 
-      if (!visibility.isVisibleWorld(world_x, world_z)) {
-        should_render = false;
+      if (should_render) {
+        m_visibleInstances.push_back(instance);
       }
     }
 
-    if (should_render) {
-      visible_instances.push_back(instance);
+    if (!m_assetInstanceBuffer) {
+      m_assetInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
+    }
+    if (!m_visibleInstances.empty()) {
+      m_assetInstanceBuffer->setData(m_visibleInstances,
+                                     Buffer::Usage::Dynamic);
     }
-  }
 
-  if (!visible_instances.empty()) {
+    m_cachedVisibilityVersion = current_version;
+    m_visibilityDirty = false;
+    m_assetInstancesDirty = false;
+  }
 
+  if (!m_visibleInstances.empty()) {
     qDebug() << "RiverbankAssetRenderer: Would render"
-             << visible_instances.size() << "of" << m_assetInstanceCount
+             << m_visibleInstances.size() << "of" << m_assetInstanceCount
              << "riverbank assets (fog of war applied)";
   }
 }
@@ -118,6 +130,9 @@ void RiverbankAssetRenderer::clear() {
   m_assetInstanceBuffer.reset();
   m_assetInstanceCount = 0;
   m_assetInstancesDirty = false;
+  m_visibleInstances.clear();
+  m_cachedVisibilityVersion = 0;
+  m_visibilityDirty = true;
 }
 
 void RiverbankAssetRenderer::generateAssetInstances() {

+ 4 - 0
render/ground/riverbank_asset_renderer.h

@@ -43,6 +43,10 @@ private:
   std::size_t m_assetInstanceCount = 0;
   RiverbankAssetBatchParams m_assetParams;
   bool m_assetInstancesDirty = false;
+
+  std::vector<RiverbankAssetInstanceGpu> m_visibleInstances;
+  std::uint64_t m_cachedVisibilityVersion = 0;
+  bool m_visibilityDirty = true;
 };
 
 } // namespace Render::GL

+ 14 - 0
render/scene_renderer.cpp

@@ -209,6 +209,20 @@ void Renderer::pineBatch(Buffer *instanceBuffer, std::size_t instance_count,
   m_activeQueue->submit(cmd);
 }
 
+void Renderer::oliveBatch(Buffer *instanceBuffer, std::size_t instance_count,
+                          const OliveBatchParams &params) {
+  if ((instanceBuffer == nullptr) || instance_count == 0 ||
+      (m_activeQueue == nullptr)) {
+    return;
+  }
+  OliveBatchCmd cmd;
+  cmd.instanceBuffer = instanceBuffer;
+  cmd.instance_count = instance_count;
+  cmd.params = params;
+  cmd.params.time = m_accumulatedTime;
+  m_activeQueue->submit(cmd);
+}
+
 void Renderer::firecampBatch(Buffer *instanceBuffer, std::size_t instance_count,
                              const FireCampBatchParams &params) {
   if ((instanceBuffer == nullptr) || instance_count == 0 ||

+ 2 - 0
render/scene_renderer.h

@@ -142,6 +142,8 @@ public:
                   const PlantBatchParams &params);
   void pineBatch(Buffer *instanceBuffer, std::size_t instance_count,
                  const PineBatchParams &params);
+  void oliveBatch(Buffer *instanceBuffer, std::size_t instance_count,
+                  const OliveBatchParams &params);
   void firecampBatch(Buffer *instanceBuffer, std::size_t instance_count,
                      const FireCampBatchParams &params);