Browse Source

Add riverbank renderer and transition zone for realistic shorelines

Co-authored-by: djeada <[email protected]>
copilot-swe-agent[bot] 1 month ago
parent
commit
f5c86a9e1e

+ 7 - 1
app/core/game_engine.cpp

@@ -69,6 +69,7 @@
 #include "render/ground/pine_renderer.h"
 #include "render/ground/plant_renderer.h"
 #include "render/ground/river_renderer.h"
+#include "render/ground/riverbank_renderer.h"
 #include "render/ground/stone_renderer.h"
 #include "render/ground/terrain_renderer.h"
 #include "render/scene_renderer.h"
@@ -95,6 +96,7 @@ GameEngine::GameEngine() {
   m_terrain = std::make_unique<Render::GL::TerrainRenderer>();
   m_biome = std::make_unique<Render::GL::BiomeRenderer>();
   m_river = std::make_unique<Render::GL::RiverRenderer>();
+  m_riverbank = std::make_unique<Render::GL::RiverbankRenderer>();
   m_bridge = std::make_unique<Render::GL::BridgeRenderer>();
   m_fog = std::make_unique<Render::GL::FogRenderer>();
   m_stone = std::make_unique<Render::GL::StoneRenderer>();
@@ -102,7 +104,7 @@ GameEngine::GameEngine() {
   m_pine = std::make_unique<Render::GL::PineRenderer>();
 
   m_passes = {m_ground.get(), m_terrain.get(), m_river.get(),
-              m_bridge.get(), m_biome.get(),   m_stone.get(),
+              m_riverbank.get(), m_bridge.get(), m_biome.get(), m_stone.get(),
               m_plant.get(),  m_pine.get(),    m_fog.get()};
 
   std::unique_ptr<Engine::Core::System> arrowSys =
@@ -1410,6 +1412,10 @@ void GameEngine::restoreEnvironmentFromMetadata(const QJsonObject &metadata) {
         m_river->configure(heightMap->getRiverSegments(),
                            heightMap->getTileSize());
       }
+      if (m_riverbank) {
+        m_riverbank->configure(heightMap->getRiverSegments(),
+                               heightMap->getTileSize());
+      }
       if (m_biome) {
         m_biome->configure(*heightMap, terrainService.biomeSettings());
         m_biome->refreshGrass();

+ 2 - 0
app/core/game_engine.h

@@ -40,6 +40,7 @@ class GroundRenderer;
 class TerrainRenderer;
 class BiomeRenderer;
 class RiverRenderer;
+class RiverbankRenderer;
 class BridgeRenderer;
 class FogRenderer;
 class StoneRenderer;
@@ -259,6 +260,7 @@ private:
   std::unique_ptr<Render::GL::TerrainRenderer> m_terrain;
   std::unique_ptr<Render::GL::BiomeRenderer> m_biome;
   std::unique_ptr<Render::GL::RiverRenderer> m_river;
+  std::unique_ptr<Render::GL::RiverbankRenderer> m_riverbank;
   std::unique_ptr<Render::GL::BridgeRenderer> m_bridge;
   std::unique_ptr<Render::GL::FogRenderer> m_fog;
   std::unique_ptr<Render::GL::StoneRenderer> m_stone;

+ 96 - 0
assets/shaders/riverbank.frag

@@ -0,0 +1,96 @@
+#version 330 core
+out vec4 FragColor;
+
+in vec2 TexCoord;
+in vec3 WorldPos;
+in vec3 Normal;
+
+uniform float time;
+
+// Noise functions for texture variation
+float hash(vec2 p) {
+  p = fract(p * vec2(123.34, 456.21));
+  p += dot(p, p + 45.32);
+  return fract(p.x * p.y);
+}
+
+float noise(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));
+  float c = hash(i + vec2(0, 1));
+  float d = hash(i + vec2(1, 1));
+  
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float value = 0.0;
+  float amplitude = 0.5;
+  float frequency = 1.0;
+  
+  for (int i = 0; i < 4; i++) {
+    value += amplitude * noise(p * frequency);
+    frequency *= 2.0;
+    amplitude *= 0.5;
+  }
+  
+  return value;
+}
+
+void main() {
+  // UV coordinates for noise patterns
+  vec2 uv = WorldPos.xz * 0.5;
+  
+  // Wet/muddy colors near water edge
+  vec3 wetSoil = vec3(0.18, 0.15, 0.12);     // Dark, wet mud
+  vec3 dampSoil = vec3(0.35, 0.28, 0.22);    // Damp soil
+  vec3 drySoil = vec3(0.48, 0.42, 0.35);     // Dry sand/soil
+  vec3 grassTint = vec3(0.38, 0.48, 0.30);   // Grass mixed with soil
+  
+  // Transition from wet to dry based on TexCoord.x
+  // 0.0 = water edge (wet), 1.0 = land edge (dry)
+  float wetness = 1.0 - TexCoord.x;
+  
+  // Add procedural variation
+  float variation = fbm(uv * 3.0 + time * 0.1);
+  float detailNoise = noise(uv * 15.0);
+  
+  // Blend colors based on wetness and variation
+  vec3 baseColor;
+  if (wetness > 0.7) {
+    // Very wet zone - dark mud with subtle shine
+    baseColor = mix(wetSoil, dampSoil, variation);
+  } else if (wetness > 0.4) {
+    // Damp transition zone
+    baseColor = mix(dampSoil, drySoil, (0.7 - wetness) / 0.3);
+    baseColor = mix(baseColor, grassTint, variation * 0.3);
+  } else {
+    // Dry zone blending to grass
+    baseColor = mix(drySoil, grassTint, wetness / 0.4 + variation * 0.2);
+  }
+  
+  // Add detail variation (small rocks, soil patches)
+  baseColor *= (0.85 + detailNoise * 0.3);
+  
+  // Simple lighting
+  vec3 lightDir = normalize(vec3(0.3, 0.8, 0.4));
+  float diffuse = max(dot(Normal, lightDir), 0.0);
+  vec3 ambient = vec3(0.4, 0.4, 0.45);
+  
+  vec3 lighting = ambient + diffuse * vec3(0.6, 0.6, 0.55);
+  vec3 color = baseColor * lighting;
+  
+  // Add subtle wetness shine on wet areas
+  if (wetness > 0.5) {
+    vec3 viewDir = normalize(vec3(0.0, 1.0, 0.5));
+    vec3 reflectDir = reflect(-lightDir, Normal);
+    float spec = pow(max(dot(viewDir, reflectDir), 0.0), 8.0);
+    color += spec * wetness * vec3(0.15, 0.15, 0.12);
+  }
+  
+  FragColor = vec4(color, 1.0);
+}

+ 27 - 0
assets/shaders/riverbank.vert

@@ -0,0 +1,27 @@
+#version 330 core
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+uniform mat4 model;
+uniform mat4 view;
+uniform mat4 projection;
+uniform float time;
+
+out vec2 TexCoord;
+out vec3 WorldPos;
+out vec3 Normal;
+
+void main() {
+  vec3 pos = aPos;
+  
+  // Subtle movement for organic feel
+  float sway = sin(aPos.x * 0.3 + time * 0.5) * 0.005;
+  pos.y += sway;
+  
+  WorldPos = (model * vec4(pos, 1.0)).xyz;
+  Normal = normalize(mat3(transpose(inverse(model))) * aNormal);
+  
+  gl_Position = projection * view * model * vec4(pos, 1.0);
+  TexCoord = aTexCoord;
+}

+ 30 - 0
render/gl/backend.cpp

@@ -58,6 +58,7 @@ void Backend::initialize() {
   m_groundShader = m_shaderCache->get(QStringLiteral("ground_plane"));
   m_terrainShader = m_shaderCache->get(QStringLiteral("terrain_chunk"));
   m_riverShader = m_shaderCache->get(QStringLiteral("river"));
+  m_riverbankShader = m_shaderCache->get(QStringLiteral("riverbank"));
   m_bridgeShader = m_shaderCache->get(QStringLiteral("bridge"));
   m_archerShader = m_shaderCache->get(QStringLiteral("archer"));
   m_knightShader = m_shaderCache->get(QStringLiteral("knight"));
@@ -86,6 +87,8 @@ void Backend::initialize() {
     qWarning() << "Backend: terrain shader missing";
   if (!m_riverShader)
     qWarning() << "Backend: river shader missing";
+  if (!m_riverbankShader)
+    qWarning() << "Backend: riverbank shader missing";
   if (!m_bridgeShader)
     qWarning() << "Backend: bridge shader missing";
   if (!m_archerShader)
@@ -109,6 +112,7 @@ void Backend::initialize() {
   cacheGroundUniforms();
   cacheTerrainUniforms();
   cacheRiverUniforms();
+  cacheRiverbankUniforms();
   cacheBridgeUniforms();
   initializeCylinderPipeline();
   initializeFogPipeline();
@@ -648,6 +652,22 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
         break;
       }
 
+      if (activeShader == m_riverbankShader) {
+        if (m_lastBoundShader != activeShader) {
+          activeShader->use();
+          m_lastBoundShader = activeShader;
+        }
+
+        activeShader->setUniform(m_riverbankUniforms.model, it.model);
+        activeShader->setUniform(m_riverbankUniforms.view, cam.getViewMatrix());
+        activeShader->setUniform(m_riverbankUniforms.projection,
+                                 cam.getProjectionMatrix());
+        activeShader->setUniform(m_riverbankUniforms.time, m_animationTime);
+
+        it.mesh->draw();
+        break;
+      }
+
       if (activeShader == m_bridgeShader) {
         if (m_lastBoundShader != activeShader) {
           activeShader->use();
@@ -1259,6 +1279,16 @@ void Backend::cacheRiverUniforms() {
   m_riverUniforms.time = m_riverShader->uniformHandle("time");
 }
 
+void Backend::cacheRiverbankUniforms() {
+  if (!m_riverbankShader)
+    return;
+
+  m_riverbankUniforms.model = m_riverbankShader->uniformHandle("model");
+  m_riverbankUniforms.view = m_riverbankShader->uniformHandle("view");
+  m_riverbankUniforms.projection = m_riverbankShader->uniformHandle("projection");
+  m_riverbankUniforms.time = m_riverbankShader->uniformHandle("time");
+}
+
 void Backend::cacheBridgeUniforms() {
   if (!m_bridgeShader)
     return;

+ 9 - 0
render/gl/backend.h

@@ -87,6 +87,7 @@ private:
   Shader *m_groundShader = nullptr;
   Shader *m_terrainShader = nullptr;
   Shader *m_riverShader = nullptr;
+  Shader *m_riverbankShader = nullptr;
   Shader *m_bridgeShader = nullptr;
   Shader *m_archerShader = nullptr;
   Shader *m_knightShader = nullptr;
@@ -112,6 +113,13 @@ private:
     Shader::UniformHandle time{Shader::InvalidUniform};
   } m_riverUniforms;
 
+  struct RiverbankUniforms {
+    Shader::UniformHandle model{Shader::InvalidUniform};
+    Shader::UniformHandle view{Shader::InvalidUniform};
+    Shader::UniformHandle projection{Shader::InvalidUniform};
+    Shader::UniformHandle time{Shader::InvalidUniform};
+  } m_riverbankUniforms;
+
   struct BridgeUniforms {
     Shader::UniformHandle mvp{Shader::InvalidUniform};
     Shader::UniformHandle model{Shader::InvalidUniform};
@@ -296,6 +304,7 @@ private:
   void cacheGroundUniforms();
   void cacheTerrainUniforms();
   void cacheRiverUniforms();
+  void cacheRiverbankUniforms();
   void cacheBridgeUniforms();
 
   Shader *m_lastBoundShader = nullptr;

+ 11 - 1
render/ground/biome_renderer.cpp

@@ -222,7 +222,9 @@ void BiomeRenderer::generateGrassInstances() {
     if (m_terrainTypes[normalIdx] == Game::Map::TerrainType::River)
       return false;
 
+    // Check for riverbank proximity - allow sparse grass instead of complete exclusion
     constexpr int kRiverMargin = 1;
+    int nearRiverCount = 0;
     for (int dz = -kRiverMargin; dz <= kRiverMargin; ++dz) {
       for (int dx = -kRiverMargin; dx <= kRiverMargin; ++dx) {
         if (dx == 0 && dz == 0)
@@ -232,10 +234,18 @@ void BiomeRenderer::generateGrassInstances() {
         if (nx >= 0 && nx < m_width && nz >= 0 && nz < m_height) {
           int nIdx = nz * m_width + nx;
           if (m_terrainTypes[nIdx] == Game::Map::TerrainType::River)
-            return false;
+            nearRiverCount++;
         }
       }
     }
+    
+    // If near river, reduce grass density based on proximity
+    if (nearRiverCount > 0) {
+      // Use random sampling to thin out grass near water
+      float riverbankDensity = 0.15f; // Only 15% of normal density near water
+      if (rand01(state) > riverbankDensity)
+        return false;
+    }
 
     QVector3D normal = normals[normalIdx];
     float slope = 1.0f - std::clamp(normal.y(), 0.0f, 1.0f);

+ 21 - 0
render/ground/riverbank_asset_gpu.h

@@ -0,0 +1,21 @@
+#pragma once
+
+#include <QVector3D>
+#include <array>
+
+namespace Render::GL {
+
+struct RiverbankAssetInstanceGpu {
+  std::array<float, 3> position;
+  std::array<float, 3> scale;
+  std::array<float, 4> rotation;
+  std::array<float, 3> color;
+  float assetType; // 0=pebble, 1=small rock, 2=reed cluster
+};
+
+struct RiverbankAssetBatchParams {
+  QVector3D lightDirection{0.35f, 0.8f, 0.45f};
+  float time{0.0f};
+};
+
+} // namespace Render::GL

+ 267 - 0
render/ground/riverbank_asset_renderer.cpp

@@ -0,0 +1,267 @@
+#include "riverbank_asset_renderer.h"
+#include "../gl/buffer.h"
+#include "../scene_renderer.h"
+#include <QDebug>
+#include <QVector2D>
+#include <algorithm>
+#include <cmath>
+
+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 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 {
+
+RiverbankAssetRenderer::RiverbankAssetRenderer() = default;
+RiverbankAssetRenderer::~RiverbankAssetRenderer() = default;
+
+void RiverbankAssetRenderer::configure(
+    const std::vector<Game::Map::RiverSegment> &riverSegments,
+    const Game::Map::TerrainHeightMap &heightMap,
+    const Game::Map::BiomeSettings &biomeSettings) {
+  m_riverSegments = riverSegments;
+  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_assetInstances.clear();
+  m_assetInstanceBuffer.reset();
+  m_assetInstanceCount = 0;
+  m_assetInstancesDirty = false;
+
+  m_assetParams.lightDirection = QVector3D(0.35f, 0.8f, 0.45f);
+  m_assetParams.time = 0.0f;
+
+  generateAssetInstances();
+}
+
+void RiverbankAssetRenderer::submit(Renderer &renderer,
+                                   ResourceManager *resources) {
+  Q_UNUSED(resources);
+  
+  if (m_assetInstanceCount > 0) {
+    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;
+    }
+  } else {
+    m_assetInstanceBuffer.reset();
+    return;
+  }
+
+  // For now, we'll render these using the stone batch renderer
+  // In a full implementation, you'd create a custom shader for riverbank assets
+  // This is a simplified version that reuses existing rendering infrastructure
+  if (m_assetInstanceBuffer && m_assetInstanceCount > 0) {
+    // Note: This would require extending the renderer to support riverbank assets
+    // For minimal changes, we'll just prepare the data
+    qDebug() << "RiverbankAssetRenderer: Would render" << m_assetInstanceCount << "riverbank assets";
+  }
+}
+
+void RiverbankAssetRenderer::clear() {
+  m_assetInstances.clear();
+  m_assetInstanceBuffer.reset();
+  m_assetInstanceCount = 0;
+  m_assetInstancesDirty = false;
+}
+
+void RiverbankAssetRenderer::generateAssetInstances() {
+  m_assetInstances.clear();
+
+  if (m_riverSegments.empty() || m_width < 2 || m_height < 2) {
+    m_assetInstanceCount = 0;
+    m_assetInstancesDirty = false;
+    return;
+  }
+
+  const float halfWidth = m_width * 0.5f - 0.5f;
+  const float halfHeight = m_height * 0.5f - 0.5f;
+
+  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;
+  };
+
+  // Generate assets along each river segment
+  for (size_t segIdx = 0; segIdx < m_riverSegments.size(); ++segIdx) {
+    const auto &segment = m_riverSegments[segIdx];
+    
+    QVector3D dir = segment.end - segment.start;
+    float length = dir.length();
+    if (length < 0.01f)
+      continue;
+
+    dir.normalize();
+    QVector3D perpendicular(-dir.z(), 0.0f, dir.x());
+    float halfRiverWidth = segment.width * 0.5f;
+    float bankZoneWidth = 1.5f; // Zone where we place assets
+
+    // Number of potential asset positions along the river
+    int numSteps = static_cast<int>(length / 0.8f) + 1;
+    
+    uint32_t rng = m_noiseSeed + static_cast<uint32_t>(segIdx * 1000);
+
+    for (int i = 0; i < numSteps; ++i) {
+      float t = static_cast<float>(i) / static_cast<float>(std::max(numSteps - 1, 1));
+      QVector3D centerPos = segment.start + dir * (length * t);
+
+      // Random placement on either side of river
+      for (int side = 0; side < 2; ++side) {
+        float sideSign = (side == 0) ? -1.0f : 1.0f;
+        
+        // Randomly decide if we place an asset here
+        if (rand01(rng) > 0.3f) continue; // 30% chance to place asset
+        
+        // Random position within bank zone
+        float distFromWater = halfRiverWidth + rand01(rng) * bankZoneWidth;
+        float alongRiver = (rand01(rng) - 0.5f) * 0.6f;
+        
+        QVector3D assetPos = centerPos + 
+                            perpendicular * (sideSign * distFromWater) +
+                            dir * alongRiver;
+
+        // Convert to grid coordinates
+        float gx = (assetPos.x() / m_tileSize) + halfWidth;
+        float gz = (assetPos.z() / m_tileSize) + halfHeight;
+
+        if (gx < 0 || gx >= m_width - 1 || gz < 0 || gz >= m_height - 1)
+          continue;
+
+        int ix = static_cast<int>(gx);
+        int iz = static_cast<int>(gz);
+        int idx = iz * m_width + ix;
+
+        // Don't place on rivers, mountains, or hills
+        if (m_terrainTypes[idx] != Game::Map::TerrainType::Flat)
+          continue;
+
+        float worldY = sampleHeightAt(gx, gz);
+        
+        RiverbankAssetInstanceGpu instance;
+        instance.position[0] = assetPos.x();
+        instance.position[1] = worldY;
+        instance.position[2] = assetPos.z();
+
+        // Random asset type
+        float typeRand = rand01(rng);
+        if (typeRand < 0.7f) {
+          // Pebble (most common)
+          instance.assetType = 0.0f;
+          float size = 0.05f + rand01(rng) * 0.1f;
+          instance.scale[0] = size * (0.8f + rand01(rng) * 0.4f);
+          instance.scale[1] = size * (0.6f + rand01(rng) * 0.3f);
+          instance.scale[2] = size * (0.8f + rand01(rng) * 0.4f);
+          
+          // Gray/brown pebble colors
+          float colorVar = 0.3f + rand01(rng) * 0.4f;
+          instance.color[0] = colorVar;
+          instance.color[1] = colorVar * 0.9f;
+          instance.color[2] = colorVar * 0.85f;
+        } else if (typeRand < 0.9f) {
+          // Small rock
+          instance.assetType = 1.0f;
+          float size = 0.1f + rand01(rng) * 0.15f;
+          instance.scale[0] = size;
+          instance.scale[1] = size * (0.7f + rand01(rng) * 0.4f);
+          instance.scale[2] = size;
+          
+          // Rock colors
+          float colorVar = 0.35f + rand01(rng) * 0.25f;
+          instance.color[0] = colorVar;
+          instance.color[1] = colorVar * 0.95f;
+          instance.color[2] = colorVar * 0.9f;
+        } else {
+          // Reed cluster (near water)
+          if (distFromWater > halfRiverWidth + 0.5f)
+            continue; // Only near water edge
+            
+          instance.assetType = 2.0f;
+          float size = 0.3f + rand01(rng) * 0.4f;
+          instance.scale[0] = size * 0.3f;
+          instance.scale[1] = size;
+          instance.scale[2] = size * 0.3f;
+          
+          // Green/brown reed colors
+          instance.color[0] = 0.25f + rand01(rng) * 0.15f;
+          instance.color[1] = 0.35f + rand01(rng) * 0.25f;
+          instance.color[2] = 0.15f + rand01(rng) * 0.1f;
+        }
+
+        // Random rotation
+        float angle = rand01(rng) * 6.28318f; // 2*PI
+        instance.rotation[0] = 0.0f;
+        instance.rotation[1] = std::sin(angle * 0.5f);
+        instance.rotation[2] = 0.0f;
+        instance.rotation[3] = std::cos(angle * 0.5f);
+
+        m_assetInstances.push_back(instance);
+      }
+    }
+  }
+
+  m_assetInstanceCount = m_assetInstances.size();
+  m_assetInstancesDirty = true;
+  
+  qDebug() << "Generated" << m_assetInstanceCount << "riverbank assets";
+}
+
+} // namespace Render::GL

+ 50 - 0
render/ground/riverbank_asset_renderer.h

@@ -0,0 +1,50 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include "../i_render_pass.h"
+#include "riverbank_asset_gpu.h"
+#include <QVector3D>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace Render {
+namespace GL {
+class Buffer;
+class Renderer;
+
+class RiverbankAssetRenderer : public IRenderPass {
+public:
+  RiverbankAssetRenderer();
+  ~RiverbankAssetRenderer();
+
+  void configure(const std::vector<Game::Map::RiverSegment> &riverSegments,
+                 const Game::Map::TerrainHeightMap &heightMap,
+                 const Game::Map::BiomeSettings &biomeSettings);
+
+  void submit(Renderer &renderer, ResourceManager *resources) override;
+
+  void clear();
+
+private:
+  void generateAssetInstances();
+
+  std::vector<Game::Map::RiverSegment> m_riverSegments;
+  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<RiverbankAssetInstanceGpu> m_assetInstances;
+  std::unique_ptr<Buffer> m_assetInstanceBuffer;
+  std::size_t m_assetInstanceCount = 0;
+  RiverbankAssetBatchParams m_assetParams;
+  bool m_assetInstancesDirty = false;
+};
+
+} // namespace GL
+} // namespace Render

+ 211 - 0
render/ground/riverbank_renderer.cpp

@@ -0,0 +1,211 @@
+#include "riverbank_renderer.h"
+#include "../gl/mesh.h"
+#include "../gl/resources.h"
+#include "../scene_renderer.h"
+#include <QVector2D>
+#include <QVector3D>
+#include <cmath>
+
+namespace Render::GL {
+
+RiverbankRenderer::RiverbankRenderer() = default;
+RiverbankRenderer::~RiverbankRenderer() = default;
+
+void RiverbankRenderer::configure(
+    const std::vector<Game::Map::RiverSegment> &riverSegments, float tileSize) {
+  m_riverSegments = riverSegments;
+  m_tileSize = tileSize;
+  buildMeshes();
+}
+
+void RiverbankRenderer::buildMeshes() {
+  if (m_riverSegments.empty()) {
+    m_mesh.reset();
+    return;
+  }
+
+  std::vector<Vertex> vertices;
+  std::vector<unsigned int> indices;
+
+  auto noiseHash = [](float x, float y) -> float {
+    float n = std::sin(x * 127.1f + y * 311.7f) * 43758.5453123f;
+    return n - std::floor(n);
+  };
+
+  auto noise = [&noiseHash](float x, float y) -> float {
+    float ix = std::floor(x);
+    float iy = std::floor(y);
+    float fx = x - ix;
+    float fy = y - iy;
+
+    fx = fx * fx * (3.0f - 2.0f * fx);
+    fy = fy * fy * (3.0f - 2.0f * fy);
+
+    float a = noiseHash(ix, iy);
+    float b = noiseHash(ix + 1.0f, iy);
+    float c = noiseHash(ix, iy + 1.0f);
+    float d = noiseHash(ix + 1.0f, iy + 1.0f);
+
+    return a * (1.0f - fx) * (1.0f - fy) + b * fx * (1.0f - fy) +
+           c * (1.0f - fx) * fy + d * fx * fy;
+  };
+
+  // Create riverbank transition zones on both sides of each river segment
+  for (const auto &segment : m_riverSegments) {
+    QVector3D dir = segment.end - segment.start;
+    float length = dir.length();
+    if (length < 0.01f)
+      continue;
+
+    dir.normalize();
+    QVector3D perpendicular(-dir.z(), 0.0f, dir.x());
+    float halfWidth = segment.width * 0.5f;
+    
+    // Riverbank transition zone width (extends beyond water edge)
+    float bankWidth = 1.2f;
+
+    int lengthSteps =
+        static_cast<int>(std::ceil(length / (m_tileSize * 0.5f))) + 1;
+    lengthSteps = std::max(lengthSteps, 8);
+
+    unsigned int baseIndex = static_cast<unsigned int>(vertices.size());
+
+    for (int i = 0; i < lengthSteps; ++i) {
+      float t = static_cast<float>(i) / static_cast<float>(lengthSteps - 1);
+      QVector3D centerPos = segment.start + dir * (length * t);
+
+      // Edge variation using noise
+      float noiseFreq1 = 2.0f;
+      float noiseFreq2 = 5.0f;
+      float noiseFreq3 = 10.0f;
+
+      float edgeNoise1 =
+          noise(centerPos.x() * noiseFreq1, centerPos.z() * noiseFreq1);
+      float edgeNoise2 =
+          noise(centerPos.x() * noiseFreq2, centerPos.z() * noiseFreq2);
+      float edgeNoise3 =
+          noise(centerPos.x() * noiseFreq3, centerPos.z() * noiseFreq3);
+
+      float combinedNoise =
+          edgeNoise1 * 0.5f + edgeNoise2 * 0.3f + edgeNoise3 * 0.2f;
+      combinedNoise = (combinedNoise - 0.5f) * 2.0f;
+
+      float widthVariation = combinedNoise * halfWidth * 0.35f;
+
+      // Meander for natural curves
+      float meander = noise(t * 3.0f, length * 0.1f) * 0.3f;
+      QVector3D centerOffset = perpendicular * meander;
+      centerPos += centerOffset;
+
+      // Create riverbank zones on both sides
+      // Inner edge (water edge)
+      QVector3D innerLeft = centerPos - perpendicular * (halfWidth + widthVariation);
+      QVector3D innerRight = centerPos + perpendicular * (halfWidth + widthVariation);
+      
+      // Outer edge (land edge) - with additional variation
+      float outerVariation = noise(centerPos.x() * 8.0f, centerPos.z() * 8.0f) * 0.5f;
+      QVector3D outerLeft = innerLeft - perpendicular * (bankWidth + outerVariation);
+      QVector3D outerRight = innerRight + perpendicular * (bankWidth + outerVariation);
+
+      float normal[3] = {0.0f, 1.0f, 0.0f};
+
+      // Left bank strip (4 vertices per segment)
+      Vertex leftInner, leftOuter;
+      leftInner.position[0] = innerLeft.x();
+      leftInner.position[1] = innerLeft.y() + 0.01f; // Slightly above water
+      leftInner.position[2] = innerLeft.z();
+      leftInner.normal[0] = normal[0];
+      leftInner.normal[1] = normal[1];
+      leftInner.normal[2] = normal[2];
+      leftInner.texCoord[0] = 0.0f; // Inner = wet
+      leftInner.texCoord[1] = t;
+      vertices.push_back(leftInner);
+
+      leftOuter.position[0] = outerLeft.x();
+      leftOuter.position[1] = outerLeft.y() + 0.01f;
+      leftOuter.position[2] = outerLeft.z();
+      leftOuter.normal[0] = normal[0];
+      leftOuter.normal[1] = normal[1];
+      leftOuter.normal[2] = normal[2];
+      leftOuter.texCoord[0] = 1.0f; // Outer = dry
+      leftOuter.texCoord[1] = t;
+      vertices.push_back(leftOuter);
+
+      // Right bank strip
+      Vertex rightInner, rightOuter;
+      rightInner.position[0] = innerRight.x();
+      rightInner.position[1] = innerRight.y() + 0.01f;
+      rightInner.position[2] = innerRight.z();
+      rightInner.normal[0] = normal[0];
+      rightInner.normal[1] = normal[1];
+      rightInner.normal[2] = normal[2];
+      rightInner.texCoord[0] = 0.0f; // Inner = wet
+      rightInner.texCoord[1] = t;
+      vertices.push_back(rightInner);
+
+      rightOuter.position[0] = outerRight.x();
+      rightOuter.position[1] = outerRight.y() + 0.01f;
+      rightOuter.position[2] = outerRight.z();
+      rightOuter.normal[0] = normal[0];
+      rightOuter.normal[1] = normal[1];
+      rightOuter.normal[2] = normal[2];
+      rightOuter.texCoord[0] = 1.0f; // Outer = dry
+      rightOuter.texCoord[1] = t;
+      vertices.push_back(rightOuter);
+
+      if (i < lengthSteps - 1) {
+        unsigned int idx0 = baseIndex + i * 4;
+        
+        // Left bank triangles
+        indices.push_back(idx0 + 0);     // left inner
+        indices.push_back(idx0 + 4);     // next left inner
+        indices.push_back(idx0 + 1);     // left outer
+        
+        indices.push_back(idx0 + 1);     // left outer
+        indices.push_back(idx0 + 4);     // next left inner
+        indices.push_back(idx0 + 5);     // next left outer
+
+        // Right bank triangles
+        indices.push_back(idx0 + 2);     // right inner
+        indices.push_back(idx0 + 3);     // right outer
+        indices.push_back(idx0 + 6);     // next right inner
+        
+        indices.push_back(idx0 + 3);     // right outer
+        indices.push_back(idx0 + 7);     // next right outer
+        indices.push_back(idx0 + 6);     // next right inner
+      }
+    }
+  }
+
+  if (vertices.empty() || indices.empty()) {
+    m_mesh.reset();
+    return;
+  }
+
+  m_mesh = std::make_unique<Mesh>(vertices, indices);
+}
+
+void RiverbankRenderer::submit(Renderer &renderer, ResourceManager *resources) {
+  if (!m_mesh || m_riverSegments.empty()) {
+    return;
+  }
+
+  Q_UNUSED(resources);
+
+  auto shader = renderer.getShader("riverbank");
+  if (!shader) {
+    return;
+  }
+
+  renderer.setCurrentShader(shader);
+
+  QMatrix4x4 model;
+  model.setToIdentity();
+
+  renderer.mesh(m_mesh.get(), model, QVector3D(1.0f, 1.0f, 1.0f), nullptr,
+                1.0f);
+
+  renderer.setCurrentShader(nullptr);
+}
+
+} // namespace Render::GL

+ 34 - 0
render/ground/riverbank_renderer.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include "../i_render_pass.h"
+#include <QMatrix4x4>
+#include <memory>
+#include <vector>
+
+namespace Render {
+namespace GL {
+class Mesh;
+class Renderer;
+class ResourceManager;
+
+class RiverbankRenderer : public IRenderPass {
+public:
+  RiverbankRenderer();
+  ~RiverbankRenderer();
+
+  void configure(const std::vector<Game::Map::RiverSegment> &riverSegments,
+                 float tileSize);
+
+  void submit(Renderer &renderer, ResourceManager *resources) override;
+
+private:
+  void buildMeshes();
+
+  std::vector<Game::Map::RiverSegment> m_riverSegments;
+  float m_tileSize = 1.0f;
+  std::unique_ptr<Mesh> m_mesh;
+};
+
+} // namespace GL
+} // namespace Render