Browse Source

Merge pull request #481 from djeada/copilot/add-roman-roads-map-asset

Add Roman Roads as Map Asset (Analogous Pipeline as Rivers)
Adam Djellouli 2 weeks ago
parent
commit
baaa67e110

+ 11 - 3
app/core/game_engine.cpp

@@ -96,6 +96,7 @@
 #include "render/ground/pine_renderer.h"
 #include "render/ground/plant_renderer.h"
 #include "render/ground/river_renderer.h"
+#include "render/ground/road_renderer.h"
 #include "render/ground/riverbank_renderer.h"
 #include "render/ground/stone_renderer.h"
 #include "render/ground/terrain_renderer.h"
@@ -126,6 +127,7 @@ GameEngine::GameEngine(QObject *parent)
   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_road = std::make_unique<Render::GL::RoadRenderer>();
   m_riverbank = std::make_unique<Render::GL::RiverbankRenderer>();
   m_bridge = std::make_unique<Render::GL::BridgeRenderer>();
   m_fog = std::make_unique<Render::GL::FogRenderer>();
@@ -135,9 +137,9 @@ GameEngine::GameEngine(QObject *parent)
   m_firecamp = std::make_unique<Render::GL::FireCampRenderer>();
 
   m_passes = {m_ground.get(),    m_terrain.get(), m_river.get(),
-              m_riverbank.get(), m_bridge.get(),  m_biome.get(),
-              m_stone.get(),     m_plant.get(),   m_pine.get(),
-              m_firecamp.get(),  m_fog.get()};
+              m_road.get(),      m_riverbank.get(), m_bridge.get(),
+              m_biome.get(),     m_stone.get(),   m_plant.get(),
+              m_pine.get(),      m_firecamp.get(), m_fog.get()};
 
   std::unique_ptr<Engine::Core::System> arrow_sys =
       std::make_unique<Game::Systems::ArrowSystem>();
@@ -325,6 +327,7 @@ void GameEngine::cleanupOpenGLResources() {
   m_terrain.reset();
   m_biome.reset();
   m_river.reset();
+  m_road.reset();
   m_riverbank.reset();
   m_bridge.reset();
   m_fog.reset();
@@ -1221,6 +1224,7 @@ void GameEngine::start_skirmish(const QString &map_path,
     loader.setTerrainRenderer(m_terrain.get());
     loader.setBiomeRenderer(m_biome.get());
     loader.setRiverRenderer(m_river.get());
+    loader.setRoadRenderer(m_road.get());
     loader.setRiverbankRenderer(m_riverbank.get());
     loader.setBridgeRenderer(m_bridge.get());
     loader.setFogRenderer(m_fog.get());
@@ -1815,6 +1819,10 @@ void GameEngine::restoreEnvironmentFromMetadata(const QJsonObject &metadata) {
         m_river->configure(height_map->getRiverSegments(),
                            height_map->getTileSize());
       }
+      if (m_road) {
+        m_road->configure(terrain_service.road_segments(),
+                          height_map->getTileSize());
+      }
       if (m_riverbank) {
         m_riverbank->configure(height_map->getRiverSegments(), *height_map);
       }

+ 2 - 0
app/core/game_engine.h

@@ -39,6 +39,7 @@ class GroundRenderer;
 class TerrainRenderer;
 class BiomeRenderer;
 class RiverRenderer;
+class RoadRenderer;
 class RiverbankRenderer;
 class BridgeRenderer;
 class FogRenderer;
@@ -278,6 +279,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::RoadRenderer> m_road;
   std::unique_ptr<Render::GL::RiverbankRenderer> m_riverbank;
   std::unique_ptr<Render::GL::BridgeRenderer> m_bridge;
   std::unique_ptr<Render::GL::FogRenderer> m_fog;

+ 50 - 0
assets/maps/map_mountain.json

@@ -578,6 +578,56 @@
       "rotation": 95
     }
   ],
+  "roads": [
+    {
+      "start": [75, 75],
+      "end": [150, 150],
+      "width": 3.5,
+      "style": "default"
+    },
+    {
+      "start": [150, 150],
+      "end": [225, 225],
+      "width": 3.5,
+      "style": "default"
+    },
+    {
+      "start": [75, 225],
+      "end": [150, 150],
+      "width": 3.0,
+      "style": "default"
+    },
+    {
+      "start": [225, 75],
+      "end": [150, 150],
+      "width": 3.0,
+      "style": "default"
+    },
+    {
+      "start": [75, 75],
+      "end": [75, 225],
+      "width": 2.5,
+      "style": "default"
+    },
+    {
+      "start": [225, 75],
+      "end": [225, 225],
+      "width": 2.5,
+      "style": "default"
+    },
+    {
+      "start": [75, 75],
+      "end": [225, 75],
+      "width": 2.5,
+      "style": "default"
+    },
+    {
+      "start": [75, 225],
+      "end": [225, 225],
+      "width": 2.5,
+      "style": "default"
+    }
+  ],
   "victory": {
     "type": "elimination",
     "key_structures": [

+ 38 - 0
assets/maps/map_rivers.json

@@ -470,6 +470,44 @@
       "height": 0.5
     }
   ],
+  "roads": [
+    {
+      "start": [15, 30],
+      "end": [105, 30],
+      "width": 3.0,
+      "style": "default"
+    },
+    {
+      "start": [15, 90],
+      "end": [105, 90],
+      "width": 3.0,
+      "style": "default"
+    },
+    {
+      "start": [30, 15],
+      "end": [30, 105],
+      "width": 3.0,
+      "style": "default"
+    },
+    {
+      "start": [90, 15],
+      "end": [90, 105],
+      "width": 3.0,
+      "style": "default"
+    },
+    {
+      "start": [30, 30],
+      "end": [90, 90],
+      "width": 2.5,
+      "style": "default"
+    },
+    {
+      "start": [30, 90],
+      "end": [90, 30],
+      "width": 2.5,
+      "style": "default"
+    }
+  ],
   "victory": {
     "type": "elimination",
     "key_structures": [

+ 208 - 0
assets/shaders/road.frag

@@ -0,0 +1,208 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_tex_coord;
+in vec3 v_world_pos;
+
+uniform vec3 u_color;           // base road color
+uniform vec3 u_light_direction; // world-space light direction
+uniform float u_alpha;          // transparency (for fog-of-war)
+
+out vec4 frag_color;
+
+// ------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------
+const float PI = 3.14159265359;
+
+float saturate_val(float x) { return clamp(x, 0.0, 1.0); }
+
+mat2 rotate_2d(float a) {
+  float c = cos(a), s = sin(a);
+  return mat2(c, -s, s, c);
+}
+
+// Hash / Noise functions
+float hash_2d(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_2d(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash_2d(i);
+  float b = hash_2d(i + vec2(1.0, 0.0));
+  float c = hash_2d(i + vec2(0.0, 1.0));
+  float d = hash_2d(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm_2d(vec2 p) {
+  float v = 0.0, a = 0.5;
+  for (int i = 0; i < 5; ++i) {
+    v += a * noise_2d(p);
+    p *= 2.0;
+    a *= 0.5;
+  }
+  return v;
+}
+
+// 2D random vector for Worley
+vec2 hash_2d_vec(vec2 p) {
+  float n = sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123;
+  return fract(vec2(n, n * 1.2154));
+}
+
+// Worley (Voronoi) for stone pattern: returns F1 and F2
+vec2 worley_f(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 = hash_2d_vec(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 fresnel_schlick(float cos_theta, float f0) {
+  return f0 + (1.0 - f0) * pow(1.0 - cos_theta, 5.0);
+}
+
+// GGX specular
+float ggx_specular(vec3 n_vec, vec3 v_vec, vec3 l_vec, float rough, float f0) {
+  vec3 h_vec = normalize(v_vec + l_vec);
+  float n_dot_v = max(dot(n_vec, v_vec), 0.0);
+  float n_dot_l = max(dot(n_vec, l_vec), 0.0);
+  float n_dot_h = max(dot(n_vec, h_vec), 0.0);
+  float v_dot_h = max(dot(v_vec, h_vec), 0.0);
+
+  float a = max(rough * rough, 0.001);
+  float a2 = a * a;
+  float denom = (n_dot_h * n_dot_h * (a2 - 1.0) + 1.0);
+  float d_val = a2 / max(PI * denom * denom, 1e-4);
+
+  float k = (a + 1.0);
+  k = (k * k) * 0.125;
+  float g_v = n_dot_v / (n_dot_v * (1.0 - k) + k);
+  float g_l = n_dot_l / (n_dot_l * (1.0 - k) + k);
+  float g_val = g_v * g_l;
+
+  float f_val = fresnel_schlick(v_dot_h, f0);
+  return (d_val * g_val * f_val) / max(4.0 * n_dot_v * n_dot_l, 1e-4);
+}
+
+// ------------------------------------------------------------
+// Main
+// ------------------------------------------------------------
+void main() {
+  // World-anchored UV mapping for consistent stone pattern
+  vec2 uv = v_world_pos.xz * 0.8;
+
+  // Irregular cobblestone layout via Worley noise
+  vec2 worley_result = worley_f(uv * 1.5);
+  float edge_metric = worley_result.y - worley_result.x;
+  float stone_mask = smoothstep(0.04, 0.25, edge_metric);
+  float mortar_mask = 1.0 - stone_mask;
+
+  // Per-cell variation for stone shapes
+  vec2 cell = floor(uv * 1.5);
+  float cell_rnd = hash_2d(cell);
+  vec2 local = fract(uv);
+  vec2 uv_var = (rotate_2d(cell_rnd * 6.2831853) * (local - 0.5) + 0.5) + floor(uv);
+
+  // Albedo variation - Roman roads have weathered stone look
+  float var_low = (fbm_2d(uv * 0.4) - 0.5) * 0.18;
+  float var_mid = (fbm_2d(uv_var * 2.5) - 0.5) * 0.12;
+  float grain = (noise_2d(uv_var * 15.0) - 0.5) * 0.06;
+
+  // Base stone color with Roman road characteristics (brownish-gray)
+  vec3 stone_base = u_color;
+  vec3 stone_color = stone_base * (1.0 + var_low + var_mid + grain);
+
+  // Mortar/gravel between stones - darker and rougher
+  vec3 mortar_color = stone_base * 0.60;
+
+  // Wear patterns - roads show wear in the center from traffic
+  float center_wear = 1.0 - abs(v_tex_coord.x - 0.5) * 1.8;
+  center_wear = smoothstep(0.0, 0.5, center_wear) * 0.08;
+  stone_color *= (1.0 + center_wear);
+
+  // Micro cracks in stones
+  float crack = smoothstep(0.02, 0.0, abs(noise_2d(uv * 12.0) - 0.5)) * 0.20;
+  stone_color *= (1.0 - crack * stone_mask);
+
+  // Ambient occlusion in mortar grooves
+  float cavity = smoothstep(0.0, 0.20, edge_metric);
+  float ao = mix(0.50, 1.0, cavity) * (0.90 + 0.10 * fbm_2d(uv * 2.0));
+
+  // Height variation for normal perturbation
+  float micro_bump = (fbm_2d(uv_var * 3.5) - 0.5) * 0.05 * stone_mask;
+  float macro_warp = (fbm_2d(uv * 1.0) - 0.5) * 0.025 * stone_mask;
+  float mortar_dip = -0.05 * mortar_mask;
+  float h = micro_bump + macro_warp + mortar_dip;
+
+  // Screen-space normal perturbation
+  float sx = dFdx(h);
+  float sy = dFdy(h);
+  float bump_strength = 12.0;
+  vec3 n_bump = normalize(vec3(-sx * bump_strength, 1.0, -sy * bump_strength));
+
+  // Blend with geometric normal
+  vec3 n_geom = normalize(v_normal);
+  vec3 n_final = normalize(mix(n_geom, n_bump, 0.60));
+
+  // Lighting
+  vec3 light_dir = normalize(u_light_direction);
+  vec3 view_dir = normalize(vec3(0.0, 0.9, 0.4)); // Fixed view direction
+
+  // Diffuse (Lambert) with AO
+  float n_dot_l = max(dot(n_final, light_dir), 0.0);
+  float diffuse = n_dot_l;
+
+  // Roughness - worn stones are smoother, edges rougher
+  float steep = saturate_val(length(vec2(sx, sy)) * bump_strength);
+  float roughness = clamp(mix(0.70, 0.95, steep), 0.02, 1.0);
+  float f0 = 0.03; // Dielectric stone
+
+  float spec = ggx_specular(n_final, view_dir, light_dir, roughness, f0);
+
+  // Final base color: blend stone and mortar
+  vec3 base_color = mix(mortar_color, stone_color, stone_mask);
+
+  // Hemispheric ambient lighting
+  vec3 hemi_sky = vec3(0.20, 0.25, 0.30);
+  vec3 hemi_ground = vec3(0.10, 0.09, 0.08);
+  float hemi = n_final.y * 0.5 + 0.5;
+
+  vec3 lit_color = base_color * (0.40 + 0.65 * diffuse) * ao;
+  lit_color += mix(hemi_ground, hemi_sky, hemi) * 0.12;
+  lit_color += vec3(1.0) * spec * 0.20;
+
+  // Dirt accumulation in grooves
+  float grime = (1.0 - cavity) * 0.20 * (0.8 + 0.2 * noise_2d(uv * 6.0));
+  float gray = dot(lit_color, vec3(0.299, 0.587, 0.114));
+  lit_color = mix(lit_color, vec3(gray * 0.85), grime);
+
+  // Edge darkening for road borders
+  float edge_fade = smoothstep(0.0, 0.08, v_tex_coord.x) *
+                    smoothstep(0.0, 0.08, 1.0 - v_tex_coord.x);
+  lit_color *= mix(0.75, 1.0, edge_fade);
+
+  frag_color = vec4(clamp(lit_color, 0.0, 1.0), u_alpha);
+}

+ 25 - 0
assets/shaders/road.vert

@@ -0,0 +1,25 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_tex_coord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+uniform mat4 u_view;
+uniform mat4 u_projection;
+
+out vec3 v_normal;
+out vec2 v_tex_coord;
+out vec3 v_world_pos;
+
+void main() {
+  // Transform normal to world space
+  v_normal = normalize(mat3(transpose(inverse(u_model))) * a_normal);
+  v_tex_coord = a_tex_coord;
+
+  // World position for lighting and procedural texturing
+  v_world_pos = vec3(u_model * vec4(a_position, 1.0));
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 46 - 4
game/core/serialization.cpp

@@ -457,7 +457,8 @@ void Serialization::deserializeEntity(Entity *entity, const QJsonObject &json) {
 
 auto Serialization::serializeTerrain(
     const Game::Map::TerrainHeightMap *height_map,
-    const Game::Map::BiomeSettings &biome) -> QJsonObject {
+    const Game::Map::BiomeSettings &biome,
+    const std::vector<Game::Map::RoadSegment> &roads) -> QJsonObject {
   QJsonObject terrain_obj;
 
   if (height_map == nullptr) {
@@ -513,6 +514,21 @@ auto Serialization::serializeTerrain(
   }
   terrain_obj["bridges"] = bridges_array;
 
+  QJsonArray roads_array;
+  for (const auto &road : roads) {
+    QJsonObject road_obj;
+    road_obj["startX"] = road.start.x();
+    road_obj["startY"] = road.start.y();
+    road_obj["startZ"] = road.start.z();
+    road_obj["endX"] = road.end.x();
+    road_obj["endY"] = road.end.y();
+    road_obj["endZ"] = road.end.z();
+    road_obj["width"] = road.width;
+    road_obj["style"] = road.style;
+    roads_array.append(road_obj);
+  }
+  terrain_obj["roads"] = roads_array;
+
   QJsonObject biome_obj;
   biome_obj["grassPrimaryR"] = biome.grass_primary.x();
   biome_obj["grassPrimaryG"] = biome.grass_primary.y();
@@ -563,6 +579,7 @@ auto Serialization::serializeTerrain(
 
 void Serialization::deserializeTerrain(Game::Map::TerrainHeightMap *height_map,
                                        Game::Map::BiomeSettings &biome,
+                                       std::vector<Game::Map::RoadSegment> &roads,
                                        const QJsonObject &json) {
   if ((height_map == nullptr) || json.isEmpty()) {
     return;
@@ -731,6 +748,29 @@ void Serialization::deserializeTerrain(Game::Map::TerrainHeightMap *height_map,
     }
   }
 
+  roads.clear();
+  if (json.contains("roads")) {
+    const auto roads_array = json["roads"].toArray();
+    roads.reserve(roads_array.size());
+    const Game::Map::RoadSegment default_road{};
+    for (const auto &val : roads_array) {
+      const auto road_obj = val.toObject();
+      Game::Map::RoadSegment road;
+      road.start =
+          QVector3D(static_cast<float>(road_obj["startX"].toDouble(0.0)),
+                    static_cast<float>(road_obj["startY"].toDouble(0.0)),
+                    static_cast<float>(road_obj["startZ"].toDouble(0.0)));
+      road.end =
+          QVector3D(static_cast<float>(road_obj["endX"].toDouble(0.0)),
+                    static_cast<float>(road_obj["endY"].toDouble(0.0)),
+                    static_cast<float>(road_obj["endZ"].toDouble(0.0)));
+      road.width = static_cast<float>(road_obj["width"].toDouble(
+          static_cast<double>(default_road.width)));
+      road.style = road_obj["style"].toString(default_road.style);
+      roads.push_back(road);
+    }
+  }
+
   height_map->restoreFromData(heights, terrain_types, rivers, bridges);
 }
 
@@ -754,7 +794,8 @@ auto Serialization::serializeWorld(const World *world) -> QJsonDocument {
   if (terrain_service.isInitialized() &&
       (terrain_service.getHeightMap() != nullptr)) {
     world_obj["terrain"] = serializeTerrain(terrain_service.getHeightMap(),
-                                            terrain_service.biomeSettings());
+                                            terrain_service.biomeSettings(),
+                                            terrain_service.road_segments());
   }
 
   return QJsonDocument(world_obj);
@@ -794,6 +835,7 @@ void Serialization::deserializeWorld(World *world, const QJsonDocument &doc) {
         static_cast<float>(terrain_obj["tile_size"].toDouble(1.0));
 
     Game::Map::BiomeSettings biome;
+    std::vector<Game::Map::RoadSegment> roads;
     std::vector<float> const heights;
     std::vector<Game::Map::TerrainType> const terrain_types;
     std::vector<Game::Map::RiverSegment> const rivers;
@@ -801,13 +843,13 @@ void Serialization::deserializeWorld(World *world, const QJsonDocument &doc) {
 
     auto temp_height_map =
         std::make_unique<Game::Map::TerrainHeightMap>(width, height, tile_size);
-    deserializeTerrain(temp_height_map.get(), biome, terrain_obj);
+    deserializeTerrain(temp_height_map.get(), biome, roads, terrain_obj);
 
     auto &terrain_service = Game::Map::TerrainService::instance();
     terrain_service.restoreFromSerialized(
         width, height, tile_size, temp_height_map->getHeightData(),
         temp_height_map->getTerrainTypes(), temp_height_map->getRiverSegments(),
-        temp_height_map->getBridges(), biome);
+        roads, temp_height_map->getBridges(), biome);
   }
 }
 

+ 10 - 4
game/core/serialization.h

@@ -3,10 +3,12 @@
 #include <QJsonDocument>
 #include <QJsonObject>
 #include <QString>
+#include <vector>
 
 namespace Game::Map {
 class TerrainHeightMap;
 struct BiomeSettings;
+struct RoadSegment;
 } // namespace Game::Map
 
 namespace Engine::Core {
@@ -21,10 +23,14 @@ public:
 
   static auto
   serializeTerrain(const Game::Map::TerrainHeightMap *height_map,
-                   const Game::Map::BiomeSettings &biome) -> QJsonObject;
-  static void deserializeTerrain(Game::Map::TerrainHeightMap *height_map,
-                                 Game::Map::BiomeSettings &biome,
-                                 const QJsonObject &json);
+                   const Game::Map::BiomeSettings &biome,
+                   const std::vector<Game::Map::RoadSegment> &roads)
+      -> QJsonObject;
+  static void
+  deserializeTerrain(Game::Map::TerrainHeightMap *height_map,
+                     Game::Map::BiomeSettings &biome,
+                     std::vector<Game::Map::RoadSegment> &roads,
+                     const QJsonObject &json);
 
   static auto saveToFile(const QString &filename,
                          const QJsonDocument &doc) -> bool;

+ 3 - 0
game/map/json_keys.h

@@ -14,6 +14,7 @@ inline constexpr const char *SPAWNS = "spawns";
 inline constexpr const char *FIRECAMPS = "firecamps";
 inline constexpr const char *TERRAIN = "terrain";
 inline constexpr const char *RIVERS = "rivers";
+inline constexpr const char *ROADS = "roads";
 inline constexpr const char *BRIDGES = "bridges";
 inline constexpr const char *VICTORY = "victory";
 inline constexpr const char *THUMBNAIL = "thumbnail";
@@ -96,4 +97,6 @@ inline constexpr const char *START = "start";
 inline constexpr const char *END = "end";
 inline constexpr const char *BRIDGE_WIDTH = "width";
 
+inline constexpr const char *ROAD_STYLE = "style";
+
 } // namespace Game::Map::JsonKeys

+ 1 - 0
game/map/map_definition.h

@@ -62,6 +62,7 @@ struct MapDefinition {
   std::vector<UnitSpawn> spawns;
   std::vector<TerrainFeature> terrain;
   std::vector<RiverSegment> rivers;
+  std::vector<RoadSegment> roads;
   std::vector<Bridge> bridges;
   std::vector<FireCamp> firecamps;
   BiomeSettings biome;

+ 73 - 0
game/map/map_loader.cpp

@@ -446,6 +446,74 @@ void readRivers(const QJsonArray &arr, std::vector<RiverSegment> &out,
   }
 }
 
+void read_roads(const QJsonArray &arr, std::vector<RoadSegment> &out,
+                const GridDefinition &grid, CoordSystem coord_sys) {
+  out.clear();
+  out.reserve(arr.size());
+
+  constexpr float grid_center_offset = 0.5F;
+  constexpr float min_tile_size = 0.0001F;
+  constexpr float default_road_width = 3.0F;
+
+  for (const auto &road_val : arr) {
+    auto road_obj = road_val.toObject();
+    RoadSegment segment;
+
+    if (road_obj.contains("start") && road_obj.value("start").isArray()) {
+      auto start_arr = road_obj.value("start").toArray();
+      if (start_arr.size() >= 2) {
+        const float start_x = float(start_arr[0].toDouble(0.0));
+        const float start_z = float(start_arr[1].toDouble(0.0));
+
+        if (coord_sys == CoordSystem::Grid) {
+          const float tile = std::max(min_tile_size, grid.tile_size);
+          segment.start.setX((start_x - (grid.width * grid_center_offset -
+                                         grid_center_offset)) *
+                             tile);
+          segment.start.setY(0.0F);
+          segment.start.setZ((start_z - (grid.height * grid_center_offset -
+                                         grid_center_offset)) *
+                             tile);
+        } else {
+          segment.start = QVector3D(start_x, 0.0F, start_z);
+        }
+      }
+    }
+
+    if (road_obj.contains("end") && road_obj.value("end").isArray()) {
+      auto end_arr = road_obj.value("end").toArray();
+      if (end_arr.size() >= 2) {
+        const float end_x = float(end_arr[0].toDouble(0.0));
+        const float end_z = float(end_arr[1].toDouble(0.0));
+
+        if (coord_sys == CoordSystem::Grid) {
+          const float tile = std::max(min_tile_size, grid.tile_size);
+          segment.end.setX(
+              (end_x - (grid.width * grid_center_offset - grid_center_offset)) *
+              tile);
+          segment.end.setY(0.0F);
+          segment.end.setZ((end_z - (grid.height * grid_center_offset -
+                                     grid_center_offset)) *
+                           tile);
+        } else {
+          segment.end = QVector3D(end_x, 0.0F, end_z);
+        }
+      }
+    }
+
+    if (road_obj.contains("width")) {
+      segment.width =
+          float(road_obj.value("width").toDouble(default_road_width));
+    }
+
+    if (road_obj.contains("style")) {
+      segment.style = road_obj.value("style").toString("default");
+    }
+
+    out.push_back(segment);
+  }
+}
+
 void readBridges(const QJsonArray &arr, std::vector<Bridge> &out,
                  const GridDefinition &grid, CoordSystem coordSys) {
   out.clear();
@@ -598,6 +666,11 @@ auto MapLoader::loadFromJsonFile(const QString &path, MapDefinition &outMap,
                outMap.coordSystem);
   }
 
+  if (root.contains(ROADS) && root.value(ROADS).isArray()) {
+    read_roads(root.value(ROADS).toArray(), outMap.roads, outMap.grid,
+               outMap.coordSystem);
+  }
+
   if (root.contains(BRIDGES) && root.value(BRIDGES).isArray()) {
     readBridges(root.value(BRIDGES).toArray(), outMap.bridges, outMap.grid,
                 outMap.coordSystem);

+ 9 - 0
game/map/skirmish_loader.cpp

@@ -23,6 +23,7 @@
 #include "render/ground/pine_renderer.h"
 #include "render/ground/plant_renderer.h"
 #include "render/ground/river_renderer.h"
+#include "render/ground/road_renderer.h"
 #include "render/ground/riverbank_renderer.h"
 #include "render/ground/stone_renderer.h"
 #include "render/ground/terrain_renderer.h"
@@ -334,6 +335,14 @@ auto SkirmishLoader::start(const QString &map_path,
     }
   }
 
+  if (m_road != nullptr) {
+    if (terrain_service.isInitialized() &&
+        (terrain_service.getHeightMap() != nullptr)) {
+      m_road->configure(terrain_service.road_segments(),
+                        terrain_service.getHeightMap()->getTileSize());
+    }
+  }
+
   if (m_riverbank != nullptr) {
     if (terrain_service.isInitialized() &&
         (terrain_service.getHeightMap() != nullptr)) {

+ 3 - 0
game/map/skirmish_loader.h

@@ -25,6 +25,7 @@ class PlantRenderer;
 class PineRenderer;
 class FireCampRenderer;
 class RiverRenderer;
+class RoadRenderer;
 class RiverbankRenderer;
 class BridgeRenderer;
 } // namespace Render::GL
@@ -64,6 +65,7 @@ public:
   }
   void setBiomeRenderer(Render::GL::BiomeRenderer *biome) { m_biome = biome; }
   void setRiverRenderer(Render::GL::RiverRenderer *river) { m_river = river; }
+  void setRoadRenderer(Render::GL::RoadRenderer *road) { m_road = road; }
   void setRiverbankRenderer(Render::GL::RiverbankRenderer *riverbank) {
     m_riverbank = riverbank;
   }
@@ -99,6 +101,7 @@ private:
   Render::GL::TerrainRenderer *m_terrain = nullptr;
   Render::GL::BiomeRenderer *m_biome = nullptr;
   Render::GL::RiverRenderer *m_river = nullptr;
+  Render::GL::RoadRenderer *m_road = nullptr;
   Render::GL::RiverbankRenderer *m_riverbank = nullptr;
   Render::GL::BridgeRenderer *m_bridge = nullptr;
   Render::GL::FogRenderer *m_fog = nullptr;

+ 7 - 0
game/map/terrain.h

@@ -405,6 +405,13 @@ struct RiverSegment {
   float width = 2.0F;
 };
 
+struct RoadSegment {
+  QVector3D start;
+  QVector3D end;
+  float width = 3.0F;
+  QString style = QStringLiteral("default");
+};
+
 struct Bridge {
   QVector3D start;
   QVector3D end;

+ 49 - 1
game/map/terrain_service.cpp

@@ -4,6 +4,7 @@
 #include "map_definition.h"
 #include "terrain.h"
 
+#include <algorithm>
 #include <cmath>
 #include <memory>
 #include <vector>
@@ -25,12 +26,14 @@ void TerrainService::initialize(const MapDefinition &mapDef) {
   m_biomeSettings = mapDef.biome;
   m_height_map->applyBiomeVariation(m_biomeSettings);
   m_fire_camps = mapDef.firecamps;
+  m_road_segments = mapDef.roads;
 }
 
 void TerrainService::clear() {
   m_height_map.reset();
   m_biomeSettings = BiomeSettings();
   m_fire_camps.clear();
+  m_road_segments.clear();
 }
 
 auto TerrainService::getTerrainHeight(float world_x,
@@ -124,11 +127,56 @@ auto TerrainService::getTerrainType(int grid_x,
 void TerrainService::restoreFromSerialized(
     int width, int height, float tile_size, const std::vector<float> &heights,
     const std::vector<TerrainType> &terrain_types,
-    const std::vector<RiverSegment> &rivers, const std::vector<Bridge> &bridges,
+    const std::vector<RiverSegment> &rivers,
+    const std::vector<RoadSegment> &roads, const std::vector<Bridge> &bridges,
     const BiomeSettings &biome) {
   m_height_map = std::make_unique<TerrainHeightMap>(width, height, tile_size);
   m_height_map->restoreFromData(heights, terrain_types, rivers, bridges);
   m_biomeSettings = biome;
+  m_road_segments = roads;
+}
+
+auto TerrainService::is_point_on_road(float world_x,
+                                      float world_z) const -> bool {
+  for (const auto &segment : m_road_segments) {
+    // Calculate distance from point to line segment
+    const float dx = segment.end.x() - segment.start.x();
+    const float dz = segment.end.z() - segment.start.z();
+    const float segment_length_sq = dx * dx + dz * dz;
+
+    if (segment_length_sq < 0.0001F) {
+      // Degenerate segment - check distance to start point
+      const float dist_x = world_x - segment.start.x();
+      const float dist_z = world_z - segment.start.z();
+      const float dist_sq = dist_x * dist_x + dist_z * dist_z;
+      const float half_width = segment.width * 0.5F;
+      if (dist_sq <= half_width * half_width) {
+        return true;
+      }
+      continue;
+    }
+
+    // Project point onto line segment
+    const float px = world_x - segment.start.x();
+    const float pz = world_z - segment.start.z();
+    float t = (px * dx + pz * dz) / segment_length_sq;
+    t = std::clamp(t, 0.0F, 1.0F);
+
+    // Find closest point on segment
+    const float closest_x = segment.start.x() + t * dx;
+    const float closest_z = segment.start.z() + t * dz;
+
+    // Check distance from point to closest point on segment
+    const float dist_x = world_x - closest_x;
+    const float dist_z = world_z - closest_z;
+    const float dist_sq = dist_x * dist_x + dist_z * dist_z;
+
+    const float half_width = segment.width * 0.5F;
+    if (dist_sq <= half_width * half_width) {
+      return true;
+    }
+  }
+  return false;
 }
 
 } // namespace Game::Map

+ 8 - 0
game/map/terrain_service.h

@@ -47,6 +47,12 @@ public:
     return m_fire_camps;
   }
 
+  [[nodiscard]] auto road_segments() const -> const std::vector<RoadSegment> & {
+    return m_road_segments;
+  }
+
+  [[nodiscard]] auto is_point_on_road(float world_x, float world_z) const -> bool;
+
   [[nodiscard]] auto isInitialized() const -> bool {
     return m_height_map != nullptr;
   }
@@ -55,6 +61,7 @@ public:
                              const std::vector<float> &heights,
                              const std::vector<TerrainType> &terrain_types,
                              const std::vector<RiverSegment> &rivers,
+                             const std::vector<RoadSegment> &roads,
                              const std::vector<Bridge> &bridges,
                              const BiomeSettings &biome);
 
@@ -68,6 +75,7 @@ private:
   std::unique_ptr<TerrainHeightMap> m_height_map;
   BiomeSettings m_biomeSettings;
   std::vector<FireCamp> m_fire_camps;
+  std::vector<RoadSegment> m_road_segments;
 };
 
 } // namespace Game::Map

+ 1 - 0
render/CMakeLists.txt

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

+ 26 - 0
render/gl/backend.cpp

@@ -1150,6 +1150,32 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
         break;
       }
 
+      if (active_shader == m_waterPipeline->m_road_shader) {
+        if (m_lastBoundShader != active_shader) {
+          active_shader->use();
+          m_lastBoundShader = active_shader;
+        }
+
+        active_shader->setUniform(m_waterPipeline->m_road_uniforms.mvp, it.mvp);
+        active_shader->setUniform(m_waterPipeline->m_road_uniforms.model,
+                                  it.model);
+        active_shader->setUniform(m_waterPipeline->m_road_uniforms.view,
+                                  cam.getViewMatrix());
+        active_shader->setUniform(m_waterPipeline->m_road_uniforms.projection,
+                                  cam.getProjectionMatrix());
+        active_shader->setUniform(m_waterPipeline->m_road_uniforms.color,
+                                  it.color);
+        active_shader->setUniform(m_waterPipeline->m_road_uniforms.alpha,
+                                  it.alpha);
+
+        QVector3D const road_light_dir(0.35F, 0.8F, 0.45F);
+        active_shader->setUniform(
+            m_waterPipeline->m_road_uniforms.light_direction, road_light_dir);
+
+        it.mesh->draw();
+        break;
+      }
+
       auto *uniforms = m_characterPipeline
                            ? m_characterPipeline->resolveUniforms(active_shader)
                            : nullptr;

+ 22 - 1
render/gl/backend/water_pipeline.cpp

@@ -15,6 +15,7 @@ auto WaterPipeline::initialize() -> bool {
   m_riverShader = m_shaderCache->get("river");
   m_riverbankShader = m_shaderCache->get("riverbank");
   m_bridgeShader = m_shaderCache->get("bridge");
+  m_road_shader = m_shaderCache->get("road");
 
   if (m_riverShader == nullptr) {
     qWarning() << "WaterPipeline: Failed to load river shader";
@@ -25,6 +26,9 @@ auto WaterPipeline::initialize() -> bool {
   if (m_bridgeShader == nullptr) {
     qWarning() << "WaterPipeline: Failed to load bridge shader";
   }
+  if (m_road_shader == nullptr) {
+    qWarning() << "WaterPipeline: Failed to load road shader";
+  }
 
   cacheUniforms();
 
@@ -35,17 +39,19 @@ void WaterPipeline::shutdown() {
   m_riverShader = nullptr;
   m_riverbankShader = nullptr;
   m_bridgeShader = nullptr;
+  m_road_shader = nullptr;
 }
 
 void WaterPipeline::cacheUniforms() {
   cacheRiverUniforms();
   cacheRiverbankUniforms();
   cacheBridgeUniforms();
+  cache_road_uniforms();
 }
 
 auto WaterPipeline::isInitialized() const -> bool {
   return m_riverShader != nullptr && m_riverbankShader != nullptr &&
-         m_bridgeShader != nullptr;
+         m_bridgeShader != nullptr && m_road_shader != nullptr;
 }
 
 void WaterPipeline::cacheRiverUniforms() {
@@ -83,4 +89,19 @@ void WaterPipeline::cacheBridgeUniforms() {
       m_bridgeShader->uniformHandle("u_lightDirection");
 }
 
+void WaterPipeline::cache_road_uniforms() {
+  if (m_road_shader == nullptr) {
+    return;
+  }
+
+  m_road_uniforms.mvp = m_road_shader->uniformHandle("u_mvp");
+  m_road_uniforms.model = m_road_shader->uniformHandle("u_model");
+  m_road_uniforms.view = m_road_shader->uniformHandle("u_view");
+  m_road_uniforms.projection = m_road_shader->uniformHandle("u_projection");
+  m_road_uniforms.color = m_road_shader->uniformHandle("u_color");
+  m_road_uniforms.light_direction =
+      m_road_shader->uniformHandle("u_light_direction");
+  m_road_uniforms.alpha = m_road_shader->uniformHandle("u_alpha");
+}
+
 } // namespace Render::GL::BackendPipelines

+ 13 - 0
render/gl/backend/water_pipeline.h

@@ -41,13 +41,25 @@ public:
     GL::Shader::UniformHandle light_direction{GL::Shader::InvalidUniform};
   };
 
+  struct RoadUniforms {
+    GL::Shader::UniformHandle mvp{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle model{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle view{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle projection{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle color{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle light_direction{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle alpha{GL::Shader::InvalidUniform};
+  };
+
   GL::Shader *m_riverShader = nullptr;
   GL::Shader *m_riverbankShader = nullptr;
   GL::Shader *m_bridgeShader = nullptr;
+  GL::Shader *m_road_shader = nullptr;
 
   RiverUniforms m_riverUniforms;
   RiverbankUniforms m_riverbankUniforms;
   BridgeUniforms m_bridgeUniforms;
+  RoadUniforms m_road_uniforms;
 
 private:
   GL::Backend *m_backend = nullptr;
@@ -56,6 +68,7 @@ private:
   void cacheRiverUniforms();
   void cacheRiverbankUniforms();
   void cacheBridgeUniforms();
+  void cache_road_uniforms();
 };
 
 } // namespace BackendPipelines

+ 6 - 0
render/gl/shader_cache.h

@@ -137,6 +137,12 @@ public:
         resolve(kShaderBase + QStringLiteral("riverbank.frag"));
     load(QStringLiteral("riverbank"), riverbankVert, riverbankFrag);
 
+    const QString roadVert =
+        resolve(kShaderBase + QStringLiteral("road.vert"));
+    const QString roadFrag =
+        resolve(kShaderBase + QStringLiteral("road.frag"));
+    load(QStringLiteral("road"), roadVert, roadFrag);
+
     const QString bridgeVert =
         resolve(kShaderBase + QStringLiteral("bridge.vert"));
     const QString bridgeFrag =

+ 7 - 0
render/ground/biome_renderer.cpp

@@ -1,4 +1,5 @@
 #include "biome_renderer.h"
+#include "../../game/map/terrain_service.h"
 #include "../../game/systems/building_collision_registry.h"
 #include "../gl/buffer.h"
 #include "../gl/render_constants.h"
@@ -255,6 +256,12 @@ void BiomeRenderer::generateGrassInstances() {
       return false;
     }
 
+    // Avoid placing grass on roads
+    auto &terrain_service = Game::Map::TerrainService::instance();
+    if (terrain_service.is_point_on_road(world_x, world_z)) {
+      return false;
+    }
+
     float const lush_noise =
         valueNoise(world_x * 0.06F, world_z * 0.06F, m_noiseSeed ^ 0x9235U);
     float const dryness_noise =

+ 7 - 0
render/ground/firecamp_renderer.cpp

@@ -1,4 +1,5 @@
 #include "firecamp_renderer.h"
+#include "../../game/map/terrain_service.h"
 #include "../../game/map/visibility_service.h"
 #include "../../game/systems/building_collision_registry.h"
 #include "../gl/buffer.h"
@@ -298,6 +299,12 @@ void FireCampRenderer::generateFireCampInstances() {
       return false;
     }
 
+    // Avoid placing fire camps on roads
+    auto &terrain_service = Game::Map::TerrainService::instance();
+    if (terrain_service.is_point_on_road(world_x, world_z)) {
+      return false;
+    }
+
     float const intensity = remap(rand_01(state), 0.8F, 1.2F);
     float const radius = remap(rand_01(state), 2.0F, 4.0F) * tile_safe;
 

+ 7 - 0
render/ground/pine_renderer.cpp

@@ -1,4 +1,5 @@
 #include "pine_renderer.h"
+#include "../../game/map/terrain_service.h"
 #include "../../game/map/visibility_service.h"
 #include "../../game/systems/building_collision_registry.h"
 #include "../gl/buffer.h"
@@ -190,6 +191,12 @@ void PineRenderer::generatePineInstances() {
       return false;
     }
 
+    // Avoid placing pine trees on roads
+    auto &terrain_service = Game::Map::TerrainService::instance();
+    if (terrain_service.is_point_on_road(world_x, world_z)) {
+      return false;
+    }
+
     float const scale = remap(rand_01(state), 3.0F, 6.0F) * tile_safe;
 
     float const color_var = remap(rand_01(state), 0.0F, 1.0F);

+ 7 - 0
render/ground/plant_renderer.cpp

@@ -1,4 +1,5 @@
 #include "plant_renderer.h"
+#include "../../game/map/terrain_service.h"
 #include "../../game/map/visibility_service.h"
 #include "../../game/systems/building_collision_registry.h"
 #include "../gl/buffer.h"
@@ -263,6 +264,12 @@ void PlantRenderer::generatePlantInstances() {
       return false;
     }
 
+    // Avoid placing plants on roads
+    auto &terrain_service = Game::Map::TerrainService::instance();
+    if (terrain_service.is_point_on_road(world_x, world_z)) {
+      return false;
+    }
+
     float const scale = remap(rand_01(state), 0.30F, 0.80F) * tile_safe;
 
     float const plant_type = std::floor(rand_01(state) * 4.0F);

+ 235 - 0
render/ground/road_renderer.cpp

@@ -0,0 +1,235 @@
+#include "road_renderer.h"
+#include "../../game/map/terrain.h"
+#include "../../game/map/visibility_service.h"
+#include "../gl/mesh.h"
+#include "../gl/resources.h"
+#include "../scene_renderer.h"
+#include "ground_utils.h"
+#include <QVector2D>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstddef>
+#include <memory>
+#include <qglobal.h>
+#include <qmatrix4x4.h>
+#include <qvectornd.h>
+#include <vector>
+
+namespace Render::GL {
+
+RoadRenderer::RoadRenderer() = default;
+RoadRenderer::~RoadRenderer() = default;
+
+void RoadRenderer::configure(
+    const std::vector<Game::Map::RoadSegment> &road_segments, float tile_size) {
+  m_road_segments = road_segments;
+  m_tile_size = tile_size;
+  build_meshes();
+}
+
+void RoadRenderer::build_meshes() {
+  m_meshes.clear();
+
+  if (m_road_segments.empty()) {
+    return;
+  }
+
+  auto noise = [](float x, float y) -> float {
+    float const ix = std::floor(x);
+    float const 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 const a = Ground::noise_hash(ix, iy);
+    float const b = Ground::noise_hash(ix + 1.0F, iy);
+    float const c = Ground::noise_hash(ix, iy + 1.0F);
+    float const d = Ground::noise_hash(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_road_segments) {
+    QVector3D dir = segment.end - segment.start;
+    float const length = dir.length();
+    if (length < 0.01F) {
+      m_meshes.push_back(nullptr);
+      continue;
+    }
+
+    dir.normalize();
+    QVector3D const perpendicular(-dir.z(), 0.0F, dir.x());
+    float const half_width = segment.width * 0.5F;
+
+    int length_steps =
+        static_cast<int>(std::ceil(length / (m_tile_size * 0.5F))) + 1;
+    length_steps = std::max(length_steps, 8);
+
+    std::vector<Vertex> vertices;
+    std::vector<unsigned int> indices;
+
+    for (int i = 0; i < length_steps; ++i) {
+      float const t =
+          static_cast<float>(i) / static_cast<float>(length_steps - 1);
+      QVector3D center_pos = segment.start + dir * (length * t);
+
+      // Roads have subtle edge variation (less than rivers)
+      constexpr float k_edge_noise_freq_1 = 1.5F;
+      constexpr float k_edge_noise_freq_2 = 4.0F;
+
+      float const edge_noise1 = noise(center_pos.x() * k_edge_noise_freq_1,
+                                      center_pos.z() * k_edge_noise_freq_1);
+      float const edge_noise2 = noise(center_pos.x() * k_edge_noise_freq_2,
+                                      center_pos.z() * k_edge_noise_freq_2);
+
+      float combined_noise = edge_noise1 * 0.6F + edge_noise2 * 0.4F;
+      combined_noise = (combined_noise - 0.5F) * 2.0F;
+
+      // Roads have less width variation than rivers
+      float const width_variation = combined_noise * half_width * 0.15F;
+
+      // Slight Y offset for road surface above ground
+      constexpr float road_y_offset = 0.02F;
+
+      QVector3D const left =
+          center_pos - perpendicular * (half_width + width_variation);
+      QVector3D const right =
+          center_pos + perpendicular * (half_width + width_variation);
+
+      float const normal[3] = {0.0F, 1.0F, 0.0F};
+
+      Vertex left_vertex;
+      left_vertex.position[0] = left.x();
+      left_vertex.position[1] = left.y() + road_y_offset;
+      left_vertex.position[2] = left.z();
+      left_vertex.normal[0] = normal[0];
+      left_vertex.normal[1] = normal[1];
+      left_vertex.normal[2] = normal[2];
+      left_vertex.tex_coord[0] = 0.0F;
+      left_vertex.tex_coord[1] = t;
+      vertices.push_back(left_vertex);
+
+      Vertex right_vertex;
+      right_vertex.position[0] = right.x();
+      right_vertex.position[1] = right.y() + road_y_offset;
+      right_vertex.position[2] = right.z();
+      right_vertex.normal[0] = normal[0];
+      right_vertex.normal[1] = normal[1];
+      right_vertex.normal[2] = normal[2];
+      right_vertex.tex_coord[0] = 1.0F;
+      right_vertex.tex_coord[1] = t;
+      vertices.push_back(right_vertex);
+
+      if (i < length_steps - 1) {
+        unsigned int const idx0 = i * 2;
+        unsigned int const idx1 = idx0 + 1;
+        unsigned int const idx2 = idx0 + 2;
+        unsigned int const idx3 = idx0 + 3;
+
+        indices.push_back(idx0);
+        indices.push_back(idx2);
+        indices.push_back(idx1);
+
+        indices.push_back(idx1);
+        indices.push_back(idx2);
+        indices.push_back(idx3);
+      }
+    }
+
+    if (!vertices.empty() && !indices.empty()) {
+      m_meshes.push_back(std::make_unique<Mesh>(vertices, indices));
+    } else {
+      m_meshes.push_back(nullptr);
+    }
+  }
+}
+
+void RoadRenderer::submit(Renderer &renderer, ResourceManager *resources) {
+  if (m_meshes.empty() || m_road_segments.empty()) {
+    return;
+  }
+
+  Q_UNUSED(resources);
+
+  auto &visibility = Game::Map::VisibilityService::instance();
+  const bool use_visibility = visibility.isInitialized();
+
+  auto *shader = renderer.getShader("road");
+  if (shader == nullptr) {
+    // Fallback to a basic shader if road shader not found
+    shader = renderer.getShader("terrain");
+    if (shader == nullptr) {
+      return;
+    }
+  }
+
+  renderer.setCurrentShader(shader);
+
+  QMatrix4x4 model;
+  model.setToIdentity();
+
+  // Road stone/gravel color (brownish-gray like Roman roads)
+  QVector3D const road_base_color(0.45F, 0.42F, 0.38F);
+
+  size_t mesh_index = 0;
+  for (const auto &segment : m_road_segments) {
+    if (mesh_index >= m_meshes.size()) {
+      break;
+    }
+
+    auto *mesh = m_meshes[mesh_index].get();
+    ++mesh_index;
+
+    if (mesh == nullptr) {
+      continue;
+    }
+
+    QVector3D dir = segment.end - segment.start;
+    float const length = dir.length();
+
+    float alpha = 1.0F;
+    QVector3D color_multiplier(1.0F, 1.0F, 1.0F);
+
+    if (use_visibility) {
+      int max_visibility_state = 0;
+      dir.normalize();
+
+      int const samples_per_segment = 5;
+      for (int i = 0; i < samples_per_segment; ++i) {
+        float const t =
+            static_cast<float>(i) / static_cast<float>(samples_per_segment - 1);
+        QVector3D const pos = segment.start + dir * (length * t);
+
+        if (visibility.isVisibleWorld(pos.x(), pos.z())) {
+          max_visibility_state = 2;
+          break;
+        }
+        if (visibility.isExploredWorld(pos.x(), pos.z())) {
+          max_visibility_state = std::max(max_visibility_state, 1);
+        }
+      }
+
+      if (max_visibility_state == 0) {
+        continue;
+      }
+      if (max_visibility_state == 1) {
+        alpha = 0.5F;
+        color_multiplier = QVector3D(0.4F, 0.4F, 0.45F);
+      }
+    }
+
+    QVector3D const final_color(road_base_color.x() * color_multiplier.x(),
+                                road_base_color.y() * color_multiplier.y(),
+                                road_base_color.z() * color_multiplier.z());
+
+    renderer.mesh(mesh, model, final_color, nullptr, alpha);
+  }
+
+  renderer.setCurrentShader(nullptr);
+}
+
+} // namespace Render::GL

+ 32 - 0
render/ground/road_renderer.h

@@ -0,0 +1,32 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include "../i_render_pass.h"
+#include <QMatrix4x4>
+#include <memory>
+#include <vector>
+
+namespace Render::GL {
+class Mesh;
+class Renderer;
+class ResourceManager;
+
+class RoadRenderer : public IRenderPass {
+public:
+  RoadRenderer();
+  ~RoadRenderer() override;
+
+  void configure(const std::vector<Game::Map::RoadSegment> &road_segments,
+                 float tile_size);
+
+  void submit(Renderer &renderer, ResourceManager *resources) override;
+
+private:
+  void build_meshes();
+
+  std::vector<Game::Map::RoadSegment> m_road_segments;
+  float m_tile_size = 1.0F;
+  std::vector<std::unique_ptr<Mesh>> m_meshes;
+};
+
+} // namespace Render::GL