Browse Source

improve river rendering and add bridges

djeada 1 month ago
parent
commit
ee0af8bb1e

+ 6 - 1
app/core/game_engine.cpp

@@ -63,6 +63,7 @@
 #include "render/gl/camera.h"
 #include "render/gl/resources.h"
 #include "render/ground/biome_renderer.h"
+#include "render/ground/bridge_renderer.h"
 #include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
 #include "render/ground/pine_renderer.h"
@@ -94,12 +95,13 @@ 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_bridge = std::make_unique<Render::GL::BridgeRenderer>();
   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_river.get(), m_biome.get(), m_stone.get(),
+  m_passes = {m_ground.get(), m_terrain.get(), m_river.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 =
@@ -843,6 +845,8 @@ void GameEngine::startSkirmish(const QString &mapPath,
     loader.setGroundRenderer(m_ground.get());
     loader.setTerrainRenderer(m_terrain.get());
     loader.setBiomeRenderer(m_biome.get());
+    loader.setRiverRenderer(m_river.get());
+    loader.setBridgeRenderer(m_bridge.get());
     loader.setFogRenderer(m_fog.get());
     loader.setStoneRenderer(m_stone.get());
     loader.setPlantRenderer(m_plant.get());
@@ -1393,6 +1397,7 @@ void GameEngine::restoreEnvironmentFromMetadata(const QJsonObject &metadata) {
         m_terrain->configure(*heightMap, terrainService.biomeSettings());
       }
       if (m_river) {
+        qDebug() << "GameEngine: Configuring river renderer with" << heightMap->getRiverSegments().size() << "segments";
         m_river->configure(heightMap->getRiverSegments(), heightMap->getTileSize());
       }
       if (m_biome) {

+ 2 - 0
app/core/game_engine.h

@@ -40,6 +40,7 @@ class GroundRenderer;
 class TerrainRenderer;
 class BiomeRenderer;
 class RiverRenderer;
+class BridgeRenderer;
 class FogRenderer;
 class StoneRenderer;
 class PlantRenderer;
@@ -257,6 +258,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::BridgeRenderer> m_bridge;
   std::unique_ptr<Render::GL::FogRenderer> m_fog;
   std::unique_ptr<Render::GL::StoneRenderer> m_stone;
   std::unique_ptr<Render::GL::PlantRenderer> m_plant;

+ 2 - 0
assets.qrc

@@ -2,6 +2,8 @@
     <qresource prefix="/">
         <file>assets/shaders/basic.vert</file>
         <file>assets/shaders/basic.frag</file>
+        <file>assets/shaders/bridge.vert</file>
+        <file>assets/shaders/bridge.frag</file>
         <file>assets/shaders/grid.frag</file>
         <file>assets/maps/test_map.json</file>
         <file>assets/visuals/unit_visuals.json</file>

+ 20 - 5
assets/maps/river_test.json

@@ -1,5 +1,5 @@
 {
-  "name": "River Test Map",
+  "name": "River Crossroads",
   "coordSystem": "grid",
   "maxTroopsPerPlayer": 200,
   "grid": {
@@ -37,7 +37,7 @@
     { "type": "barracks", "x": 30, "z": 30, "playerId": 1, "maxPopulation": 100 },
     { "type": "archer", "x": 28, "z": 32, "playerId": 1 },
     { "type": "archer", "x": 32, "z": 28, "playerId": 1 },
-    
+
     { "type": "barracks", "x": 70, "z": 70, "playerId": 2, "maxPopulation": 100 },
     { "type": "archer", "x": 68, "z": 72, "playerId": 2 },
     { "type": "archer", "x": 72, "z": 68, "playerId": 2 }
@@ -45,13 +45,28 @@
   "terrain": [
     { "type": "mountain", "x": 25, "z": 75, "radius": 8, "height": 8.0, "rotation": 15 },
     { "type": "mountain", "x": 75, "z": 25, "radius": 8, "height": 8.0, "rotation": 45 },
-    { "type": "hill", "x": 50, "z": 25, "width": 12, "depth": 8, "height": 3.0, "rotation": 0, "entrances": [{"x": 50, "z": 18}, {"x": 50, "z": 32}] }
+
+    { "type": "hill", "x": 50, "z": 25, "width": 12, "depth": 8, "height": 3.0, "rotation": 0,
+      "entrances": [{"x": 50, "z": 18}, {"x": 50, "z": 32}] },
+
+    { "type": "hill", "x": 50, "z": 75, "width": 12, "depth": 8, "height": 3.0, "rotation": 180,
+      "entrances": [{"x": 50, "z": 68}, {"x": 50, "z": 82}] }
   ],
   "rivers": [
     { "start": [10, 10], "end": [10, 90], "width": 3.0 },
     { "start": [90, 10], "end": [90, 90], "width": 3.0 },
-    { "start": [10, 50], "end": [45, 50], "width": 2.5 },
-    { "start": [55, 50], "end": [90, 50], "width": 2.5 }
+    { "start": [10, 50], "end": [90, 50], "width": 2.5 }   
+  ],
+  "bridges": [
+    { "start": [8, 30], "end": [12, 30], "width": 4.0, "height": 0.5 },  
+    { "start": [8, 50], "end": [12, 50], "width": 4.0, "height": 0.5 },  
+    { "start": [8, 70], "end": [12, 70], "width": 4.0, "height": 0.5 }, 
+
+    { "start": [48, 50], "end": [52, 50], "width": 4.0, "height": 0.5 },  
+
+    { "start": [88, 30], "end": [92, 30], "width": 4.0, "height": 0.5 }, 
+    { "start": [88, 50], "end": [92, 50], "width": 4.0, "height": 0.5 },  
+    { "start": [88, 70], "end": [92, 70], "width": 4.0, "height": 0.5 }   
   ],
   "victory": {
     "type": "elimination",

+ 180 - 0
assets/shaders/bridge.frag

@@ -0,0 +1,180 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform vec3 u_color;
+uniform vec3 u_lightDirection;
+uniform sampler2D u_fogTexture;
+
+out vec4 FragColor;
+
+// ------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------
+const float PI = 3.14159265359;
+
+mat2 rot(float a){ float c=cos(a), s=sin(a); return mat2(c,-s,s,c); }
+
+// Simple hash / noise (compatible with your original)
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+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, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+float fbm(vec2 p){
+  float v=0.0, a=0.5, f=1.0;
+  for(int i=0;i<5;i++){ v += a*noise(p*f); f*=2.0; a*=0.5; }
+  return v;
+}
+// 2D random vector
+vec2 hash2(vec2 p){
+  float n = sin(dot(p, vec2(127.1,311.7))) * 43758.5453123;
+  return fract(vec2(n, n*1.2154));
+}
+
+// Worley (Voronoi) distances: returns F1 and F2 (nearest & second-nearest)
+vec2 worleyF(vec2 p){
+  vec2 n = floor(p);
+  vec2 f = fract(p);
+  float F1 = 1e9;
+  float F2 = 1e9;
+  for(int j=-1;j<=1;j++){
+    for(int i=-1;i<=1;i++){
+      vec2 g = vec2(float(i), float(j));
+      vec2 o = hash2(n+g);
+      vec2 r = g + o - f;
+      float d = dot(r,r);
+      if(d < F1){ F2 = F1; F1 = d; }
+      else if(d < F2){ F2 = d; }
+    }
+  }
+  return vec2(sqrt(F1), sqrt(F2));
+}
+
+// Fresnel (Schlick)
+float fresnelSchlick(float cosTheta, float F0){
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+// Minimal GGX specular (Smith-Schlick)
+float ggxSpecular(vec3 N, vec3 V, vec3 L, float rough, float F0){
+  vec3 H = normalize(V + L);
+  float NdotV = max(dot(N,V),0.0);
+  float NdotL = max(dot(N,L),0.0);
+  float NdotH = max(dot(N,H),0.0);
+  float VdotH = max(dot(V,H),0.0);
+
+  float a = max(rough*rough, 0.001);
+  float a2 = a*a;
+  float denom = (NdotH*NdotH*(a2-1.0)+1.0);
+  float D = a2 / (PI * denom * denom);
+
+  float k = (a + 1.0); k = (k*k)/8.0;
+  float Gv = NdotV / (NdotV*(1.0 - k) + k);
+  float Gl = NdotL / (NdotL*(1.0 - k) + k);
+  float G = Gv*Gl;
+
+  float F = fresnelSchlick(VdotH, F0);
+  return (D * G * F) / max(4.0 * NdotV * NdotL, 0.001);
+}
+
+// ------------------------------------------------------------
+// Main
+// ------------------------------------------------------------
+void main(){
+  // -----------------------------
+  // Procedural medieval stones
+  // -----------------------------
+  // World-anchored UVs to avoid tiling with mesh UVs
+  vec2 uv = v_worldPos.xz * 0.6;
+
+  // Irregular stone layout via Voronoi
+  // F1 = distance to cell center; F2-F1 is small near borders (mortar)
+  vec2 F = worleyF(uv * 1.2);
+  float edgeMetric = F.y - F.x;          // smaller near borders
+  float stoneMask  = smoothstep(0.05, 0.30, edgeMetric); // 1 inside stones, 0 at mortar
+  float mortarMask = 1.0 - stoneMask;
+
+  // Subtly vary stone shapes (non-uniform scaling & rotation per cell)
+  vec2 cell = floor(uv*1.2);
+  float cellRnd = hash(cell);
+  vec2 uvVar = (rot(cellRnd*6.2831) * (uv - floor(uv))) + cell; // shape drift
+
+  // Albedo variation: low-frequency tint + mid/high-frequency grain
+  float varLow  = (fbm(uv*0.5) - 0.5) * 0.20;
+  float varMid  = (fbm(uvVar*3.0) - 0.5) * 0.15;
+  float grain   = (noise(uvVar*18.0) - 0.5) * 0.08;
+
+  vec3 stoneColor = u_color;
+  stoneColor *= (1.0 + varLow + varMid + grain);
+
+  // Damp variation in mortar so it stays darker & flatter
+  vec3 mortarColor = stoneColor * 0.55;
+
+  // Micro cracks inside stones (non-repeating lines)
+  float crack = smoothstep(0.02, 0.0, abs(noise(uv*10.0) - 0.5)) * 0.25;
+  stoneColor *= (1.0 - crack * stoneMask);
+
+  // Ambient occlusion / cavity darkening near mortar edges
+  float cavity = smoothstep(0.0, 0.18, edgeMetric); // 0 at border, 1 inside
+  float ao = mix(0.55, 1.0, cavity) * (0.92 + 0.08*fbm(uv*2.5));
+
+  // Height map: stones bulge slightly; mortar recessed
+  float microBump  = (fbm(uvVar*4.0) - 0.5) * 0.06 * stoneMask;
+  float macroWarp  = (fbm(uv*1.2) - 0.5) * 0.03 * stoneMask;
+  float mortarDip  = -0.06 * mortarMask;
+  float h = microBump + macroWarp + mortarDip;
+
+  // Normal from screen-space derivatives of height
+  float sx = dFdx(h);
+  float sy = dFdy(h);
+  float strength = 14.0;                       // increase for sharper normals
+  vec3 N = normalize(vec3(-sx*strength, 1.0, -sy*strength));
+
+  // Lighting vectors
+  vec3 L = normalize(u_lightDirection);
+  vec3 V = normalize(vec3(0.0, 0.9, 0.4));     // plausible view dir without new uniforms
+
+  // Diffuse (Lambert) + AO
+  float NdotL = max(dot(N, L), 0.0);
+  float diffuse = NdotL;
+
+  // Roughness varies: smoother on worn tops, rougher near edges
+  float steep = clamp(length(vec2(sx, sy))*strength, 0.0, 1.0);
+  float roughness = mix(0.65, 0.95, steep);    // chippy edges are rougher
+  float F0 = 0.035;                            // dielectric stone
+
+  float spec = ggxSpecular(N, V, L, roughness, F0);
+
+  // Base color blend: pick stone vs mortar, then apply AO
+  vec3 baseColor = mix(mortarColor, stoneColor, stoneMask);
+  vec3 litColor  = baseColor * (0.35 + 0.70*diffuse) * ao;
+
+  // Add subtle warm bounce and cool skylight without extra uniforms
+  vec3 hemiSky   = vec3(0.18, 0.24, 0.30);
+  vec3 hemiGround= vec3(0.12, 0.10, 0.09);
+  float hemi     = N.y*0.5 + 0.5;
+  litColor += mix(hemiGround, hemiSky, hemi) * 0.15;
+
+  // Specular highlight (very subtle on stone)
+  litColor += vec3(1.0) * spec * 0.25;
+
+  // Dirt/grime accumulation in cavities (slight desaturation & darken)
+  float grime = (1.0 - cavity) * 0.25 * (0.8 + 0.2*noise(uv*7.0));
+  float gray = dot(litColor, vec3(0.299, 0.587, 0.114));
+  litColor = mix(litColor, vec3(gray*0.9), grime);
+
+  FragColor = vec4(litColor, 1.0);
+}

+ 23 - 0
assets/shaders/bridge.vert

@@ -0,0 +1,23 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+
+void main() {
+  // Transform normal to world space
+  v_normal = normalize(mat3(transpose(inverse(u_model))) * a_normal);
+  v_texCoord = a_texCoord;
+  
+  // World position for lighting and fog
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+  
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 218 - 32
assets/shaders/river.frag

@@ -1,39 +1,225 @@
 #version 330 core
 out vec4 FragColor;
 in vec2 TexCoord;
+in vec3 WorldPos;
 
 uniform float time;
 
-// Simple hash for procedural water texture
-float hash21(vec2 p) {
-  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
-}
-
-float noise21(vec2 p) {
-  vec2 i = floor(p);
-  vec2 f = fract(p);
-  
-  float a = hash21(i);
-  float b = hash21(i + vec2(1.0, 0.0));
-  float c = hash21(i + vec2(0.0, 1.0));
-  float d = hash21(i + vec2(1.0, 1.0));
-  
-  vec2 u = f * f * (3.0 - 2.0 * f);
-  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
-}
-
-void main() {
-    // Animate UVs for water flow
-    vec2 flowUV = vec2(TexCoord.x + time * 0.05, TexCoord.y);
-    
-    // Create procedural water texture with multiple layers
-    float n1 = noise21(flowUV * 8.0);
-    float n2 = noise21(flowUV * 16.0 + vec2(time * 0.1, 0.0));
-    float waterPattern = n1 * 0.6 + n2 * 0.4;
-    
-    // Base water color (blue-green tint)
-    vec3 waterColor = vec3(0.2, 0.4, 0.6) + vec3(waterPattern * 0.2);
-    
-    // Add slight transparency and color tint
-    FragColor = vec4(waterColor * 0.8 + vec3(0.0, 0.05, 0.1), 0.9);
+// ------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------
+const float PI = 3.14159265359;
+
+mat2 rot(float a){ float c=cos(a), s=sin(a); return mat2(c,-s,s,c); }
+
+// Hash / noise / fbm (kept compatible with original)
+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, 0.0));
+    float c = hash(i + vec2(0.0, 1.0));
+    float d = hash(i + vec2(1.0, 1.0));
+    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+    float v = 0.0;
+    float a = 0.5;
+    float f = 1.0;
+    for(int i=0; i<5; i++){
+        v += a * noise(p * f);
+        f *= 2.0;
+        a *= 0.5;
+    }
+    return v;
+}
+
+// Lightweight Voronoi for animated caustics
+float voronoi(vec2 p) {
+    vec2 n = floor(p);
+    vec2 f = fract(p);
+    float md = 1.0;
+    for(int j=-1; j<=1; j++){
+        for(int i=-1; i<=1; i++){
+            vec2 g = vec2(float(i), float(j));
+            vec2 r = vec2(hash(n+g), hash(n+g+1.37));
+            r = 0.5 + 0.5 * sin(time*0.5 + 6.2831*r);
+            vec2 d = g + r - f;
+            md = min(md, length(d));
+        }
+    }
+    return md;
+}
+
+// ------------------------------------------------------------
+// Water surface synthesis (height field only for shading)
+// ------------------------------------------------------------
+
+// Domain warping to break tiling and add turbulence
+vec2 warp(vec2 uv){
+    vec2 w = vec2(fbm(uv*0.8 + time*0.05),
+                  fbm(uv*0.9 - time*0.04));
+    return uv + 0.25*w;
+}
+
+// Multi-directional sine/gerstner-ish waves (height only)
+float waveHeight(vec2 uv){
+    // Three primary swell directions
+    vec2 d1 = normalize(vec2(1.0, 0.3));
+    vec2 d2 = normalize(vec2(-0.6, 1.0));
+    vec2 d3 = normalize(vec2(0.2, 1.0));
+
+    uv = warp(uv);
+
+    float h  = 0.0;
+    float t  = time;
+
+    // Long swells
+    h += 0.35 * sin(dot(uv*1.6, d1)*6.0 - t*0.8);
+    h += 0.25 * sin(dot(uv*1.9, d2)*7.0 - t*0.9);
+    // Choppy mid-frequency
+    h += 0.20 * sin(dot(uv*3.2, d3)*10.5 - t*1.6);
+    // Fine ripples via fbm
+    h += 0.10 * (fbm(uv*4.0 + t*0.2) - 0.5);
+    h += 0.05 * (fbm(rot(1.2)*uv*7.0 - t*0.35) - 0.5);
+
+    return h;
+}
+
+// Estimate normal from screen-space derivatives of the height field
+vec3 waterNormal(float h){
+    // Derivatives of height across screen; scale to control "normal strength"
+    float sx = dFdx(h);
+    float sy = dFdy(h);
+    float strength = 8.0; // increase for choppier normals
+    vec3 N = normalize(vec3(-sx*strength, 1.0, -sy*strength));
+    return N;
+}
+
+// Simple sky model for reflections
+vec3 skyColor(vec3 rd, vec3 sunDir){
+    float t = clamp(rd.y*0.5 + 0.5, 0.0, 1.0);
+    vec3 horizon = vec3(0.75, 0.85, 0.95);
+    vec3 zenith  = vec3(0.20, 0.42, 0.70);
+    vec3 sky = mix(horizon, zenith, t);
+    // Sun glow
+    float sun = pow(max(dot(rd, sunDir), 0.0), 600.0);
+    sky += vec3(1.0, 0.95, 0.85) * sun * 2.5;
+    return sky;
+}
+
+// Schlick Fresnel
+float fresnelSchlick(float cosTheta, float F0){
+    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+// Minimal GGX specular (Smith-Schlick) for a crisp sun highlight
+float ggxSpecular(vec3 N, vec3 V, vec3 L, float rough, float F0){
+    vec3 H = normalize(V + L);
+    float NdotV = max(dot(N, V), 0.0);
+    float NdotL = max(dot(N, L), 0.0);
+    float NdotH = max(dot(N, H), 0.0);
+    float VdotH = max(dot(V, H), 0.0);
+
+    float a = max(rough*rough, 0.001);
+    float a2 = a*a;
+
+    float denom = (NdotH*NdotH*(a2-1.0)+1.0);
+    float D = a2 / (PI * denom * denom);
+
+    float k = (a + 1.0);
+    k = (k*k)/8.0;
+    float Gv = NdotV / (NdotV*(1.0 - k) + k);
+    float Gl = NdotL / (NdotL*(1.0 - k) + k);
+    float G = Gv * Gl;
+
+    float F = fresnelSchlick(VdotH, F0);
+    return (D * G * F) / max(4.0 * NdotV * NdotL, 0.001);
+}
+
+// ------------------------------------------------------------
+// Main
+// ------------------------------------------------------------
+void main()
+{
+    // Use a mix of TexCoord and WorldPos to reduce tiling and anchor to world
+    vec2 uv = TexCoord * 4.0 + WorldPos.xz * 0.15;
+
+    // Height field and derived features
+    float h = waveHeight(uv);
+    vec3  N = waterNormal(h);
+
+    // Lighting setup (constants: no new uniforms)
+    vec3 sunDir = normalize(vec3(0.28, 0.85, 0.43)); // warm afternoon sun
+    vec3 V      = normalize(vec3(0.0, 0.7, 0.7));    // approximate view from above
+    // (If you prefer a fixed camera anchor, uncomment this instead)
+    // vec3 camPos = vec3(0.0, 2.7, 3.0);
+    // vec3 V = normalize(camPos - WorldPos);
+
+    // Fresnel & reflection
+    float NdotV = max(dot(N, V), 0.0);
+    float F0 = 0.02; // water IOR ~1.33
+    float F = fresnelSchlick(NdotV, F0);
+
+    vec3 R = reflect(-V, N);
+    vec3 reflection = skyColor(R, sunDir);
+
+    // Base transmission color (absorption-tinted)
+    vec3 deepWater    = vec3(0.02, 0.07, 0.11);
+    vec3 shallowWater = vec3(0.12, 0.30, 0.38);
+
+    // Pseudo "shallowness": calmer patches look shallower; also tie to uv to vary
+    float calm = smoothstep(0.0, 0.45, abs(h));
+    float shallowFactor = clamp(0.35 + 0.35*(fbm(uv*0.6) * (1.0 - calm)), 0.0, 1.0);
+    vec3 transmission = mix(deepWater, shallowWater, shallowFactor);
+
+    // Caustics: brighten transmission where cells converge
+    float c1 = voronoi(uv*2.0 + time*0.1);
+    float c2 = voronoi(rot(0.7)*(uv*1.5 - time*0.08));
+    float caustics = pow(1.0 - 0.7*(c1*0.6 + c2*0.4), 2.5);
+    transmission += vec3(0.55, 0.70, 0.85) * caustics * 0.20;
+
+    // Sun lighting (GGX specular + a touch of diffuse subsurface)
+    float roughness = mix(0.08, 0.18, smoothstep(0.0, 0.6, length(vec2(dFdx(h), dFdy(h)))));
+    float spec = ggxSpecular(N, V, sunDir, roughness, F0);
+    float NdotL = max(dot(N, sunDir), 0.0);
+    vec3 sunDiffuse = transmission * NdotL * 0.25;
+
+    // Foam: crest (steepness) + screen edge foam from original idea
+    float steep = length(vec2(dFdx(h), dFdy(h)));
+    float crestFoam = smoothstep(0.38, 0.95, steep);
+    // Animate foam breakup
+    crestFoam *= 0.6 + 0.4*fbm(uv*3.5 + time*0.7);
+
+    // Edge foam (reuse TexCoord idea but subtler and 2D)
+    float edgeX = smoothstep(0.02, 0.12, TexCoord.x) * smoothstep(0.02, 0.12, 1.0 - TexCoord.x);
+    float edgeY = smoothstep(0.02, 0.12, TexCoord.y) * smoothstep(0.02, 0.12, 1.0 - TexCoord.y);
+    float frameEdge = (1.0 - edgeX*edgeY);
+    float foamNoise = noise(uv*6.0 + time*0.5);
+    float foam = clamp(crestFoam*0.85 + frameEdge*foamNoise*0.35, 0.0, 1.0);
+
+    vec3 foamColor = vec3(0.93, 0.96, 1.0);
+
+    // Combine transmission and reflection with Fresnel
+    vec3 color = mix(transmission, reflection, F);
+    // Add lighting
+    color += vec3(1.0) * spec * 1.2;
+    color += sunDiffuse;
+
+    // Mix in foam on top
+    color = mix(color, foamColor, foam);
+
+    // Subtle blue highlight along glancing angles
+    color += vec3(0.05, 0.08, 0.12) * pow(1.0 - NdotV, 3.0);
+
+    // Final tone and alpha
+    FragColor = vec4(color, 0.85);
 }

+ 18 - 2
assets/shaders/river.vert

@@ -8,11 +8,27 @@ uniform mat4 projection;
 uniform float time;
 
 out vec2 TexCoord;
+out vec3 WorldPos;
 
 void main() {
-    // Slight vertex wave to simulate water movement
+    // Multi-directional wave simulation
     vec3 pos = aPos;
-    pos.y += sin(aPos.x * 0.1 + time * 0.8) * 0.05;
+    
+    // Primary waves along river flow
+    float wave1 = sin(aPos.z * 0.5 + time * 2.0) * 0.04;
+    float wave2 = sin(aPos.x * 0.8 + time * 1.5) * 0.03;
+    float wave3 = sin((aPos.x + aPos.z) * 0.3 + time * 2.5) * 0.02;
+    
+    // Cross-ripples for realism
+    float ripple1 = sin(aPos.x * 2.0 + aPos.z * 1.5 + time * 3.0) * 0.015;
+    float ripple2 = cos(aPos.z * 2.5 - aPos.x * 1.2 + time * 2.2) * 0.012;
+    
+    // Combine all wave motions
+    pos.y += wave1 + wave2 + wave3 + ripple1 + ripple2;
+    
+    // Output world position for fragment shader
+    WorldPos = (model * vec4(pos, 1.0)).xyz;
+    
     gl_Position = projection * view * model * vec4(pos, 1.0);
     TexCoord = aTexCoord;
 }

+ 1 - 0
game/map/map_definition.h

@@ -50,6 +50,7 @@ struct MapDefinition {
   std::vector<UnitSpawn> spawns;
   std::vector<TerrainFeature> terrain;
   std::vector<RiverSegment> rivers;
+  std::vector<Bridge> bridges;
   BiomeSettings biome;
   CoordSystem coordSystem = CoordSystem::Grid;
   int maxTroopsPerPlayer = 50;

+ 67 - 0
game/map/map_loader.cpp

@@ -1,5 +1,6 @@
 #include "map_loader.h"
 
+#include <QDebug>
 #include <QFile>
 #include <QJsonArray>
 #include <QJsonDocument>
@@ -262,6 +263,7 @@ static void readTerrain(const QJsonArray &arr, std::vector<TerrainFeature> &out,
 
 static void readRivers(const QJsonArray &arr, std::vector<RiverSegment> &out,
                        const GridDefinition &grid, CoordSystem coordSys) {
+  qDebug() << "readRivers: Processing" << arr.size() << "river segments";
   out.clear();
   out.reserve(arr.size());
 
@@ -307,8 +309,68 @@ static void readRivers(const QJsonArray &arr, std::vector<RiverSegment> &out,
       segment.width = float(o.value("width").toDouble(2.0));
     }
 
+    qDebug() << "  River segment: start=" << segment.start << "end=" << segment.end << "width=" << segment.width;
     out.push_back(segment);
   }
+  qDebug() << "readRivers: Loaded" << out.size() << "river segments";
+}
+
+static void readBridges(const QJsonArray &arr, std::vector<Bridge> &out,
+                        const GridDefinition &grid, CoordSystem coordSys) {
+  qDebug() << "readBridges: Processing" << arr.size() << "bridges";
+  out.clear();
+  out.reserve(arr.size());
+
+  for (const auto &v : arr) {
+    auto o = v.toObject();
+    Bridge bridge;
+
+    if (o.contains("start") && o.value("start").isArray()) {
+      auto startArr = o.value("start").toArray();
+      if (startArr.size() >= 2) {
+        float x = float(startArr[0].toDouble(0.0));
+        float z = float(startArr[1].toDouble(0.0));
+
+        if (coordSys == CoordSystem::Grid) {
+          const float tile = std::max(0.0001f, grid.tileSize);
+          bridge.start.setX((x - (grid.width * 0.5f - 0.5f)) * tile);
+          bridge.start.setY(0.2f); // Slightly above ground
+          bridge.start.setZ((z - (grid.height * 0.5f - 0.5f)) * tile);
+        } else {
+          bridge.start = QVector3D(x, 0.2f, z);
+        }
+      }
+    }
+
+    if (o.contains("end") && o.value("end").isArray()) {
+      auto endArr = o.value("end").toArray();
+      if (endArr.size() >= 2) {
+        float x = float(endArr[0].toDouble(0.0));
+        float z = float(endArr[1].toDouble(0.0));
+
+        if (coordSys == CoordSystem::Grid) {
+          const float tile = std::max(0.0001f, grid.tileSize);
+          bridge.end.setX((x - (grid.width * 0.5f - 0.5f)) * tile);
+          bridge.end.setY(0.2f); // Slightly above ground
+          bridge.end.setZ((z - (grid.height * 0.5f - 0.5f)) * tile);
+        } else {
+          bridge.end = QVector3D(x, 0.2f, z);
+        }
+      }
+    }
+
+    if (o.contains("width")) {
+      bridge.width = float(o.value("width").toDouble(3.0));
+    }
+    
+    if (o.contains("height")) {
+      bridge.height = float(o.value("height").toDouble(0.5));
+    }
+
+    qDebug() << "  Bridge: start=" << bridge.start << "end=" << bridge.end << "width=" << bridge.width;
+    out.push_back(bridge);
+  }
+  qDebug() << "readBridges: Loaded" << out.size() << "bridges";
 }
 
 bool MapLoader::loadFromJsonFile(const QString &path, MapDefinition &outMap,
@@ -378,6 +440,11 @@ bool MapLoader::loadFromJsonFile(const QString &path, MapDefinition &outMap,
                outMap.coordSystem);
   }
 
+  if (root.contains("bridges") && root.value("bridges").isArray()) {
+    readBridges(root.value("bridges").toArray(), outMap.bridges, outMap.grid,
+                outMap.coordSystem);
+  }
+
   if (root.contains("biome") && root.value("biome").isObject()) {
     readBiome(root.value("biome").toObject(), outMap.biome);
   }

+ 20 - 0
game/map/skirmish_loader.cpp

@@ -13,10 +13,12 @@
 #include "game/systems/troop_count_registry.h"
 #include "game/visuals/team_colors.h"
 #include "render/ground/biome_renderer.h"
+#include "render/ground/bridge_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/river_renderer.h"
 #include "render/ground/stone_renderer.h"
 #include "render/ground/terrain_renderer.h"
 #include "render/scene_renderer.h"
@@ -237,6 +239,24 @@ SkirmishLoadResult SkirmishLoader::start(const QString &mapPath,
     }
   }
 
+  if (m_river) {
+    if (terrainService.isInitialized() && terrainService.getHeightMap()) {
+      qDebug() << "SkirmishLoader: Configuring river renderer with" 
+               << terrainService.getHeightMap()->getRiverSegments().size() << "segments";
+      m_river->configure(terrainService.getHeightMap()->getRiverSegments(),
+                        terrainService.getHeightMap()->getTileSize());
+    }
+  }
+
+  if (m_bridge) {
+    if (terrainService.isInitialized() && terrainService.getHeightMap()) {
+      qDebug() << "SkirmishLoader: Configuring bridge renderer with" 
+               << terrainService.getHeightMap()->getBridges().size() << "bridges";
+      m_bridge->configure(terrainService.getHeightMap()->getBridges(),
+                         terrainService.getHeightMap()->getTileSize());
+    }
+  }
+
   if (m_stone) {
     if (terrainService.isInitialized() && terrainService.getHeightMap()) {
       m_stone->configure(*terrainService.getHeightMap(),

+ 6 - 0
game/map/skirmish_loader.h

@@ -25,6 +25,8 @@ class FogRenderer;
 class StoneRenderer;
 class PlantRenderer;
 class PineRenderer;
+class RiverRenderer;
+class BridgeRenderer;
 } // namespace GL
 } // namespace Render
 
@@ -63,6 +65,8 @@ public:
     m_terrain = terrain;
   }
   void setBiomeRenderer(Render::GL::BiomeRenderer *biome) { m_biome = biome; }
+  void setRiverRenderer(Render::GL::RiverRenderer *river) { m_river = river; }
+  void setBridgeRenderer(Render::GL::BridgeRenderer *bridge) { m_bridge = bridge; }
   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; }
@@ -88,6 +92,8 @@ private:
   Render::GL::GroundRenderer *m_ground = nullptr;
   Render::GL::TerrainRenderer *m_terrain = nullptr;
   Render::GL::BiomeRenderer *m_biome = nullptr;
+  Render::GL::RiverRenderer *m_river = nullptr;
+  Render::GL::BridgeRenderer *m_bridge = nullptr;
   Render::GL::FogRenderer *m_fog = nullptr;
   Render::GL::StoneRenderer *m_stone = nullptr;
   Render::GL::PlantRenderer *m_plant = nullptr;

+ 62 - 0
game/map/terrain.cpp

@@ -506,4 +506,66 @@ void TerrainHeightMap::addRiverSegments(
   }
 }
 
+void TerrainHeightMap::addBridges(const std::vector<Bridge> &bridges) {
+  m_bridges = bridges;
+  qDebug() << "TerrainHeightMap: Added" << bridges.size() << "bridges";
+  
+  // Make bridge areas walkable by restoring terrain type
+  const float gridHalfWidth = m_width * 0.5f - 0.5f;
+  const float gridHalfHeight = m_height * 0.5f - 0.5f;
+
+  for (const auto &bridge : bridges) {
+    QVector3D dir = bridge.end - bridge.start;
+    float length = dir.length();
+    if (length < 0.01f)
+      continue;
+
+    dir.normalize();
+    QVector3D perpendicular(-dir.z(), 0.0f, dir.x());
+
+    int steps = static_cast<int>(std::ceil(length / m_tileSize)) + 1;
+
+    for (int i = 0; i < steps; ++i) {
+      float t = static_cast<float>(i) / std::max(1.0f, static_cast<float>(steps - 1));
+      QVector3D centerPos = bridge.start + dir * (length * t);
+      
+      // Calculate bridge deck height at this position (matching bridge_renderer.cpp logic)
+      float archCurve = 4.0f * t * (1.0f - t); // Peaks at t=0.5
+      float archHeight = bridge.height * archCurve * 0.8f;
+      float deckHeight = bridge.start.y() + bridge.height + archHeight * 0.5f;
+
+      float gridCenterX = (centerPos.x() / m_tileSize) + gridHalfWidth;
+      float gridCenterZ = (centerPos.z() / m_tileSize) + gridHalfHeight;
+
+      float halfWidth = bridge.width * 0.5f / m_tileSize;
+
+      int minX = std::max(0, static_cast<int>(std::floor(gridCenterX - halfWidth)));
+      int maxX = std::min(m_width - 1, static_cast<int>(std::ceil(gridCenterX + halfWidth)));
+      int minZ = std::max(0, static_cast<int>(std::floor(gridCenterZ - halfWidth)));
+      int maxZ = std::min(m_height - 1, static_cast<int>(std::ceil(gridCenterZ + halfWidth)));
+
+      for (int z = minZ; z <= maxZ; ++z) {
+        for (int x = minX; x <= maxX; ++x) {
+          float dx = static_cast<float>(x) - gridCenterX;
+          float dz = static_cast<float>(z) - gridCenterZ;
+
+          float distAlongPerp = std::abs(dx * perpendicular.x() + dz * perpendicular.z());
+
+          if (distAlongPerp <= halfWidth) {
+            int idx = indexAt(x, z);
+            // Make bridge areas walkable (convert River back to Flat)
+            if (m_terrainTypes[idx] == TerrainType::River) {
+              m_terrainTypes[idx] = TerrainType::Flat;
+              // Set height to match the bridge deck surface
+              m_heights[idx] = deckHeight;
+            }
+          }
+        }
+      }
+    }
+  }
+  
+  qDebug() << "TerrainHeightMap: Bridges configured - areas are now walkable";
+}
+
 } // namespace Game::Map

+ 13 - 0
game/map/terrain.h

@@ -62,6 +62,13 @@ struct RiverSegment {
   float width = 2.0f;
 };
 
+struct Bridge {
+  QVector3D start;
+  QVector3D end;
+  float width = 3.0f;
+  float height = 0.5f; // Height above water
+};
+
 class TerrainHeightMap {
 public:
   TerrainHeightMap(int width, int height, float tileSize);
@@ -91,6 +98,11 @@ public:
   const std::vector<RiverSegment> &getRiverSegments() const {
     return m_riverSegments;
   }
+  
+  void addBridges(const std::vector<Bridge> &bridges);
+  const std::vector<Bridge> &getBridges() const {
+    return m_bridges;
+  }
 
   void applyBiomeVariation(const BiomeSettings &settings);
 
@@ -104,6 +116,7 @@ private:
   std::vector<bool> m_hillEntrances;
   std::vector<bool> m_hillWalkable;
   std::vector<RiverSegment> m_riverSegments;
+  std::vector<Bridge> m_bridges;
 
   int indexAt(int x, int z) const;
   bool inBounds(int x, int z) const;

+ 3 - 0
game/map/terrain_service.cpp

@@ -16,7 +16,10 @@ void TerrainService::initialize(const MapDefinition &mapDef) {
       mapDef.grid.width, mapDef.grid.height, mapDef.grid.tileSize);
 
   m_heightMap->buildFromFeatures(mapDef.terrain);
+  qDebug() << "TerrainService: Adding" << mapDef.rivers.size() << "river segments to height map";
   m_heightMap->addRiverSegments(mapDef.rivers);
+  qDebug() << "TerrainService: Adding" << mapDef.bridges.size() << "bridges to height map";
+  m_heightMap->addBridges(mapDef.bridges);
   m_biomeSettings = mapDef.biome;
   m_heightMap->applyBiomeVariation(m_biomeSettings);
 }

+ 1 - 0
render/CMakeLists.txt

@@ -16,6 +16,7 @@ add_library(render_gl STATIC
     ground/fog_renderer.cpp
     ground/terrain_renderer.cpp
     ground/river_renderer.cpp
+    ground/bridge_renderer.cpp
     ground/biome_renderer.cpp
     ground/stone_renderer.cpp
     ground/plant_renderer.cpp

+ 64 - 0
render/gl/backend.cpp

@@ -57,6 +57,8 @@ void Backend::initialize() {
   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_riverShader = m_shaderCache->get(QStringLiteral("river"));
+  m_bridgeShader = m_shaderCache->get(QStringLiteral("bridge"));
   m_archerShader = m_shaderCache->get(QStringLiteral("archer"));
   m_knightShader = m_shaderCache->get(QStringLiteral("knight"));
   if (!m_basicShader)
@@ -81,6 +83,10 @@ void Backend::initialize() {
     qWarning() << "Backend: ground_plane shader missing";
   if (!m_terrainShader)
     qWarning() << "Backend: terrain shader missing";
+  if (!m_riverShader)
+    qWarning() << "Backend: river shader missing";
+  if (!m_bridgeShader)
+    qWarning() << "Backend: bridge shader missing";
   if (!m_archerShader)
     qWarning() << "Backend: archer shader missing";
   if (!m_knightShader)
@@ -98,6 +104,8 @@ void Backend::initialize() {
   cachePineUniforms();
   cacheGroundUniforms();
   cacheTerrainUniforms();
+  cacheRiverUniforms();
+  cacheBridgeUniforms();
   initializeCylinderPipeline();
   initializeFogPipeline();
   initializeGrassPipeline();
@@ -620,6 +628,42 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
       if (!activeShader)
         break;
 
+      // Handle river shader specially
+      if (activeShader == m_riverShader) {
+        if (m_lastBoundShader != activeShader) {
+          activeShader->use();
+          m_lastBoundShader = activeShader;
+        }
+
+        activeShader->setUniform(m_riverUniforms.model, it.model);
+        activeShader->setUniform(m_riverUniforms.view, cam.getViewMatrix());
+        activeShader->setUniform(m_riverUniforms.projection, cam.getProjectionMatrix());
+        activeShader->setUniform(m_riverUniforms.time, m_animationTime);
+
+        it.mesh->draw();
+        break;
+      }
+
+      // Handle bridge shader specially
+      if (activeShader == m_bridgeShader) {
+        if (m_lastBoundShader != activeShader) {
+          activeShader->use();
+          m_lastBoundShader = activeShader;
+        }
+
+        // Use proper bridge uniforms
+        activeShader->setUniform(m_bridgeUniforms.mvp, it.mvp);
+        activeShader->setUniform(m_bridgeUniforms.model, it.model);
+        activeShader->setUniform(m_bridgeUniforms.color, it.color);
+        
+        // Set light direction for stone shading
+        QVector3D lightDir(0.35f, 0.8f, 0.45f);
+        activeShader->setUniform(m_bridgeUniforms.lightDirection, lightDir);
+
+        it.mesh->draw();
+        break;
+      }
+
       BasicUniforms *uniforms = &m_basicUniforms;
       if (activeShader == m_archerShader)
         uniforms = &m_archerUniforms;
@@ -1189,6 +1233,26 @@ void Backend::cacheTerrainUniforms() {
   m_terrainUniforms.lightDir = m_terrainShader->uniformHandle("u_lightDir");
 }
 
+void Backend::cacheRiverUniforms() {
+  if (!m_riverShader)
+    return;
+
+  m_riverUniforms.model = m_riverShader->uniformHandle("model");
+  m_riverUniforms.view = m_riverShader->uniformHandle("view");
+  m_riverUniforms.projection = m_riverShader->uniformHandle("projection");
+  m_riverUniforms.time = m_riverShader->uniformHandle("time");
+}
+
+void Backend::cacheBridgeUniforms() {
+  if (!m_bridgeShader)
+    return;
+
+  m_bridgeUniforms.mvp = m_bridgeShader->uniformHandle("u_mvp");
+  m_bridgeUniforms.model = m_bridgeShader->uniformHandle("u_model");
+  m_bridgeUniforms.color = m_bridgeShader->uniformHandle("u_color");
+  m_bridgeUniforms.lightDirection = m_bridgeShader->uniformHandle("u_lightDirection");
+}
+
 void Backend::initializeGrassPipeline() {
   initializeOpenGLFunctions();
   shutdownGrassPipeline();

+ 20 - 0
render/gl/backend.h

@@ -27,6 +27,7 @@ public:
   void beginFrame();
   void setViewport(int w, int h);
   void setClearColor(float r, float g, float b, float a);
+  void setAnimationTime(float time) { m_animationTime = time; }
   void execute(const DrawQueue &queue, const Camera &cam);
 
   ResourceManager *resources() const { return m_resources.get(); }
@@ -85,6 +86,8 @@ private:
   Shader *m_pineShader = nullptr;
   Shader *m_groundShader = nullptr;
   Shader *m_terrainShader = nullptr;
+  Shader *m_riverShader = nullptr;
+  Shader *m_bridgeShader = nullptr;
   Shader *m_archerShader = nullptr;
   Shader *m_knightShader = nullptr;
 
@@ -100,6 +103,20 @@ private:
   BasicUniforms m_archerUniforms;
   BasicUniforms m_knightUniforms;
 
+  struct RiverUniforms {
+    Shader::UniformHandle model{Shader::InvalidUniform};
+    Shader::UniformHandle view{Shader::InvalidUniform};
+    Shader::UniformHandle projection{Shader::InvalidUniform};
+    Shader::UniformHandle time{Shader::InvalidUniform};
+  } m_riverUniforms;
+
+  struct BridgeUniforms {
+    Shader::UniformHandle mvp{Shader::InvalidUniform};
+    Shader::UniformHandle model{Shader::InvalidUniform};
+    Shader::UniformHandle color{Shader::InvalidUniform};
+    Shader::UniformHandle lightDirection{Shader::InvalidUniform};
+  } m_bridgeUniforms;
+
   struct GridUniforms {
     Shader::UniformHandle mvp{Shader::InvalidUniform};
     Shader::UniformHandle model{Shader::InvalidUniform};
@@ -275,11 +292,14 @@ private:
   void shutdownPinePipeline();
   void cacheGroundUniforms();
   void cacheTerrainUniforms();
+  void cacheRiverUniforms();
+  void cacheBridgeUniforms();
 
   Shader *m_lastBoundShader = nullptr;
   Texture *m_lastBoundTexture = nullptr;
   bool m_depthTestEnabled = true;
   bool m_blendEnabled = false;
+  float m_animationTime = 0.0f;
 };
 
 } // namespace Render::GL

+ 4 - 0
render/gl/shader_cache.h

@@ -95,6 +95,10 @@ public:
     const QString riverFrag = kShaderBase + QStringLiteral("river.frag");
     load(QStringLiteral("river"), riverVert, riverFrag);
 
+    const QString bridgeVert = kShaderBase + QStringLiteral("bridge.vert");
+    const QString bridgeFrag = kShaderBase + QStringLiteral("bridge.frag");
+    load(QStringLiteral("bridge"), bridgeVert, bridgeFrag);
+
     const QString archerVert = kShaderBase + QStringLiteral("archer.vert");
     const QString archerFrag = kShaderBase + QStringLiteral("archer.frag");
     load(QStringLiteral("archer"), archerVert, archerFrag);

+ 255 - 0
render/ground/bridge_renderer.cpp

@@ -0,0 +1,255 @@
+#include "bridge_renderer.h"
+#include "../gl/mesh.h"
+#include "../gl/resources.h"
+#include "../scene_renderer.h"
+#include "terrain_gpu.h"
+#include "../../game/map/visibility_service.h"
+#include <QDebug>
+#include <QVector2D>
+#include <QVector3D>
+#include <cmath>
+
+namespace Render::GL {
+
+BridgeRenderer::BridgeRenderer() = default;
+BridgeRenderer::~BridgeRenderer() = default;
+
+void BridgeRenderer::configure(const std::vector<Game::Map::Bridge> &bridges,
+                                float tileSize) {
+  m_bridges = bridges;
+  m_tileSize = tileSize;
+  qDebug() << "BridgeRenderer::configure() called with" << bridges.size()
+           << "bridges, tileSize:" << tileSize;
+  buildMeshes();
+}
+
+void BridgeRenderer::buildMeshes() {
+  if (m_bridges.empty()) {
+    qDebug() << "BridgeRenderer::buildMeshes() - No bridges to build";
+    m_mesh.reset();
+    return;
+  }
+
+  qDebug() << "BridgeRenderer::buildMeshes() - Building meshes for"
+           << m_bridges.size() << "bridges";
+  std::vector<Vertex> vertices;
+  std::vector<unsigned int> indices;
+
+  for (const auto &bridge : m_bridges) {
+    QVector3D dir = bridge.end - bridge.start;
+    float length = dir.length();
+    if (length < 0.01f)
+      continue;
+
+    dir.normalize();
+    QVector3D perpendicular(-dir.z(), 0.0f, dir.x());
+    float halfWidth = bridge.width * 0.5f;
+
+    int lengthSegments = static_cast<int>(std::ceil(length / (m_tileSize * 0.3f)));
+    lengthSegments = std::max(lengthSegments, 8);
+
+    unsigned int baseIndex = static_cast<unsigned int>(vertices.size());
+
+    // Medieval stone bridge with arched underside
+    for (int i = 0; i <= lengthSegments; ++i) {
+      float t = static_cast<float>(i) / static_cast<float>(lengthSegments);
+      QVector3D centerPos = bridge.start + dir * (length * t);
+
+      // Arch curve (parabolic)
+      float archCurve = 4.0f * t * (1.0f - t); // Peaks at t=0.5
+      float archHeight = bridge.height * archCurve * 0.8f;
+
+      // Bridge deck (top surface)
+      float deckHeight = bridge.start.y() + bridge.height + archHeight * 0.3f;
+
+      // Stone texture variation using position-based noise
+      float stoneNoise = std::sin(centerPos.x() * 3.0f) * std::cos(centerPos.z() * 2.5f) * 0.02f;
+
+      // Create bridge deck vertices
+      for (int side = 0; side < 2; ++side) {
+        float sideSign = (side == 0) ? -1.0f : 1.0f;
+        QVector3D edgePos = centerPos + perpendicular * (halfWidth * sideSign);
+        edgePos.setY(deckHeight + stoneNoise);
+
+        Vertex deckVertex;
+        deckVertex.position[0] = edgePos.x();
+        deckVertex.position[1] = edgePos.y();
+        deckVertex.position[2] = edgePos.z();
+        deckVertex.normal[0] = 0.0f;
+        deckVertex.normal[1] = 1.0f;
+        deckVertex.normal[2] = 0.0f;
+        deckVertex.texCoord[0] = side * 1.0f;
+        deckVertex.texCoord[1] = t * length * 0.5f;
+        vertices.push_back(deckVertex);
+      }
+
+      // Create arch support vertices (underneath)
+      float archUnderHeight = bridge.start.y() + archHeight;
+      for (int side = 0; side < 2; ++side) {
+        float sideSign = (side == 0) ? -1.0f : 1.0f;
+        float archWidth = halfWidth * 0.7f; // Narrower arch
+        QVector3D archPos = centerPos + perpendicular * (archWidth * sideSign);
+        archPos.setY(archUnderHeight);
+
+        Vertex archVertex;
+        archVertex.position[0] = archPos.x();
+        archVertex.position[1] = archPos.y();
+        archVertex.position[2] = archPos.z();
+        archVertex.normal[0] = perpendicular.x() * sideSign;
+        archVertex.normal[1] = -0.5f;
+        archVertex.normal[2] = perpendicular.z() * sideSign;
+        archVertex.texCoord[0] = side * 1.0f;
+        archVertex.texCoord[1] = t * length * 0.5f;
+        vertices.push_back(archVertex);
+      }
+
+      // Stone parapet (railing) vertices
+      float parapetHeight = deckHeight + 0.25f;
+      for (int side = 0; side < 2; ++side) {
+        float sideSign = (side == 0) ? -1.0f : 1.0f;
+        QVector3D parapetPos = centerPos + perpendicular * (halfWidth * sideSign * 1.05f);
+        parapetPos.setY(parapetHeight);
+
+        Vertex parapetVertex;
+        parapetVertex.position[0] = parapetPos.x();
+        parapetVertex.position[1] = parapetPos.y();
+        parapetVertex.position[2] = parapetPos.z();
+        parapetVertex.normal[0] = perpendicular.x() * sideSign;
+        parapetVertex.normal[1] = 0.0f;
+        parapetVertex.normal[2] = perpendicular.z() * sideSign;
+        parapetVertex.texCoord[0] = side * 1.0f;
+        parapetVertex.texCoord[1] = t * length * 0.5f;
+        vertices.push_back(parapetVertex);
+      }
+
+      // Create indices for bridge geometry
+      if (i < lengthSegments) {
+        unsigned int idx = baseIndex + i * 6;
+
+        // Bridge deck (top)
+        indices.push_back(idx + 0);
+        indices.push_back(idx + 6);
+        indices.push_back(idx + 1);
+        indices.push_back(idx + 1);
+        indices.push_back(idx + 6);
+        indices.push_back(idx + 7);
+
+        // Arch underside (left)
+        indices.push_back(idx + 2);
+        indices.push_back(idx + 8);
+        indices.push_back(idx + 0);
+        indices.push_back(idx + 0);
+        indices.push_back(idx + 8);
+        indices.push_back(idx + 6);
+
+        // Arch underside (right)
+        indices.push_back(idx + 1);
+        indices.push_back(idx + 9);
+        indices.push_back(idx + 3);
+        indices.push_back(idx + 1);
+        indices.push_back(idx + 7);
+        indices.push_back(idx + 9);
+
+        // Parapet walls (left)
+        indices.push_back(idx + 0);
+        indices.push_back(idx + 6);
+        indices.push_back(idx + 4);
+        indices.push_back(idx + 4);
+        indices.push_back(idx + 6);
+        indices.push_back(idx + 10);
+
+        // Parapet walls (right)
+        indices.push_back(idx + 1);
+        indices.push_back(idx + 5);
+        indices.push_back(idx + 7);
+        indices.push_back(idx + 5);
+        indices.push_back(idx + 11);
+        indices.push_back(idx + 7);
+      }
+    }
+  }
+
+  if (vertices.empty() || indices.empty()) {
+    qDebug() << "BridgeRenderer::buildMeshes() - No vertices/indices generated";
+    m_mesh.reset();
+    return;
+  }
+
+  qDebug() << "BridgeRenderer::buildMeshes() - Created mesh with"
+           << vertices.size() << "vertices and" << indices.size() << "indices";
+  m_mesh = std::make_unique<Mesh>(vertices, indices);
+}
+
+void BridgeRenderer::submit(Renderer &renderer, ResourceManager *resources) {
+  if (!m_mesh || m_bridges.empty()) {
+    return;
+  }
+
+  Q_UNUSED(resources);
+
+  // Check fog of war visibility - if any bridge is visible, render all bridges
+  auto &visibility = Game::Map::VisibilityService::instance();
+  const bool useVisibility = visibility.isInitialized();
+  
+  if (useVisibility) {
+    bool anyVisible = false;
+    
+    // Check if any part of any bridge is visible
+    for (const auto &bridge : m_bridges) {
+      QVector3D dir = bridge.end - bridge.start;
+      float length = dir.length();
+      if (length < 0.01f)
+        continue;
+      
+      dir.normalize();
+      
+      // Check start, middle, and end points
+      for (int i = 0; i <= 2 && !anyVisible; ++i) {
+        float t = i * 0.5f;
+        QVector3D pos = bridge.start + dir * (length * t);
+        
+        if (visibility.isVisibleWorld(pos.x(), pos.z())) {
+          anyVisible = true;
+          break;
+        }
+      }
+      
+      if (anyVisible)
+        break;
+    }
+    
+    if (!anyVisible) {
+      // No bridges visible - skip rendering entirely
+      return;
+    }
+  }
+
+  // Get the bridge shader
+  auto shader = renderer.getShader("bridge");
+  if (!shader) {
+    qDebug() << "BridgeRenderer::submit() - Bridge shader not found! Falling back to basic shader";
+    shader = renderer.getShader("basic");
+    if (!shader) {
+      qDebug() << "BridgeRenderer::submit() - Basic shader also not found!";
+      return;
+    }
+  }
+
+  // Set the shader as current
+  renderer.setCurrentShader(shader);
+
+  // Submit the bridge mesh
+  QMatrix4x4 model;
+  model.setToIdentity();
+  
+  // Medieval stone color (weathered gray stone)
+  QVector3D stoneColor(0.55f, 0.52f, 0.48f);
+  
+  // Use mesh() which will use the bridge shader
+  renderer.mesh(m_mesh.get(), model, stoneColor, nullptr, 1.0f);
+
+  // Reset the current shader
+  renderer.setCurrentShader(nullptr);
+}
+
+} // namespace Render::GL

+ 34 - 0
render/ground/bridge_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 BridgeRenderer : public IRenderPass {
+public:
+  BridgeRenderer();
+  ~BridgeRenderer();
+
+  void configure(const std::vector<Game::Map::Bridge> &bridges,
+                 float tileSize);
+
+  void submit(Renderer &renderer, ResourceManager *resources) override;
+
+private:
+  void buildMeshes();
+
+  std::vector<Game::Map::Bridge> m_bridges;
+  float m_tileSize = 1.0f;
+  std::unique_ptr<Mesh> m_mesh;
+};
+
+} // namespace GL
+} // namespace Render

+ 100 - 28
render/ground/river_renderer.cpp

@@ -2,6 +2,7 @@
 #include "../gl/mesh.h"
 #include "../gl/resources.h"
 #include "../scene_renderer.h"
+#include <QDebug>
 #include <QVector2D>
 #include <QVector3D>
 #include <cmath>
@@ -15,18 +16,48 @@ void RiverRenderer::configure(
     const std::vector<Game::Map::RiverSegment> &riverSegments, float tileSize) {
   m_riverSegments = riverSegments;
   m_tileSize = tileSize;
+  qDebug() << "RiverRenderer::configure() called with" << riverSegments.size() << "segments, tileSize:" << tileSize;
   buildMeshes();
 }
 
 void RiverRenderer::buildMeshes() {
   if (m_riverSegments.empty()) {
+    qDebug() << "RiverRenderer::buildMeshes() - No river segments to build";
     m_mesh.reset();
     return;
   }
 
-  std::vector<float> vertices;
+  qDebug() << "RiverRenderer::buildMeshes() - Building meshes for" << m_riverSegments.size() << "river segments";
+  std::vector<Vertex> vertices;
   std::vector<unsigned int> indices;
 
+  // Helper function for noise-based edge variation
+  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;
+    
+    // Smooth interpolation
+    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;
+  };
+
   for (const auto &segment : m_riverSegments) {
     QVector3D dir = segment.end - segment.start;
     float length = dir.length();
@@ -37,32 +68,66 @@ void RiverRenderer::buildMeshes() {
     QVector3D perpendicular(-dir.z(), 0.0f, dir.x());
     float halfWidth = segment.width * 0.5f;
 
-    int lengthSteps = static_cast<int>(std::ceil(length / m_tileSize)) + 1;
-    lengthSteps = std::max(lengthSteps, 2);
+    // Increase steps for smoother organic edges
+    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() / 5);
+    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);
+      float t = static_cast<float>(i) / static_cast<float>(lengthSteps - 1);
       QVector3D centerPos = segment.start + dir * (length * t);
 
-      QVector3D left = centerPos - perpendicular * halfWidth;
-      QVector3D right = centerPos + perpendicular * halfWidth;
+      // Add multi-octave noise to edge width for organic look
+      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);
+      
+      // Combine noise octaves
+      float combinedNoise = edgeNoise1 * 0.5f + edgeNoise2 * 0.3f + edgeNoise3 * 0.2f;
+      combinedNoise = (combinedNoise - 0.5f) * 2.0f; // Range: -1 to 1
+      
+      // Apply noise to width variation (±20% of halfWidth)
+      float widthVariation = combinedNoise * halfWidth * 0.35f;
+      
+      // Also add slight meandering to center position
+      float meander = noise(t * 3.0f, length * 0.1f) * 0.3f;
+      QVector3D centerOffset = perpendicular * meander;
+      centerPos += centerOffset;
+      
+      QVector3D left = centerPos - perpendicular * (halfWidth + widthVariation);
+      QVector3D right = centerPos + perpendicular * (halfWidth + widthVariation);
+
+      // Normal pointing up for the water surface
+      float normal[3] = {0.0f, 1.0f, 0.0f};
 
       // Left vertex
-      vertices.push_back(left.x());
-      vertices.push_back(left.y());
-      vertices.push_back(left.z());
-      vertices.push_back(0.0f);
-      vertices.push_back(t);
+      Vertex leftVertex;
+      leftVertex.position[0] = left.x();
+      leftVertex.position[1] = left.y();
+      leftVertex.position[2] = left.z();
+      leftVertex.normal[0] = normal[0];
+      leftVertex.normal[1] = normal[1];
+      leftVertex.normal[2] = normal[2];
+      leftVertex.texCoord[0] = 0.0f;
+      leftVertex.texCoord[1] = t;
+      vertices.push_back(leftVertex);
 
       // Right vertex
-      vertices.push_back(right.x());
-      vertices.push_back(right.y());
-      vertices.push_back(right.z());
-      vertices.push_back(1.0f);
-      vertices.push_back(t);
+      Vertex rightVertex;
+      rightVertex.position[0] = right.x();
+      rightVertex.position[1] = right.y();
+      rightVertex.position[2] = right.z();
+      rightVertex.normal[0] = normal[0];
+      rightVertex.normal[1] = normal[1];
+      rightVertex.normal[2] = normal[2];
+      rightVertex.texCoord[0] = 1.0f;
+      rightVertex.texCoord[1] = t;
+      vertices.push_back(rightVertex);
 
       if (i < lengthSteps - 1) {
         unsigned int idx0 = baseIndex + i * 2;
@@ -82,36 +147,43 @@ void RiverRenderer::buildMeshes() {
   }
 
   if (vertices.empty() || indices.empty()) {
+    qDebug() << "RiverRenderer::buildMeshes() - No vertices/indices generated";
     m_mesh.reset();
     return;
   }
 
+  qDebug() << "RiverRenderer::buildMeshes() - Created mesh with" << vertices.size() << "vertices and" << indices.size() << "indices";
   m_mesh = std::make_unique<Mesh>(vertices, indices);
 }
 
 void RiverRenderer::submit(Renderer &renderer, ResourceManager *resources) {
   if (!m_mesh || m_riverSegments.empty()) {
+    qDebug() << "RiverRenderer::submit() - No mesh or empty segments, skipping";
     return;
   }
 
-  auto shader = resources->getShader("river");
+  Q_UNUSED(resources);
+
+  // Get the river shader
+  auto shader = renderer.getShader("river");
   if (!shader) {
+    qDebug() << "RiverRenderer::submit() - River shader not found!";
     return;
   }
+  
 
-  shader->bind();
+  // Set the shader as current so the mesh command will use it
+  renderer.setCurrentShader(shader);
 
+  // Submit the river mesh using the standard mesh interface
   QMatrix4x4 model;
   model.setToIdentity();
+  
+  // Use mesh() which will queue the render command with the current shader
+  renderer.mesh(m_mesh.get(), model, QVector3D(1.0f, 1.0f, 1.0f), nullptr, 1.0f);
 
-  shader->setUniformValue("model", model);
-  shader->setUniformValue("view", renderer.getViewMatrix());
-  shader->setUniformValue("projection", renderer.getProjectionMatrix());
-  shader->setUniformValue("time", renderer.getAnimationTime());
-
-  m_mesh->draw();
-
-  shader->release();
+  // Reset the current shader
+  renderer.setCurrentShader(nullptr);
 }
 
 } // namespace Render::GL

+ 1 - 0
render/scene_renderer.cpp

@@ -53,6 +53,7 @@ void Renderer::endFrame() {
     std::swap(m_fillQueueIndex, m_renderQueueIndex);
     DrawQueue &renderQueue = m_queues[m_renderQueueIndex];
     renderQueue.sortForBatching();
+    m_backend->setAnimationTime(m_accumulatedTime);
     m_backend->execute(renderQueue, *m_camera);
   }
 }