Browse Source

Add dedicated road shader for Roman roads rendering

djeada 2 weeks ago
parent
commit
712f8d9719

+ 1 - 1
app/core/game_engine.cpp

@@ -1820,7 +1820,7 @@ void GameEngine::restoreEnvironmentFromMetadata(const QJsonObject &metadata) {
                            height_map->getTileSize());
       }
       if (m_road) {
-        m_road->configure(terrain_service.roadSegments(),
+        m_road->configure(terrain_service.road_segments(),
                           height_map->getTileSize());
       }
       if (m_riverbank) {

+ 50 - 0
assets/maps/map_mountain.json

@@ -577,6 +577,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

@@ -469,6 +469,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);
+}

+ 1 - 1
game/core/serialization.cpp

@@ -794,7 +794,7 @@ auto Serialization::serializeWorld(const World *world) -> QJsonDocument {
       (terrain_service.getHeightMap() != nullptr)) {
     world_obj["terrain"] = serializeTerrain(terrain_service.getHeightMap(),
                                             terrain_service.biomeSettings(),
-                                            terrain_service.roadSegments());
+                                            terrain_service.road_segments());
   }
 
   return QJsonDocument(world_obj);

+ 7 - 7
game/map/map_loader.cpp

@@ -430,14 +430,14 @@ void readRivers(const QJsonArray &arr, std::vector<RiverSegment> &out,
   }
 }
 
-void readRoads(const QJsonArray &arr, std::vector<RoadSegment> &out,
-               const GridDefinition &grid, CoordSystem coordSys) {
+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 double default_road_width = 3.0;
+  constexpr float default_road_width = 3.0F;
 
   for (const auto &road_val : arr) {
     auto road_obj = road_val.toObject();
@@ -449,7 +449,7 @@ void readRoads(const QJsonArray &arr, std::vector<RoadSegment> &out,
         const float start_x = float(start_arr[0].toDouble(0.0));
         const float start_z = float(start_arr[1].toDouble(0.0));
 
-        if (coordSys == CoordSystem::Grid) {
+        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)) *
@@ -470,7 +470,7 @@ void readRoads(const QJsonArray &arr, std::vector<RoadSegment> &out,
         const float end_x = float(end_arr[0].toDouble(0.0));
         const float end_z = float(end_arr[1].toDouble(0.0));
 
-        if (coordSys == CoordSystem::Grid) {
+        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)) *
@@ -651,8 +651,8 @@ auto MapLoader::loadFromJsonFile(const QString &path, MapDefinition &outMap,
   }
 
   if (root.contains(ROADS) && root.value(ROADS).isArray()) {
-    readRoads(root.value(ROADS).toArray(), outMap.roads, outMap.grid,
-              outMap.coordSystem);
+    read_roads(root.value(ROADS).toArray(), outMap.roads, outMap.grid,
+               outMap.coordSystem);
   }
 
   if (root.contains(BRIDGES) && root.value(BRIDGES).isArray()) {

+ 1 - 1
game/map/skirmish_loader.cpp

@@ -338,7 +338,7 @@ auto SkirmishLoader::start(const QString &map_path,
   if (m_road != nullptr) {
     if (terrain_service.isInitialized() &&
         (terrain_service.getHeightMap() != nullptr)) {
-      m_road->configure(terrain_service.roadSegments(),
+      m_road->configure(terrain_service.road_segments(),
                         terrain_service.getHeightMap()->getTileSize());
     }
   }

+ 47 - 3
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,14 +26,14 @@ void TerrainService::initialize(const MapDefinition &mapDef) {
   m_biomeSettings = mapDef.biome;
   m_height_map->applyBiomeVariation(m_biomeSettings);
   m_fire_camps = mapDef.firecamps;
-  m_roadSegments = mapDef.roads;
+  m_road_segments = mapDef.roads;
 }
 
 void TerrainService::clear() {
   m_height_map.reset();
   m_biomeSettings = BiomeSettings();
   m_fire_camps.clear();
-  m_roadSegments.clear();
+  m_road_segments.clear();
 }
 
 auto TerrainService::getTerrainHeight(float world_x,
@@ -132,7 +133,50 @@ void TerrainService::restoreFromSerialized(
   m_height_map = std::make_unique<TerrainHeightMap>(width, height, tile_size);
   m_height_map->restoreFromData(heights, terrain_types, rivers, bridges);
   m_biomeSettings = biome;
-  m_roadSegments = roads;
+  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

+ 5 - 3
game/map/terrain_service.h

@@ -47,10 +47,12 @@ public:
     return m_fire_camps;
   }
 
-  [[nodiscard]] auto roadSegments() const -> const std::vector<RoadSegment> & {
-    return m_roadSegments;
+  [[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;
   }
@@ -73,7 +75,7 @@ private:
   std::unique_ptr<TerrainHeightMap> m_height_map;
   BiomeSettings m_biomeSettings;
   std::vector<FireCamp> m_fire_camps;
-  std::vector<RoadSegment> m_roadSegments;
+  std::vector<RoadSegment> m_road_segments;
 };
 
 } // namespace Game::Map

+ 26 - 0
render/gl/backend.cpp

@@ -1064,6 +1064,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);

+ 9 - 9
render/ground/road_renderer.cpp

@@ -1,10 +1,10 @@
 #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 "map/terrain.h"
 #include <QVector2D>
 #include <QVector3D>
 #include <algorithm>
@@ -22,16 +22,16 @@ RoadRenderer::RoadRenderer() = default;
 RoadRenderer::~RoadRenderer() = default;
 
 void RoadRenderer::configure(
-    const std::vector<Game::Map::RoadSegment> &roadSegments, float tile_size) {
-  m_roadSegments = roadSegments;
+    const std::vector<Game::Map::RoadSegment> &road_segments, float tile_size) {
+  m_road_segments = road_segments;
   m_tile_size = tile_size;
-  buildMeshes();
+  build_meshes();
 }
 
-void RoadRenderer::buildMeshes() {
+void RoadRenderer::build_meshes() {
   m_meshes.clear();
 
-  if (m_roadSegments.empty()) {
+  if (m_road_segments.empty()) {
     return;
   }
 
@@ -53,7 +53,7 @@ void RoadRenderer::buildMeshes() {
            c * (1.0F - fx) * fy + d * fx * fy;
   };
 
-  for (const auto &segment : m_roadSegments) {
+  for (const auto &segment : m_road_segments) {
     QVector3D dir = segment.end - segment.start;
     float const length = dir.length();
     if (length < 0.01F) {
@@ -149,7 +149,7 @@ void RoadRenderer::buildMeshes() {
 }
 
 void RoadRenderer::submit(Renderer &renderer, ResourceManager *resources) {
-  if (m_meshes.empty() || m_roadSegments.empty()) {
+  if (m_meshes.empty() || m_road_segments.empty()) {
     return;
   }
 
@@ -176,7 +176,7 @@ void RoadRenderer::submit(Renderer &renderer, ResourceManager *resources) {
   QVector3D const road_base_color(0.45F, 0.42F, 0.38F);
 
   size_t mesh_index = 0;
-  for (const auto &segment : m_roadSegments) {
+  for (const auto &segment : m_road_segments) {
     if (mesh_index >= m_meshes.size()) {
       break;
     }

+ 3 - 3
render/ground/road_renderer.h

@@ -16,15 +16,15 @@ public:
   RoadRenderer();
   ~RoadRenderer() override;
 
-  void configure(const std::vector<Game::Map::RoadSegment> &roadSegments,
+  void configure(const std::vector<Game::Map::RoadSegment> &road_segments,
                  float tile_size);
 
   void submit(Renderer &renderer, ResourceManager *resources) override;
 
 private:
-  void buildMeshes();
+  void build_meshes();
 
-  std::vector<Game::Map::RoadSegment> m_roadSegments;
+  std::vector<Game::Map::RoadSegment> m_road_segments;
   float m_tile_size = 1.0F;
   std::vector<std::unique_ptr<Mesh>> m_meshes;
 };