Browse Source

Add plant renderer system (third biome layer)

djeada 1 month ago
parent
commit
946e56215c

+ 7 - 1
app/core/game_engine.cpp

@@ -65,6 +65,8 @@
 #include "render/ground/biome_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
+#include "render/ground/pine_renderer.h"
+#include "render/ground/plant_renderer.h"
 #include "render/ground/stone_renderer.h"
 #include "render/ground/terrain_renderer.h"
 #include "render/scene_renderer.h"
@@ -92,9 +94,11 @@ GameEngine::GameEngine() {
   m_biome = std::make_unique<Render::GL::BiomeRenderer>();
   m_fog = std::make_unique<Render::GL::FogRenderer>();
   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_passes = {m_ground.get(), m_terrain.get(), m_biome.get(), m_stone.get(),
-              m_fog.get()};
+              m_plant.get(), m_pine.get(), m_fog.get()};
 
   std::unique_ptr<Engine::Core::System> arrowSys =
       std::make_unique<Game::Systems::ArrowSystem>();
@@ -828,6 +832,8 @@ void GameEngine::startSkirmish(const QString &mapPath,
     loader.setBiomeRenderer(m_biome.get());
     loader.setFogRenderer(m_fog.get());
     loader.setStoneRenderer(m_stone.get());
+    loader.setPlantRenderer(m_plant.get());
+    loader.setPineRenderer(m_pine.get());
 
     loader.setOnOwnersUpdated([this]() { emit ownerInfoChanged(); });
 

+ 4 - 0
app/core/game_engine.h

@@ -41,6 +41,8 @@ class TerrainRenderer;
 class BiomeRenderer;
 class FogRenderer;
 class StoneRenderer;
+class PlantRenderer;
+class PineRenderer;
 struct IRenderPass;
 } // namespace GL
 } // namespace Render
@@ -254,6 +256,8 @@ private:
   std::unique_ptr<Render::GL::BiomeRenderer> m_biome;
   std::unique_ptr<Render::GL::FogRenderer> m_fog;
   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::vector<Render::GL::IRenderPass *> m_passes;
   std::unique_ptr<Game::Systems::PickingService> m_pickingService;
   std::unique_ptr<Game::Systems::VictoryService> m_victoryService;

+ 2 - 1
assets/maps/barrack_capture_test.json

@@ -22,7 +22,8 @@
     "grassDry": [0.55, 0.48, 0.38],
     "soilColor": [0.3, 0.25, 0.2],
     "rockLow": [0.48, 0.46, 0.44],
-    "rockHigh": [0.65, 0.66, 0.7]
+    "rockHigh": [0.65, 0.66, 0.7],
+    "plantDensity": 0.5
   },
   "camera": {
     "center": [60, 0, 60],

+ 2 - 1
assets/maps/knight_test.json

@@ -21,7 +21,8 @@
     "grassDry": [0.6, 0.5, 0.35],
     "soilColor": [0.3, 0.25, 0.2],
     "rockLow": [0.5, 0.5, 0.45],
-    "rockHigh": [0.7, 0.7, 0.75]
+    "rockHigh": [0.7, 0.7, 0.75],
+    "plantDensity": 0.55
   },
   "camera": {
     "center": [100, 0, 100],

+ 2 - 1
assets/maps/neutral_barracks_test.json

@@ -22,7 +22,8 @@
     "grassDry": [0.55, 0.48, 0.38],
     "soilColor": [0.3, 0.25, 0.2],
     "rockLow": [0.48, 0.46, 0.44],
-    "rockHigh": [0.65, 0.66, 0.7]
+    "rockHigh": [0.65, 0.66, 0.7],
+    "plantDensity": 0.5
   },
   "camera": {
     "center": [50, 0, 50],

+ 2 - 1
assets/maps/survival_challenge.json

@@ -22,7 +22,8 @@
     "grassDry": [0.6, 0.52, 0.38],
     "soilColor": [0.3, 0.26, 0.2],
     "rockLow": [0.48, 0.46, 0.44],
-    "rockHigh": [0.66, 0.67, 0.71]
+    "rockHigh": [0.66, 0.67, 0.71],
+    "plantDensity": 0.65
   },
   "camera": {
     "center": [50, 0, 50],

+ 2 - 1
assets/maps/team_battle_2v2.json

@@ -22,7 +22,8 @@
     "grassDry": [0.58, 0.5, 0.36],
     "soilColor": [0.28, 0.24, 0.18],
     "rockLow": [0.5, 0.48, 0.46],
-    "rockHigh": [0.68, 0.69, 0.73]
+    "rockHigh": [0.68, 0.69, 0.73],
+    "plantDensity": 0.6
   },
   "camera": {
     "center": [100, 0, 100],

+ 2 - 1
assets/maps/test_map.json

@@ -21,7 +21,8 @@
     "grassDry": [0.58, 0.5, 0.36],
     "soilColor": [0.28, 0.24, 0.18],
     "rockLow": [0.5, 0.48, 0.46],
-    "rockHigh": [0.68, 0.69, 0.73]
+    "rockHigh": [0.68, 0.69, 0.73],
+    "plantDensity": 0.6
   },
   "camera": {
     "center": [150, 0, 150],

+ 81 - 0
assets/shaders/pine_instanced.frag

@@ -0,0 +1,81 @@
+#version 330 core
+
+in vec3 vWorldPos;
+in vec3 vNormal;
+in vec3 vColor;
+in vec2 vTexCoord;
+in float vFoliageMask; // 0 = bark, 1 = foliage
+in float vNeedleSeed;  // per-instance variation
+in float vBarkSeed;    // per-instance variation
+
+uniform vec3 uLightDirection;
+
+out vec4 FragColor;
+
+const float PI = 3.14159265359;
+const float TWO_PI = 6.28318530718;
+
+// Hash-based noise (cheap, repeatable)
+float hash(vec2 p) {
+    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
+}
+
+void main() {
+    // --- Lighting ---
+    vec3 n = normalize(vNormal);
+    vec3 l = normalize(uLightDirection);
+    float diffuse = max(dot(n, l), 0.0);
+    float ambient = 0.4;
+    float lighting = ambient + diffuse * 0.7;
+
+    // --- Foliage (needles) variation ---
+    float needleNoise = hash(vec2(
+        vTexCoord.x * 28.0 + vNeedleSeed * 7.1,
+        vTexCoord.y * 24.0 + vNeedleSeed * 5.3
+    ));
+
+    // vertical streaking; quantize Y to bands
+    float needleStreak = hash(vec2(
+        vTexCoord.x * 12.0 + vNeedleSeed * 3.7,
+        floor(vTexCoord.y * 6.0 + vNeedleSeed * 2.0)
+    ));
+
+    vec3 needleColor = vColor * (0.78 + needleNoise * 0.28);
+    needleColor += vec3(0.02, 0.05, 0.02) * needleStreak;
+
+    // Subtle brightening toward tips (top of UVs)
+    float tipBlend = smoothstep(0.82, 1.02, vTexCoord.y);
+    needleColor = mix(needleColor, needleColor * vec3(1.08, 1.04, 1.10), tipBlend);
+
+    // --- Bark variation: stripes + noise ---
+    float barkStripe = sin(vTexCoord.y * 45.0 + vBarkSeed * TWO_PI) * 0.1 + 0.9;
+    float barkNoise  = hash(vec2(
+        vTexCoord.x * 18.0 + vBarkSeed * 4.3,
+        vTexCoord.y * 10.0 + vBarkSeed * 7.7
+    ));
+
+    vec3 trunkBase  = vec3(0.32, 0.24, 0.16) * barkStripe;
+    vec3 trunkColor = trunkBase * (0.85 + barkNoise * 0.35);
+
+    // --- Final color ---
+    vec3 baseColor = mix(trunkColor, needleColor, vFoliageMask);
+    vec3 color = baseColor * lighting;
+
+    // --- Alpha shaping (silhouette for foliage + bottom fade) ---
+    float silhouetteNoise = hash(vec2(
+        vTexCoord.x * 30.0 + vNeedleSeed * 9.0,
+        vTexCoord.y * 40.0 + vNeedleSeed * 5.5
+    ));
+
+    float alphaFoliage = 0.70 + silhouetteNoise * 0.25;
+    float alpha = mix(1.0, alphaFoliage, vFoliageMask);
+
+    // fade near bottom (ground contact)
+    alpha *= smoothstep(0.00, 0.05, vTexCoord.y);
+
+    // clamp and alpha test
+    alpha = clamp(alpha, 0.0, 1.0);
+    if (alpha < 0.06) discard;
+
+    FragColor = vec4(color, alpha);
+}

+ 117 - 0
assets/shaders/pine_instanced.vert

@@ -0,0 +1,117 @@
+#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 vNeedleSeed;
+out float vBarkSeed;
+
+// ─────────────────────────────────────────────────────────────
+// 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 needleSeed     = aRotation.z;
+    float barkSeed       = aRotation.w;
+
+    vec3 modelPos = aPos;
+
+    // ── Foliage and tip region masks ──────────────────────────
+    float foliageMask = smoothstep(0.34, 0.42, aTexCoord.y);
+    float tipMask     = smoothstep(0.88, 1.02, aTexCoord.y);
+    float angle       = aTexCoord.x * TWO_PI;
+
+    // ── Irregular silhouette shaping ──────────────────────────
+    float irregularBase = sin(angle * 3.0 + silhouetteSeed * TWO_PI);
+    float irregularFine = sin(angle * 5.0 + silhouetteSeed * TWO_PI * 2.0);
+    float irregular = (irregularBase * 0.11 + irregularFine * 0.05) *
+                      foliageMask * (1.0 - tipMask * 0.6);
+
+    modelPos.xz *= (1.0 + irregular);
+
+    // Slight droop on foliage
+    float droop = foliageMask * (1.0 - tipMask) * 0.08;
+    modelPos.y -= droop;
+
+    // Height-based influence
+    float heightFactor = clamp(modelPos.y, 0.0, 1.1);
+    vec3  localPos = modelPos * scale;
+
+    // ── Wind sway (stronger at upper regions) ─────────────────
+    float sway = sin(uTime * uWindSpeed * 0.5 + swayPhase) *
+                 uWindStrength * 0.8 * heightFactor * heightFactor;
+
+    float swayInfluence = mix(0.04, 0.12, foliageMask);
+    localPos.x += sway * swayInfluence;
+
+    // Slight forward bend for branches
+    localPos.y -= sway * 0.02 * foliageMask;
+
+    // ── Adjust normals for irregular foliage ──────────────────
+    vec3 localNormal = aNormal;
+    if (foliageMask > 0.0) {
+        float normalScale = 1.0 + irregular;
+        localNormal = normalize(vec3(
+            localNormal.x * normalScale,
+            localNormal.y - foliageMask * 0.2,
+            localNormal.z * normalScale
+        ));
+    }
+
+    // ── Instance rotation about Y-axis ────────────────────────
+    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 * localNormal.xz;
+    vec3 finalNormal = normalize(vec3(
+        rotatedNormalXZ.x,
+        localNormal.y,
+        rotatedNormalXZ.y
+    ));
+
+    // ── Outputs ───────────────────────────────────────────────
+    vWorldPos    = localPos + worldPos;
+    vNormal      = finalNormal;
+    vColor       = aColorSway.rgb;
+    vTexCoord    = aTexCoord;
+    vFoliageMask = foliageMask;
+    vNeedleSeed  = needleSeed;
+    vBarkSeed    = barkSeed;
+
+    gl_Position = uViewProj * vec4(vWorldPos, 1.0);
+}

+ 205 - 0
assets/shaders/plant_instanced.frag

@@ -0,0 +1,205 @@
+#version 330 core
+
+in vec3  vWorldPos;
+in vec3  vNormal;
+in vec3  vColor;
+in vec2  vTexCoord;
+in float vAlpha;     // (unused)
+in float vHeight;
+in float vSeed;
+in float vType;
+in vec3  vTangent;
+in vec3  vBitangent;
+
+uniform vec3 uLightDirection;
+
+out vec4 FragColor;
+
+// ─────────────────────────────────────────────────────────────
+// Toggles
+// ─────────────────────────────────────────────────────────────
+// Use when distant shimmer persists and you want depth-stable masking.
+//#define USE_HASHED_ALPHA 1
+// If your renderer uses TAA, screen-anchor the dither; otherwise leave off for world-anchored.
+//#define DITHER_SCREEN_ANCHORED 1
+
+// ─────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────
+float h11(float n) { return fract(sin(n) * 43758.5453123); }
+
+// Stable, screen-space UV footprint with power-of-two quantization.
+float aawidthUV(vec2 uv) {
+    vec2 dx = dFdx(uv), dy = dFdy(uv);
+    float w = 0.5 * (length(dx) + length(dy));
+    float q = exp2(floor(log2(max(w, 1e-6)) + 0.5)); // quantize to PoT buckets
+    return clamp(q, 0.0015, 0.0060);
+}
+
+// Conservative, quantized alpha from SDF + UV footprint.
+float sdf_to_alpha_uv_stable(float sdf, vec2 uv) {
+    float w = aawidthUV(uv);
+
+    // Slight inward bias stabilizes coverage at threshold.
+    sdf -= 0.25 * w;
+
+    // Analytic coverage.
+    float a = 1.0 - smoothstep(-w, w, sdf);
+
+    // Mild alpha quantization (finer as alpha grows) to reduce sub-8bit flicker.
+    float steps = mix(24.0, 64.0, a);
+    a = floor(a * steps + 0.5) / steps;
+
+    return a;
+}
+
+// Deterministic noise; anchor chosen below.
+float interleavedGradientNoise(vec2 p) {
+    float f = dot(p, vec2(0.06711056, 0.00583715));
+    return fract(52.9829189 * fract(f));
+}
+
+float stableDither(float seed) {
+#ifdef DITHER_SCREEN_ANCHORED
+    return interleavedGradientNoise(gl_FragCoord.xy);
+#else
+    return interleavedGradientNoise(floor(vWorldPos.xz * 4.0 + seed * 17.0));
+#endif
+}
+
+// Quantized step for stable finite differences.
+float quantStep(float w) {
+    return exp2(floor(log2(max(w, 1e-6)) + 0.5));
+}
+
+// ─────────────────────────────────────────────────────────────
+// SDF Silhouettes (non-destructive)
+// ─────────────────────────────────────────────────────────────
+float bushSDF(vec2 uv, float seed) {
+    vec2 p = (uv - 0.5) * vec2(1.08, 0.96);
+    float sdf = 1e9;
+    for (int i = 0; i < 5; i++) {
+        float fi  = float(i);
+        float ang = fi * 1.25663706 + seed * 3.7;
+        vec2 c    = vec2(cos(ang), sin(ang)) * (0.18 + h11(seed * 7.9 + fi) * 0.05);
+        float r   = 0.30 + h11(seed * 5.7 + fi) * 0.06;
+        float d   = length(p - c) - r;
+        sdf = min(sdf, d);
+    }
+    return sdf - 0.007;
+}
+
+float rosetteSDF(vec2 uv, float seed) {
+    vec2 p = uv - 0.5;
+    float a = atan(p.y, p.x);
+    float r = length(p);
+    float petals = mix(10.0, 16.0, h11(seed * 2.7));
+    float wave   = 0.20 + 0.06 * sin(a * petals + seed * 5.1);
+    return (r - wave) - 0.006;
+}
+
+float cactusSDF(vec2 uv, float seed) {
+    vec2 p = (uv - 0.5) * vec2(0.92, 1.08);
+    float sdf = length(p) - 0.48; // body
+    for (int i = 0; i < 3; i++) {
+        float fi  = float(i);
+        float ang = mix(-1.6, 1.6, h11(seed * 3.3 + fi));
+        vec2 c    = vec2(0.22 * cos(ang), 0.12 + 0.25 * abs(sin(ang)));
+        vec2 e    = vec2(0.22, 0.30) * mix(0.7, 1.1, h11(seed * 6.1 + fi));
+        float d   = length((p - c) / e) - 1.0;
+        sdf = min(sdf, d);
+    }
+    return sdf - 0.006;
+}
+
+float plantSDF(vec2 uv, float typeVal, float seed) {
+    if (typeVal < 0.45) return bushSDF(uv, seed);
+    if (typeVal < 0.80) return rosetteSDF(uv, seed);
+    return cactusSDF(uv, seed);
+}
+
+// Finite-difference SDF gradient in UV (step tied & quantized to pixel footprint).
+vec2 sdfGrad(vec2 uv, float typeVal, float seed, float stepUV) {
+    stepUV = quantStep(stepUV);
+    vec2 e = vec2(stepUV, 0.0);
+    float sx1 = plantSDF(uv + e.xy, typeVal, seed);
+    float sx2 = plantSDF(uv - e.xy, typeVal, seed);
+    float sy1 = plantSDF(uv + e.yx, typeVal, seed);
+    float sy2 = plantSDF(uv - e.yx, typeVal, seed);
+    return vec2(sx1 - sx2, sy1 - sy2) * (0.5 / stepUV);
+}
+
+// ─────────────────────────────────────────────────────────────
+// Shading
+// ─────────────────────────────────────────────────────────────
+void main() {
+    // Base palette
+    float dryness = mix(0.35, 0.92, h11(vSeed * 2.7 + vType * 0.73));
+    vec3 lush = vec3(0.17, 0.32, 0.19);
+    vec3 dry  = vec3(0.46, 0.44, 0.28);
+    vec3 base = mix(lush, dry, dryness);
+    base = mix(base, vColor, 0.40);
+    base *= 0.88;
+
+    // Bulged normals for shrub fullness
+    vec2 uv2 = (vTexCoord - 0.5) * 2.0;
+    float r2 = clamp(dot(uv2, uv2), 0.0, 1.0);
+    float z  = sqrt(max(1.0 - r2, 0.0));
+    vec3 Nbulge = normalize(vTangent * uv2.x + vBitangent * uv2.y + vNormal * (z * 1.8));
+    vec3 N      = normalize(mix(vNormal, Nbulge, 0.85));
+
+    // SDF & stable alpha
+    float typeVal = fract(vType);
+    float sdf     = plantSDF(vTexCoord, typeVal, vSeed);
+    float alpha   = sdf_to_alpha_uv_stable(sdf, vTexCoord);
+
+    if (alpha <= 0.002) discard;
+
+    // Optional hashed alpha for ultra-thin coverage → stable depth
+#ifdef USE_HASHED_ALPHA
+    {
+        float w    = aawidthUV(vTexCoord);
+        float thin = smoothstep(0.0, 2.0 * w, alpha);
+        if (thin < 0.98) {
+            if (thin < stableDither(vSeed)) discard;
+            alpha = 1.0; // write solid depth for kept pixels
+        }
+    }
+#endif
+
+    // Shape-space normal near silhouettes (with stabilized FD step)
+    float stepUV = aawidthUV(vTexCoord);
+    vec2 g = sdfGrad(vTexCoord, typeVal, vSeed, stepUV);
+    vec3 Nshape = normalize(vTangent * (-g.x) + vBitangent * (-g.y) + vNormal * 3.0);
+    float edgeMix = smoothstep(0.30, 0.0, sdf); // only inside & near edge
+    vec3 Ntemp = normalize(mix(N, Nshape, 0.6 * edgeMix));
+
+    // Edge attenuation to avoid sparkling highlights & reduce normal swapping
+    float wAA     = aawidthUV(vTexCoord);
+    float edge1   = 1.0 - smoothstep(-wAA, wAA, sdf);         // 1 at edge, 0 inside
+    N             = normalize(mix(Ntemp, vNormal, edge1 * 0.5));
+    float edgeAtten = mix(0.6, 1.0, pow(1.0 - edge1, 1.5));   // 0.6 at very edge
+
+    // Lighting
+    vec3 L = normalize(uLightDirection);
+    float nl          = max(dot(N, L), 0.0);
+    float halfLambert = nl * 0.5 + 0.5;
+    float wrap        = clamp((dot(N, L) + 0.20) / 1.20, 0.0, 1.0);
+    float diffuse     = mix(halfLambert, wrap, 0.30) * edgeAtten;
+    float sss         = pow(clamp(dot(-N, L), 0.0, 1.0), 2.2) * 0.22 * edgeAtten;
+    float ambient     = 0.16;
+
+    float aoStem = mix(0.50, 1.0, smoothstep(0.0, 0.55, vHeight));
+
+    // Subtle tip brightening & inner-edge occlusion (view-stable)
+    float tip   = smoothstep(0.25, 1.0, r2);
+    float inner = smoothstep(-2.0 * wAA, -0.2 * wAA, sdf);
+    vec3 albedo = base;
+    albedo *= mix(1.0, 1.08, tip);
+    albedo *= mix(0.95, 1.0, inner);
+
+    vec3 color = albedo * (ambient + diffuse * aoStem)
+               + albedo * sss * vec3(1.0, 0.95, 0.85);
+
+    FragColor = vec4(color, alpha);
+}

+ 113 - 0
assets/shaders/plant_instanced.vert

@@ -0,0 +1,113 @@
+// ──────────────────────────────────────────────────────────────────────────────
+// VERTEX — larger shrubs, irregular lean, varied wind, tangents for bulge
+// ──────────────────────────────────────────────────────────────────────────────
+
+#version 330 core
+
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec2 aTexCoord;
+layout(location = 2) in vec3 aNormal;
+layout(location = 3) in vec4 aPosScale;     // xyz = world pos, w = scale
+layout(location = 4) in vec4 aColorSway;    // rgb = tint, a = sway phase
+layout(location = 5) in vec4 aTypeParams;   // x = type, y = rotation, z = sway strength, w = sway speed
+
+uniform mat4 uViewProj;
+uniform float uTime;
+uniform float uWindStrength;
+uniform float uWindSpeed;
+
+out vec3 vWorldPos;
+out vec3 vNormal;
+out vec3 vColor;
+out vec2 vTexCoord;
+out float vAlpha;
+out float vHeight;
+out float vSeed;
+out float vType;
+out vec3 vTangent;
+out vec3 vBitangent;
+
+float h11(float n) {
+    return fract(sin(n) * 43758.5453123);
+}
+
+float h31(vec3 p) {
+    return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
+}
+
+void main() {
+    float scale = aPosScale.w;
+    vec3 worldOrigin = aPosScale.xyz;
+    float rotation = aTypeParams.y;
+    float swayStrength = aTypeParams.z;
+    float swaySpeed = aTypeParams.w;
+    float swayPhase = aColorSway.a;
+
+    // stable per-instance seed
+    float seed = h31(worldOrigin * 0.173 + vec3(0.27, 0.49, 0.19));
+
+    // bigger plants + size jitter
+    const float SIZE_MULT = 1.85; // ← bump this if still small
+    float sizeJitter = mix(0.90, 1.45, h11(seed * 3.9));
+    float finalScale = scale * SIZE_MULT * sizeJitter;
+
+    vec3 localPos = aPos * finalScale;
+    float h = clamp(aPos.y, 0.0, 1.0);
+
+    // irregular lean (looks like full shrubs, not thin leaves)
+    float leanAngle = (h11(seed * 2.1) - 0.5) * 0.18; // ±10°
+    float leanYaw = h11(seed * 3.7) * 6.28318;
+    vec2 leanDir = vec2(cos(leanYaw), sin(leanYaw));
+    localPos.xz += leanDir * (h * h) * tan(leanAngle) * finalScale;
+
+    // wind: gentle gusts with per-instance direction
+    float gust = sin(uTime * 0.35 + seed * 6.0) * 0.5 + 0.5;
+    float sway = sin(uTime * swaySpeed * uWindSpeed + swayPhase + seed * 4.0);
+    sway *= (0.22 + 0.55 * gust) * swayStrength * uWindStrength * pow(h, 1.25);
+
+    float windYaw = seed * 9.0;
+    vec2 windDir = normalize(vec2(cos(windYaw), sin(windYaw)) + vec2(0.6, 0.8));
+    localPos.xz += windDir * (0.10 * sway);
+
+    // mild twist toward the tip for volume
+    float twist = (h11(seed * 5.5) - 0.5) * 0.30; // ±17°
+    float twistAngle = twist * h;
+    mat2 tw = mat2(cos(twistAngle), -sin(twistAngle),
+                   sin(twistAngle),  cos(twistAngle));
+    localPos.xz = tw * localPos.xz;
+
+    // instance rotation about Y
+    float cs = cos(rotation), sn = sin(rotation);
+    mat2 rot = mat2(cs, -sn, sn, cs);
+    localPos.xz = rot * localPos.xz;
+
+    vWorldPos = localPos + worldOrigin;
+
+    // rotate normal the same way
+    vec3 n = aNormal;
+    n.xz = tw * n.xz;
+    n.xz = rot * n.xz;
+    vNormal = normalize(n);
+
+    // world-space tangents (card X/Z) for bulge normal in FS
+    vec3 t = vec3(1.0, 0.0, 0.0);
+    vec3 b = vec3(0.0, 0.0, 1.0);
+    t.xz = tw * t.xz;
+    b.xz = tw * b.xz;
+    t.xz = rot * t.xz;
+    b.xz = rot * b.xz;
+
+    vTangent = normalize(t);
+    vBitangent = normalize(b);
+
+    vHeight = h;
+    vSeed = seed;
+    vType = aTypeParams.x;
+    vColor = aColorSway.rgb;
+    vTexCoord = aTexCoord;
+
+    // fuller base alpha (final silhouette handled in FS)
+    vAlpha = 1.0 - smoothstep(0.49, 0.56, abs(aTexCoord.x - 0.5));
+
+    gl_Position = uViewProj * vec4(vWorldPos, 1.0);
+}

+ 3 - 0
game/map/map_loader.cpp

@@ -148,6 +148,9 @@ static void readBiome(const QJsonObject &obj, BiomeSettings &out) {
     out.backgroundScatterRadius =
         float(obj.value("backgroundScatterRadius")
                   .toDouble(out.backgroundScatterRadius));
+  if (obj.contains("plantDensity"))
+    out.plantDensity =
+        float(obj.value("plantDensity").toDouble(out.plantDensity));
 }
 
 static void readVictoryConfig(const QJsonObject &obj, VictoryConfig &out) {

+ 16 - 0
game/map/skirmish_loader.cpp

@@ -15,6 +15,8 @@
 #include "render/ground/biome_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
+#include "render/ground/pine_renderer.h"
+#include "render/ground/plant_renderer.h"
 #include "render/ground/stone_renderer.h"
 #include "render/ground/terrain_renderer.h"
 #include "render/scene_renderer.h"
@@ -242,6 +244,20 @@ SkirmishLoadResult SkirmishLoader::start(const QString &mapPath,
     }
   }
 
+  if (m_plant) {
+    if (terrainService.isInitialized() && terrainService.getHeightMap()) {
+      m_plant->configure(*terrainService.getHeightMap(),
+                         terrainService.biomeSettings());
+    }
+  }
+
+  if (m_pine) {
+    if (terrainService.isInitialized() && terrainService.getHeightMap()) {
+      m_pine->configure(*terrainService.getHeightMap(),
+                        terrainService.biomeSettings());
+    }
+  }
+
   int mapWidth = lr.ok ? lr.gridWidth : 100;
   int mapHeight = lr.ok ? lr.gridHeight : 100;
   Game::Systems::CommandService::initialize(mapWidth, mapHeight);

+ 6 - 0
game/map/skirmish_loader.h

@@ -23,6 +23,8 @@ class TerrainRenderer;
 class BiomeRenderer;
 class FogRenderer;
 class StoneRenderer;
+class PlantRenderer;
+class PineRenderer;
 } // namespace GL
 } // namespace Render
 
@@ -63,6 +65,8 @@ public:
   void setBiomeRenderer(Render::GL::BiomeRenderer *biome) { m_biome = biome; }
   void setFogRenderer(Render::GL::FogRenderer *fog) { m_fog = fog; }
   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 setOnOwnersUpdated(OwnersUpdatedCallback callback) {
     m_onOwnersUpdated = callback;
@@ -86,6 +90,8 @@ private:
   Render::GL::BiomeRenderer *m_biome = nullptr;
   Render::GL::FogRenderer *m_fog = nullptr;
   Render::GL::StoneRenderer *m_stone = nullptr;
+  Render::GL::PlantRenderer *m_plant = nullptr;
+  Render::GL::PineRenderer *m_pine = nullptr;
   OwnersUpdatedCallback m_onOwnersUpdated;
   VisibilityMaskReadyCallback m_onVisibilityMaskReady;
 };

+ 1 - 0
game/map/terrain.h

@@ -37,6 +37,7 @@ struct BiomeSettings {
   float terrainRockDetailStrength = 0.35f;
   float backgroundSwayVariance = 0.2f;
   float backgroundScatterRadius = 0.35f;
+  float plantDensity = 0.5f;
   std::uint32_t seed = 1337u;
 };
 

+ 2 - 0
render/CMakeLists.txt

@@ -17,6 +17,8 @@ add_library(render_gl STATIC
     ground/terrain_renderer.cpp
     ground/biome_renderer.cpp
     ground/stone_renderer.cpp
+    ground/plant_renderer.cpp
+    ground/pine_renderer.cpp
     entity/registry.cpp
     entity/archer_renderer.cpp
     entity/knight_renderer.cpp

+ 67 - 3
render/draw_queue.h

@@ -1,6 +1,8 @@
 #pragma once
 
 #include "ground/grass_gpu.h"
+#include "ground/pine_gpu.h"
+#include "ground/plant_gpu.h"
 #include "ground/stone_gpu.h"
 #include "ground/terrain_gpu.h"
 #include <QMatrix4x4>
@@ -62,6 +64,18 @@ struct StoneBatchCmd {
   StoneBatchParams params;
 };
 
+struct PlantBatchCmd {
+  Buffer *instanceBuffer = nullptr;
+  std::size_t instanceCount = 0;
+  PlantBatchParams params;
+};
+
+struct PineBatchCmd {
+  Buffer *instanceBuffer = nullptr;
+  std::size_t instanceCount = 0;
+  PineBatchParams params;
+};
+
 struct TerrainChunkCmd {
   Mesh *mesh = nullptr;
   QMatrix4x4 model;
@@ -98,7 +112,8 @@ struct SelectionSmokeCmd {
 
 using DrawCmd = std::variant<GridCmd, SelectionRingCmd, SelectionSmokeCmd,
                              CylinderCmd, MeshCmd, FogBatchCmd, GrassBatchCmd,
-                             StoneBatchCmd, TerrainChunkCmd>;
+                             StoneBatchCmd, PlantBatchCmd, PineBatchCmd,
+                             TerrainChunkCmd>;
 
 enum class DrawCmdType : std::uint8_t {
   Grid = 0,
@@ -109,7 +124,9 @@ enum class DrawCmdType : std::uint8_t {
   FogBatch = 5,
   GrassBatch = 6,
   StoneBatch = 7,
-  TerrainChunk = 8
+  PlantBatch = 8,
+  PineBatch = 9,
+  TerrainChunk = 10
 };
 
 constexpr std::size_t MeshCmdIndex =
@@ -128,6 +145,10 @@ constexpr std::size_t GrassBatchCmdIndex =
     static_cast<std::size_t>(DrawCmdType::GrassBatch);
 constexpr std::size_t StoneBatchCmdIndex =
     static_cast<std::size_t>(DrawCmdType::StoneBatch);
+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 TerrainChunkCmdIndex =
     static_cast<std::size_t>(DrawCmdType::TerrainChunk);
 
@@ -147,6 +168,8 @@ public:
   void submit(const FogBatchCmd &c) { m_items.emplace_back(c); }
   void submit(const GrassBatchCmd &c) { m_items.emplace_back(c); }
   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 TerrainChunkCmd &c) { m_items.emplace_back(c); }
 
   bool empty() const { return m_items.empty(); }
@@ -225,7 +248,38 @@ private:
   }
 
   uint64_t computeSortKey(const DrawCmd &cmd) const {
-    static constexpr uint8_t kTypeOrder[] = {0, 4, 7, 6, 5, 8, 2, 3, 1};
+    // Rendering order (lower order value = earlier in frame):
+    // TerrainChunk (0) → GrassBatch (1) → StoneBatch (2) → PlantBatch (3) → 
+    // PineBatch (4) → FogBatch (5) → Mesh (6) → Cylinder (7) → SelectionSmoke (8) → SelectionRing (9) → Grid (10)
+    
+    enum class RenderOrder : uint8_t {
+      TerrainChunk = 0,    // Opaque ground base (renders first)
+      GrassBatch = 1,      // Grass on terrain
+      StoneBatch = 2,      // Stones on terrain
+      PlantBatch = 3,      // Plants on terrain (3rd biome layer)
+      PineBatch = 4,       // Pine trees on terrain (4th biome layer)
+      FogBatch = 5,        // Fog of war (covers all biome layers)
+      Mesh = 6,            // Units and objects
+      Cylinder = 7,        // Unit body parts
+      SelectionSmoke = 8,  // Selection effects
+      SelectionRing = 9,   // Selection UI
+      Grid = 10            // Debug grid (renders last)
+    };
+    
+    // Map variant index to render order using enum values
+    static constexpr uint8_t kTypeOrder[] = {
+      static_cast<uint8_t>(RenderOrder::Grid),           // Grid (variant index 0)
+      static_cast<uint8_t>(RenderOrder::SelectionRing),  // SelectionRing (variant index 1)
+      static_cast<uint8_t>(RenderOrder::SelectionSmoke), // SelectionSmoke (variant index 2)
+      static_cast<uint8_t>(RenderOrder::Cylinder),       // Cylinder (variant index 3)
+      static_cast<uint8_t>(RenderOrder::Mesh),           // Mesh (variant index 4)
+      static_cast<uint8_t>(RenderOrder::FogBatch),       // FogBatch (variant index 5)
+      static_cast<uint8_t>(RenderOrder::GrassBatch),     // GrassBatch (variant index 6)
+      static_cast<uint8_t>(RenderOrder::StoneBatch),     // StoneBatch (variant index 7)
+      static_cast<uint8_t>(RenderOrder::PlantBatch),     // PlantBatch (variant index 8)
+      static_cast<uint8_t>(RenderOrder::PineBatch),      // PineBatch (variant index 9)
+      static_cast<uint8_t>(RenderOrder::TerrainChunk)    // TerrainChunk (variant index 10)
+    };
 
     const std::size_t typeIndex = cmd.index();
     constexpr std::size_t typeCount =
@@ -252,6 +306,16 @@ private:
       uint64_t bufferPtr = reinterpret_cast<uintptr_t>(stone.instanceBuffer) &
                            0x0000FFFFFFFFFFFF;
       key |= bufferPtr;
+    } else if (cmd.index() == PlantBatchCmdIndex) {
+      const auto &plant = std::get<PlantBatchCmdIndex>(cmd);
+      uint64_t bufferPtr = reinterpret_cast<uintptr_t>(plant.instanceBuffer) &
+                           0x0000FFFFFFFFFFFF;
+      key |= bufferPtr;
+    } else if (cmd.index() == PineBatchCmdIndex) {
+      const auto &pine = std::get<PineBatchCmdIndex>(cmd);
+      uint64_t bufferPtr = reinterpret_cast<uintptr_t>(pine.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);

+ 14 - 1
render/draw_queue_soa.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "ground/grass_gpu.h"
+#include "ground/plant_gpu.h"
 #include "ground/stone_gpu.h"
 #include "ground/terrain_gpu.h"
 #include <QMatrix4x4>
@@ -59,6 +60,12 @@ struct StoneBatchCmd {
   StoneBatchParams params;
 };
 
+struct PlantBatchCmd {
+  Buffer *instanceBuffer = nullptr;
+  std::size_t instanceCount = 0;
+  PlantBatchParams params;
+};
+
 struct TerrainChunkCmd {
   Mesh *mesh = nullptr;
   QMatrix4x4 model;
@@ -103,6 +110,7 @@ public:
     m_fogBatchCmds.clear();
     m_grassBatchCmds.clear();
     m_stoneBatchCmds.clear();
+    m_plantBatchCmds.clear();
     m_terrainChunkCmds.clear();
   }
 
@@ -118,6 +126,7 @@ public:
   void submit(const FogBatchCmd &cmd) { m_fogBatchCmds.push_back(cmd); }
   void submit(const GrassBatchCmd &cmd) { m_grassBatchCmds.push_back(cmd); }
   void submit(const StoneBatchCmd &cmd) { m_stoneBatchCmds.push_back(cmd); }
+  void submit(const PlantBatchCmd &cmd) { m_plantBatchCmds.push_back(cmd); }
   void submit(const TerrainChunkCmd &cmd) { m_terrainChunkCmds.push_back(cmd); }
 
   bool empty() const {
@@ -125,7 +134,7 @@ public:
            m_selectionSmokeCmds.empty() && m_cylinderCmds.empty() &&
            m_meshCmds.empty() && m_fogBatchCmds.empty() &&
            m_grassBatchCmds.empty() && m_stoneBatchCmds.empty() &&
-           m_terrainChunkCmds.empty();
+           m_plantBatchCmds.empty() && m_terrainChunkCmds.empty();
   }
 
   void sortForBatching() {
@@ -162,6 +171,9 @@ public:
   const std::vector<StoneBatchCmd> &stoneBatchCmds() const {
     return m_stoneBatchCmds;
   }
+  const std::vector<PlantBatchCmd> &plantBatchCmds() const {
+    return m_plantBatchCmds;
+  }
   const std::vector<TerrainChunkCmd> &terrainChunkCmds() const {
     return m_terrainChunkCmds;
   }
@@ -175,6 +187,7 @@ private:
   std::vector<FogBatchCmd> m_fogBatchCmds;
   std::vector<GrassBatchCmd> m_grassBatchCmds;
   std::vector<StoneBatchCmd> m_stoneBatchCmds;
+  std::vector<PlantBatchCmd> m_plantBatchCmds;
   std::vector<TerrainChunkCmd> m_terrainChunkCmds;
 };
 

+ 445 - 0
render/gl/backend.cpp

@@ -10,8 +10,10 @@
 #include "texture.h"
 #include <QDebug>
 #include <algorithm>
+#include <cmath>
 #include <cstddef>
 #include <memory>
+#include <vector>
 
 namespace Render::GL {
 
@@ -25,6 +27,7 @@ Backend::~Backend() {
   shutdownFogPipeline();
   shutdownGrassPipeline();
   shutdownStonePipeline();
+  shutdownPlantPipeline();
 }
 
 void Backend::initialize() {
@@ -50,6 +53,8 @@ void Backend::initialize() {
   m_fogShader = m_shaderCache->get(QStringLiteral("fog_instanced"));
   m_grassShader = m_shaderCache->get(QStringLiteral("grass_instanced"));
   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_groundShader = m_shaderCache->get(QStringLiteral("ground_plane"));
   m_terrainShader = m_shaderCache->get(QStringLiteral("terrain_chunk"));
   m_archerShader = m_shaderCache->get(QStringLiteral("archer"));
@@ -66,6 +71,10 @@ void Backend::initialize() {
     qWarning() << "Backend: grass shader missing";
   if (!m_stoneShader)
     qWarning() << "Backend: stone shader missing";
+  if (!m_plantShader)
+    qWarning() << "Backend: plant shader missing - check plant_instanced.vert/frag";
+  if (!m_pineShader)
+    qWarning() << "Backend: pine shader missing - check pine_instanced.vert/frag";
   if (!m_groundShader)
     qWarning() << "Backend: ground_plane shader missing";
   if (!m_terrainShader)
@@ -83,12 +92,16 @@ void Backend::initialize() {
   cacheFogUniforms();
   cacheGrassUniforms();
   cacheStoneUniforms();
+  cachePlantUniforms();
+  cachePineUniforms();
   cacheGroundUniforms();
   cacheTerrainUniforms();
   initializeCylinderPipeline();
   initializeFogPipeline();
   initializeGrassPipeline();
   initializeStonePipeline();
+  initializePlantPipeline();
+  initializePinePipeline();
 }
 
 void Backend::beginFrame() {
@@ -314,6 +327,143 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
 
       break;
     }
+    case PlantBatchCmdIndex: {
+      const auto &plant = std::get<PlantBatchCmdIndex>(cmd);
+      
+      if (!plant.instanceBuffer || plant.instanceCount == 0 || !m_plantShader ||
+          !m_plantVao || m_plantIndexCount == 0) {
+        break;
+      }
+
+      // IMPORTANT: Plants need depth testing ENABLED to render behind units
+      DepthMaskScope depthMask(false); // Don't write to depth buffer
+      // But still TEST against it
+      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_plantShader) {
+        m_plantShader->use();
+        m_lastBoundShader = m_plantShader;
+        m_lastBoundTexture = nullptr;
+      }
+
+      if (m_plantUniforms.viewProj != Shader::InvalidUniform) {
+        m_plantShader->setUniform(m_plantUniforms.viewProj, viewProj);
+      }
+      if (m_plantUniforms.time != Shader::InvalidUniform) {
+        m_plantShader->setUniform(m_plantUniforms.time, plant.params.time);
+      }
+      if (m_plantUniforms.windStrength != Shader::InvalidUniform) {
+        m_plantShader->setUniform(m_plantUniforms.windStrength,
+                                  plant.params.windStrength);
+      }
+      if (m_plantUniforms.windSpeed != Shader::InvalidUniform) {
+        m_plantShader->setUniform(m_plantUniforms.windSpeed,
+                                  plant.params.windSpeed);
+      }
+      if (m_plantUniforms.lightDirection != Shader::InvalidUniform) {
+        QVector3D lightDir = plant.params.lightDirection;
+        if (!lightDir.isNull())
+          lightDir.normalize();
+        m_plantShader->setUniform(m_plantUniforms.lightDirection, lightDir);
+      }
+
+      glBindVertexArray(m_plantVao);
+      plant.instanceBuffer->bind();
+      const GLsizei stride = static_cast<GLsizei>(sizeof(PlantInstanceGpu));
+      glVertexAttribPointer(
+          3, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(PlantInstanceGpu, posScale)));
+      glVertexAttribPointer(
+          4, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(PlantInstanceGpu, colorSway)));
+      glVertexAttribPointer(
+          5, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(PlantInstanceGpu, typeParams)));
+      plant.instanceBuffer->unbind();
+
+      glDrawElementsInstanced(GL_TRIANGLES, m_plantIndexCount,
+                              GL_UNSIGNED_SHORT, nullptr,
+                              static_cast<GLsizei>(plant.instanceCount));
+      glBindVertexArray(0);
+
+      if (prevCull)
+        glEnable(GL_CULL_FACE);
+
+      break;
+    }
+    case PineBatchCmdIndex: {
+      const auto &pine = std::get<PineBatchCmdIndex>(cmd);
+      
+      if (!pine.instanceBuffer || pine.instanceCount == 0 || !m_pineShader ||
+          !m_pineVao || m_pineIndexCount == 0) {
+        break;
+      }
+
+      // Pine trees: similar rendering to plants but taller geometry
+      DepthMaskScope depthMask(false); // Don't write to depth buffer
+      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_pineShader) {
+        m_pineShader->use();
+        m_lastBoundShader = m_pineShader;
+        m_lastBoundTexture = nullptr;
+      }
+
+      if (m_pineUniforms.viewProj != Shader::InvalidUniform) {
+        m_pineShader->setUniform(m_pineUniforms.viewProj, viewProj);
+      }
+      if (m_pineUniforms.time != Shader::InvalidUniform) {
+        m_pineShader->setUniform(m_pineUniforms.time, pine.params.time);
+      }
+      if (m_pineUniforms.windStrength != Shader::InvalidUniform) {
+        m_pineShader->setUniform(m_pineUniforms.windStrength,
+                                  pine.params.windStrength);
+      }
+      if (m_pineUniforms.windSpeed != Shader::InvalidUniform) {
+        m_pineShader->setUniform(m_pineUniforms.windSpeed,
+                                  pine.params.windSpeed);
+      }
+      if (m_pineUniforms.lightDirection != Shader::InvalidUniform) {
+        QVector3D lightDir = pine.params.lightDirection;
+        if (!lightDir.isNull())
+          lightDir.normalize();
+        m_pineShader->setUniform(m_pineUniforms.lightDirection, lightDir);
+      }
+
+      glBindVertexArray(m_pineVao);
+      pine.instanceBuffer->bind();
+      const GLsizei stride = static_cast<GLsizei>(sizeof(PineInstanceGpu));
+      glVertexAttribPointer(
+          3, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(PineInstanceGpu, posScale)));
+      glVertexAttribPointer(
+          4, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(PineInstanceGpu, colorSway)));
+      glVertexAttribPointer(
+          5, 4, GL_FLOAT, GL_FALSE, stride,
+          reinterpret_cast<void *>(offsetof(PineInstanceGpu, rotation)));
+      pine.instanceBuffer->unbind();
+
+      glDrawElementsInstanced(GL_TRIANGLES, m_pineIndexCount,
+                              GL_UNSIGNED_SHORT, nullptr,
+                              static_cast<GLsizei>(pine.instanceCount));
+      glBindVertexArray(0);
+
+      if (prevCull)
+        glEnable(GL_CULL_FACE);
+
+      break;
+    }
     case TerrainChunkCmdIndex: {
       const auto &terrain = std::get<TerrainChunkCmdIndex>(cmd);
 
@@ -1204,4 +1354,299 @@ void Backend::shutdownStonePipeline() {
   m_stoneIndexCount = 0;
 }
 
+void Backend::cachePlantUniforms() {
+  if (m_plantShader) {
+    m_plantUniforms.viewProj = m_plantShader->uniformHandle("uViewProj");
+    m_plantUniforms.time = m_plantShader->uniformHandle("uTime");
+    m_plantUniforms.windStrength =
+        m_plantShader->uniformHandle("uWindStrength");
+    m_plantUniforms.windSpeed = m_plantShader->uniformHandle("uWindSpeed");
+    m_plantUniforms.lightDirection =
+        m_plantShader->uniformHandle("uLightDirection");
+  }
+}
+
+void Backend::cachePineUniforms() {
+  if (m_pineShader) {
+    m_pineUniforms.viewProj = m_pineShader->uniformHandle("uViewProj");
+    m_pineUniforms.time = m_pineShader->uniformHandle("uTime");
+    m_pineUniforms.windStrength =
+        m_pineShader->uniformHandle("uWindStrength");
+    m_pineUniforms.windSpeed = m_pineShader->uniformHandle("uWindSpeed");
+    m_pineUniforms.lightDirection =
+        m_pineShader->uniformHandle("uLightDirection");
+  }
+}
+
+void Backend::initializePlantPipeline() {
+  initializeOpenGLFunctions();
+  shutdownPlantPipeline();
+
+  struct PlantVertex {
+    QVector3D position;
+    QVector2D texCoord;
+    QVector3D normal;
+  };
+
+  // Cross-quad billboard for realistic 3D plant appearance (not grass!)
+  // Creates an X-shape when viewed from above, visible from all angles
+  const PlantVertex plantVertices[] = {
+      // First quad (front-back aligned)
+      {{-0.5f, 0.0f, 0.0f}, {0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}},  // bottom left
+      {{0.5f, 0.0f, 0.0f}, {1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}},   // bottom right
+      {{0.5f, 1.0f, 0.0f}, {1.0f, 1.0f}, {0.0f, 0.0f, 1.0f}},   // top right
+      {{-0.5f, 1.0f, 0.0f}, {0.0f, 1.0f}, {0.0f, 0.0f, 1.0f}},  // top left
+      
+      // First quad back face
+      {{0.5f, 0.0f, 0.0f}, {0.0f, 0.0f}, {0.0f, 0.0f, -1.0f}},
+      {{-0.5f, 0.0f, 0.0f}, {1.0f, 0.0f}, {0.0f, 0.0f, -1.0f}},
+      {{-0.5f, 1.0f, 0.0f}, {1.0f, 1.0f}, {0.0f, 0.0f, -1.0f}},
+      {{0.5f, 1.0f, 0.0f}, {0.0f, 1.0f}, {0.0f, 0.0f, -1.0f}},
+      
+      // Second quad (perpendicular, left-right aligned for X shape)
+      {{0.0f, 0.0f, -0.5f}, {0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}},  // bottom left
+      {{0.0f, 0.0f, 0.5f}, {1.0f, 0.0f}, {1.0f, 0.0f, 0.0f}},   // bottom right
+      {{0.0f, 1.0f, 0.5f}, {1.0f, 1.0f}, {1.0f, 0.0f, 0.0f}},   // top right
+      {{0.0f, 1.0f, -0.5f}, {0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}},  // top left
+      
+      // Second quad back face
+      {{0.0f, 0.0f, 0.5f}, {0.0f, 0.0f}, {-1.0f, 0.0f, 0.0f}},
+      {{0.0f, 0.0f, -0.5f}, {1.0f, 0.0f}, {-1.0f, 0.0f, 0.0f}},
+      {{0.0f, 1.0f, -0.5f}, {1.0f, 1.0f}, {-1.0f, 0.0f, 0.0f}},
+      {{0.0f, 1.0f, 0.5f}, {0.0f, 1.0f}, {-1.0f, 0.0f, 0.0f}},
+  };
+
+  const unsigned short plantIndices[] = {
+      // First quad front
+      0, 1, 2, 0, 2, 3,
+      // First quad back
+      4, 5, 6, 4, 6, 7,
+      // Second quad front
+      8, 9, 10, 8, 10, 11,
+      // Second quad back
+      12, 13, 14, 12, 14, 15,
+  };
+
+  glGenVertexArrays(1, &m_plantVao);
+  glBindVertexArray(m_plantVao);
+
+  glGenBuffers(1, &m_plantVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_plantVertexBuffer);
+  glBufferData(GL_ARRAY_BUFFER, sizeof(plantVertices), plantVertices,
+               GL_STATIC_DRAW);
+  m_plantVertexCount = 16;  // 16 vertices for cross-quad
+
+  // Position attribute
+  glEnableVertexAttribArray(0);
+  glVertexAttribPointer(
+      0, 3, GL_FLOAT, GL_FALSE, sizeof(PlantVertex),
+      reinterpret_cast<void *>(offsetof(PlantVertex, position)));
+  // TexCoord attribute
+  glEnableVertexAttribArray(1);
+  glVertexAttribPointer(
+      1, 2, GL_FLOAT, GL_FALSE, sizeof(PlantVertex),
+      reinterpret_cast<void *>(offsetof(PlantVertex, texCoord)));
+  // Normal attribute
+  glEnableVertexAttribArray(2);
+  glVertexAttribPointer(
+      2, 3, GL_FLOAT, GL_FALSE, sizeof(PlantVertex),
+      reinterpret_cast<void *>(offsetof(PlantVertex, normal)));
+
+  // Index buffer
+  glGenBuffers(1, &m_plantIndexBuffer);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_plantIndexBuffer);
+  glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(plantIndices), plantIndices,
+               GL_STATIC_DRAW);
+  m_plantIndexCount = 24;  // 24 indices (4 quads × 6 indices)
+
+  // Instance attributes (will be set per-draw)
+  glEnableVertexAttribArray(3);
+  glVertexAttribDivisor(3, 1);
+  glEnableVertexAttribArray(4);
+  glVertexAttribDivisor(4, 1);
+  glEnableVertexAttribArray(5);
+  glVertexAttribDivisor(5, 1);
+
+  glBindVertexArray(0);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
+}
+
+void Backend::shutdownPlantPipeline() {
+  initializeOpenGLFunctions();
+  if (m_plantIndexBuffer) {
+    glDeleteBuffers(1, &m_plantIndexBuffer);
+    m_plantIndexBuffer = 0;
+  }
+  if (m_plantVertexBuffer) {
+    glDeleteBuffers(1, &m_plantVertexBuffer);
+    m_plantVertexBuffer = 0;
+  }
+  if (m_plantVao) {
+    glDeleteVertexArrays(1, &m_plantVao);
+    m_plantVao = 0;
+  }
+  m_plantVertexCount = 0;
+  m_plantIndexCount = 0;
+}
+
+void Backend::initializePinePipeline() {
+  initializeOpenGLFunctions();
+  shutdownPinePipeline();
+
+  struct PineVertex {
+    QVector3D position;
+    QVector2D texCoord;
+    QVector3D normal;
+  };
+
+  constexpr int kSegments = 6;
+  constexpr float kTwoPi = 6.28318530718f;
+
+  std::vector<PineVertex> vertices;
+  vertices.reserve(kSegments * 5 + 1);
+
+  std::vector<unsigned short> indices;
+  indices.reserve(kSegments * 6 * 4 + kSegments * 3);
+
+  auto addRing = [&](float radius, float y, float normalUp,
+                     float vCoord) -> int {
+    const int start = static_cast<int>(vertices.size());
+    for (int i = 0; i < kSegments; ++i) {
+      const float t = static_cast<float>(i) / static_cast<float>(kSegments);
+      const float angle = t * kTwoPi;
+      const float nx = std::cos(angle);
+      const float nz = std::sin(angle);
+      QVector3D normal(nx, normalUp, nz);
+      normal.normalize();
+      QVector3D position(radius * nx, y, radius * nz);
+      QVector2D texCoord(t, vCoord);
+      vertices.push_back({position, texCoord, normal});
+    }
+    return start;
+  };
+
+  auto connectRings = [&](int lowerStart, int upperStart) {
+    for (int i = 0; i < kSegments; ++i) {
+      const int next = (i + 1) % kSegments;
+      const unsigned short lower0 =
+          static_cast<unsigned short>(lowerStart + i);
+      const unsigned short lower1 =
+          static_cast<unsigned short>(lowerStart + next);
+      const unsigned short upper0 =
+          static_cast<unsigned short>(upperStart + i);
+      const unsigned short upper1 =
+          static_cast<unsigned short>(upperStart + next);
+
+      indices.push_back(lower0);
+      indices.push_back(lower1);
+      indices.push_back(upper1);
+      indices.push_back(lower0);
+      indices.push_back(upper1);
+      indices.push_back(upper0);
+    }
+  };
+
+  const int trunkBottom = addRing(0.12f, 0.0f, 0.0f, 0.0f);
+  const int trunkMid = addRing(0.11f, 0.35f, 0.0f, 0.12f);
+  const int trunkTop = addRing(0.10f, 0.58f, 0.05f, 0.30f);
+  const int branchBase = addRing(0.60f, 0.64f, 0.35f, 0.46f);
+  const int branchMid = addRing(0.42f, 0.82f, 0.6f, 0.68f);
+  const int branchUpper = addRing(0.24f, 1.00f, 0.7f, 0.88f);
+  const int branchTip = addRing(0.12f, 1.10f, 0.85f, 0.96f);
+
+  connectRings(trunkBottom, trunkMid);
+  connectRings(trunkMid, trunkTop);
+  connectRings(trunkTop, branchBase);
+  connectRings(branchBase, branchMid);
+  connectRings(branchMid, branchUpper);
+  connectRings(branchUpper, branchTip);
+
+  const unsigned short trunkCapIndex =
+      static_cast<unsigned short>(vertices.size());
+  vertices.push_back({QVector3D(0.0f, 0.0f, 0.0f), QVector2D(0.5f, 0.0f),
+                      QVector3D(0.0f, -1.0f, 0.0f)});
+  for (int i = 0; i < kSegments; ++i) {
+    const int next = (i + 1) % kSegments;
+    indices.push_back(static_cast<unsigned short>(trunkBottom + next));
+    indices.push_back(static_cast<unsigned short>(trunkBottom + i));
+    indices.push_back(trunkCapIndex);
+  }
+
+  const unsigned short apexIndex =
+      static_cast<unsigned short>(vertices.size());
+  vertices.push_back({QVector3D(0.0f, 1.18f, 0.0f), QVector2D(0.5f, 1.0f),
+                      QVector3D(0.0f, 1.0f, 0.0f)});
+  for (int i = 0; i < kSegments; ++i) {
+    const int next = (i + 1) % kSegments;
+    indices.push_back(static_cast<unsigned short>(branchTip + i));
+    indices.push_back(static_cast<unsigned short>(branchTip + next));
+    indices.push_back(apexIndex);
+  }
+
+  glGenVertexArrays(1, &m_pineVao);
+  glBindVertexArray(m_pineVao);
+
+  glGenBuffers(1, &m_pineVertexBuffer);
+  glBindBuffer(GL_ARRAY_BUFFER, m_pineVertexBuffer);
+  glBufferData(GL_ARRAY_BUFFER,
+               static_cast<GLsizeiptr>(vertices.size() * sizeof(PineVertex)),
+               vertices.data(), GL_STATIC_DRAW);
+  m_pineVertexCount = static_cast<GLsizei>(vertices.size());
+
+  // Position attribute
+  glEnableVertexAttribArray(0);
+  glVertexAttribPointer(
+      0, 3, GL_FLOAT, GL_FALSE, sizeof(PineVertex),
+      reinterpret_cast<void *>(offsetof(PineVertex, position)));
+  // TexCoord attribute
+  glEnableVertexAttribArray(1);
+  glVertexAttribPointer(
+      1, 2, GL_FLOAT, GL_FALSE, sizeof(PineVertex),
+      reinterpret_cast<void *>(offsetof(PineVertex, texCoord)));
+  // Normal attribute
+  glEnableVertexAttribArray(2);
+  glVertexAttribPointer(
+      2, 3, GL_FLOAT, GL_FALSE, sizeof(PineVertex),
+      reinterpret_cast<void *>(offsetof(PineVertex, normal)));
+
+  // Index buffer
+  glGenBuffers(1, &m_pineIndexBuffer);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_pineIndexBuffer);
+  glBufferData(GL_ELEMENT_ARRAY_BUFFER,
+               static_cast<GLsizeiptr>(indices.size() * sizeof(unsigned short)),
+               indices.data(), GL_STATIC_DRAW);
+  m_pineIndexCount = static_cast<GLsizei>(indices.size());  // triangles * 3
+
+  // Instance attributes (will be set per-draw)
+  glEnableVertexAttribArray(3);
+  glVertexAttribDivisor(3, 1);
+  glEnableVertexAttribArray(4);
+  glVertexAttribDivisor(4, 1);
+  glEnableVertexAttribArray(5);
+  glVertexAttribDivisor(5, 1);
+
+  glBindVertexArray(0);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
+}
+
+void Backend::shutdownPinePipeline() {
+  initializeOpenGLFunctions();
+  if (m_pineIndexBuffer) {
+    glDeleteBuffers(1, &m_pineIndexBuffer);
+    m_pineIndexBuffer = 0;
+  }
+  if (m_pineVertexBuffer) {
+    glDeleteBuffers(1, &m_pineVertexBuffer);
+    m_pineVertexBuffer = 0;
+  }
+  if (m_pineVao) {
+    glDeleteVertexArrays(1, &m_pineVao);
+    m_pineVao = 0;
+  }
+  m_pineVertexCount = 0;
+  m_pineIndexCount = 0;
+}
+
 } // namespace Render::GL

+ 37 - 0
render/gl/backend.h

@@ -2,6 +2,7 @@
 
 #include "../draw_queue.h"
 #include "../ground/grass_gpu.h"
+#include "../ground/plant_gpu.h"
 #include "../ground/stone_gpu.h"
 #include "../ground/terrain_gpu.h"
 #include "camera.h"
@@ -80,6 +81,8 @@ private:
   Shader *m_fogShader = nullptr;
   Shader *m_grassShader = nullptr;
   Shader *m_stoneShader = nullptr;
+  Shader *m_plantShader = nullptr;
+  Shader *m_pineShader = nullptr;
   Shader *m_groundShader = nullptr;
   Shader *m_terrainShader = nullptr;
   Shader *m_archerShader = nullptr;
@@ -128,6 +131,22 @@ private:
     Shader::UniformHandle lightDirection{Shader::InvalidUniform};
   } m_stoneUniforms;
 
+  struct PlantUniforms {
+    Shader::UniformHandle viewProj{Shader::InvalidUniform};
+    Shader::UniformHandle time{Shader::InvalidUniform};
+    Shader::UniformHandle windStrength{Shader::InvalidUniform};
+    Shader::UniformHandle windSpeed{Shader::InvalidUniform};
+    Shader::UniformHandle lightDirection{Shader::InvalidUniform};
+  } m_plantUniforms;
+
+  struct PineUniforms {
+    Shader::UniformHandle viewProj{Shader::InvalidUniform};
+    Shader::UniformHandle time{Shader::InvalidUniform};
+    Shader::UniformHandle windStrength{Shader::InvalidUniform};
+    Shader::UniformHandle windSpeed{Shader::InvalidUniform};
+    Shader::UniformHandle lightDirection{Shader::InvalidUniform};
+  } m_pineUniforms;
+
   struct GroundUniforms {
     Shader::UniformHandle mvp{Shader::InvalidUniform};
     Shader::UniformHandle model{Shader::InvalidUniform};
@@ -216,6 +235,18 @@ private:
   GLsizei m_stoneIndexCount = 0;
   GLsizei m_stoneVertexCount = 0;
 
+  GLuint m_plantVao = 0;
+  GLuint m_plantVertexBuffer = 0;
+  GLuint m_plantIndexBuffer = 0;
+  GLsizei m_plantIndexCount = 0;
+  GLsizei m_plantVertexCount = 0;
+
+  GLuint m_pineVao = 0;
+  GLuint m_pineVertexBuffer = 0;
+  GLuint m_pineIndexBuffer = 0;
+  GLsizei m_pineIndexCount = 0;
+  GLsizei m_pineVertexCount = 0;
+
   void cacheBasicUniforms();
   void cacheArcherUniforms();
   void cacheKnightUniforms();
@@ -236,6 +267,12 @@ private:
   void cacheStoneUniforms();
   void initializeStonePipeline();
   void shutdownStonePipeline();
+  void cachePlantUniforms();
+  void initializePlantPipeline();
+  void shutdownPlantPipeline();
+  void cachePineUniforms();
+  void initializePinePipeline();
+  void shutdownPinePipeline();
   void cacheGroundUniforms();
   void cacheTerrainUniforms();
 

+ 12 - 0
render/gl/shader_cache.h

@@ -67,6 +67,18 @@ public:
         kShaderBase + QStringLiteral("stone_instanced.frag");
     load(QStringLiteral("stone_instanced"), stoneVert, stoneFrag);
 
+    const QString plantVert =
+        kShaderBase + QStringLiteral("plant_instanced.vert");
+    const QString plantFrag =
+        kShaderBase + QStringLiteral("plant_instanced.frag");
+    load(QStringLiteral("plant_instanced"), plantVert, plantFrag);
+
+    const QString pineVert =
+        kShaderBase + QStringLiteral("pine_instanced.vert");
+    const QString pineFrag =
+        kShaderBase + QStringLiteral("pine_instanced.frag");
+    load(QStringLiteral("pine_instanced"), pineVert, pineFrag);
+
     const QString groundVert =
         kShaderBase + QStringLiteral("ground_plane.vert");
     const QString groundFrag =

+ 24 - 0
render/ground/pine_gpu.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <QVector3D>
+#include <QVector4D>
+#include <cstdint>
+
+namespace Render::GL {
+
+// GPU instance data for pine trees
+struct PineInstanceGpu {
+  QVector4D posScale;    // xyz = world position, w = scale multiplier
+  QVector4D colorSway;   // rgb = tint color, a = sway phase offset
+  QVector4D rotation;    // x = Y-axis rotation, yzw = reserved for future use
+};
+
+// Parameters for pine tree batch rendering
+struct PineBatchParams {
+  QVector3D lightDirection{0.35f, 0.8f, 0.45f};
+  float time = 0.0f;
+  float windStrength = 0.3f;  // Gentler than plants
+  float windSpeed = 0.5f;     // Slower than plants
+};
+
+} // namespace Render::GL

+ 272 - 0
render/ground/pine_renderer.cpp

@@ -0,0 +1,272 @@
+#include "pine_renderer.h"
+#include "../../game/systems/building_collision_registry.h"
+#include "../gl/buffer.h"
+#include "../scene_renderer.h"
+#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 {
+
+PineRenderer::PineRenderer() = default;
+PineRenderer::~PineRenderer() = default;
+
+void PineRenderer::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_pineInstances.clear();
+  m_pineInstanceBuffer.reset();
+  m_pineInstanceCount = 0;
+  m_pineInstancesDirty = false;
+
+  m_pineParams.lightDirection = QVector3D(0.35f, 0.8f, 0.45f);
+  m_pineParams.time = 0.0f;
+  m_pineParams.windStrength = 0.3f;  // Gentler than plants
+  m_pineParams.windSpeed = 0.5f;     // Slower than plants
+
+  generatePineInstances();
+}
+
+void PineRenderer::submit(Renderer &renderer, ResourceManager *resources) {
+  (void)resources;
+  
+  m_pineInstanceCount = static_cast<uint32_t>(m_pineInstances.size());
+
+  if (m_pineInstanceCount > 0) {
+    if (!m_pineInstanceBuffer) {
+      m_pineInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
+    }
+    if (m_pineInstancesDirty && m_pineInstanceBuffer) {
+      m_pineInstanceBuffer->setData(m_pineInstances, Buffer::Usage::Static);
+      m_pineInstancesDirty = false;
+    }
+  } else {
+    m_pineInstanceBuffer.reset();
+    return;
+  }
+
+  if (m_pineInstanceBuffer && m_pineInstanceCount > 0) {
+    PineBatchParams params = m_pineParams;
+    params.time = renderer.getAnimationTime();
+    renderer.pineBatch(m_pineInstanceBuffer.get(), m_pineInstanceCount,
+                        params);
+  }
+}
+
+void PineRenderer::clear() {
+  m_pineInstances.clear();
+  m_pineInstanceBuffer.reset();
+  m_pineInstanceCount = 0;
+  m_pineInstancesDirty = false;
+}
+
+void PineRenderer::generatePineInstances() {
+  m_pineInstances.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);
+
+  // Pine density from biome settings (default 0.2, much lower than plants)
+  float pineDensity = 0.2f;
+  if (m_biomeSettings.plantDensity > 0.0f) {
+    // Reuse plantDensity but at reduced rate for trees
+    pineDensity = m_biomeSettings.plantDensity * 0.3f;
+  }
+
+  // Pre-compute normals for slope calculation
+  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 addPine = [&](float gx, float gz, uint32_t &state) -> bool {
+    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;
+
+    // Pines prefer hills and moderate terrain
+    // Avoid extremely steep slopes
+    QVector3D normal = normals[normalIdx];
+    float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);
+
+    // Pines can handle more slope than plants but not extreme
+    if (slope > 0.75f)
+      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;
+    }
+
+    // Pines are TALL trees (3-6 units instead of 0.3-0.8 for plants)
+    float scale = remap(rand01(state), 3.0f, 6.0f) * tileSafe;
+
+    // Pine color variation (darker green/brown tones)
+    float colorVar = remap(rand01(state), 0.0f, 1.0f);
+    QVector3D baseColor(0.15f, 0.35f, 0.20f);  // Dark pine green
+    QVector3D varColor(0.20f, 0.40f, 0.25f);   // Slightly lighter
+    QVector3D tintColor = baseColor * (1.0f - colorVar) + varColor * colorVar;
+
+    // Add brownish tint for trunk variation
+    float brownMix = remap(rand01(state), 0.10f, 0.25f);
+    QVector3D brownTint(0.35f, 0.30f, 0.20f);
+    tintColor = tintColor * (1.0f - brownMix) + brownTint * brownMix;
+
+  // Sway parameters (gentler and slower than plants)
+  float swayPhase = rand01(state) * 6.2831853f;
+
+  // Y-axis rotation for variety
+  float rotation = rand01(state) * 6.2831853f;
+
+  // Additional per-instance variation for silhouette and shading
+  float silhouetteSeed = rand01(state);
+  float needleSeed = rand01(state);
+  float barkSeed = rand01(state);
+
+    PineInstanceGpu instance;
+    // Pine trees on ground level (not elevated like plants were)
+    instance.posScale = QVector4D(worldX, worldY, worldZ, scale);
+    instance.colorSway =
+        QVector4D(tintColor.x(), tintColor.y(), tintColor.z(), swayPhase);
+  instance.rotation = QVector4D(rotation, silhouetteSeed, needleSeed, barkSeed);
+    m_pineInstances.push_back(instance);
+    return true;
+  };
+
+  // Generate pines in a sparse grid pattern (every 6 tiles instead of 3)
+  // Lower density for trees compared to plants
+  for (int z = 0; z < m_height; z += 6) {
+    for (int x = 0; x < m_width; x += 6) {
+      int idx = z * m_width + x;
+
+      // Check terrain slope - avoid steep areas
+      QVector3D normal = normals[idx];
+      float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);
+      if (slope > 0.75f)
+        continue;
+
+      uint32_t state = hashCoords(
+          x, z, m_noiseSeed ^ 0xAB12CD34u ^ static_cast<uint32_t>(idx));
+
+      float worldX = (x - halfWidth) * m_tileSize;
+      float worldZ = (z - halfHeight) * m_tileSize;
+
+      // Use forest/tree clustering noise
+      float clusterNoise =
+          valueNoise(worldX * 0.03f, worldZ * 0.03f, m_noiseSeed ^ 0x7F8E9D0Au);
+
+      // Pines cluster in forest areas
+      if (clusterNoise < 0.35f)
+        continue;
+
+      // Terrain-based density multiplier
+      float densityMult = 1.0f;
+      if (m_terrainTypes[idx] == Game::Map::TerrainType::Hill) {
+        densityMult = 1.2f; // More trees on hills
+      } else if (m_terrainTypes[idx] == Game::Map::TerrainType::Mountain) {
+        densityMult = 0.4f; // Fewer on mountains
+      }
+
+      // Calculate pine count (much lower than plants)
+      float effectiveDensity = pineDensity * densityMult * 0.8f;
+      int pineCount = static_cast<int>(std::floor(effectiveDensity));
+      float frac = effectiveDensity - float(pineCount);
+      if (rand01(state) < frac)
+        pineCount += 1;
+
+      // Place pines in small clusters
+      for (int i = 0; i < pineCount; ++i) {
+        float gx = float(x) + rand01(state) * 6.0f;
+        float gz = float(z) + rand01(state) * 6.0f;
+        addPine(gx, gz, state);
+      }
+    }
+  }
+
+  m_pineInstanceCount = m_pineInstances.size();
+  m_pineInstancesDirty = m_pineInstanceCount > 0;
+}
+
+} // namespace Render::GL

+ 48 - 0
render/ground/pine_renderer.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include "../i_render_pass.h"
+#include "pine_gpu.h"
+#include <QVector3D>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace Render {
+namespace GL {
+class Buffer;
+class Renderer;
+
+class PineRenderer : public IRenderPass {
+public:
+  PineRenderer();
+  ~PineRenderer();
+
+  void configure(const Game::Map::TerrainHeightMap &heightMap,
+                 const Game::Map::BiomeSettings &biomeSettings);
+
+  void submit(Renderer &renderer, ResourceManager *resources) override;
+
+  void clear();
+
+private:
+  void generatePineInstances();
+
+  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<PineInstanceGpu> m_pineInstances;
+  std::unique_ptr<Buffer> m_pineInstanceBuffer;
+  std::size_t m_pineInstanceCount = 0;
+  PineBatchParams m_pineParams;
+  bool m_pineInstancesDirty = false;
+};
+
+} // namespace GL
+} // namespace Render

+ 24 - 0
render/ground/plant_gpu.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <QVector3D>
+#include <QVector4D>
+
+namespace Render::GL {
+
+struct PlantInstanceGpu {
+  QVector4D posScale;   // xyz=world pos, w=scale
+  QVector4D colorSway;  // rgb=tint color, a=sway phase
+  QVector4D typeParams; // x=plant type, y=rotation, z=sway strength, w=sway
+                        // speed
+};
+
+struct PlantBatchParams {
+  QVector3D lightDirection{0.35f, 0.8f, 0.45f};
+  float time = 0.0f;
+  float windStrength = 0.25f;
+  float windSpeed = 1.4f;
+  float pad0 = 0.0f;
+  float pad1 = 0.0f;
+};
+
+} // namespace Render::GL

+ 317 - 0
render/ground/plant_renderer.cpp

@@ -0,0 +1,317 @@
+#include "plant_renderer.h"
+#include "../../game/systems/building_collision_registry.h"
+#include "../gl/buffer.h"
+#include "../scene_renderer.h"
+#include <QVector2D>
+#include <algorithm>
+#include <array>
+#include <cmath>
+#include <optional>
+
+namespace {
+
+using std::uint32_t;
+
+inline uint32_t hashCoords(int x, int z, uint32_t salt = 0u) {
+  uint32_t ux = static_cast<uint32_t>(x * 73856093);
+  uint32_t uz = static_cast<uint32_t>(z * 19349663);
+  return ux ^ uz ^ (salt * 83492791u);
+}
+
+inline float rand01(uint32_t &state) {
+  state = state * 1664525u + 1013904223u;
+  return static_cast<float>((state >> 8) & 0xFFFFFF) /
+         static_cast<float>(0xFFFFFF);
+}
+
+inline float remap(float value, float minOut, float maxOut) {
+  return minOut + (maxOut - minOut) * value;
+}
+
+inline float hashTo01(uint32_t h) {
+  h ^= h >> 17;
+  h *= 0xed5ad4bbu;
+  h ^= h >> 11;
+  h *= 0xac4c1b51u;
+  h ^= h >> 15;
+  h *= 0x31848babu;
+  h ^= h >> 14;
+  return (h & 0x00FFFFFFu) / float(0x01000000);
+}
+
+inline float valueNoise(float x, float z, uint32_t salt = 0u) {
+  int x0 = int(std::floor(x)), z0 = int(std::floor(z));
+  int x1 = x0 + 1, z1 = z0 + 1;
+  float tx = x - float(x0), tz = z - float(z0);
+  float n00 = hashTo01(hashCoords(x0, z0, salt));
+  float n10 = hashTo01(hashCoords(x1, z0, salt));
+  float n01 = hashTo01(hashCoords(x0, z1, salt));
+  float n11 = hashTo01(hashCoords(x1, z1, salt));
+  float nx0 = n00 * (1 - tx) + n10 * tx;
+  float nx1 = n01 * (1 - tx) + n11 * tx;
+  return nx0 * (1 - tz) + nx1 * tz;
+}
+
+} // namespace
+
+namespace Render::GL {
+
+PlantRenderer::PlantRenderer() = default;
+PlantRenderer::~PlantRenderer() = default;
+
+void PlantRenderer::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_plantInstances.clear();
+  m_plantInstanceBuffer.reset();
+  m_plantInstanceCount = 0;
+  m_plantInstancesDirty = false;
+
+  m_plantParams.lightDirection = QVector3D(0.35f, 0.8f, 0.45f);
+  m_plantParams.time = 0.0f;
+  m_plantParams.windStrength = m_biomeSettings.swayStrength;
+  m_plantParams.windSpeed = m_biomeSettings.swaySpeed;
+
+  generatePlantInstances();
+}
+
+void PlantRenderer::submit(Renderer &renderer, ResourceManager *resources) {
+  (void)resources; // Unused parameter
+  
+  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 {
+    m_plantInstanceBuffer.reset();
+    return;
+  }
+
+  if (m_plantInstanceBuffer && m_plantInstanceCount > 0) {
+    PlantBatchParams params = m_plantParams;
+    params.time = renderer.getAnimationTime();
+    renderer.plantBatch(m_plantInstanceBuffer.get(), m_plantInstanceCount,
+                        params);
+  }
+}
+
+void PlantRenderer::clear() {
+  m_plantInstances.clear();
+  m_plantInstanceBuffer.reset();
+  m_plantInstanceCount = 0;
+  m_plantInstancesDirty = false;
+}
+
+void PlantRenderer::generatePlantInstances() {
+  m_plantInstances.clear();
+
+  if (m_width < 2 || m_height < 2 || m_heightData.empty()) {
+    m_plantInstanceCount = 0;
+    m_plantInstancesDirty = false;
+    return;
+  }
+
+  // Use plant density from biome settings (default to moderate density)
+  const float plantDensity =
+      std::clamp(m_biomeSettings.plantDensity, 0.0f, 2.0f);
+  qDebug() << "PlantRenderer: plantDensity =" << plantDensity 
+           << "from biome settings";
+  
+  if (plantDensity < 0.01f) {
+    qDebug() << "PlantRenderer: plantDensity too low, skipping generation";
+    m_plantInstanceCount = 0;
+    m_plantInstancesDirty = false;
+    return;
+  }
+
+  const float halfWidth = m_width * 0.5f - 0.5f;
+  const float halfHeight = m_height * 0.5f - 0.5f;
+  const float tileSafe = std::max(0.001f, m_tileSize);
+
+  std::vector<QVector3D> normals(m_width * m_height,
+                                 QVector3D(0.0f, 1.0f, 0.0f));
+
+  auto sampleHeightAt = [&](float gx, float gz) -> float {
+    gx = std::clamp(gx, 0.0f, float(m_width - 1));
+    gz = std::clamp(gz, 0.0f, float(m_height - 1));
+    int x0 = int(std::floor(gx));
+    int z0 = int(std::floor(gz));
+    int x1 = std::min(x0 + 1, m_width - 1);
+    int z1 = std::min(z0 + 1, m_height - 1);
+    float tx = gx - float(x0);
+    float tz = gz - float(z0);
+    float h00 = m_heightData[z0 * m_width + x0];
+    float h10 = m_heightData[z0 * m_width + x1];
+    float h01 = m_heightData[z1 * m_width + x0];
+    float h11 = m_heightData[z1 * m_width + x1];
+    float h0 = h00 * (1.0f - tx) + h10 * tx;
+    float h1 = h01 * (1.0f - tx) + h11 * tx;
+    return h0 * (1.0f - tz) + h1 * tz;
+  };
+
+  // Compute normals for slope detection
+  for (int z = 0; z < m_height; ++z) {
+    for (int x = 0; x < m_width; ++x) {
+      int idx = z * m_width + x;
+      float gx0 = std::clamp(float(x) - 1.0f, 0.0f, float(m_width - 1));
+      float gx1 = std::clamp(float(x) + 1.0f, 0.0f, float(m_width - 1));
+      float gz0 = std::clamp(float(z) - 1.0f, 0.0f, float(m_height - 1));
+      float gz1 = std::clamp(float(z) + 1.0f, 0.0f, float(m_height - 1));
+
+      float hL = sampleHeightAt(gx0, float(z));
+      float hR = sampleHeightAt(gx1, float(z));
+      float hD = sampleHeightAt(float(x), gz0);
+      float hU = sampleHeightAt(float(x), gz1);
+
+      QVector3D dx(2.0f * m_tileSize, hR - hL, 0.0f);
+      QVector3D dz(0.0f, hU - hD, 2.0f * m_tileSize);
+      QVector3D n = QVector3D::crossProduct(dz, dx);
+      if (n.lengthSquared() > 0.0f) {
+        n.normalize();
+      } else {
+        n = QVector3D(0, 1, 0);
+      }
+      normals[idx] = n;
+    }
+  }
+
+  auto addPlant = [&](float gx, float gz, uint32_t &state) -> bool {
+    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;
+
+    // Plants prefer flat terrain, avoid mountains
+    if (m_terrainTypes[normalIdx] == Game::Map::TerrainType::Mountain)
+      return false;
+
+    QVector3D normal = normals[normalIdx];
+    float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);
+
+    // Avoid steep slopes
+    if (slope > 0.65f)
+      return false;
+
+    float worldX = (gx - halfWidth) * m_tileSize;
+    float worldZ = (gz - halfHeight) * m_tileSize;
+    float worldY = sampleHeightAt(sgx, sgz);
+
+    auto &buildingRegistry =
+        Game::Systems::BuildingCollisionRegistry::instance();
+    if (buildingRegistry.isPointInBuilding(worldX, worldZ)) {
+      return false;
+    }
+
+    // Plants are taller than grass for visibility
+    float scale = remap(rand01(state), 0.30f, 0.80f) * tileSafe;
+
+    // Plant type variation (0-3 for different plant models)
+    float plantType = std::floor(rand01(state) * 4.0f);
+
+    // Plants use darker, more saturated colors than grass
+    float colorVar = remap(rand01(state), 0.0f, 1.0f);
+    QVector3D baseColor = m_biomeSettings.grassPrimary * 0.7f; // Darker than grass
+    QVector3D varColor = m_biomeSettings.grassSecondary * 0.8f;
+    QVector3D tintColor = baseColor * (1.0f - colorVar) + varColor * colorVar;
+
+    // Add brownish/woody tint for plant stems and variety
+    float brownMix = remap(rand01(state), 0.15f, 0.35f);
+    QVector3D brownTint(0.55f, 0.50f, 0.35f);
+    tintColor = tintColor * (1.0f - brownMix) + brownTint * brownMix;
+
+    // Sway parameters
+    float swayPhase = rand01(state) * 6.2831853f;
+    float swayStrength = remap(rand01(state), 0.6f, 1.2f);
+    float swaySpeed = remap(rand01(state), 0.8f, 1.3f);
+
+    float rotation = rand01(state) * 6.2831853f;
+
+    PlantInstanceGpu instance;
+    // Plants elevated slightly above terrain to avoid z-fighting
+    instance.posScale = QVector4D(worldX, worldY + 0.05f, worldZ, scale);
+    instance.colorSway =
+        QVector4D(tintColor.x(), tintColor.y(), tintColor.z(), swayPhase);
+    instance.typeParams = QVector4D(plantType, rotation, swayStrength, swaySpeed);
+    m_plantInstances.push_back(instance);
+    return true;
+  };
+
+  // Generate plants in a grid pattern with clustering
+  int cellsChecked = 0;
+  int cellsPassed = 0;
+  int plantsAdded = 0;
+  
+  for (int z = 0; z < m_height; z += 3) {
+    for (int x = 0; x < m_width; x += 3) {
+      cellsChecked++;
+      int idx = z * m_width + x;
+
+      // Skip mountains completely
+      if (m_terrainTypes[idx] == Game::Map::TerrainType::Mountain)
+        continue;
+
+      QVector3D normal = normals[idx];
+      float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);
+      if (slope > 0.65f)
+        continue;
+
+      uint32_t state = hashCoords(
+          x, z, m_noiseSeed ^ 0x8F3C5A7Eu ^ static_cast<uint32_t>(idx));
+
+      float worldX = (x - halfWidth) * m_tileSize;
+      float worldZ = (z - halfHeight) * m_tileSize;
+
+      // Use clustering noise to create natural-looking plant distribution
+      float clusterNoise =
+          valueNoise(worldX * 0.05f, worldZ * 0.05f, m_noiseSeed ^ 0x4B9D2F1Au);
+
+      // Plants cluster in certain areas
+      if (clusterNoise < 0.45f)
+        continue;
+      
+      cellsPassed++;
+
+      // Adjust density based on terrain type
+      float densityMult = 1.0f;
+      if (m_terrainTypes[idx] == Game::Map::TerrainType::Hill) {
+        densityMult = 0.6f; // Fewer plants on hills
+      }
+
+      // Calculate plant count (higher multiplier for visibility)
+      float effectiveDensity = plantDensity * densityMult * 2.0f;
+      int plantCount = static_cast<int>(std::floor(effectiveDensity));
+      float frac = effectiveDensity - float(plantCount);
+      if (rand01(state) < frac)
+        plantCount += 1;
+
+      // Place plants in small clusters
+      for (int i = 0; i < plantCount; ++i) {
+        float gx = float(x) + rand01(state) * 3.0f;
+        float gz = float(z) + rand01(state) * 3.0f;
+        if (addPlant(gx, gz, state)) {
+          plantsAdded++;
+        }
+      }
+    }
+  }
+
+  m_plantInstanceCount = m_plantInstances.size();
+  m_plantInstancesDirty = m_plantInstanceCount > 0;
+}
+
+} // namespace Render::GL

+ 48 - 0
render/ground/plant_renderer.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include "../i_render_pass.h"
+#include "plant_gpu.h"
+#include <QVector3D>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace Render {
+namespace GL {
+class Buffer;
+class Renderer;
+
+class PlantRenderer : public IRenderPass {
+public:
+  PlantRenderer();
+  ~PlantRenderer();
+
+  void configure(const Game::Map::TerrainHeightMap &heightMap,
+                 const Game::Map::BiomeSettings &biomeSettings);
+
+  void submit(Renderer &renderer, ResourceManager *resources) override;
+
+  void clear();
+
+private:
+  void generatePlantInstances();
+
+  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<PlantInstanceGpu> m_plantInstances;
+  std::unique_ptr<Buffer> m_plantInstanceBuffer;
+  std::size_t m_plantInstanceCount = 0;
+  PlantBatchParams m_plantParams;
+  bool m_plantInstancesDirty = false;
+};
+
+} // namespace GL
+} // namespace Render

+ 24 - 0
render/scene_renderer.cpp

@@ -144,6 +144,30 @@ void Renderer::stoneBatch(Buffer *instanceBuffer, std::size_t instanceCount,
   m_activeQueue->submit(cmd);
 }
 
+void Renderer::plantBatch(Buffer *instanceBuffer, std::size_t instanceCount,
+                          const PlantBatchParams &params) {
+  if (!instanceBuffer || instanceCount == 0 || !m_activeQueue)
+    return;
+  PlantBatchCmd cmd;
+  cmd.instanceBuffer = instanceBuffer;
+  cmd.instanceCount = instanceCount;
+  cmd.params = params;
+  cmd.params.time = m_accumulatedTime;
+  m_activeQueue->submit(cmd);
+}
+
+void Renderer::pineBatch(Buffer *instanceBuffer, std::size_t instanceCount,
+                         const PineBatchParams &params) {
+  if (!instanceBuffer || instanceCount == 0 || !m_activeQueue)
+    return;
+  PineBatchCmd 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,

+ 4 - 0
render/scene_renderer.h

@@ -134,6 +134,10 @@ public:
                   const GrassBatchParams &params);
   void stoneBatch(Buffer *instanceBuffer, std::size_t instanceCount,
                   const StoneBatchParams &params);
+  void plantBatch(Buffer *instanceBuffer, std::size_t instanceCount,
+                  const PlantBatchParams &params);
+  void pineBatch(Buffer *instanceBuffer, std::size_t instanceCount,
+                 const PineBatchParams &params);
 
 private:
   Camera *m_camera = nullptr;