浏览代码

Add GPU-based shadow shaders and integrate into humanoid renderer

djeada 3 周之前
父节点
当前提交
55bc6dc9c1
共有 37 个文件被更改,包括 376 次插入88 次删除
  1. 2 0
      assets.qrc
  2. 4 3
      assets/shaders/archer_carthage.frag
  3. 2 2
      assets/shaders/healer_carthage.frag
  4. 12 7
      assets/shaders/horse_archer_carthage.frag
  5. 2 2
      assets/shaders/horse_archer_roman_republic.frag
  6. 12 7
      assets/shaders/horse_spearman_carthage.frag
  7. 2 2
      assets/shaders/horse_spearman_roman_republic.frag
  8. 12 7
      assets/shaders/horse_swordsman_carthage.frag
  9. 2 2
      assets/shaders/horse_swordsman_roman_republic.frag
  10. 2 1
      assets/shaders/spearman_carthage.frag
  11. 2 1
      assets/shaders/swordsman_carthage.frag
  12. 74 0
      assets/shaders/troop_shadow.frag
  13. 17 0
      assets/shaders/troop_shadow.vert
  14. 2 3
      render/equipment/armor/armor_heavy_carthage.cpp
  15. 2 2
      render/equipment/armor/armor_light_carthage.cpp
  16. 7 4
      render/equipment/helmets/carthage_heavy_helmet.cpp
  17. 6 4
      render/equipment/helmets/carthage_light_helmet.cpp
  18. 2 1
      render/equipment/horse/armor/crupper_renderer.cpp
  19. 0 1
      render/equipment/horse/saddles/carthage_saddle_renderer.cpp
  20. 4 2
      render/equipment/weapons/bow_renderer.cpp
  21. 1 1
      render/equipment/weapons/bow_renderer.h
  22. 1 1
      render/equipment/weapons/quiver_renderer.h
  23. 2 1
      render/equipment/weapons/roman_scutum.cpp
  24. 6 4
      render/equipment/weapons/shield_carthage.cpp
  25. 6 3
      render/equipment/weapons/shield_renderer.cpp
  26. 1 1
      render/equipment/weapons/shield_renderer.h
  27. 2 1
      render/equipment/weapons/spear_renderer.cpp
  28. 1 1
      render/equipment/weapons/spear_renderer.h
  29. 10 5
      render/equipment/weapons/sword_renderer.cpp
  30. 1 1
      render/equipment/weapons/sword_renderer.h
  31. 17 5
      render/gl/backend.cpp
  32. 1 1
      render/gl/backend/character_pipeline.cpp
  33. 24 11
      render/gl/shader.cpp
  34. 2 1
      render/gl/shader.h
  35. 6 0
      render/gl/shader_cache.h
  36. 98 0
      render/humanoid/rig.cpp
  37. 29 0
      utils/resource_utils.h

+ 2 - 0
assets.qrc

@@ -72,6 +72,8 @@
         <file>assets/shaders/stone_instanced.vert</file>
         <file>assets/shaders/terrain_chunk.frag</file>
         <file>assets/shaders/terrain_chunk.vert</file>
+        <file>assets/shaders/troop_shadow.frag</file>
+        <file>assets/shaders/troop_shadow.vert</file>
         
         <!-- Map files -->
         <file>assets/maps/map_forest.json</file>

+ 4 - 3
assets/shaders/archer_carthage.frag

@@ -507,16 +507,17 @@ void main() {
 
   bool preferLeather = (paletteLeather && blueRatio < 0.42) ||
                        (likelyLeather && !looksWood && blueRatio < 0.4);
-  
+
   // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
   bool isArmor = (u_materialId == 1);
   bool isHelmet = (u_materialId == 2);
   bool isWeapon = (u_materialId == 3);
   bool isShield = (u_materialId == 4);
-  
+
   // Use mask IDs when available, fallback to height-based detection
   bool isHelmetRegion = isHelmet || (u_materialId == 0 && v_bodyHeight > 0.92);
-  bool isFaceRegion = (u_materialId == 0 && v_bodyHeight > 0.45 && v_bodyHeight < 0.92);
+  bool isFaceRegion =
+      (u_materialId == 0 && v_bodyHeight > 0.45 && v_bodyHeight < 0.92);
 
   vec3 Nw = normalize(v_worldNormal);
   vec3 Tw = normalize(v_tangent);

+ 2 - 2
assets/shaders/healer_carthage.frag

@@ -57,12 +57,12 @@ void main() {
   bool isArmor = (u_materialId == 1);
   bool isHelmet = (u_materialId == 2);
   bool isWeapon = (u_materialId == 3);
-  
+
   // Fallback to color-based detection when u_materialId == 0
   if (u_materialId == 0) {
     isHelmet = (v_armorLayer == 0.0);
   }
-  
+
   bool isLight = (avgColor > 0.74);
   bool isPurple = (color.b > color.g * 1.15 && color.b > color.r * 1.08);
 

+ 12 - 7
assets/shaders/horse_archer_carthage.frag

@@ -161,15 +161,20 @@ void main() {
   bool isHelmet = (u_materialId == 2);
   bool isWeapon = (u_materialId == 3);
   bool isSaddle = (u_materialId == 4);
-  
+
   // Fallback to color-based detection when u_materialId == 0
-  bool isBrass = isHelmet || (u_materialId == 0 && baseColor.r > baseColor.g * 1.15 &&
-                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isBrass =
+      isHelmet || (u_materialId == 0 && baseColor.r > baseColor.g * 1.15 &&
+                   baseColor.r > baseColor.b * 1.20 && avg > 0.50);
   bool isSteel = isArmor || (u_materialId == 0 && avg > 0.60 && !isBrass);
-  bool isChain = isArmor || (u_materialId == 0 && !isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
-  bool isFabric = (u_materialId == 0 && !isSteel && !isBrass && !isChain && avg > 0.25);
-  bool isLeather = isSaddle || (u_materialId == 0 && !isSteel && !isBrass && !isChain && !isFabric);
-  bool isHorseHide = (u_materialId == 0 && avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+  bool isChain = isArmor || (u_materialId == 0 && !isSteel && !isBrass &&
+                             avg > 0.40 && avg <= 0.60);
+  bool isFabric =
+      (u_materialId == 0 && !isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = isSaddle || (u_materialId == 0 && !isSteel && !isBrass &&
+                                !isChain && !isFabric);
+  bool isHorseHide =
+      (u_materialId == 0 && avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
 
   // lighting frame
   vec3 L = normalize(vec3(1.0, 1.2, 1.0));

+ 2 - 2
assets/shaders/horse_archer_roman_republic.frag

@@ -584,7 +584,7 @@ void main() {
     float leatherSheen = pow(1.0 - NdotV, 4.0) * contactWear * 0.08;
     col += leatherSheen * vec3(0.90, 0.85, 0.75);
 
-  } else if (isBridleLeather) {
+  } else if (isBridle) {
     // =====================================================================
     // BRIDLE & REINS RENDERING (Phase 3)
     // Leather straps with bronze/brass fittings
@@ -898,7 +898,7 @@ void main() {
     float armorRim = pow(1.0 - NdotV, 4.0) * 0.15;
     col += armorRim * vec3(0.95, 0.90, 0.82);
 
-  } else if (isHelmet) {
+  } else if (isRiderHelmet) {
     // =====================================================================
     // HELMET RENDERING (Phase 4)
     // Attic, Phrygian, and Thracian style variations

+ 12 - 7
assets/shaders/horse_spearman_carthage.frag

@@ -161,15 +161,20 @@ void main() {
   bool isHelmet = (u_materialId == 2);
   bool isWeapon = (u_materialId == 3);
   bool isSaddle = (u_materialId == 4);
-  
+
   // Fallback to color-based detection when u_materialId == 0
-  bool isBrass = isHelmet || (u_materialId == 0 && baseColor.r > baseColor.g * 1.15 &&
-                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isBrass =
+      isHelmet || (u_materialId == 0 && baseColor.r > baseColor.g * 1.15 &&
+                   baseColor.r > baseColor.b * 1.20 && avg > 0.50);
   bool isSteel = isArmor || (u_materialId == 0 && avg > 0.60 && !isBrass);
-  bool isChain = isArmor || (u_materialId == 0 && !isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
-  bool isFabric = (u_materialId == 0 && !isSteel && !isBrass && !isChain && avg > 0.25);
-  bool isLeather = isSaddle || (u_materialId == 0 && !isSteel && !isBrass && !isChain && !isFabric);
-  bool isHorseHide = (u_materialId == 0 && avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+  bool isChain = isArmor || (u_materialId == 0 && !isSteel && !isBrass &&
+                             avg > 0.40 && avg <= 0.60);
+  bool isFabric =
+      (u_materialId == 0 && !isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = isSaddle || (u_materialId == 0 && !isSteel && !isBrass &&
+                                !isChain && !isFabric);
+  bool isHorseHide =
+      (u_materialId == 0 && avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
 
   // lighting frame
   vec3 L = normalize(vec3(1.0, 1.2, 1.0));

+ 2 - 2
assets/shaders/horse_spearman_roman_republic.frag

@@ -588,7 +588,7 @@ void main() {
     float leatherSheen = pow(1.0 - NdotV, 4.0) * contactWear * 0.08;
     col += leatherSheen * vec3(0.90, 0.85, 0.75);
 
-  } else if (isBridleLeather) {
+  } else if (isBridle) {
     // =====================================================================
     // BRIDLE & REINS RENDERING (Phase 3)
     // Leather straps with bronze/brass fittings
@@ -902,7 +902,7 @@ void main() {
     float armorRim = pow(1.0 - NdotV, 4.0) * 0.15;
     col += armorRim * vec3(0.95, 0.90, 0.82);
 
-  } else if (isHelmet) {
+  } else if (isRiderHelmet) {
     // =====================================================================
     // HELMET RENDERING (Phase 4)
     // Attic, Phrygian, and Thracian style variations

+ 12 - 7
assets/shaders/horse_swordsman_carthage.frag

@@ -161,15 +161,20 @@ void main() {
   bool isHelmet = (u_materialId == 2);
   bool isWeapon = (u_materialId == 3);
   bool isSaddle = (u_materialId == 4);
-  
+
   // Fallback to color-based detection when u_materialId == 0
-  bool isBrass = isHelmet || (u_materialId == 0 && baseColor.r > baseColor.g * 1.15 &&
-                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isBrass =
+      isHelmet || (u_materialId == 0 && baseColor.r > baseColor.g * 1.15 &&
+                   baseColor.r > baseColor.b * 1.20 && avg > 0.50);
   bool isSteel = isArmor || (u_materialId == 0 && avg > 0.60 && !isBrass);
-  bool isChain = isArmor || (u_materialId == 0 && !isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
-  bool isFabric = (u_materialId == 0 && !isSteel && !isBrass && !isChain && avg > 0.25);
-  bool isLeather = isSaddle || (u_materialId == 0 && !isSteel && !isBrass && !isChain && !isFabric);
-  bool isHorseHide = (u_materialId == 0 && avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+  bool isChain = isArmor || (u_materialId == 0 && !isSteel && !isBrass &&
+                             avg > 0.40 && avg <= 0.60);
+  bool isFabric =
+      (u_materialId == 0 && !isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = isSaddle || (u_materialId == 0 && !isSteel && !isBrass &&
+                                !isChain && !isFabric);
+  bool isHorseHide =
+      (u_materialId == 0 && avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
 
   // lighting frame
   vec3 L = normalize(vec3(1.0, 1.2, 1.0));

+ 2 - 2
assets/shaders/horse_swordsman_roman_republic.frag

@@ -588,7 +588,7 @@ void main() {
     float leatherSheen = pow(1.0 - NdotV, 4.0) * contactWear * 0.08;
     col += leatherSheen * vec3(0.90, 0.85, 0.75);
 
-  } else if (isBridleLeather) {
+  } else if (isBridle) {
     // =====================================================================
     // BRIDLE & REINS RENDERING (Phase 3)
     // Leather straps with bronze/brass fittings
@@ -909,7 +909,7 @@ void main() {
     float armorRim = pow(1.0 - NdotV, 4.0) * 0.16; // Elite: brighter rim
     col += armorRim * vec3(0.95, 0.90, 0.82);
 
-  } else if (isHelmet) {
+  } else if (isRiderHelmet) {
     // =====================================================================
     // HELMET RENDERING (Phase 4)
     // Attic, Phrygian, and Thracian style variations

+ 2 - 1
assets/shaders/spearman_carthage.frag

@@ -181,7 +181,8 @@ void main() {
   }
 
   bool helmetRegion = isHelmet;
-  bool upperRegion = isArmor || (u_materialId == 0 && v_armorLayer >= 0.5 && v_armorLayer < 1.5);
+  bool upperRegion = isArmor || (u_materialId == 0 && v_armorLayer >= 0.5 &&
+                                 v_armorLayer < 1.5);
 
   vec3 Nw = normalize(v_worldNormal);
   vec3 Tw = normalize(v_tangent);

+ 2 - 1
assets/shaders/swordsman_carthage.frag

@@ -230,7 +230,8 @@ void main() {
   }
 
   bool helmetRegion = isHelmet;
-  bool torsoRegion = isArmor || (u_materialId == 0 && v_armorLayer >= 0.5 && v_armorLayer < 1.5);
+  bool torsoRegion = isArmor || (u_materialId == 0 && v_armorLayer >= 0.5 &&
+                                 v_armorLayer < 1.5);
 
   vec3 Nw = normalize(v_worldNormal);
   vec3 Tw = normalize(v_tangent);

+ 74 - 0
assets/shaders/troop_shadow.frag

@@ -0,0 +1,74 @@
+#version 330 core
+
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform float u_alpha;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform sampler2D u_texture;
+uniform vec2 u_lightDir; // normalized XZ direction of light
+
+out vec4 FragColor;
+
+void main() {
+  // v_texCoord is [0,1]. Map to [-1,1] so the quad border is around |uv| = 1.
+  vec2 uv = v_texCoord * 2.0 - 1.0;
+
+  // Slight stretch in light direction so it’s not a perfect circle.
+  vec2 dir = u_lightDir;
+  if (length(dir) < 1e-4)
+    dir = vec2(0.0, 1.0);
+  dir = normalize(dir);
+  vec2 tangent = vec2(-dir.y, dir.x);
+
+  float along = dot(uv, dir);
+  float across = dot(uv, tangent);
+
+  // Ellipse shape: close to circular with a tiny light-lean.
+  float alongScale = 1.15;  // bigger = longer soft shadow
+  float acrossScale = 0.95; // smaller = thinner shadow
+
+  float ax = along / alongScale;
+  float ay = across / acrossScale;
+
+  // 0 at center, grows toward ellipse boundary (>1 outside).
+  float r = length(vec2(ax, ay));
+
+  // Tiny wobble so the outline isn’t too clean.
+  float wobble = 0.04 * sin(uv.x * 5.3) * sin(uv.y * 4.7);
+  r = max(0.0, r + wobble);
+
+  // Blend a wide gaussian with a gentle linear falloff to get a fuzzy blob.
+  float gaussian = exp(-r * r * 2.2);       // wide, very soft bell curve
+  float feather = clamp(1.0 - r, 0.0, 1.0); // keeps a hint of shape
+  float shadowIntensity = mix(feather, gaussian, 0.7);
+  shadowIntensity = pow(shadowIntensity, 1.35); // keep soft but less vanishing
+
+  // Fade slightly with height so the model uniform stays in the program.
+  float heightFade = clamp(1.0 - max(v_worldPos.y, 0.0) * 0.08, 0.6, 1.0);
+  shadowIntensity *= heightFade;
+
+  // Texture tint / mask if provided.
+  vec3 texColor = vec3(1.0);
+  float texAlpha = 1.0;
+  if (u_useTexture) {
+    vec4 tex = texture(u_texture, v_texCoord);
+    texColor = tex.rgb;
+    texAlpha = tex.a;
+  }
+
+  shadowIntensity *= texAlpha;
+
+  // Base shadow color (dark but not black), intentionally very faint.
+  vec3 shadowColor = vec3(0.013) * u_color * texColor;
+
+  // Keep the shadow extremely transparent.
+  float finalAlpha = shadowIntensity * u_alpha * 0.95;
+
+  // Also modulate color by intensity so the blob looks soft even if blending is
+  // odd.
+  vec3 finalColor = shadowColor * shadowIntensity;
+
+  FragColor = vec4(finalColor, finalAlpha);
+}

+ 17 - 0
assets/shaders/troop_shadow.vert

@@ -0,0 +1,17 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+
+void main() {
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 2 - 3
render/equipment/armor/armor_heavy_carthage.cpp

@@ -73,13 +73,12 @@ void ArmorHeavyCarthageRenderer::render(const DrawContext &ctx,
     submitter.mesh(getUnitTorso(), m, color, nullptr, 1.0F, material_id);
   };
 
-  // Material ID: 1 = armor (chainmail and bronze plates)
   draw_torso(top, chainmail_bottom, torso_r * 0.90F, chainmail_color, 1.00F,
              0.88F, 1);
 
   draw_torso(top + forward * (torso_r * 0.02F),
-             bottom + forward * (torso_r * 0.02F), torso_r * 0.98F, bronze_color,
-             1.05F, 0.84F, 1);
+             bottom + forward * (torso_r * 0.02F), torso_r * 0.98F,
+             bronze_color, 1.05F, 0.84F, 1);
 
   draw_torso(top, bottom, torso_r * 0.90F, bronze_core, 0.98F, 0.80F, 1);
 }

+ 2 - 2
render/equipment/armor/armor_light_carthage.cpp

@@ -65,7 +65,6 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
   float main_radius = torso_r * 0.96F;
   float const main_depth = torso_depth * 0.92F;
 
-  // Material ID: 1 = armor (leather cuirass)
   QMatrix4x4 cuirass = cylinderBetween(ctx.model, top, bottom, main_radius);
   cuirass.scale(1.0F, 1.0F, std::max(0.15F, main_depth / main_radius));
   submitter.mesh(getUnitTorso(), cuirass, leather_highlight, nullptr, 1.0F, 1);
@@ -91,7 +90,8 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
                                            front_panel_bottom, torso_r * 0.48F);
   front_panel.scale(0.95F, 1.0F,
                     std::max(0.12F, (torso_depth * 0.5F) / (torso_r * 0.48F)));
-  submitter.mesh(getUnitTorso(), front_panel, leather_highlight, nullptr, 1.0F, 1);
+  submitter.mesh(getUnitTorso(), front_panel, leather_highlight, nullptr, 1.0F,
+                 1);
 
   QVector3D back_panel_top =
       top - forward * (torso_depth * 0.32F) - up * (torso_r * 0.05F);

+ 7 - 4
render/equipment/helmets/carthage_heavy_helmet.cpp

@@ -76,7 +76,7 @@ void CarthageHeavyHelmetRenderer::render_bowl(const DrawContext &ctx,
 
   QVector3D luminous_bronze =
       mixColor(m_config.bronze_color, m_config.glow_color, 0.35F);
-  // Material ID: 2 = helmet
+
   submitter.mesh(getUnitSphere(), bowl, luminous_bronze, nullptr, 0.3f, 2);
 
   QVector3D rim_center = head_point(QVector3D(0.0f, 1.17f, 0.0f));
@@ -118,7 +118,8 @@ void CarthageHeavyHelmetRenderer::render_cheek_guards(
   right_guard.scale(R * 0.32f, R * 0.48f, R * 0.18f);
   right_guard.rotate(6.0f, QVector3D(0.0f, 0.0f, 1.0f));
 
-  submitter.mesh(getUnitSphere(), left_guard, m_config.bronze_color, nullptr, 0.6f, 2);
+  submitter.mesh(getUnitSphere(), left_guard, m_config.bronze_color, nullptr,
+                 0.6f, 2);
   submitter.mesh(getUnitSphere(), right_guard, m_config.bronze_color, nullptr,
                  0.6f, 2);
 }
@@ -154,7 +155,8 @@ void CarthageHeavyHelmetRenderer::render_face_plate(const DrawContext &ctx,
   QVector3D nose_bottom = head_point(QVector3D(0.0f, 0.53f, 0.46f));
   QMatrix4x4 nose = cylinderBetween(ctx.model, nose_bottom, nose_top,
                                     std::max(0.05f, R * 0.12f));
-  submitter.mesh(getUnitCylinder(), nose, m_config.glow_color, nullptr, 0.65f, 2);
+  submitter.mesh(getUnitCylinder(), nose, m_config.glow_color, nullptr, 0.65f,
+                 2);
 
   render_brow_arch(ctx, head, submitter);
 }
@@ -218,7 +220,8 @@ void CarthageHeavyHelmetRenderer::render_brow_arch(const DrawContext &ctx,
   QMatrix4x4 ridge = ctx.model;
   ridge.translate(ridge_top);
   ridge.scale(R * 0.22f, R * 0.10f, R * 0.26f);
-  submitter.mesh(getUnitSphere(), ridge, m_config.glow_color, nullptr, 0.58f, 2);
+  submitter.mesh(getUnitSphere(), ridge, m_config.glow_color, nullptr, 0.58f,
+                 2);
 }
 
 void CarthageHeavyHelmetRenderer::render_crest(const DrawContext &ctx,

+ 6 - 4
render/equipment/helmets/carthage_light_helmet.cpp

@@ -18,7 +18,8 @@ using Render::Geom::sphereAt;
 
 static constexpr float k_helmet_vertical_lift = 0.14F;
 
-static inline auto helmet_lift_vector(const AttachmentFrame &head) -> QVector3D {
+static inline auto
+helmet_lift_vector(const AttachmentFrame &head) -> QVector3D {
   QVector3D up = head.up;
   if (up.lengthSquared() < 1e-6F) {
     up = QVector3D(0.0F, 1.0F, 0.0F);
@@ -40,7 +41,7 @@ static inline void submit_disk(ISubmitter &submitter, const DrawContext &ctx,
   n.normalize();
   QVector3D a = center - 0.5f * thickness * n;
   QVector3D b = center + 0.5f * thickness * n;
-  // Material ID: 2 = helmet
+
   submitter.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, radius),
                  color, nullptr, roughness, material_id);
 }
@@ -56,7 +57,7 @@ static inline void submit_spike(ISubmitter &submitter, const DrawContext &ctx,
   }
   d.normalize();
   QVector3D tip = base + d * length;
-  // Material ID: 2 = helmet
+
   submitter.mesh(getUnitCylinder(),
                  cylinderBetween(ctx.model, base, tip, base_radius), color,
                  nullptr, roughness, material_id);
@@ -64,7 +65,8 @@ static inline void submit_spike(ISubmitter &submitter, const DrawContext &ctx,
   m = ctx.model;
   m.translate(tip);
   m.scale(base_radius * 1.1f);
-  submitter.mesh(getUnitSphere(), m, color * 1.05f, nullptr, roughness, material_id);
+  submitter.mesh(getUnitSphere(), m, color * 1.05f, nullptr, roughness,
+                 material_id);
 }
 
 void CarthageLightHelmetRenderer::render(const DrawContext &ctx,

+ 2 - 1
render/equipment/horse/armor/crupper_renderer.cpp

@@ -26,7 +26,8 @@ void CrupperRenderer::render(const DrawContext &ctx,
     QMatrix4x4 side_plate = rump.make_local_transform(
         ctx.model, QVector3D(side * 0.28F, -0.05F, -0.20F), 0.8F);
     side_plate.scale(0.20F, 0.25F, 0.22F);
-    out.mesh(getUnitSphere(), side_plate, armor_color * 0.95F, nullptr, 1.0F, 1);
+    out.mesh(getUnitSphere(), side_plate, armor_color * 0.95F, nullptr, 1.0F,
+             1);
   }
 }
 

+ 0 - 1
render/equipment/horse/saddles/carthage_saddle_renderer.cpp

@@ -17,7 +17,6 @@ void CarthageSaddleRenderer::render(const DrawContext &ctx,
   QMatrix4x4 saddle_transform = back.make_local_transform(
       ctx.model, QVector3D(0.0F, 0.008F, 0.0F), 0.25F);
 
-  // Material ID: 4 = saddle
   QMatrix4x4 seat = saddle_transform;
   seat.scale(0.38F, 0.14F, 1.20F);
   out.mesh(getUnitSphere(), seat, variant.saddleColor, nullptr, 1.0F, 4);

+ 4 - 2
render/equipment/weapons/bow_renderer.cpp

@@ -123,9 +123,11 @@ void BowRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     QVector3D const f2a = f2b + forward * 0.06F;
 
     submitter.mesh(getUnitCone(), coneFromTo(ctx.model, f1b, f1a, 0.04F),
-                   m_config.fletching_color, nullptr, 1.0F, m_config.material_id);
+                   m_config.fletching_color, nullptr, 1.0F,
+                   m_config.material_id);
     submitter.mesh(getUnitCone(), coneFromTo(ctx.model, f2a, f2b, 0.04F),
-                   m_config.fletching_color, nullptr, 1.0F, m_config.material_id);
+                   m_config.fletching_color, nullptr, 1.0F,
+                   m_config.material_id);
   }
 }
 

+ 1 - 1
render/equipment/weapons/bow_renderer.h

@@ -19,7 +19,7 @@ struct BowRenderConfig {
   float bow_bot_y = 0.0F;
   float bow_height_scale = 1.0F;
   float bow_curve_factor = 1.0F;
-  int material_id = 3; // Material ID: 3 = weapon
+  int material_id = 3;
 };
 
 class BowRenderer : public IEquipmentRenderer {

+ 1 - 1
render/equipment/weapons/quiver_renderer.h

@@ -14,7 +14,7 @@ struct QuiverRenderConfig {
   float quiver_radius = 0.08F;
   float quiver_height = 0.30F;
   int num_arrows = 2;
-  int material_id = 3; // Material ID: 3 = weapon
+  int material_id = 3;
 };
 
 class QuiverRenderer : public IEquipmentRenderer {

+ 2 - 1
render/equipment/weapons/roman_scutum.cpp

@@ -158,7 +158,8 @@ void RomanScutumRenderer::render(const DrawContext &ctx,
       QMatrix4x4 m = ctx.model;
       m.translate(rim_pos);
       m.scale(rim_thickness);
-      submitter.mesh(getUnitSphere(), m, bronze_color * 0.95F, nullptr, 1.0F, 4);
+      submitter.mesh(getUnitSphere(), m, bronze_color * 0.95F, nullptr, 1.0F,
+                     4);
     }
   }
 

+ 6 - 4
render/equipment/weapons/shield_carthage.cpp

@@ -21,7 +21,7 @@ using Render::Geom::sphereAt;
 namespace {
 
 auto create_unit_hemisphere_mesh(int lat_segments = 12,
-                              int lon_segments = 32) -> Mesh * {
+                                 int lon_segments = 32) -> Mesh * {
   std::vector<Vertex> vertices;
   std::vector<unsigned int> indices;
   vertices.reserve((lat_segments + 1) * (lon_segments + 1));
@@ -33,7 +33,8 @@ auto create_unit_hemisphere_mesh(int lat_segments = 12,
     float const ring_r = std::sin(phi);
 
     for (int lon = 0; lon <= lon_segments; ++lon) {
-      float const u = static_cast<float>(lon) / static_cast<float>(lon_segments);
+      float const u =
+          static_cast<float>(lon) / static_cast<float>(lon_segments);
       float const theta = u * 2.0F * std::numbers::pi_v<float>;
       float const x = ring_r * std::cos(theta);
       float const y = ring_r * std::sin(theta);
@@ -117,8 +118,9 @@ void CarthageShieldRenderer::render(const DrawContext &ctx,
     m.translate(shield_center);
     m.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
     m.scale(shield_radius, shield_radius, dome_depth);
-    // Material ID: 4 = shield
-    submitter.mesh(get_unit_hemisphere_mesh(), m, shield_color, nullptr, 1.0F, 4);
+
+    submitter.mesh(get_unit_hemisphere_mesh(), m, shield_color, nullptr, 1.0F,
+                   4);
   }
 
   constexpr int rim_segments = 24;

+ 6 - 3
render/equipment/weapons/shield_renderer.cpp

@@ -49,7 +49,8 @@ void ShieldRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     m.translate(shield_center + n * plate_half);
     m.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
     m.scale(shield_width, shield_height, plate_full);
-    submitter.mesh(getUnitCylinder(), m, m_config.shield_color, nullptr, 1.0F, m_config.material_id);
+    submitter.mesh(getUnitCylinder(), m, m_config.shield_color, nullptr, 1.0F,
+                   m_config.material_id);
   }
 
   {
@@ -57,7 +58,8 @@ void ShieldRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     m.translate(shield_center - n * plate_half);
     m.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
     m.scale(shield_width * 0.985F, shield_height * 0.985F, plate_full);
-    submitter.mesh(getUnitCylinder(), m, palette.leather * 0.8F, nullptr, 1.0F, m_config.material_id);
+    submitter.mesh(getUnitCylinder(), m, palette.leather * 0.8F, nullptr, 1.0F,
+                   m_config.material_id);
   }
 
   auto draw_ring_rotated = [&](float width, float height, float thickness,
@@ -89,7 +91,8 @@ void ShieldRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     QMatrix4x4 m = ctx.model;
     m.translate(shield_center + n * (0.02F * k_scale_factor));
     m.scale(0.045F * k_scale_factor);
-    submitter.mesh(getUnitSphere(), m, m_config.metal_color, nullptr, 1.0F, m_config.material_id);
+    submitter.mesh(getUnitSphere(), m, m_config.metal_color, nullptr, 1.0F,
+                   m_config.material_id);
   }
 
   {

+ 1 - 1
render/equipment/weapons/shield_renderer.h

@@ -16,7 +16,7 @@ struct ShieldRenderConfig {
   float shield_radius = 0.18F;
   float shield_aspect = 1.0F;
   bool has_cross_decal = false;
-  int material_id = 4; // Material ID: 4 = shield
+  int material_id = 4;
 };
 
 class ShieldRenderer : public IEquipmentRenderer {

+ 2 - 1
render/equipment/weapons/spear_renderer.cpp

@@ -79,7 +79,8 @@ void SpearRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
   submitter.mesh(getUnitCylinder(),
                  cylinderBetween(ctx.model, shaft_mid, shaft_tip,
                                  m_config.shaft_radius * 0.95F),
-                 m_config.shaft_color * 0.98F, nullptr, 1.0F, m_config.material_id);
+                 m_config.shaft_color * 0.98F, nullptr, 1.0F,
+                 m_config.material_id);
 
   QVector3D const spearhead_base = shaft_tip;
   QVector3D const spearhead_tip =

+ 1 - 1
render/equipment/weapons/spear_renderer.h

@@ -15,7 +15,7 @@ struct SpearRenderConfig {
   float spear_length = 1.20F;
   float shaft_radius = 0.020F;
   float spearhead_length = 0.18F;
-  int material_id = 3; // Material ID: 3 = weapon
+  int material_id = 3;
 };
 
 class SpearRenderer : public IEquipmentRenderer {

+ 10 - 5
render/equipment/weapons/sword_renderer.cpp

@@ -113,11 +113,13 @@ void SwordRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
   QMatrix4x4 gl = ctx.model;
   gl.translate(guard_l);
   gl.scale(0.018F);
-  submitter.mesh(getUnitSphere(), gl, m_config.metal_color, nullptr, 1.0F, m_config.material_id);
+  submitter.mesh(getUnitSphere(), gl, m_config.metal_color, nullptr, 1.0F,
+                 m_config.material_id);
   QMatrix4x4 gr = ctx.model;
   gr.translate(guard_r);
   gr.scale(0.018F);
-  submitter.mesh(getUnitSphere(), gr, m_config.metal_color, nullptr, 1.0F, m_config.material_id);
+  submitter.mesh(getUnitSphere(), gr, m_config.metal_color, nullptr, 1.0F,
+                 m_config.material_id);
 
   float const l = m_config.sword_length;
   float const base_w = m_config.sword_width;
@@ -174,7 +176,8 @@ void SwordRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     submitter.mesh(
         getUnitCylinder(),
         cylinderBetween(ctx.model, seg_start, seg_end, blade_thickness),
-        m_config.metal_color * (1.0F - i * 0.03F), nullptr, 1.0F, m_config.material_id);
+        m_config.metal_color * (1.0F - i * 0.03F), nullptr, 1.0F,
+        m_config.material_id);
   }
 
   QVector3D const fuller_start = blade_base + sword_dir * (ricasso_len + 0.02F);
@@ -183,7 +186,8 @@ void SwordRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
   submitter.mesh(getUnitCylinder(),
                  cylinderBetween(ctx.model, fuller_start, fuller_end,
                                  blade_thickness * 0.6F),
-                 m_config.metal_color * 0.65F, nullptr, 1.0F, m_config.material_id);
+                 m_config.metal_color * 0.65F, nullptr, 1.0F,
+                 m_config.material_id);
 
   QVector3D const pommel = handle_end - sword_dir * 0.02F;
   QMatrix4x4 pommel_mat = ctx.model;
@@ -199,7 +203,8 @@ void SwordRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     QVector3D const trail_end = blade_base - sword_dir * (0.28F + 0.15F * t);
     submitter.mesh(getUnitCone(),
                    coneFromTo(ctx.model, trail_end, trail_start, base_w * 0.9F),
-                   m_config.metal_color * 0.9F, nullptr, alpha, m_config.material_id);
+                   m_config.metal_color * 0.9F, nullptr, alpha,
+                   m_config.material_id);
   }
 }
 

+ 1 - 1
render/equipment/weapons/sword_renderer.h

@@ -19,7 +19,7 @@ struct SwordRenderConfig {
   float blade_ricasso = 0.16F;
   float blade_taper_bias = 0.65F;
   bool has_scabbard = true;
-  int material_id = 3; // Material ID: 3 = weapon
+  int material_id = 3;
 };
 
 class SwordRenderer : public IEquipmentRenderer {

+ 17 - 5
render/gl/backend.cpp

@@ -969,17 +969,29 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
         break;
       }
 
-      glDepthMask(GL_TRUE);
-      if (glIsEnabled(GL_POLYGON_OFFSET_FILL) != 0U) {
-        glDisable(GL_POLYGON_OFFSET_FILL);
-      }
-
       Shader *active_shader =
           (it.shader != nullptr) ? it.shader : m_basicShader;
       if (active_shader == nullptr) {
         break;
       }
 
+      if (glIsEnabled(GL_POLYGON_OFFSET_FILL) != 0U) {
+        glDisable(GL_POLYGON_OFFSET_FILL);
+      }
+
+      Shader *shadowShader =
+          m_shaderCache ? m_shaderCache->get(QStringLiteral("troop_shadow"))
+                        : nullptr;
+      bool const isShadowShader = (active_shader == shadowShader);
+      std::unique_ptr<DepthMaskScope> shadow_depth_scope;
+      std::unique_ptr<BlendScope> shadow_blend_scope;
+      if (isShadowShader) {
+        shadow_depth_scope = std::make_unique<DepthMaskScope>(false);
+        shadow_blend_scope = std::make_unique<BlendScope>(true);
+      } else {
+        glDepthMask(GL_TRUE);
+      }
+
       if (active_shader == m_waterPipeline->m_riverShader) {
         if (m_lastBoundShader != active_shader) {
           active_shader->use();

+ 1 - 1
render/gl/backend/character_pipeline.cpp

@@ -108,7 +108,7 @@ auto CharacterPipeline::buildUniformSet(GL::Shader *shader) const
   uniforms.useTexture = shader->uniformHandle("u_useTexture");
   uniforms.color = shader->uniformHandle("u_color");
   uniforms.alpha = shader->uniformHandle("u_alpha");
-  uniforms.materialId = shader->uniformHandle("u_materialId");
+  uniforms.materialId = shader->optionalUniformHandle("u_materialId");
   return uniforms;
 }
 

+ 24 - 11
render/gl/shader.cpp

@@ -94,27 +94,40 @@ void Shader::release() {
   glUseProgram(0);
 }
 
-auto Shader::uniformHandle(const char *name) -> Shader::UniformHandle {
-  if ((name == nullptr) || *name == '\0' || m_program == 0) {
-    return InvalidUniform;
+namespace {
+auto uniformHandleImpl(
+    QOpenGLFunctions_3_3_Core &fn, GLuint program,
+    std::unordered_map<std::string, Shader::UniformHandle> &cache,
+    const char *name, bool warn) -> Shader::UniformHandle {
+  if ((name == nullptr) || *name == '\0' || program == 0) {
+    return Shader::InvalidUniform;
   }
 
-  auto it = m_uniformCache.find(name);
-  if (it != m_uniformCache.end()) {
+  auto it = cache.find(name);
+  if (it != cache.end()) {
     return it->second;
   }
 
-  initializeOpenGLFunctions();
-  UniformHandle const location = glGetUniformLocation(m_program, name);
+  fn.initializeOpenGLFunctions();
+  Shader::UniformHandle const location = fn.glGetUniformLocation(program, name);
 
-  if (location == InvalidUniform) {
-    qWarning() << "Shader uniform not found:" << name
-               << "(program:" << m_program << ")";
+  if (warn && (location == Shader::InvalidUniform)) {
+    qWarning() << "Shader uniform not found:" << name << "(program:" << program
+               << ")";
   }
 
-  m_uniformCache.emplace(name, location);
+  cache.emplace(name, location);
   return location;
 }
+} // namespace
+
+auto Shader::uniformHandle(const char *name) -> Shader::UniformHandle {
+  return uniformHandleImpl(*this, m_program, m_uniformCache, name, true);
+}
+
+auto Shader::optionalUniformHandle(const char *name) -> Shader::UniformHandle {
+  return uniformHandleImpl(*this, m_program, m_uniformCache, name, false);
+}
 
 void Shader::setUniform(UniformHandle handle, float value) {
   initializeOpenGLFunctions();

+ 2 - 1
render/gl/shader.h

@@ -26,6 +26,7 @@ public:
   void release();
 
   auto uniformHandle(const char *name) -> UniformHandle;
+  auto optionalUniformHandle(const char *name) -> UniformHandle;
 
   void setUniform(UniformHandle handle, float value);
   void setUniform(UniformHandle handle, const QVector3D &value);
@@ -56,4 +57,4 @@ private:
   std::unordered_map<std::string, UniformHandle> m_uniformCache;
 };
 
-} // namespace Render::GL
+} // namespace Render::GL

+ 6 - 0
render/gl/shader_cache.h

@@ -143,6 +143,12 @@ public:
         resolve(kShaderBase + QStringLiteral("bridge.frag"));
     load(QStringLiteral("bridge"), bridgeVert, bridgeFrag);
 
+    const QString troopShadowVert =
+        resolve(kShaderBase + QStringLiteral("troop_shadow.vert"));
+    const QString troopShadowFrag =
+        resolve(kShaderBase + QStringLiteral("troop_shadow.frag"));
+    load(QStringLiteral("troop_shadow"), troopShadowVert, troopShadowFrag);
+
     const auto loadBaseShader = [&](const QString &name) {
       const QString vert =
           resolve(kShaderBase + name + QStringLiteral(".vert"));

+ 98 - 0
render/humanoid/rig.cpp

@@ -3,21 +3,26 @@
 #include "../../game/core/component.h"
 #include "../../game/core/entity.h"
 #include "../../game/core/world.h"
+#include "../../game/map/terrain_service.h"
 #include "../../game/units/spawn_type.h"
 #include "../../game/units/troop_config.h"
 #include "../../game/visuals/team_colors.h"
 #include "../entity/registry.h"
 #include "../geom/math_utils.h"
 #include "../geom/transforms.h"
+#include "../gl/backend.h"
 #include "../gl/humanoid/animation/animation_inputs.h"
 #include "../gl/humanoid/animation/gait.h"
 #include "../gl/humanoid/humanoid_constants.h"
 #include "../gl/primitives.h"
 #include "../gl/render_constants.h"
+#include "../gl/resources.h"
 #include "../palette.h"
+#include "../scene_renderer.h"
 #include "../submitter.h"
 #include "humanoid_math.h"
 #include <QMatrix4x4>
+#include <QVector2D>
 #include <QVector4D>
 #include <QtMath>
 #include <algorithm>
@@ -33,6 +38,15 @@ using Render::Geom::coneFromTo;
 using Render::Geom::cylinderBetween;
 using Render::Geom::sphereAt;
 
+namespace {
+
+constexpr float k_shadow_size_infantry = 0.16F;
+constexpr float k_shadow_size_mounted = 0.35F;
+constexpr float k_shadow_ground_offset = 0.02F;
+constexpr float k_shadow_base_alpha = 0.24F;
+constexpr QVector3D k_shadow_light_dir(0.4F, 1.0F, 0.25F);
+} // namespace
+
 auto HumanoidRendererBase::frameLocalPosition(
     const AttachmentFrame &frame, const QVector3D &local) -> QVector3D {
   float const lx = local.x() * frame.radius;
@@ -1235,6 +1249,90 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
       }
     }
 
+    if (inst_ctx.backend != nullptr && inst_ctx.resources != nullptr) {
+      auto *shadowShader =
+          inst_ctx.backend->shader(QStringLiteral("troop_shadow"));
+      auto *quadMesh = inst_ctx.resources->quad();
+
+      if (shadowShader != nullptr && quadMesh != nullptr) {
+
+        float const shadowSize =
+            is_mounted_spawn ? k_shadow_size_mounted : k_shadow_size_infantry;
+        float depth_boost = 1.0F;
+        float width_boost = 1.0F;
+        if (unit_comp != nullptr) {
+          using Game::Units::SpawnType;
+          switch (unit_comp->spawn_type) {
+          case SpawnType::Spearman:
+            depth_boost = 1.8F;
+            width_boost = 0.95F;
+            break;
+          case SpawnType::HorseSpearman:
+            depth_boost = 2.1F;
+            width_boost = 1.05F;
+            break;
+          case SpawnType::Archer:
+          case SpawnType::HorseArcher:
+            depth_boost = 1.2F;
+            width_boost = 0.95F;
+            break;
+          default:
+            break;
+          }
+        }
+
+        float const shadowWidth =
+            shadowSize * (is_mounted_spawn ? 1.05F : 1.0F) * width_boost;
+        float const shadowDepth =
+            shadowSize * (is_mounted_spawn ? 1.30F : 1.10F) * depth_boost;
+
+        QVector3D const instPos =
+            inst_ctx.model.map(QVector3D(0.0F, 0.0F, 0.0F));
+
+        float shadowY = instPos.y();
+        auto &terrain_service = Game::Map::TerrainService::instance();
+        if (terrain_service.isInitialized()) {
+          shadowY = terrain_service.getTerrainHeight(instPos.x(), instPos.z());
+        }
+
+        QVector3D light_dir = k_shadow_light_dir.normalized();
+        QVector2D light_dir_xz(light_dir.x(), light_dir.z());
+        if (light_dir_xz.lengthSquared() < 1e-6F) {
+          light_dir_xz = QVector2D(0.0F, 1.0F);
+        } else {
+          light_dir_xz.normalize();
+        }
+        QVector2D const shadow_dir = -light_dir_xz;
+        QVector2D dir_for_use = shadow_dir;
+        if (dir_for_use.lengthSquared() < 1e-6F) {
+          dir_for_use = QVector2D(0.0F, 1.0F);
+        } else {
+          dir_for_use.normalize();
+        }
+        float const shadowOffset = shadowDepth * 1.25F;
+        QVector2D const offset2d = dir_for_use * shadowOffset;
+        float const lightYawDeg = qRadiansToDegrees(
+            std::atan2(double(dir_for_use.x()), double(dir_for_use.y())));
+
+        QMatrix4x4 shadowModel;
+        shadowModel.translate(instPos.x() + offset2d.x(),
+                              shadowY + k_shadow_ground_offset,
+                              instPos.z() + offset2d.y());
+        shadowModel.rotate(lightYawDeg, 0.0F, 1.0F, 0.0F);
+        shadowModel.rotate(-90.0F, 1.0F, 0.0F, 0.0F);
+        shadowModel.scale(shadowWidth, shadowDepth, 1.0F);
+
+        if (auto *renderer = dynamic_cast<Renderer *>(&out)) {
+          renderer->setCurrentShader(shadowShader);
+          shadowShader->setUniform(QStringLiteral("u_lightDir"), dir_for_use);
+
+          out.mesh(quadMesh, shadowModel, QVector3D(0.0F, 0.0F, 0.0F), nullptr,
+                   k_shadow_base_alpha, 0);
+          renderer->setCurrentShader(nullptr);
+        }
+      }
+    }
+
     drawCommonBody(inst_ctx, variant, pose, out);
     drawFacialHair(inst_ctx, variant, pose, out);
     draw_armor(inst_ctx, variant, pose, anim_ctx, out);

+ 29 - 0
utils/resource_utils.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <QCoreApplication>
 #include <QDir>
 #include <QFileInfo>
 #include <QString>
@@ -47,6 +48,34 @@ inline auto resolveResourcePath(const QString &path) -> QString {
     }
   }
 
+  // Fallbacks for development and packaging where resources live on disk
+  auto search_upwards = [&](const QString &startDir) -> QString {
+    if (startDir.isEmpty()) {
+      return {};
+    }
+    QDir dir(startDir);
+    for (int i = 0; i < 5; ++i) {
+      QString candidate = dir.filePath(relative);
+      if (exists(candidate)) {
+        return candidate;
+      }
+      if (!dir.cdUp()) {
+        break;
+      }
+    }
+    return {};
+  };
+
+  if (QString candidate =
+          search_upwards(QCoreApplication::applicationDirPath());
+      !candidate.isEmpty()) {
+    return candidate;
+  }
+  if (QString candidate = search_upwards(QDir::currentPath());
+      !candidate.isEmpty()) {
+    return candidate;
+  }
+
   return path;
 }