浏览代码

Merge pull request #26 from djeada/feature/terrain

Feature/terrain
Adam Djellouli 2 月之前
父节点
当前提交
28b7b81550

+ 52 - 1
app/game_engine.cpp

@@ -10,6 +10,8 @@
 #include "game/core/component.h"
 #include "game/core/world.h"
 #include "game/map/level_loader.h"
+#include "game/map/terrain_service.h"
+#include "game/map/visibility_service.h"
 #include "game/systems/ai_system.h"
 #include "game/systems/arrow_system.h"
 #include "game/systems/building_collision_registry.h"
@@ -24,12 +26,15 @@
 #include "game/systems/production_service.h"
 #include "game/systems/production_system.h"
 #include "game/systems/selection_system.h"
+#include "game/systems/terrain_alignment_system.h"
 #include "render/geom/arrow.h"
 #include "render/geom/patrol_flags.h"
 #include "render/gl/bootstrap.h"
 #include "render/gl/camera.h"
 #include "render/gl/resources.h"
+#include "render/ground/fog_renderer.h"
 #include "render/ground/ground_renderer.h"
+#include "render/ground/terrain_renderer.h"
 #include "render/scene_renderer.h"
 #include "selected_units_model.h"
 #include <QDir>
@@ -44,6 +49,8 @@ GameEngine::GameEngine() {
   m_renderer = std::make_unique<Render::GL::Renderer>();
   m_camera = std::make_unique<Render::GL::Camera>();
   m_ground = std::make_unique<Render::GL::GroundRenderer>();
+  m_terrain = std::make_unique<Render::GL::TerrainRenderer>();
+  m_fog = std::make_unique<Render::GL::FogRenderer>();
 
   std::unique_ptr<Engine::Core::System> arrowSys =
       std::make_unique<Game::Systems::ArrowSystem>();
@@ -55,6 +62,7 @@ GameEngine::GameEngine() {
   m_world->addSystem(std::make_unique<Game::Systems::CombatSystem>());
   m_world->addSystem(std::make_unique<Game::Systems::AISystem>());
   m_world->addSystem(std::make_unique<Game::Systems::ProductionSystem>());
+  m_world->addSystem(std::make_unique<Game::Systems::TerrainAlignmentSystem>());
 
   m_selectionSystem = std::make_unique<Game::Systems::SelectionSystem>();
   m_world->addSystem(std::make_unique<Game::Systems::SelectionSystem>());
@@ -398,8 +406,23 @@ void GameEngine::update(float dt) {
     m_renderer->updateAnimationTime(dt);
   }
 
-  if (m_world)
+  if (m_world) {
     m_world->update(dt);
+
+    auto &visibilityService = Game::Map::VisibilityService::instance();
+    if (visibilityService.isInitialized()) {
+      visibilityService.update(*m_world, m_runtime.localOwnerId);
+      const auto newVersion = visibilityService.version();
+      if (newVersion != m_runtime.visibilityVersion) {
+        if (m_fog) {
+          m_fog->updateMask(
+              visibilityService.getWidth(), visibilityService.getHeight(),
+              visibilityService.getTileSize(), visibilityService.cells());
+        }
+        m_runtime.visibilityVersion = newVersion;
+      }
+    }
+  }
   syncSelectionFlags();
   checkVictoryCondition();
 
@@ -437,6 +460,14 @@ void GameEngine::render(int pixelWidth, int pixelHeight) {
     if (auto *res = m_renderer->resources())
       m_ground->submit(*m_renderer, *res);
   }
+  if (m_terrain && m_renderer) {
+    if (auto *res = m_renderer->resources())
+      m_terrain->submit(*m_renderer, *res);
+  }
+  if (m_fog && m_renderer) {
+    if (auto *res = m_renderer->resources())
+      m_fog->submit(*m_renderer, *res);
+  }
   if (m_renderer)
     m_renderer->setHoveredEntityId(m_hover.entityId);
   m_renderer->renderWorld(m_world.get());
@@ -766,10 +797,30 @@ void GameEngine::startSkirmish(const QString &mapPath) {
         m_ground->configureExtent(50.0f);
     }
 
+    if (m_terrain) {
+      auto &terrainService = Game::Map::TerrainService::instance();
+      if (terrainService.isInitialized() && terrainService.getHeightMap()) {
+        m_terrain->configure(*terrainService.getHeightMap());
+      }
+    }
+
     int mapWidth = lr.ok ? lr.gridWidth : 100;
     int mapHeight = lr.ok ? lr.gridHeight : 100;
     Game::Systems::CommandService::initialize(mapWidth, mapHeight);
 
+    auto &visibilityService = Game::Map::VisibilityService::instance();
+    visibilityService.initialize(mapWidth, mapHeight, lr.tileSize);
+    if (m_world)
+      visibilityService.update(*m_world, m_runtime.localOwnerId);
+    if (m_fog && visibilityService.isInitialized()) {
+      m_fog->updateMask(
+          visibilityService.getWidth(), visibilityService.getHeight(),
+          visibilityService.getTileSize(), visibilityService.cells());
+      m_runtime.visibilityVersion = visibilityService.version();
+    } else {
+      m_runtime.visibilityVersion = 0;
+    }
+
     m_level.mapName = lr.mapName;
     m_level.playerUnitId = lr.playerUnitId;
     m_level.camFov = lr.camFov;

+ 6 - 0
app/game_engine.h

@@ -7,6 +7,7 @@
 #include <QVariant>
 #include <QVector3D>
 #include <algorithm>
+#include <cstdint>
 #include <memory>
 #include <vector>
 
@@ -26,6 +27,8 @@ class Renderer;
 class Camera;
 class ResourceManager;
 class GroundRenderer;
+class TerrainRenderer;
+class FogRenderer;
 } // namespace GL
 } // namespace Render
 
@@ -129,6 +132,7 @@ private:
     QString victoryState = "";
     QString cursorMode = "normal";
     int lastTroopCount = 0;
+    std::uint64_t visibilityVersion = 0;
   };
   struct ViewportState {
     int width = 0;
@@ -163,6 +167,8 @@ private:
   std::unique_ptr<Render::GL::Camera> m_camera;
   std::shared_ptr<Render::GL::ResourceManager> m_resources;
   std::unique_ptr<Render::GL::GroundRenderer> m_ground;
+  std::unique_ptr<Render::GL::TerrainRenderer> m_terrain;
+  std::unique_ptr<Render::GL::FogRenderer> m_fog;
   std::unique_ptr<Game::Systems::SelectionSystem> m_selectionSystem;
   std::unique_ptr<Game::Systems::PickingService> m_pickingService;
   QQuickWindow *m_window = nullptr;

+ 34 - 4
assets/maps/test_map.json

@@ -3,17 +3,17 @@
   "coordSystem": "grid",
   "maxTroopsPerPlayer": 30,
   "grid": {
-    "width": 100,
-    "height": 100,
+    "width": 120,
+    "height": 120,
     "tileSize": 1.0
   },
   "camera": {
     "center": [0.0, 0.0, 0.0],
-    "distance": 15.0,
+    "distance": 25.0,
     "tiltDeg": 45.0,
     "fovY": 45.0,
     "near": 1.0,
-    "far": 200.0
+    "far": 400.0
   },
   "spawns": [
     { "type": "barracks", "x": 45, "z": 50, "playerId": 1 },
@@ -29,5 +29,35 @@
     
     { "type": "barracks", "x": 65, "z": 50, "playerId": 2 },
     { "type": "archer", "x": 56, "z": 50, "playerId": 2 }
+  ],
+  "terrain": [
+    {
+      "type": "mountain",
+      "x": 58,
+      "z": 42,
+      "radius": 18,
+      "height": 7.2,
+      "rotation": 18
+    },
+    {
+      "type": "mountain",
+      "x": 34,
+      "z": 86,
+      "radius": 16,
+      "height": 6.4,
+      "rotation": 108
+    },
+    {
+      "type": "hill",
+      "x": 78,
+      "z": 74,
+      "radius": 15,
+      "height": 3.1,
+      "entrances": [
+        { "x": 74, "z": 58 },
+        { "x": 88, "z": 78 },
+        { "x": 66, "z": 88 }
+      ]
+    }
   ]
 }

+ 4 - 0
game/CMakeLists.txt

@@ -26,10 +26,14 @@ add_library(game_systems STATIC
     systems/command_service.cpp
     systems/production_service.cpp
     systems/production_system.cpp
+    systems/terrain_alignment_system.cpp
     map/map_loader.cpp
     map/level_loader.cpp
     map/map_transformer.cpp
     map/environment.cpp
+    map/terrain.cpp
+    map/terrain_service.cpp
+    map/visibility_service.cpp
     visuals/visual_catalog.cpp
     units/unit.cpp
     units/archer.cpp

+ 5 - 2
game/core/component.h

@@ -47,14 +47,17 @@ public:
 
 class UnitComponent : public Component {
 public:
-  UnitComponent(int health = 100, int maxHealth = 100, float speed = 1.0f)
-      : health(health), maxHealth(maxHealth), speed(speed), ownerId(0) {}
+  UnitComponent(int health = 100, int maxHealth = 100, float speed = 1.0f,
+                float vision = 12.0f)
+      : health(health), maxHealth(maxHealth), speed(speed), ownerId(0),
+        visionRange(vision) {}
 
   int health;
   int maxHealth;
   float speed;
   std::string unitType;
   int ownerId;
+  float visionRange;
 };
 
 class MovementComponent : public Component {

+ 3 - 0
game/map/level_loader.cpp

@@ -8,6 +8,7 @@
 #include "environment.h"
 #include "map_loader.h"
 #include "map_transformer.h"
+#include "terrain_service.h"
 #include <QDebug>
 
 namespace Game {
@@ -34,6 +35,8 @@ LevelLoadResult LevelLoader::loadFromAssets(const QString &mapPath,
     res.ok = true;
     res.mapName = def.name;
 
+    Game::Map::TerrainService::instance().initialize(def);
+
     Game::Map::Environment::apply(def, renderer, camera);
     res.camFov = def.camera.fovY;
     res.camNear = def.camera.nearPlane;

+ 2 - 0
game/map/map_definition.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include "terrain.h"
 #include <QString>
 #include <QVector3D>
 #include <vector>
@@ -36,6 +37,7 @@ struct MapDefinition {
   GridDefinition grid;
   CameraDefinition camera;
   std::vector<UnitSpawn> spawns;
+  std::vector<TerrainFeature> terrain;
   CoordSystem coordSystem = CoordSystem::Grid;
   int maxTroopsPerPlayer = 50;
 };

+ 54 - 0
game/map/map_loader.cpp

@@ -53,6 +53,55 @@ static void readSpawns(const QJsonArray &arr, std::vector<UnitSpawn> &out) {
   }
 }
 
+static void readTerrain(const QJsonArray &arr, std::vector<TerrainFeature> &out,
+                        const GridDefinition &grid, CoordSystem coordSys) {
+  out.clear();
+  out.reserve(arr.size());
+
+  for (const auto &v : arr) {
+    auto o = v.toObject();
+    TerrainFeature feature;
+
+    QString typeStr = o.value("type").toString("flat").toLower();
+    if (typeStr == "mountain") {
+      feature.type = TerrainType::Mountain;
+    } else if (typeStr == "hill") {
+      feature.type = TerrainType::Hill;
+    } else {
+      feature.type = TerrainType::Flat;
+    }
+
+    float x = float(o.value("x").toDouble(0.0));
+    float z = float(o.value("z").toDouble(0.0));
+
+    if (coordSys == CoordSystem::Grid) {
+      const float tile = std::max(0.0001f, grid.tileSize);
+      feature.centerX = (x - (grid.width * 0.5f - 0.5f)) * tile;
+      feature.centerZ = (z - (grid.height * 0.5f - 0.5f)) * tile;
+    } else {
+      feature.centerX = x;
+      feature.centerZ = z;
+    }
+
+    feature.radius = float(o.value("radius").toDouble(5.0));
+    feature.height = float(o.value("height").toDouble(2.0));
+    feature.rotationDeg = float(o.value("rotation").toDouble(0.0));
+
+    if (o.contains("entrances") && o.value("entrances").isArray()) {
+      auto entranceArr = o.value("entrances").toArray();
+      for (const auto &e : entranceArr) {
+        auto eObj = e.toObject();
+        float ex = float(eObj.value("x").toDouble(0.0));
+        float ez = float(eObj.value("z").toDouble(0.0));
+
+        feature.entrances.push_back(QVector3D(ex, 0.0f, ez));
+      }
+    }
+
+    out.push_back(feature);
+  }
+}
+
 bool MapLoader::loadFromJsonFile(const QString &path, MapDefinition &outMap,
                                  QString *outError) {
   QFile f(path);
@@ -110,6 +159,11 @@ bool MapLoader::loadFromJsonFile(const QString &path, MapDefinition &outMap,
     readSpawns(root.value("spawns").toArray(), outMap.spawns);
   }
 
+  if (root.contains("terrain") && root.value("terrain").isArray()) {
+    readTerrain(root.value("terrain").toArray(), outMap.terrain, outMap.grid,
+                outMap.coordSystem);
+  }
+
   return true;
 }
 

+ 2 - 0
game/map/map_transformer.cpp

@@ -63,6 +63,7 @@ MapTransformer::applyToWorld(const MapDefinition &def,
       auto *u = e->addComponent<Engine::Core::UnitComponent>();
       u->unitType = s.type.toStdString();
       u->ownerId = s.playerId;
+      u->visionRange = 14.0f;
 
       QVector3D tc;
       switch (u->ownerId) {
@@ -89,6 +90,7 @@ MapTransformer::applyToWorld(const MapDefinition &def,
         u->health = 80;
         u->maxHealth = 80;
         u->speed = 3.0f;
+        u->visionRange = 16.0f;
         auto *atk = e->addComponent<Engine::Core::AttackComponent>();
         atk->range = 6.0f;
         atk->damage = 12;

+ 353 - 0
game/map/terrain.cpp

@@ -0,0 +1,353 @@
+#include "terrain.h"
+#include <QDebug>
+#include <algorithm>
+#include <cmath>
+
+namespace {
+constexpr float kDegToRad = static_cast<float>(M_PI) / 180.0f;
+}
+
+namespace Game::Map {
+
+TerrainHeightMap::TerrainHeightMap(int width, int height, float tileSize)
+    : m_width(width), m_height(height), m_tileSize(tileSize) {
+  const int count = width * height;
+  m_heights.resize(count, 0.0f);
+  m_terrainTypes.resize(count, TerrainType::Flat);
+  m_hillEntrances.resize(count, false);
+  m_hillWalkable.resize(count, false);
+}
+
+void TerrainHeightMap::buildFromFeatures(
+    const std::vector<TerrainFeature> &features) {
+
+  std::fill(m_heights.begin(), m_heights.end(), 0.0f);
+  std::fill(m_terrainTypes.begin(), m_terrainTypes.end(), TerrainType::Flat);
+  std::fill(m_hillEntrances.begin(), m_hillEntrances.end(), false);
+  std::fill(m_hillWalkable.begin(), m_hillWalkable.end(), false);
+
+  qDebug() << "Building terrain from" << features.size() << "features";
+
+  const float gridHalfWidth = m_width * 0.5f - 0.5f;
+  const float gridHalfHeight = m_height * 0.5f - 0.5f;
+
+  for (const auto &feature : features) {
+    qDebug() << "  Feature:"
+             << (feature.type == TerrainType::Mountain ? "Mountain"
+                 : feature.type == TerrainType::Hill   ? "Hill"
+                                                       : "Flat")
+             << "at (" << feature.centerX << "," << feature.centerZ << ")"
+             << "radius:" << feature.radius << "height:" << feature.height;
+
+    const float gridCenterX = (feature.centerX / m_tileSize) + gridHalfWidth;
+    const float gridCenterZ = (feature.centerZ / m_tileSize) + gridHalfHeight;
+    const float gridRadius = std::max(feature.radius / m_tileSize, 1.0f);
+
+    if (feature.type == TerrainType::Mountain) {
+      const float majorRadius = std::max(gridRadius * 1.8f, gridRadius + 3.0f);
+      const float minorRadius = std::max(gridRadius * 0.22f, 0.8f);
+      const float bound = std::max(majorRadius, minorRadius) + 2.0f;
+      const int minX = std::max(0, int(std::floor(gridCenterX - bound)));
+      const int maxX =
+          std::min(m_width - 1, int(std::ceil(gridCenterX + bound)));
+      const int minZ = std::max(0, int(std::floor(gridCenterZ - bound)));
+      const int maxZ =
+          std::min(m_height - 1, int(std::ceil(gridCenterZ + bound)));
+
+  const float angleRad = feature.rotationDeg * kDegToRad;
+      const float cosA = std::cos(angleRad);
+      const float sinA = std::sin(angleRad);
+
+      for (int z = minZ; z <= maxZ; ++z) {
+        for (int x = minX; x <= maxX; ++x) {
+          const float localX = float(x) - gridCenterX;
+          const float localZ = float(z) - gridCenterZ;
+
+          const float rotatedX = localX * cosA + localZ * sinA;
+          const float rotatedZ = -localX * sinA + localZ * cosA;
+
+          const float norm =
+              std::sqrt((rotatedX * rotatedX) / (majorRadius * majorRadius) +
+                        (rotatedZ * rotatedZ) / (minorRadius * minorRadius));
+
+          if (norm <= 1.0f) {
+            float blend = std::clamp(1.0f - norm, 0.0f, 1.0f);
+
+            float height = feature.height * std::pow(blend, 3.5f);
+            if (blend > 0.92f) {
+              height = feature.height;
+            }
+
+            if (height > 0.01f) {
+              int idx = indexAt(x, z);
+              if (height > m_heights[idx]) {
+                m_heights[idx] = height;
+                m_terrainTypes[idx] = TerrainType::Mountain;
+              }
+            }
+          }
+        }
+      }
+      continue;
+    }
+
+    if (feature.type == TerrainType::Hill) {
+      const float plateauRadius = std::max(1.5f, gridRadius * 0.45f);
+      const float slopeRadius = std::max(plateauRadius + 1.5f, gridRadius);
+      const int minX =
+          std::max(0, int(std::floor(gridCenterX - slopeRadius - 1.0f)));
+      const int maxX = std::min(
+          m_width - 1, int(std::ceil(gridCenterX + slopeRadius + 1.0f)));
+      const int minZ =
+          std::max(0, int(std::floor(gridCenterZ - slopeRadius - 1.0f)));
+      const int maxZ = std::min(
+          m_height - 1, int(std::ceil(gridCenterZ + slopeRadius + 1.0f)));
+
+      std::vector<int> plateauCells;
+      plateauCells.reserve(int(M_PI * plateauRadius * plateauRadius));
+
+      const float slopeSpan = std::max(1.0f, slopeRadius - plateauRadius);
+
+      for (int z = minZ; z <= maxZ; ++z) {
+        for (int x = minX; x <= maxX; ++x) {
+          const float dx = float(x) - gridCenterX;
+          const float dz = float(z) - gridCenterZ;
+          const float dist = std::sqrt(dx * dx + dz * dz);
+
+          if (dist > slopeRadius) {
+            continue;
+          }
+
+          const int idx = indexAt(x, z);
+
+          float height = 0.0f;
+          if (dist <= plateauRadius) {
+            height = feature.height;
+            plateauCells.push_back(idx);
+          } else {
+            float t =
+                std::clamp((dist - plateauRadius) / slopeSpan, 0.0f, 1.0f);
+            float smooth = 0.5f * (1.0f + std::cos(t * float(M_PI)));
+            height = feature.height * smooth;
+          }
+
+          if (height > m_heights[idx]) {
+            m_heights[idx] = height;
+            m_terrainTypes[idx] = TerrainType::Hill;
+          }
+        }
+      }
+
+      for (int idx : plateauCells) {
+        m_hillWalkable[idx] = true;
+      }
+
+      for (const auto &entrance : feature.entrances) {
+        int ex = int(std::round(entrance.x()));
+        int ez = int(std::round(entrance.z()));
+        if (!inBounds(ex, ez)) {
+          continue;
+        }
+
+        const int entranceIdx = indexAt(ex, ez);
+        m_hillEntrances[entranceIdx] = true;
+        m_hillWalkable[entranceIdx] = true;
+
+        float dirX = gridCenterX - float(ex);
+        float dirZ = gridCenterZ - float(ez);
+        float length = std::sqrt(dirX * dirX + dirZ * dirZ);
+        if (length < 0.001f) {
+          continue;
+        }
+
+        dirX /= length;
+        dirZ /= length;
+
+        float curX = float(ex);
+        float curZ = float(ez);
+        const int steps = int(length) + 3;
+
+        for (int step = 0; step < steps; ++step) {
+          int ix = int(std::round(curX));
+          int iz = int(std::round(curZ));
+          if (!inBounds(ix, iz)) {
+            break;
+          }
+
+          const int idx = indexAt(ix, iz);
+          float cellDist =
+              std::sqrt((float(ix) - gridCenterX) * (float(ix) - gridCenterX) +
+                        (float(iz) - gridCenterZ) * (float(iz) - gridCenterZ));
+          if (cellDist > slopeRadius + 1.0f) {
+            break;
+          }
+
+          m_hillWalkable[idx] = true;
+          if (m_terrainTypes[idx] != TerrainType::Mountain) {
+            m_terrainTypes[idx] = TerrainType::Hill;
+          }
+
+          if (m_heights[idx] < feature.height * 0.25f) {
+            float t = std::clamp(cellDist / slopeRadius, 0.0f, 1.0f);
+            float rampHeight = feature.height * (1.0f - t * 0.85f);
+            m_heights[idx] = std::max(m_heights[idx], rampHeight);
+          }
+
+          for (int oz = -1; oz <= 1; ++oz) {
+            for (int ox = -1; ox <= 1; ++ox) {
+              if (ox == 0 && oz == 0)
+                continue;
+              int nx = ix + ox;
+              int nz = iz + oz;
+              if (!inBounds(nx, nz))
+                continue;
+
+              float neighborDist = std::sqrt(
+                  (float(nx) - gridCenterX) * (float(nx) - gridCenterX) +
+                  (float(nz) - gridCenterZ) * (float(nz) - gridCenterZ));
+              if (neighborDist <= slopeRadius + 0.5f) {
+                int nIdx = indexAt(nx, nz);
+                if (m_terrainTypes[nIdx] != TerrainType::Mountain) {
+                  m_hillWalkable[nIdx] = true;
+                  if (m_terrainTypes[nIdx] == TerrainType::Flat) {
+                    m_terrainTypes[nIdx] = TerrainType::Hill;
+                  }
+                  if (m_heights[nIdx] < m_heights[idx] * 0.8f) {
+                    m_heights[nIdx] =
+                        std::max(m_heights[nIdx], m_heights[idx] * 0.7f);
+                  }
+                }
+              }
+            }
+          }
+
+          if (cellDist <= plateauRadius + 0.5f) {
+            break;
+          }
+
+          curX += dirX;
+          curZ += dirZ;
+        }
+      }
+
+      continue;
+    }
+
+    const float flatRadius = gridRadius;
+    const int minX = std::max(0, int(std::floor(gridCenterX - flatRadius)));
+    const int maxX =
+        std::min(m_width - 1, int(std::ceil(gridCenterX + flatRadius)));
+    const int minZ = std::max(0, int(std::floor(gridCenterZ - flatRadius)));
+    const int maxZ =
+        std::min(m_height - 1, int(std::ceil(gridCenterZ + flatRadius)));
+
+    for (int z = minZ; z <= maxZ; ++z) {
+      for (int x = minX; x <= maxX; ++x) {
+        const float dx = float(x) - gridCenterX;
+        const float dz = float(z) - gridCenterZ;
+        const float dist = std::sqrt(dx * dx + dz * dz);
+        if (dist > flatRadius)
+          continue;
+
+        float t = dist / std::max(flatRadius, 0.0001f);
+        float height = feature.height * (1.0f - t);
+        if (height <= 0.0f)
+          continue;
+
+        int idx = indexAt(x, z);
+        if (height > m_heights[idx]) {
+          m_heights[idx] = height;
+          m_terrainTypes[idx] = TerrainType::Flat;
+        }
+      }
+    }
+  }
+}
+
+float TerrainHeightMap::getHeightAt(float worldX, float worldZ) const {
+
+  const float gridHalfWidth = m_width * 0.5f - 0.5f;
+  const float gridHalfHeight = m_height * 0.5f - 0.5f;
+
+  float gx = worldX / m_tileSize + gridHalfWidth;
+  float gz = worldZ / m_tileSize + gridHalfHeight;
+
+  int x0 = int(std::floor(gx));
+  int z0 = int(std::floor(gz));
+  int x1 = x0 + 1;
+  int z1 = z0 + 1;
+
+  if (!inBounds(x0, z0))
+    return 0.0f;
+
+  float tx = gx - x0;
+  float tz = gz - z0;
+
+  float h00 = inBounds(x0, z0) ? m_heights[indexAt(x0, z0)] : 0.0f;
+  float h10 = inBounds(x1, z0) ? m_heights[indexAt(x1, z0)] : 0.0f;
+  float h01 = inBounds(x0, z1) ? m_heights[indexAt(x0, z1)] : 0.0f;
+  float h11 = inBounds(x1, z1) ? m_heights[indexAt(x1, z1)] : 0.0f;
+
+  float h0 = h00 * (1.0f - tx) + h10 * tx;
+  float h1 = h01 * (1.0f - tx) + h11 * tx;
+
+  return h0 * (1.0f - tz) + h1 * tz;
+}
+
+float TerrainHeightMap::getHeightAtGrid(int gridX, int gridZ) const {
+  if (!inBounds(gridX, gridZ))
+    return 0.0f;
+  return m_heights[indexAt(gridX, gridZ)];
+}
+
+bool TerrainHeightMap::isWalkable(int gridX, int gridZ) const {
+  if (!inBounds(gridX, gridZ))
+    return false;
+
+  TerrainType type = m_terrainTypes[indexAt(gridX, gridZ)];
+
+  if (type == TerrainType::Mountain)
+    return false;
+
+  if (type == TerrainType::Hill) {
+    return m_hillWalkable[indexAt(gridX, gridZ)];
+  }
+
+  return true;
+}
+
+bool TerrainHeightMap::isHillEntrance(int gridX, int gridZ) const {
+  if (!inBounds(gridX, gridZ))
+    return false;
+  return m_hillEntrances[indexAt(gridX, gridZ)];
+}
+
+TerrainType TerrainHeightMap::getTerrainType(int gridX, int gridZ) const {
+  if (!inBounds(gridX, gridZ))
+    return TerrainType::Flat;
+  return m_terrainTypes[indexAt(gridX, gridZ)];
+}
+
+int TerrainHeightMap::indexAt(int x, int z) const { return z * m_width + x; }
+
+bool TerrainHeightMap::inBounds(int x, int z) const {
+  return x >= 0 && x < m_width && z >= 0 && z < m_height;
+}
+
+float TerrainHeightMap::calculateFeatureHeight(const TerrainFeature &feature,
+                                               float worldX,
+                                               float worldZ) const {
+  float dx = worldX - feature.centerX;
+  float dz = worldZ - feature.centerZ;
+  float dist = std::sqrt(dx * dx + dz * dz);
+
+  if (dist > feature.radius)
+    return 0.0f;
+
+  float t = dist / feature.radius;
+  float heightFactor = (std::cos(t * M_PI) + 1.0f) * 0.5f;
+
+  return feature.height * heightFactor;
+}
+
+} // namespace Game::Map

+ 65 - 0
game/map/terrain.h

@@ -0,0 +1,65 @@
+#pragma once
+
+#include <QVector3D>
+#include <memory>
+#include <vector>
+
+namespace Game::Map {
+
+enum class TerrainType { Flat, Hill, Mountain };
+
+struct TerrainFeature {
+  TerrainType type;
+  float centerX;
+  float centerZ;
+  float radius;
+  float height;
+
+  std::vector<QVector3D> entrances;
+
+  float rotationDeg = 0.0f;
+};
+
+class TerrainHeightMap {
+public:
+  TerrainHeightMap(int width, int height, float tileSize);
+
+  void buildFromFeatures(const std::vector<TerrainFeature> &features);
+
+  float getHeightAt(float worldX, float worldZ) const;
+
+  float getHeightAtGrid(int gridX, int gridZ) const;
+
+  bool isWalkable(int gridX, int gridZ) const;
+
+  bool isHillEntrance(int gridX, int gridZ) const;
+
+  TerrainType getTerrainType(int gridX, int gridZ) const;
+
+  int getWidth() const { return m_width; }
+  int getHeight() const { return m_height; }
+  float getTileSize() const { return m_tileSize; }
+
+  const std::vector<float> &getHeightData() const { return m_heights; }
+  const std::vector<TerrainType> &getTerrainTypes() const {
+    return m_terrainTypes;
+  }
+
+private:
+  int m_width;
+  int m_height;
+  float m_tileSize;
+
+  std::vector<float> m_heights;
+  std::vector<TerrainType> m_terrainTypes;
+  std::vector<bool> m_hillEntrances;
+  std::vector<bool> m_hillWalkable;
+
+  int indexAt(int x, int z) const;
+  bool inBounds(int x, int z) const;
+
+  float calculateFeatureHeight(const TerrainFeature &feature, float worldX,
+                               float worldZ) const;
+};
+
+} // namespace Game::Map

+ 52 - 0
game/map/terrain_service.cpp

@@ -0,0 +1,52 @@
+#include "terrain_service.h"
+#include "map_definition.h"
+#include <QDebug>
+
+namespace Game::Map {
+
+TerrainService &TerrainService::instance() {
+  static TerrainService s_instance;
+  return s_instance;
+}
+
+void TerrainService::initialize(const MapDefinition &mapDef) {
+  m_heightMap = std::make_unique<TerrainHeightMap>(
+      mapDef.grid.width, mapDef.grid.height, mapDef.grid.tileSize);
+
+  m_heightMap->buildFromFeatures(mapDef.terrain);
+
+  qDebug() << "TerrainService initialized with" << mapDef.terrain.size()
+           << "terrain features";
+}
+
+float TerrainService::getTerrainHeight(float worldX, float worldZ) const {
+  if (!m_heightMap)
+    return 0.0f;
+  return m_heightMap->getHeightAt(worldX, worldZ);
+}
+
+float TerrainService::getTerrainHeightGrid(int gridX, int gridZ) const {
+  if (!m_heightMap)
+    return 0.0f;
+  return m_heightMap->getHeightAtGrid(gridX, gridZ);
+}
+
+bool TerrainService::isWalkable(int gridX, int gridZ) const {
+  if (!m_heightMap)
+    return true;
+  return m_heightMap->isWalkable(gridX, gridZ);
+}
+
+bool TerrainService::isHillEntrance(int gridX, int gridZ) const {
+  if (!m_heightMap)
+    return false;
+  return m_heightMap->isHillEntrance(gridX, gridZ);
+}
+
+TerrainType TerrainService::getTerrainType(int gridX, int gridZ) const {
+  if (!m_heightMap)
+    return TerrainType::Flat;
+  return m_heightMap->getTerrainType(gridX, gridZ);
+}
+
+} // namespace Game::Map

+ 40 - 0
game/map/terrain_service.h

@@ -0,0 +1,40 @@
+#pragma once
+
+#include "terrain.h"
+#include <memory>
+
+namespace Game::Map {
+
+struct MapDefinition;
+
+class TerrainService {
+public:
+  static TerrainService &instance();
+
+  void initialize(const MapDefinition &mapDef);
+
+  float getTerrainHeight(float worldX, float worldZ) const;
+
+  float getTerrainHeightGrid(int gridX, int gridZ) const;
+
+  bool isWalkable(int gridX, int gridZ) const;
+
+  bool isHillEntrance(int gridX, int gridZ) const;
+
+  TerrainType getTerrainType(int gridX, int gridZ) const;
+
+  const TerrainHeightMap *getHeightMap() const { return m_heightMap.get(); }
+
+  bool isInitialized() const { return m_heightMap != nullptr; }
+
+private:
+  TerrainService() = default;
+  ~TerrainService() = default;
+
+  TerrainService(const TerrainService &) = delete;
+  TerrainService &operator=(const TerrainService &) = delete;
+
+  std::unique_ptr<TerrainHeightMap> m_heightMap;
+};
+
+} // namespace Game::Map

+ 150 - 0
game/map/visibility_service.cpp

@@ -0,0 +1,150 @@
+#include "visibility_service.h"
+
+#include "../core/component.h"
+#include "../core/world.h"
+#include <algorithm>
+#include <cmath>
+
+namespace Game::Map {
+
+namespace {
+constexpr float kDefaultVisionRange = 12.0f;
+}
+
+VisibilityService &VisibilityService::instance() {
+  static VisibilityService s_instance;
+  return s_instance;
+}
+
+void VisibilityService::initialize(int width, int height, float tileSize) {
+  m_width = std::max(1, width);
+  m_height = std::max(1, height);
+  m_tileSize = std::max(0.0001f, tileSize);
+  m_halfWidth = m_width * 0.5f - 0.5f;
+  m_halfHeight = m_height * 0.5f - 0.5f;
+  const int count = m_width * m_height;
+  m_cells.assign(count, static_cast<std::uint8_t>(VisibilityState::Unseen));
+  m_currentVisible.assign(count, 0);
+  m_version++;
+  m_initialized = true;
+}
+
+void VisibilityService::reset() {
+  if (!m_initialized)
+    return;
+  std::fill(m_cells.begin(), m_cells.end(),
+            static_cast<std::uint8_t>(VisibilityState::Unseen));
+  std::fill(m_currentVisible.begin(), m_currentVisible.end(), 0);
+  m_version++;
+}
+
+void VisibilityService::update(Engine::Core::World &world, int playerId) {
+  if (!m_initialized)
+    return;
+
+  std::fill(m_currentVisible.begin(), m_currentVisible.end(), 0);
+
+  auto entities = world.getEntitiesWith<Engine::Core::TransformComponent>();
+  const float rangePadding = m_tileSize * 0.5f;
+
+  for (auto *entity : entities) {
+    auto *transform = entity->getComponent<Engine::Core::TransformComponent>();
+    auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
+    if (!transform || !unit)
+      continue;
+    if (unit->ownerId != playerId)
+      continue;
+    if (unit->health <= 0)
+      continue;
+
+    const float visionRange = std::max(unit->visionRange, kDefaultVisionRange);
+
+    const int centerX = worldToGrid(transform->position.x, m_halfWidth);
+    const int centerZ = worldToGrid(transform->position.z, m_halfHeight);
+    if (!inBounds(centerX, centerZ))
+      continue;
+    const int cellRadius =
+        std::max(1, static_cast<int>(std::ceil(visionRange / m_tileSize)));
+
+    for (int dz = -cellRadius; dz <= cellRadius; ++dz) {
+      const int gz = centerZ + dz;
+      if (!inBounds(centerX, gz))
+        continue;
+      const float worldDz = dz * m_tileSize;
+      for (int dx = -cellRadius; dx <= cellRadius; ++dx) {
+        const int gx = centerX + dx;
+        if (!inBounds(gx, gz))
+          continue;
+        const float worldDx = dx * m_tileSize;
+        const float distSq = worldDx * worldDx + worldDz * worldDz;
+        if (distSq <=
+            (visionRange + rangePadding) * (visionRange + rangePadding)) {
+          const int idx = index(gx, gz);
+          m_currentVisible[idx] = 1;
+        }
+      }
+    }
+  }
+
+  bool changed = false;
+  for (int idx = 0; idx < static_cast<int>(m_cells.size()); ++idx) {
+    const std::uint8_t nowVisible = m_currentVisible[idx];
+
+    if (nowVisible) {
+      if (m_cells[idx] != static_cast<std::uint8_t>(VisibilityState::Visible)) {
+        m_cells[idx] = static_cast<std::uint8_t>(VisibilityState::Visible);
+        changed = true;
+      }
+    } else {
+      if (m_cells[idx] == static_cast<std::uint8_t>(VisibilityState::Visible)) {
+        m_cells[idx] = static_cast<std::uint8_t>(VisibilityState::Explored);
+        changed = true;
+      }
+    }
+  }
+
+  if (changed)
+    ++m_version;
+}
+
+VisibilityState VisibilityService::stateAt(int gridX, int gridZ) const {
+  if (!m_initialized || !inBounds(gridX, gridZ))
+    return VisibilityState::Visible;
+  return static_cast<VisibilityState>(m_cells[index(gridX, gridZ)]);
+}
+
+bool VisibilityService::isVisibleWorld(float worldX, float worldZ) const {
+  if (!m_initialized)
+    return true;
+  const int gx = worldToGrid(worldX, m_halfWidth);
+  const int gz = worldToGrid(worldZ, m_halfHeight);
+  if (!inBounds(gx, gz))
+    return false;
+  return m_cells[index(gx, gz)] ==
+         static_cast<std::uint8_t>(VisibilityState::Visible);
+}
+
+bool VisibilityService::isExploredWorld(float worldX, float worldZ) const {
+  if (!m_initialized)
+    return true;
+  const int gx = worldToGrid(worldX, m_halfWidth);
+  const int gz = worldToGrid(worldZ, m_halfHeight);
+  if (!inBounds(gx, gz))
+    return false;
+  const auto state = m_cells[index(gx, gz)];
+  return state == static_cast<std::uint8_t>(VisibilityState::Visible) ||
+         state == static_cast<std::uint8_t>(VisibilityState::Explored);
+}
+
+bool VisibilityService::inBounds(int x, int z) const {
+  return x >= 0 && x < m_width && z >= 0 && z < m_height;
+}
+
+int VisibilityService::index(int x, int z) const { return z * m_width + x; }
+
+int VisibilityService::worldToGrid(float worldCoord, float half) const {
+  float gridCoord = worldCoord / m_tileSize + half;
+  return static_cast<int>(std::floor(gridCoord + 0.5f));
+}
+
+} // namespace Game::Map

+ 62 - 0
game/map/visibility_service.h

@@ -0,0 +1,62 @@
+#pragma once
+
+#include <cstdint>
+#include <vector>
+
+namespace Engine {
+namespace Core {
+class World;
+}
+} // namespace Engine
+
+namespace Game {
+namespace Map {
+
+enum class VisibilityState : std::uint8_t {
+  Unseen = 0,
+  Explored = 1,
+  Visible = 2
+};
+
+class VisibilityService {
+public:
+  static VisibilityService &instance();
+
+  void initialize(int width, int height, float tileSize);
+  void reset();
+  void update(Engine::Core::World &world, int playerId);
+
+  bool isInitialized() const { return m_initialized; }
+
+  int getWidth() const { return m_width; }
+  int getHeight() const { return m_height; }
+  float getTileSize() const { return m_tileSize; }
+
+  VisibilityState stateAt(int gridX, int gridZ) const;
+  bool isVisibleWorld(float worldX, float worldZ) const;
+  bool isExploredWorld(float worldX, float worldZ) const;
+
+  const std::vector<std::uint8_t> &cells() const { return m_cells; }
+  std::uint64_t version() const { return m_version; }
+
+private:
+  bool inBounds(int x, int z) const;
+  int index(int x, int z) const;
+  int worldToGrid(float worldCoord, float half) const;
+
+  VisibilityService() = default;
+
+  bool m_initialized = false;
+  int m_width = 0;
+  int m_height = 0;
+  float m_tileSize = 1.0f;
+  float m_halfWidth = 0.0f;
+  float m_halfHeight = 0.0f;
+
+  std::vector<std::uint8_t> m_cells;
+  std::vector<std::uint8_t> m_currentVisible;
+  std::uint64_t m_version = 0;
+};
+
+} // namespace Map
+} // namespace Game

+ 15 - 0
game/systems/pathfinding.cpp

@@ -1,4 +1,5 @@
 #include "pathfinding.h"
+#include "../map/terrain_service.h"
 #include "building_collision_registry.h"
 #include <algorithm>
 #include <cmath>
@@ -61,6 +62,20 @@ void Pathfinding::updateBuildingObstacles() {
     std::fill(row.begin(), row.end(), false);
   }
 
+  auto &terrainService = Game::Map::TerrainService::instance();
+  if (terrainService.isInitialized()) {
+    for (int z = 0; z < m_height; ++z) {
+      for (int x = 0; x < m_width; ++x) {
+        int terrainX = x + static_cast<int>(m_gridOffsetX);
+        int terrainZ = z + static_cast<int>(m_gridOffsetZ);
+
+        if (!terrainService.isWalkable(terrainX, terrainZ)) {
+          m_obstacles[z][x] = true;
+        }
+      }
+    }
+  }
+
   auto &registry = BuildingCollisionRegistry::instance();
   const auto &buildings = registry.getAllBuildings();
 

+ 38 - 0
game/systems/terrain_alignment_system.cpp

@@ -0,0 +1,38 @@
+#include "terrain_alignment_system.h"
+#include "../core/component.h"
+#include "../core/entity.h"
+#include "../core/world.h"
+#include "../map/terrain_service.h"
+
+namespace Game::Systems {
+
+void TerrainAlignmentSystem::update(Engine::Core::World *world,
+                                    float deltaTime) {
+  auto &terrainService = Game::Map::TerrainService::instance();
+
+  if (!terrainService.isInitialized()) {
+    return;
+  }
+
+  auto entities = world->getEntitiesWith<Engine::Core::TransformComponent>();
+  for (auto *entity : entities) {
+    alignEntityToTerrain(entity);
+  }
+}
+
+void TerrainAlignmentSystem::alignEntityToTerrain(
+    Engine::Core::Entity *entity) {
+  auto *transform = entity->getComponent<Engine::Core::TransformComponent>();
+  if (!transform)
+    return;
+
+  auto &terrainService = Game::Map::TerrainService::instance();
+
+  float terrainHeight = terrainService.getTerrainHeight(transform->position.x,
+                                                        transform->position.z);
+
+  const float entityBaseOffset = 0.0f;
+  transform->position.y = terrainHeight + entityBaseOffset;
+}
+
+} // namespace Game::Systems

+ 19 - 0
game/systems/terrain_alignment_system.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include "../core/system.h"
+
+namespace Engine::Core {
+class Entity;
+}
+
+namespace Game::Systems {
+
+class TerrainAlignmentSystem : public Engine::Core::System {
+public:
+  void update(Engine::Core::World *world, float deltaTime) override;
+
+private:
+  void alignEntityToTerrain(Engine::Core::Entity *entity);
+};
+
+} // namespace Game::Systems

+ 1 - 0
game/units/archer.cpp

@@ -47,6 +47,7 @@ void Archer::init(const SpawnParams &params) {
   m_u->maxHealth = 80;
   m_u->speed = 3.0f;
   m_u->ownerId = params.playerId;
+  m_u->visionRange = 16.0f;
 
   QVector3D tc = teamColor(m_u->ownerId);
   m_r->color[0] = tc.x();

+ 1 - 0
game/units/barracks.cpp

@@ -35,6 +35,7 @@ void Barracks::init(const SpawnParams &params) {
   m_u->maxHealth = 2000;
   m_u->speed = 0.0f;
   m_u->ownerId = params.playerId;
+  m_u->visionRange = 22.0f;
 
   QVector3D tc = Game::Visuals::teamColorForOwner(m_u->ownerId);
   m_r->color[0] = tc.x();

+ 2 - 0
render/CMakeLists.txt

@@ -13,6 +13,8 @@ add_library(render_gl STATIC
     gl/state_scopes.cpp
     draw_queue.cpp
     ground/ground_renderer.cpp
+    ground/fog_renderer.cpp
+    ground/terrain_renderer.cpp
     entity/registry.cpp
     entity/archer_renderer.cpp
     entity/barracks_renderer.cpp

+ 22 - 0
render/entity/archer_renderer.cpp

@@ -8,11 +8,13 @@
 #include "../geom/transforms.h"
 #include "../gl/mesh.h"
 #include "../gl/primitives.h"
+#include "../gl/resources.h"
 #include "../gl/texture.h"
 #include "registry.h"
 
 #include <QMatrix4x4>
 #include <QVector3D>
+#include <algorithm>
 #include <cmath>
 #include <cstdint>
 
@@ -414,6 +416,23 @@ static inline void drawSelectionFX(const DrawContext &p, ISubmitter &out) {
   }
 }
 
+static inline void drawGroundShadow(const DrawContext &p, ISubmitter &out,
+                                    float formationWidth,
+                                    float formationDepth) {
+  if (!p.resources)
+    return;
+  Mesh *quad = p.resources->quad();
+  Texture *white = p.resources->white();
+  if (!quad || !white)
+    return;
+
+  QMatrix4x4 decal = p.model;
+  decal.translate(0.0f, 0.02f, 0.0f);
+  decal.rotate(-90.0f, 1.0f, 0.0f, 0.0f);
+  decal.scale(formationWidth * 0.55f, formationDepth * 0.45f, 1.0f);
+  out.mesh(quad, decal, QVector3D(0.07f, 0.07f, 0.066f), white, 0.52f);
+}
+
 void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
   registry.registerRenderer("archer", [](const DrawContext &p,
                                          ISubmitter &out) {
@@ -442,6 +461,9 @@ void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
 
     ArcherColors colors = makeColors(tunic);
 
+    drawGroundShadow(p, out, std::max(1, cols - 1) * spacing + 0.8f,
+                     std::max(1, rows - 1) * spacing + 0.9f);
+
     bool isMoving = false;
     bool isAttacking = false;
     float targetRotationY = 0.0f;

+ 157 - 0
render/ground/fog_renderer.cpp

@@ -0,0 +1,157 @@
+#include "fog_renderer.h"
+
+#include "../gl/mesh.h"
+#include "../gl/resources.h"
+#include "../scene_renderer.h"
+#include <QElapsedTimer>
+#include <algorithm>
+
+namespace Render::GL {
+
+void FogRenderer::updateMask(int width, int height, float tileSize,
+                             const std::vector<std::uint8_t> &cells) {
+  m_width = std::max(0, width);
+  m_height = std::max(0, height);
+  m_tileSize = std::max(0.0001f, tileSize);
+  m_halfWidth = m_width * 0.5f - 0.5f;
+  m_halfHeight = m_height * 0.5f - 0.5f;
+  m_cells = cells;
+  buildChunks();
+}
+
+void FogRenderer::submit(Renderer &renderer, ResourceManager &resources) {
+  if (!m_enabled)
+    return;
+  if (m_width <= 0 || m_height <= 0)
+    return;
+  if (static_cast<int>(m_cells.size()) != m_width * m_height)
+    return;
+
+  Texture *white = resources.white();
+  if (!white)
+    return;
+
+  QMatrix4x4 model;
+
+  for (const auto &chunk : m_chunks) {
+    if (!chunk.mesh)
+      continue;
+    renderer.mesh(chunk.mesh.get(), model, chunk.color, white, chunk.alpha);
+  }
+}
+
+void FogRenderer::buildChunks() {
+  m_chunks.clear();
+
+  if (m_width <= 0 || m_height <= 0)
+    return;
+  if (static_cast<int>(m_cells.size()) != m_width * m_height)
+    return;
+
+  QElapsedTimer timer;
+  timer.start();
+
+  const float halfTile = m_tileSize * 0.5f;
+  const int chunkSize = 16;
+  std::size_t totalQuads = 0;
+
+  for (int chunkZ = 0; chunkZ < m_height; chunkZ += chunkSize) {
+    int chunkMaxZ = std::min(chunkZ + chunkSize, m_height);
+    for (int chunkX = 0; chunkX < m_width; chunkX += chunkSize) {
+      int chunkMaxX = std::min(chunkX + chunkSize, m_width);
+
+      struct SectionData {
+        std::vector<Vertex> vertices;
+        std::vector<unsigned int> indices;
+      };
+
+      SectionData sections[2];
+
+      auto appendQuad = [&](SectionData &section, float centerX,
+                            float centerZ) {
+        Vertex v0{};
+        Vertex v1{};
+        Vertex v2{};
+        Vertex v3{};
+
+        v0.position[0] = centerX - halfTile;
+        v0.position[1] = 0.25f;
+        v0.position[2] = centerZ - halfTile;
+        v1.position[0] = centerX + halfTile;
+        v1.position[1] = 0.25f;
+        v1.position[2] = centerZ - halfTile;
+        v2.position[0] = centerX - halfTile;
+        v2.position[1] = 0.25f;
+        v2.position[2] = centerZ + halfTile;
+        v3.position[0] = centerX + halfTile;
+        v3.position[1] = 0.25f;
+        v3.position[2] = centerZ + halfTile;
+
+        v0.normal[0] = v1.normal[0] = v2.normal[0] = v3.normal[0] = 0.0f;
+        v0.normal[1] = v1.normal[1] = v2.normal[1] = v3.normal[1] = 1.0f;
+        v0.normal[2] = v1.normal[2] = v2.normal[2] = v3.normal[2] = 0.0f;
+
+        v0.texCoord[0] = 0.0f;
+        v0.texCoord[1] = 0.0f;
+        v1.texCoord[0] = 1.0f;
+        v1.texCoord[1] = 0.0f;
+        v2.texCoord[0] = 0.0f;
+        v2.texCoord[1] = 1.0f;
+        v3.texCoord[0] = 1.0f;
+        v3.texCoord[1] = 1.0f;
+
+        const unsigned int base =
+            static_cast<unsigned int>(section.vertices.size());
+        section.vertices.push_back(v0);
+        section.vertices.push_back(v1);
+        section.vertices.push_back(v2);
+        section.vertices.push_back(v3);
+
+        section.indices.push_back(base + 0);
+        section.indices.push_back(base + 1);
+        section.indices.push_back(base + 2);
+        section.indices.push_back(base + 2);
+        section.indices.push_back(base + 1);
+        section.indices.push_back(base + 3);
+      };
+
+      for (int z = chunkZ; z < chunkMaxZ; ++z) {
+        for (int x = chunkX; x < chunkMaxX; ++x) {
+          const std::uint8_t state = m_cells[z * m_width + x];
+          if (state >= 2)
+            continue;
+
+          const float worldX = (x - m_halfWidth) * m_tileSize;
+          const float worldZ = (z - m_halfHeight) * m_tileSize;
+
+          SectionData &section = sections[std::min<int>(state, 1)];
+          appendQuad(section, worldX, worldZ);
+        }
+      }
+
+      if (!sections[0].indices.empty()) {
+        FogChunk chunk;
+        chunk.mesh =
+            std::make_unique<Mesh>(sections[0].vertices, sections[0].indices);
+        chunk.color = QVector3D(0.02f, 0.02f, 0.05f);
+        chunk.alpha = 0.9f;
+        totalQuads += sections[0].indices.size() / 6;
+        m_chunks.push_back(std::move(chunk));
+      }
+      if (!sections[1].indices.empty()) {
+        FogChunk chunk;
+        chunk.mesh =
+            std::make_unique<Mesh>(sections[1].vertices, sections[1].indices);
+        chunk.color = QVector3D(0.05f, 0.05f, 0.05f);
+        chunk.alpha = 0.45f;
+        totalQuads += sections[1].indices.size() / 6;
+        m_chunks.push_back(std::move(chunk));
+      }
+    }
+  }
+
+  qDebug() << "FogRenderer: built" << m_chunks.size() << "chunks in"
+           << timer.elapsed() << "ms" << "quads:" << totalQuads;
+}
+
+} // namespace Render::GL

+ 49 - 0
render/ground/fog_renderer.h

@@ -0,0 +1,49 @@
+#pragma once
+
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace Render {
+namespace GL {
+class Renderer;
+class ResourceManager;
+class Mesh;
+class Texture;
+
+class FogRenderer {
+public:
+  FogRenderer() = default;
+  ~FogRenderer() = default;
+
+  void setEnabled(bool enabled) { m_enabled = enabled; }
+  bool isEnabled() const { return m_enabled; }
+
+  void updateMask(int width, int height, float tileSize,
+                  const std::vector<std::uint8_t> &cells);
+
+  void submit(Renderer &renderer, ResourceManager &resources);
+
+private:
+  void buildChunks();
+
+  struct FogChunk {
+    std::unique_ptr<Mesh> mesh;
+    QVector3D color{0.05f, 0.05f, 0.05f};
+    float alpha = 0.45f;
+  };
+
+  bool m_enabled = true;
+  int m_width = 0;
+  int m_height = 0;
+  float m_tileSize = 1.0f;
+  float m_halfWidth = 0.0f;
+  float m_halfHeight = 0.0f;
+  std::vector<std::uint8_t> m_cells;
+  std::vector<FogChunk> m_chunks;
+};
+
+} // namespace GL
+} // namespace Render

+ 477 - 0
render/ground/terrain_renderer.cpp

@@ -0,0 +1,477 @@
+#include "terrain_renderer.h"
+#include "../../game/map/visibility_service.h"
+#include "../gl/mesh.h"
+#include "../gl/resources.h"
+#include "../scene_renderer.h"
+#include <QDebug>
+#include <QElapsedTimer>
+#include <algorithm>
+#include <cmath>
+#include <unordered_map>
+
+namespace {
+
+using std::uint32_t;
+
+inline uint32_t hashCoords(int x, int z, uint32_t salt = 0u) {
+  uint32_t ux = static_cast<uint32_t>(x * 73856093);
+  uint32_t uz = static_cast<uint32_t>(z * 19349663);
+  return ux ^ uz ^ (salt * 83492791u);
+}
+
+inline float rand01(uint32_t &state) {
+  state = state * 1664525u + 1013904223u;
+  return static_cast<float>((state >> 8) & 0xFFFFFF) /
+         static_cast<float>(0xFFFFFF);
+}
+
+inline float remap(float value, float minOut, float maxOut) {
+  return minOut + (maxOut - minOut) * value;
+}
+
+inline QVector3D applyTint(const QVector3D &color, float tint) {
+  QVector3D c = color * tint;
+  return QVector3D(std::clamp(c.x(), 0.0f, 1.0f), std::clamp(c.y(), 0.0f, 1.0f),
+                   std::clamp(c.z(), 0.0f, 1.0f));
+}
+
+} // namespace
+
+namespace Render::GL {
+
+TerrainRenderer::TerrainRenderer() = default;
+TerrainRenderer::~TerrainRenderer() = default;
+
+void TerrainRenderer::configure(const Game::Map::TerrainHeightMap &heightMap) {
+  m_width = heightMap.getWidth();
+  m_height = heightMap.getHeight();
+  m_tileSize = heightMap.getTileSize();
+
+  m_heightData = heightMap.getHeightData();
+  m_terrainTypes = heightMap.getTerrainTypes();
+  buildMeshes();
+
+  qDebug() << "TerrainRenderer configured:" << m_width << "x" << m_height
+           << "grid";
+}
+
+void TerrainRenderer::submit(Renderer &renderer, ResourceManager &resources) {
+  if (m_chunks.empty()) {
+    return;
+  }
+
+  Texture *white = resources.white();
+  if (!white)
+    return;
+
+  auto &visibility = Game::Map::VisibilityService::instance();
+  const bool useVisibility = visibility.isInitialized();
+
+  Mesh *unitMesh = resources.unit();
+  Mesh *quadMesh = resources.quad();
+  const float halfWidth = m_width * 0.5f - 0.5f;
+  const float halfHeight = m_height * 0.5f - 0.5f;
+
+  for (const auto &chunk : m_chunks) {
+    if (!chunk.mesh)
+      continue;
+
+    if (useVisibility) {
+      bool anyVisible = false;
+      for (int gz = chunk.minZ; gz <= chunk.maxZ && !anyVisible; ++gz) {
+        for (int gx = chunk.minX; gx <= chunk.maxX; ++gx) {
+          if (visibility.stateAt(gx, gz) ==
+              Game::Map::VisibilityState::Visible) {
+            anyVisible = true;
+            break;
+          }
+        }
+      }
+      if (!anyVisible)
+        continue;
+    }
+
+    QMatrix4x4 model;
+    renderer.mesh(chunk.mesh.get(), model, chunk.color, white, 1.0f);
+  }
+
+  for (const auto &prop : m_props) {
+    if (useVisibility) {
+      int gridX = static_cast<int>(
+          std::round(prop.position.x() / m_tileSize + halfWidth));
+      int gridZ = static_cast<int>(
+          std::round(prop.position.z() / m_tileSize + halfHeight));
+      if (visibility.stateAt(gridX, gridZ) !=
+          Game::Map::VisibilityState::Visible)
+        continue;
+    }
+
+    Mesh *mesh = nullptr;
+    switch (prop.type) {
+    case PropType::Pebble:
+      mesh = unitMesh;
+      break;
+    case PropType::Tuft:
+      mesh = unitMesh;
+      break;
+    case PropType::Stick:
+      mesh = unitMesh;
+      break;
+    }
+    if (!mesh)
+      continue;
+
+    QMatrix4x4 model;
+    model.translate(prop.position);
+    model.rotate(prop.rotationDeg, 0.0f, 1.0f, 0.0f);
+    model.scale(prop.scale);
+    renderer.mesh(mesh, model, prop.color, white, prop.alpha);
+
+    if (quadMesh) {
+      QMatrix4x4 decal;
+      decal.translate(prop.position.x(), prop.position.y() + 0.01f,
+                      prop.position.z());
+      decal.rotate(-90.0f, 1.0f, 0.0f, 0.0f);
+      decal.scale(prop.scale.x() * 1.4f, prop.scale.z() * 1.4f, 1.0f);
+      QVector3D aoColor(0.06f, 0.06f, 0.055f);
+      renderer.mesh(quadMesh, decal, aoColor, white, 0.35f);
+    }
+  }
+}
+
+int TerrainRenderer::sectionFor(Game::Map::TerrainType type) const {
+  switch (type) {
+  case Game::Map::TerrainType::Mountain:
+    return 2;
+  case Game::Map::TerrainType::Hill:
+    return 1;
+  case Game::Map::TerrainType::Flat:
+  default:
+    return 0;
+  }
+}
+
+void TerrainRenderer::buildMeshes() {
+  QElapsedTimer timer;
+  timer.start();
+
+  m_chunks.clear();
+  m_props.clear();
+
+  if (m_width < 2 || m_height < 2 || m_heightData.empty()) {
+    return;
+  }
+
+  const float halfWidth = m_width * 0.5f - 0.5f;
+  const float halfHeight = m_height * 0.5f - 0.5f;
+  const int vertexCount = m_width * m_height;
+
+  std::vector<QVector3D> positions(vertexCount);
+  std::vector<QVector3D> normals(vertexCount, QVector3D(0.0f, 0.0f, 0.0f));
+
+  for (int z = 0; z < m_height; ++z) {
+    for (int x = 0; x < m_width; ++x) {
+      int idx = z * m_width + x;
+      float worldX = (x - halfWidth) * m_tileSize;
+      float worldZ = (z - halfHeight) * m_tileSize;
+      positions[idx] = QVector3D(worldX, m_heightData[idx], worldZ);
+    }
+  }
+
+  auto accumulateNormal = [&](int i0, int i1, int i2) {
+    const QVector3D &v0 = positions[i0];
+    const QVector3D &v1 = positions[i1];
+    const QVector3D &v2 = positions[i2];
+    QVector3D normal = QVector3D::crossProduct(v1 - v0, v2 - v0);
+    normals[i0] += normal;
+    normals[i1] += normal;
+    normals[i2] += normal;
+  };
+
+  for (int z = 0; z < m_height - 1; ++z) {
+    for (int x = 0; x < m_width - 1; ++x) {
+      int idx0 = z * m_width + x;
+      int idx1 = idx0 + 1;
+      int idx2 = (z + 1) * m_width + x;
+      int idx3 = idx2 + 1;
+      accumulateNormal(idx0, idx1, idx2);
+      accumulateNormal(idx2, idx1, idx3);
+    }
+  }
+
+  for (int i = 0; i < vertexCount; ++i) {
+    normals[i].normalize();
+    if (normals[i].isNull()) {
+      normals[i] = QVector3D(0.0f, 1.0f, 0.0f);
+    }
+  }
+
+  auto quadSection = [&](Game::Map::TerrainType a, Game::Map::TerrainType b,
+                         Game::Map::TerrainType c, Game::Map::TerrainType d) {
+    int priorityA = sectionFor(a);
+    int priorityB = sectionFor(b);
+    int priorityC = sectionFor(c);
+    int priorityD = sectionFor(d);
+    int result = priorityA;
+    result = std::max(result, priorityB);
+    result = std::max(result, priorityC);
+    result = std::max(result, priorityD);
+    return result;
+  };
+
+  const int chunkSize = 16;
+  std::size_t totalTriangles = 0;
+
+  for (int chunkZ = 0; chunkZ < m_height - 1; chunkZ += chunkSize) {
+    int chunkMaxZ = std::min(chunkZ + chunkSize, m_height - 1);
+    for (int chunkX = 0; chunkX < m_width - 1; chunkX += chunkSize) {
+      int chunkMaxX = std::min(chunkX + chunkSize, m_width - 1);
+
+      struct SectionData {
+        std::vector<Vertex> vertices;
+        std::vector<unsigned int> indices;
+        std::unordered_map<int, unsigned int> remap;
+        float heightSum = 0.0f;
+        int heightCount = 0;
+        float rotationDeg = 0.0f;
+        bool flipU = false;
+        float tint = 1.0f;
+      };
+
+      SectionData sections[3];
+
+      uint32_t chunkSeed = hashCoords(chunkX, chunkZ);
+      uint32_t variantSeed = chunkSeed ^ 0x9e3779b9u;
+      float rotationStep = static_cast<float>((variantSeed >> 5) & 3) * 90.0f;
+      bool flip = ((variantSeed >> 7) & 1u) != 0u;
+      static const float tintVariants[5] = {0.92f, 0.96f, 1.0f, 1.04f, 1.08f};
+      float tint = tintVariants[(variantSeed >> 12) % 5];
+
+      for (auto &section : sections) {
+        section.rotationDeg = rotationStep;
+        section.flipU = flip;
+        section.tint = tint;
+      }
+
+      auto ensureVertex = [&](SectionData &section,
+                              int globalIndex) -> unsigned int {
+        auto it = section.remap.find(globalIndex);
+        if (it != section.remap.end()) {
+          return it->second;
+        }
+        Vertex v{};
+        const QVector3D &pos = positions[globalIndex];
+        const QVector3D &normal = normals[globalIndex];
+        int gx = globalIndex % m_width;
+        int gz = globalIndex / m_width;
+        v.position[0] = pos.x();
+        v.position[1] = pos.y();
+        v.position[2] = pos.z();
+        v.normal[0] = normal.x();
+        v.normal[1] = normal.y();
+        v.normal[2] = normal.z();
+        float uu = gx / float(std::max(m_width - 1, 1));
+        float vv = gz / float(std::max(m_height - 1, 1));
+        if (section.flipU)
+          uu = 1.0f - uu;
+        float ru = uu - 0.5f;
+        float rv = vv - 0.5f;
+        switch (static_cast<int>(section.rotationDeg)) {
+        case 90:
+          std::swap(ru, rv);
+          ru = -ru;
+          break;
+        case 180:
+          ru = -ru;
+          rv = -rv;
+          break;
+        case 270:
+          std::swap(ru, rv);
+          rv = -rv;
+          break;
+        default:
+          break;
+        }
+        uu = ru + 0.5f;
+        vv = rv + 0.5f;
+        v.texCoord[0] = uu;
+        v.texCoord[1] = vv;
+        section.vertices.push_back(v);
+        unsigned int localIndex =
+            static_cast<unsigned int>(section.vertices.size() - 1);
+        section.remap.emplace(globalIndex, localIndex);
+        return localIndex;
+      };
+
+      for (int z = chunkZ; z < chunkMaxZ; ++z) {
+        for (int x = chunkX; x < chunkMaxX; ++x) {
+          int idx0 = z * m_width + x;
+          int idx1 = idx0 + 1;
+          int idx2 = (z + 1) * m_width + x;
+          int idx3 = idx2 + 1;
+
+          float maxHeight =
+              std::max(std::max(m_heightData[idx0], m_heightData[idx1]),
+                       std::max(m_heightData[idx2], m_heightData[idx3]));
+          if (maxHeight <= 0.05f) {
+            continue;
+          }
+
+          int sectionIndex =
+              quadSection(m_terrainTypes[idx0], m_terrainTypes[idx1],
+                          m_terrainTypes[idx2], m_terrainTypes[idx3]);
+
+          SectionData &section = sections[sectionIndex];
+          unsigned int v0 = ensureVertex(section, idx0);
+          unsigned int v1 = ensureVertex(section, idx1);
+          unsigned int v2 = ensureVertex(section, idx2);
+          unsigned int v3 = ensureVertex(section, idx3);
+          section.indices.push_back(v0);
+          section.indices.push_back(v1);
+          section.indices.push_back(v2);
+          section.indices.push_back(v2);
+          section.indices.push_back(v1);
+          section.indices.push_back(v3);
+
+          float quadHeight = (m_heightData[idx0] + m_heightData[idx1] +
+                              m_heightData[idx2] + m_heightData[idx3]) *
+                             0.25f;
+          section.heightSum += quadHeight;
+          section.heightCount += 1;
+        }
+      }
+
+      for (int i = 0; i < 3; ++i) {
+        SectionData &section = sections[i];
+        if (section.indices.empty()) {
+          continue;
+        }
+
+        auto mesh = std::make_unique<Mesh>(section.vertices, section.indices);
+        if (!mesh)
+          continue;
+
+        ChunkMesh chunk;
+        chunk.mesh = std::move(mesh);
+        chunk.minX = chunkX;
+        chunk.maxX = chunkMaxX - 1;
+        chunk.minZ = chunkZ;
+        chunk.maxZ = chunkMaxZ - 1;
+        chunk.type = (i == 0)   ? Game::Map::TerrainType::Flat
+                     : (i == 1) ? Game::Map::TerrainType::Hill
+                                : Game::Map::TerrainType::Mountain;
+        chunk.averageHeight =
+            (section.heightCount > 0)
+                ? section.heightSum / float(section.heightCount)
+                : 0.0f;
+        QVector3D baseColor = getTerrainColor(chunk.type, chunk.averageHeight);
+        chunk.tint = section.tint;
+        chunk.color = applyTint(baseColor, section.tint);
+        chunk.color = 0.88f * chunk.color + QVector3D(0.07f, 0.07f, 0.07f);
+
+        if (chunk.type != Game::Map::TerrainType::Mountain) {
+          uint32_t propSeed = hashCoords(chunk.minX, chunk.minZ,
+                                         static_cast<uint32_t>(chunk.type));
+          uint32_t state = propSeed ^ 0x6d2b79f5u;
+          float spawnChance = rand01(state);
+          int clusterCount = 0;
+          if (spawnChance > 0.65f) {
+            clusterCount = 1;
+            if (rand01(state) > 0.8f)
+              clusterCount += 1;
+          }
+
+          for (int cluster = 0; cluster < clusterCount; ++cluster) {
+            float gridSpanX = float(chunk.maxX - chunk.minX + 1);
+            float gridSpanZ = float(chunk.maxZ - chunk.minZ + 1);
+            float centerGX = float(chunk.minX) + rand01(state) * gridSpanX;
+            float centerGZ = float(chunk.minZ) + rand01(state) * gridSpanZ;
+
+            int propsPerCluster = 2 + static_cast<int>(rand01(state) * 3.0f);
+            float scatterRadius = remap(rand01(state), 0.2f, 0.8f) * m_tileSize;
+
+            for (int p = 0; p < propsPerCluster; ++p) {
+              float angle = rand01(state) * 6.2831853f;
+              float radius = scatterRadius * rand01(state);
+              float gx = centerGX + std::cos(angle) * radius / m_tileSize;
+              float gz = centerGZ + std::sin(angle) * radius / m_tileSize;
+
+              int sampleGX =
+                  std::clamp(static_cast<int>(std::round(gx)), 0, m_width - 1);
+              int sampleGZ =
+                  std::clamp(static_cast<int>(std::round(gz)), 0, m_height - 1);
+              float worldX = (gx - halfWidth) * m_tileSize;
+              float worldZ = (gz - halfHeight) * m_tileSize;
+              float worldY = m_heightData[sampleGZ * m_width + sampleGX];
+
+              float pick = rand01(state);
+              PropInstance instance;
+              if (pick < 0.45f) {
+                instance.type = PropType::Tuft;
+                instance.color = applyTint(QVector3D(0.26f, 0.6f, 0.22f),
+                                           remap(rand01(state), 0.9f, 1.15f));
+                instance.scale = QVector3D(remap(rand01(state), 0.18f, 0.28f),
+                                           remap(rand01(state), 0.4f, 0.6f),
+                                           remap(rand01(state), 0.18f, 0.28f));
+                instance.alpha = 1.0f;
+              } else if (pick < 0.8f) {
+                instance.type = PropType::Pebble;
+                instance.color = applyTint(QVector3D(0.42f, 0.41f, 0.39f),
+                                           remap(rand01(state), 0.85f, 1.05f));
+                instance.scale = QVector3D(remap(rand01(state), 0.12f, 0.22f),
+                                           remap(rand01(state), 0.06f, 0.1f),
+                                           remap(rand01(state), 0.12f, 0.22f));
+                instance.alpha = 1.0f;
+              } else {
+                instance.type = PropType::Stick;
+                instance.color = applyTint(QVector3D(0.35f, 0.24f, 0.12f),
+                                           remap(rand01(state), 0.95f, 1.1f));
+                instance.scale = QVector3D(remap(rand01(state), 0.05f, 0.09f),
+                                           remap(rand01(state), 0.35f, 0.55f),
+                                           remap(rand01(state), 0.05f, 0.09f));
+                instance.alpha = 1.0f;
+              }
+              instance.rotationDeg = rand01(state) * 360.0f;
+              instance.position = QVector3D(worldX, worldY, worldZ);
+              m_props.push_back(instance);
+            }
+          }
+        }
+
+        totalTriangles += chunk.mesh->getIndices().size() / 3;
+        m_chunks.push_back(std::move(chunk));
+      }
+    }
+  }
+
+  qDebug() << "TerrainRenderer: built" << m_chunks.size() << "chunks in"
+           << timer.elapsed() << "ms" << "triangles:" << totalTriangles;
+}
+
+QVector3D TerrainRenderer::getTerrainColor(Game::Map::TerrainType type,
+                                           float height) const {
+  switch (type) {
+  case Game::Map::TerrainType::Mountain:
+
+    if (height > 4.0f) {
+      return QVector3D(0.68f, 0.69f, 0.72f);
+    } else {
+      return QVector3D(0.52f, 0.49f, 0.47f);
+    }
+
+  case Game::Map::TerrainType::Hill:
+
+  {
+    float t = std::clamp(height / 3.0f, 0.0f, 1.0f);
+    QVector3D lushGrass(0.36f, 0.65f, 0.30f);
+    QVector3D sunKissed(0.58f, 0.48f, 0.34f);
+    return lushGrass * (1.0f - t) + sunKissed * t;
+  }
+
+  case Game::Map::TerrainType::Flat:
+  default:
+    return QVector3D(0.26f, 0.56f, 0.29f);
+  }
+}
+
+} // namespace Render::GL

+ 69 - 0
render/ground/terrain_renderer.h

@@ -0,0 +1,69 @@
+#pragma once
+
+#include "../../game/map/terrain.h"
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <cmath>
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+namespace Render {
+namespace GL {
+class Renderer;
+class ResourceManager;
+class Mesh;
+class Texture;
+
+class TerrainRenderer {
+public:
+  TerrainRenderer();
+  ~TerrainRenderer();
+
+  void configure(const Game::Map::TerrainHeightMap &heightMap);
+
+  void submit(Renderer &renderer, ResourceManager &resources);
+
+  void setWireframe(bool enable) { m_wireframe = enable; }
+
+private:
+  void buildMeshes();
+  int sectionFor(Game::Map::TerrainType type) const;
+
+  QVector3D getTerrainColor(Game::Map::TerrainType type, float height) const;
+  struct ChunkMesh {
+    std::unique_ptr<Mesh> mesh;
+    int minX = 0;
+    int maxX = 0;
+    int minZ = 0;
+    int maxZ = 0;
+    Game::Map::TerrainType type = Game::Map::TerrainType::Flat;
+    QVector3D color{0.3f, 0.5f, 0.3f};
+    float averageHeight = 0.0f;
+    float tint = 1.0f;
+  };
+
+  enum class PropType { Pebble, Tuft, Stick };
+
+  struct PropInstance {
+    PropType type = PropType::Pebble;
+    QVector3D position{0.0f, 0.0f, 0.0f};
+    QVector3D scale{1.0f, 1.0f, 1.0f};
+    QVector3D color{0.4f, 0.4f, 0.4f};
+    float alpha = 1.0f;
+    float rotationDeg = 0.0f;
+  };
+
+  int m_width = 0;
+  int m_height = 0;
+  float m_tileSize = 1.0f;
+  bool m_wireframe = false;
+
+  std::vector<float> m_heightData;
+  std::vector<Game::Map::TerrainType> m_terrainTypes;
+  std::vector<ChunkMesh> m_chunks;
+  std::vector<PropInstance> m_props;
+};
+
+} // namespace GL
+} // namespace Render

+ 16 - 0
render/scene_renderer.cpp

@@ -179,6 +179,22 @@ void Renderer::renderWorld(Engine::Core::World *world) {
       meshToDraw = res->quad();
     QVector3D color = QVector3D(renderable->color[0], renderable->color[1],
                                 renderable->color[2]);
+
+    if (res) {
+      Mesh *contactQuad = res->quad();
+      Texture *white = res->white();
+      if (contactQuad && white) {
+        QMatrix4x4 contact;
+        contact.translate(transform->position.x, transform->position.y + 0.03f,
+                          transform->position.z);
+        contact.rotate(-90.0f, 1.0f, 0.0f, 0.0f);
+        float footprint =
+            std::max({transform->scale.x, transform->scale.z, 0.6f});
+        contact.scale(footprint * 0.55f, footprint * 0.35f, 1.0f);
+        mesh(contactQuad, contact, QVector3D(0.08f, 0.08f, 0.075f), white,
+             0.55f);
+      }
+    }
     mesh(meshToDraw, modelMatrix, color, res ? res->white() : nullptr, 1.0f);
   }
 }