Browse Source

introduce mask id for roman troops shaders

djeada 4 weeks ago
parent
commit
e735b11fe7
35 changed files with 1381 additions and 1219 deletions
  1. 6 0
      CMakeLists.txt
  2. 18 0
      assets.qrc
  3. 71 1
      assets/data/nations/carthage.json
  4. 1 1
      assets/data/nations/kingdom_of_iron.json
  5. 71 1
      assets/data/nations/roman_republic.json
  6. 3 3
      assets/data/troops/base.json
  7. 12 0
      assets/maps/map_rivers.json
  8. 98 72
      assets/shaders/archer_roman_republic.frag
  9. 96 13
      assets/shaders/archer_roman_republic.vert
  10. 141 277
      assets/shaders/spearman_roman_republic.frag
  11. 100 13
      assets/shaders/spearman_roman_republic.vert
  12. 131 104
      assets/shaders/swordsman_roman_republic.frag
  13. 108 13
      assets/shaders/swordsman_roman_republic.vert
  14. 66 1
      game/units/troop_catalog.cpp
  15. 1 0
      render/draw_queue.h
  16. 0 16
      render/entity/nations/carthage/swordsman_renderer.cpp
  17. 0 16
      render/entity/nations/kingdom/swordsman_renderer.cpp
  18. 1 1
      render/entity/nations/roman/spearman_renderer.cpp
  19. 7 17
      render/entity/nations/roman/swordsman_renderer.cpp
  20. 123 233
      render/equipment/armor/roman_armor.cpp
  21. 36 119
      render/equipment/helmets/roman_heavy_helmet.cpp
  22. 21 40
      render/equipment/helmets/roman_light_helmet.cpp
  23. 43 78
      render/equipment/weapons/shield_roman.cpp
  24. 1 0
      render/gl/backend.cpp
  25. 1 0
      render/gl/backend/character_pipeline.cpp
  26. 1 0
      render/gl/backend/character_pipeline.h
  27. 5 0
      render/gl/primitives.cpp
  28. 1 0
      render/gl/primitives.h
  29. 194 194
      render/horse/rig.cpp
  30. 16 0
      render/humanoid/rig.cpp
  31. 2 1
      render/scene_renderer.cpp
  32. 1 1
      render/scene_renderer.h
  33. 3 2
      render/submitter.h
  34. 1 1
      tests/render/helmet_renderers_test.cpp
  35. 1 1
      tests/render/horse_equipment_renderers_test.cpp

+ 6 - 0
CMakeLists.txt

@@ -204,6 +204,12 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/shaders/ground_plane.vert
             assets/shaders/ground_plane.vert
             assets/shaders/swordsman.frag
             assets/shaders/swordsman.frag
             assets/shaders/swordsman.vert
             assets/shaders/swordsman.vert
+            assets/shaders/swordsman_kingdom_of_iron.frag
+            assets/shaders/swordsman_kingdom_of_iron.vert
+            assets/shaders/swordsman_roman_republic.frag
+            assets/shaders/swordsman_roman_republic.vert
+            assets/shaders/swordsman_carthage.frag
+            assets/shaders/swordsman_carthage.vert
             assets/shaders/horse_swordsman.frag
             assets/shaders/horse_swordsman.frag
             assets/shaders/horse_swordsman.vert
             assets/shaders/horse_swordsman.vert
             assets/shaders/horse_swordsman_kingdom_of_iron.frag
             assets/shaders/horse_swordsman_kingdom_of_iron.frag

+ 18 - 0
assets.qrc

@@ -27,13 +27,31 @@
         <file>assets/shaders/swordsman.frag</file>
         <file>assets/shaders/swordsman.frag</file>
         <file>assets/shaders/swordsman.vert</file>
         <file>assets/shaders/swordsman.vert</file>
         <file>assets/shaders/swordsman_kingdom_of_iron.frag</file>
         <file>assets/shaders/swordsman_kingdom_of_iron.frag</file>
+        <file>assets/shaders/swordsman_kingdom_of_iron.vert</file>
         <file>assets/shaders/swordsman_roman_republic.frag</file>
         <file>assets/shaders/swordsman_roman_republic.frag</file>
+        <file>assets/shaders/swordsman_roman_republic.vert</file>
         <file>assets/shaders/swordsman_carthage.frag</file>
         <file>assets/shaders/swordsman_carthage.frag</file>
+        <file>assets/shaders/swordsman_carthage.vert</file>
         <file>assets/shaders/horse_swordsman.frag</file>
         <file>assets/shaders/horse_swordsman.frag</file>
         <file>assets/shaders/horse_swordsman.vert</file>
         <file>assets/shaders/horse_swordsman.vert</file>
         <file>assets/shaders/horse_swordsman_kingdom_of_iron.frag</file>
         <file>assets/shaders/horse_swordsman_kingdom_of_iron.frag</file>
+        <file>assets/shaders/horse_swordsman_kingdom_of_iron.vert</file>
         <file>assets/shaders/horse_swordsman_roman_republic.frag</file>
         <file>assets/shaders/horse_swordsman_roman_republic.frag</file>
+        <file>assets/shaders/horse_swordsman_roman_republic.vert</file>
         <file>assets/shaders/horse_swordsman_carthage.frag</file>
         <file>assets/shaders/horse_swordsman_carthage.frag</file>
+        <file>assets/shaders/horse_swordsman_carthage.vert</file>
+        <file>assets/shaders/horse_archer_kingdom_of_iron.frag</file>
+        <file>assets/shaders/horse_archer_kingdom_of_iron.vert</file>
+        <file>assets/shaders/horse_archer_roman_republic.frag</file>
+        <file>assets/shaders/horse_archer_roman_republic.vert</file>
+        <file>assets/shaders/horse_archer_carthage.frag</file>
+        <file>assets/shaders/horse_archer_carthage.vert</file>
+        <file>assets/shaders/horse_spearman_kingdom_of_iron.frag</file>
+        <file>assets/shaders/horse_spearman_kingdom_of_iron.vert</file>
+        <file>assets/shaders/horse_spearman_roman_republic.frag</file>
+        <file>assets/shaders/horse_spearman_roman_republic.vert</file>
+        <file>assets/shaders/horse_spearman_carthage.frag</file>
+        <file>assets/shaders/horse_spearman_carthage.vert</file>
         <file>assets/shaders/pine_instanced.frag</file>
         <file>assets/shaders/pine_instanced.frag</file>
         <file>assets/shaders/pine_instanced.vert</file>
         <file>assets/shaders/pine_instanced.vert</file>
         <file>assets/shaders/plant_instanced.frag</file>
         <file>assets/shaders/plant_instanced.frag</file>

+ 71 - 1
assets/data/nations/carthage.json

@@ -121,7 +121,7 @@
       "combat": {
       "combat": {
         "health": 195,
         "health": 195,
         "max_health": 195,
         "max_health": 195,
-        "speed": 8.6,
+        "speed": 3.0,
         "vision_range": 18.0,
         "vision_range": 18.0,
         "ranged_range": 1.5,
         "ranged_range": 1.5,
         "ranged_damage": 5,
         "ranged_damage": 5,
@@ -178,6 +178,76 @@
         "individuals_per_unit": 1,
         "individuals_per_unit": 1,
         "max_units_per_row": 1
         "max_units_per_row": 1
       }
       }
+    },
+    {
+      "id": "horse_archer",
+      "display_name": "Horse Archer",
+      "production": {
+        "cost": 120,
+        "build_time": 9.0,
+        "priority": 12,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 140,
+        "max_health": 140,
+        "speed": 3.0,
+        "vision_range": 18.0,
+        "ranged_range": 7.0,
+        "ranged_damage": 14,
+        "ranged_cooldown": 1.5,
+        "melee_range": 1.8,
+        "melee_damage": 8,
+        "melee_cooldown": 0.9,
+        "can_ranged": true,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.75,
+        "selection_ring_size": 1.8,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.2,
+        "renderer_id": "troops/carthage/horse_archer"
+      },
+      "formation": {
+        "individuals_per_unit": 10,
+        "max_units_per_row": 3
+      }
+    },
+    {
+      "id": "horse_spearman",
+      "display_name": "Horse Spearman",
+      "production": {
+        "cost": 140,
+        "build_time": 9.5,
+        "priority": 13,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 180,
+        "max_health": 180,
+        "speed": 3.0,
+        "vision_range": 16.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 5,
+        "ranged_cooldown": 2.0,
+        "melee_range": 3.0,
+        "melee_damage": 28,
+        "melee_cooldown": 0.9,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.78,
+        "selection_ring_size": 1.9,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.3,
+        "renderer_id": "troops/carthage/horse_spearman"
+      },
+      "formation": {
+        "individuals_per_unit": 9,
+        "max_units_per_row": 3
+      }
     }
     }
   ]
   ]
 }
 }

+ 1 - 1
assets/data/nations/kingdom_of_iron.json

@@ -121,7 +121,7 @@
       "combat": {
       "combat": {
         "health": 200,
         "health": 200,
         "max_health": 200,
         "max_health": 200,
-        "speed": 8.0,
+        "speed": 3.0,
         "vision_range": 16.0,
         "vision_range": 16.0,
         "ranged_range": 1.5,
         "ranged_range": 1.5,
         "ranged_damage": 5,
         "ranged_damage": 5,

+ 71 - 1
assets/data/nations/roman_republic.json

@@ -121,7 +121,7 @@
       "combat": {
       "combat": {
         "health": 210,
         "health": 210,
         "max_health": 210,
         "max_health": 210,
-        "speed": 8.2,
+        "speed": 4.5,
         "vision_range": 17.5,
         "vision_range": 17.5,
         "ranged_range": 1.5,
         "ranged_range": 1.5,
         "ranged_damage": 5,
         "ranged_damage": 5,
@@ -178,6 +178,76 @@
         "individuals_per_unit": 1,
         "individuals_per_unit": 1,
         "max_units_per_row": 1
         "max_units_per_row": 1
       }
       }
+    },
+    {
+      "id": "horse_archer",
+      "display_name": "Horse Archer",
+      "production": {
+        "cost": 120,
+        "build_time": 9.0,
+        "priority": 12,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 140,
+        "max_health": 140,
+        "speed": 3.0,
+        "vision_range": 18.0,
+        "ranged_range": 7.0,
+        "ranged_damage": 14,
+        "ranged_cooldown": 1.5,
+        "melee_range": 1.8,
+        "melee_damage": 8,
+        "melee_cooldown": 0.9,
+        "can_ranged": true,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.75,
+        "selection_ring_size": 1.8,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.2,
+        "renderer_id": "troops/roman/horse_archer"
+      },
+      "formation": {
+        "individuals_per_unit": 10,
+        "max_units_per_row": 3
+      }
+    },
+    {
+      "id": "horse_spearman",
+      "display_name": "Horse Spearman",
+      "production": {
+        "cost": 140,
+        "build_time": 9.5,
+        "priority": 13,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 180,
+        "max_health": 180,
+        "speed": 3.0,
+        "vision_range": 16.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 5,
+        "ranged_cooldown": 2.0,
+        "melee_range": 3.0,
+        "melee_damage": 28,
+        "melee_cooldown": 0.9,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.78,
+        "selection_ring_size": 1.9,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.3,
+        "renderer_id": "troops/roman/horse_spearman"
+      },
+      "formation": {
+        "individuals_per_unit": 9,
+        "max_units_per_row": 3
+      }
     }
     }
   ]
   ]
 }
 }

+ 3 - 3
assets/data/troops/base.json

@@ -117,7 +117,7 @@
       "combat": {
       "combat": {
         "health": 200,
         "health": 200,
         "max_health": 200,
         "max_health": 200,
-        "speed": 8.0,
+        "speed": 3.0,
         "vision_range": 16.0,
         "vision_range": 16.0,
         "ranged_range": 1.5,
         "ranged_range": 1.5,
         "ranged_damage": 5,
         "ranged_damage": 5,
@@ -152,7 +152,7 @@
       "combat": {
       "combat": {
         "health": 140,
         "health": 140,
         "max_health": 140,
         "max_health": 140,
-        "speed": 7.5,
+        "speed": 3.0,
         "vision_range": 18.0,
         "vision_range": 18.0,
         "ranged_range": 7.0,
         "ranged_range": 7.0,
         "ranged_damage": 14,
         "ranged_damage": 14,
@@ -187,7 +187,7 @@
       "combat": {
       "combat": {
         "health": 180,
         "health": 180,
         "max_health": 180,
         "max_health": 180,
-        "speed": 7.8,
+        "speed": 3.0,
         "vision_range": 16.0,
         "vision_range": 16.0,
         "ranged_range": 1.5,
         "ranged_range": 1.5,
         "ranged_damage": 5,
         "ranged_damage": 5,

+ 12 - 0
assets/maps/map_rivers.json

@@ -98,6 +98,18 @@
       "z": 35,
       "z": 35,
       "playerId": 1
       "playerId": 1
     },
     },
+    {
+      "type": "horse_archer",
+      "x": 30,
+      "z": 35,
+      "playerId": 1
+    },
+    {
+      "type": "horse_spearman",
+      "x": 30,
+      "z": 35,
+      "playerId": 1
+    },
     {
     {
       "type": "spearman",
       "type": "spearman",
       "x": 35,
       "x": 35,

+ 98 - 72
assets/shaders/archer_roman_republic.frag

@@ -1,13 +1,23 @@
 #version 330 core
 #version 330 core
 
 
 in vec3 v_normal;
 in vec3 v_normal;
+in vec3 v_worldNormal;
+in vec3 v_tangent;
+in vec3 v_bitangent;
 in vec2 v_texCoord;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
 in vec3 v_worldPos;
+in float v_armorLayer;
+in float v_bodyHeight;
+in float v_helmetDetail;
+in float v_chainmailPhase;
+in float v_leatherWear;
+in float v_curvature;
 
 
 uniform sampler2D u_texture;
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform vec3 u_color;
 uniform bool u_useTexture;
 uniform bool u_useTexture;
 uniform float u_alpha;
 uniform float u_alpha;
+uniform int u_materialId;
 
 
 out vec4 FragColor;
 out vec4 FragColor;
 
 
@@ -67,109 +77,125 @@ void main() {
 
 
   vec3 normal = normalize(v_normal);
   vec3 normal = normalize(v_normal);
   vec2 uv = v_worldPos.xz * 4.5;
   vec2 uv = v_worldPos.xz * 4.5;
-  float avgColor = (color.r + color.g + color.b) / 3.0;
-
-  // Detect bronze vs steel by color warmth
-  bool isBronze =
-      (color.r > color.g * 1.08 && color.r > color.b * 1.15 && avgColor > 0.50);
-  bool isRedCape = (color.r > color.g * 1.3 && color.r > color.b * 1.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);
+  
+  // Fallback to old layer system for non-armor meshes
+  if (u_materialId == 0) {
+    isHelmet = (v_armorLayer < 0.5);
+    isArmor = false;  // Body mesh should not get armor effects
+  }
+  
+  bool isLegs = (v_armorLayer >= 1.5);
 
 
   // === ROMAN ARCHER (SAGITTARIUS) MATERIALS ===
   // === ROMAN ARCHER (SAGITTARIUS) MATERIALS ===
 
 
-  // BRONZE GALEA HELMET & PHALERAE (warm golden metal)
-  if (isBronze) {
-    // Ancient bronze patina and wear
+  // LIGHT BRONZE HELMET (warm golden auxiliary helmet)
+  if (isHelmet) {
+    // Use vertex-computed helmet detail
+    float bands = v_helmetDetail * 0.15;
+    
+    // Warm bronze patina and wear
     float bronzePatina = noise(uv * 8.0) * 0.12;
     float bronzePatina = noise(uv * 8.0) * 0.12;
-    float verdigris = noise(uv * 15.0) * 0.08; // Green oxidation
-
-    // Bronze is less reflective than polished steel
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float verdigris = noise(uv * 15.0) * 0.08;
+    
+    // Hammer marks from forging (using vertex curvature)
+    float hammerMarks = noise(uv * 25.0) * 0.035 * (1.0 - v_curvature * 0.3);
+    
+    // Conical shape highlight
+    float apex = smoothstep(0.85, 1.0, v_bodyHeight) * 0.12;
+    
+    // Bronze sheen using tangent space
+    vec3 N = normalize(v_worldNormal);
+    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+    float viewAngle = max(dot(N, V), 0.0);
     float bronzeSheen = pow(viewAngle, 7.0) * 0.25;
     float bronzeSheen = pow(viewAngle, 7.0) * 0.25;
     float bronzeFresnel = pow(1.0 - viewAngle, 2.2) * 0.18;
     float bronzeFresnel = pow(1.0 - viewAngle, 2.2) * 0.18;
 
 
-    // Hammer marks from forging
-    float hammerMarks = noise(uv * 25.0) * 0.035;
-
-    color += vec3(bronzeSheen + bronzeFresnel);
+    color += vec3(bronzeSheen + bronzeFresnel + bands + apex);
     color -= vec3(bronzePatina * 0.4 + verdigris * 0.3);
     color -= vec3(bronzePatina * 0.4 + verdigris * 0.3);
     color += vec3(hammerMarks * 0.5);
     color += vec3(hammerMarks * 0.5);
   }
   }
-  // STEEL CHAINMAIL (lorica hamata - grey-blue tint)
-  else if (avgColor > 0.40 && avgColor <= 0.60 && !isRedCape) {
-    // Interlocked iron rings
-    float rings = chainmailRings(v_worldPos.xz);
-
-    // Chainmail has dull metallic sheen
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float chainSheen = pow(viewAngle, 5.0) * 0.16;
-
-    // Iron rust spots
-    float rust = noise(uv * 10.0) * 0.08;
-
-    color += vec3(rings + chainSheen);
-    color -= vec3(rust * 0.4);              // Darken with age
-    color *= 1.0 - noise(uv * 18.0) * 0.06; // Shadow between rings
-  }
-  // RED SAGUM CAPE (bright red woolen cloak)
-  else if (isRedCape) {
-    // Thick woolen weave
-    float weaveX = sin(v_worldPos.x * 55.0);
-    float weaveZ = sin(v_worldPos.z * 55.0);
-    float weave = weaveX * weaveZ * 0.045;
-
-    // Wool texture (fuzzy)
-    float woolFuzz = noise(uv * 20.0) * 0.10;
-
-    // Fabric folds and draping
-    float folds = noise(uv * 6.0) * 0.12 - 0.06;
-
-    // Soft fabric sheen
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float capeSheen = pow(1.0 - viewAngle, 8.0) * 0.08;
-
-    color *= 1.0 + woolFuzz - 0.05 + folds;
-    color += vec3(weave + capeSheen);
+  // LIGHT CHAINMAIL ARMOR (lorica hamata - historically accurate 4-in-1 pattern)
+  else if (isArmor) {
+    // DRAMATICALLY VISIBLE chainmail - base color much darker than body
+    // Start with strong grey base that's clearly NOT skin
+    color = color * vec3(0.45, 0.48, 0.52);  // Force grey metal base
+    
+    // Roman chainmail: butted iron rings, 8-10mm diameter, 1.2mm wire
+    vec2 chainUV = v_worldPos.xz * 22.0;  // Larger, more visible rings
+    
+    // MUCH STRONGER ring pattern - these need to be OBVIOUS
+    float rings = chainmailRings(chainUV) * 2.5;  // 3x stronger
+    
+    // Deep shadows in ring gaps - CRITICAL for visibility
+    float ringGaps = (1.0 - chainmailRings(chainUV)) * 0.45;
+    
+    // Ring structure with STRONG contrast
+    float ringHighlight = rings * 0.85;
+    float ringShadow = ringGaps * 0.60;
+    
+    // Oxidation creates rust color variance
+    float oxidation = noise(chainUV * 7.0) * 0.25;
+    vec3 rustTint = vec3(0.35, 0.25, 0.20);  // Brown rust color
+    
+    // Battle damage - visible broken rings
+    float damageSeed = noise(chainUV * 0.8);
+    float damage = step(0.88, damageSeed) * 0.35;
+    
+    // STRONG specular highlights on ring surfaces
+    vec3 N = normalize(v_worldNormal);
+    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+    float viewAngle = max(dot(N, V), 0.0);
+    float chainSheen = pow(viewAngle, 4.0) * 0.65;  // Much stronger
+    
+    // Metallic shimmer as rings catch light
+    float shimmer = abs(sin(chainUV.x * 32.0) * sin(chainUV.y * 32.0)) * 0.25;
+
+    // Apply all chainmail effects with STRONG visibility
+    color += vec3(ringHighlight + chainSheen + shimmer);
+    color -= vec3(ringShadow + damage);
+    color = mix(color, rustTint, oxidation * 0.35);
+    
+    // Ensure chainmail is CLEARLY visible - never blend into skin
+    color = clamp(color, vec3(0.35), vec3(0.85));
   }
   }
-  // LEATHER PTERUGES & ARMOR STRIPS (tan/brown leather strips)
-  else if (avgColor > 0.35) {
-    // Thick leather with visible grain
-    float leatherGrain = noise(uv * 10.0) * 0.16;
+  // LEATHER PTERUGES & BELT (tan/brown leather strips)
+  else if (isLegs) {
+    // Thick leather with visible grain (using vertex wear data)
+    float leatherGrain = noise(uv * 10.0) * 0.16 * (0.5 + v_leatherWear * 0.5);
     float leatherPores = noise(uv * 22.0) * 0.08;
     float leatherPores = noise(uv * 22.0) * 0.08;
 
 
     // Pteruges strip pattern
     // Pteruges strip pattern
-    float strips = pterugesStrips(v_worldPos.xz, v_worldPos.y);
+    float strips = pterugesStrips(v_worldPos.xz, v_bodyHeight);
 
 
     // Worn leather edges
     // Worn leather edges
-    float wear = noise(uv * 4.0) * 0.10 - 0.05;
+    float wear = noise(uv * 4.0) * v_leatherWear * 0.10 - 0.05;
 
 
     // Leather has subtle sheen
     // Leather has subtle sheen
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    vec3 N = normalize(v_worldNormal);
+    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+    float viewAngle = max(dot(N, V), 0.0);
     float leatherSheen = pow(1.0 - viewAngle, 4.5) * 0.10;
     float leatherSheen = pow(1.0 - viewAngle, 4.5) * 0.10;
 
 
     color *= 1.0 + leatherGrain + leatherPores - 0.08 + wear;
     color *= 1.0 + leatherGrain + leatherPores - 0.08 + wear;
     color += vec3(strips * 0.15 + leatherSheen);
     color += vec3(strips * 0.15 + leatherSheen);
   }
   }
-  // DARK ELEMENTS (cingulum belt, straps, manicae)
-  else {
-    float leatherDetail = noise(uv * 8.0) * 0.14;
-    float tooling = noise(uv * 16.0) * 0.06; // Decorative tooling
-    float darkening = noise(uv * 2.5) * 0.08;
-
-    color *= 1.0 + leatherDetail - 0.07 + tooling - darkening;
-  }
 
 
   color = clamp(color, 0.0, 1.0);
   color = clamp(color, 0.0, 1.0);
 
 
-  // Lighting model - soft wrap for leather/fabric, harder for metal
+  // Lighting model per material
   vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
   vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
   float nDotL = dot(normal, lightDir);
   float nDotL = dot(normal, lightDir);
 
 
-  // Metal = harder shadows, Fabric/leather = soft wrap
-  float wrapAmount = isBronze ? 0.15 : 0.38;
+  float wrapAmount = isHelmet ? 0.15 : (isArmor ? 0.22 : 0.38);
   float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.22);
   float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.22);
 
 
-  // Enhance contrast for bronze
-  if (isBronze) {
+  if (isHelmet) {
     diff = pow(diff, 0.90);
     diff = pow(diff, 0.90);
   }
   }
 
 

+ 96 - 13
assets/shaders/archer_roman_republic.vert

@@ -6,27 +6,110 @@ layout(location = 2) in vec2 a_texCoord;
 
 
 uniform mat4 u_mvp;
 uniform mat4 u_mvp;
 uniform mat4 u_model;
 uniform mat4 u_model;
+uniform int u_materialId;
 
 
 out vec3 v_normal;
 out vec3 v_normal;
+out vec3 v_worldNormal;
+out vec3 v_tangent;
+out vec3 v_bitangent;
 out vec2 v_texCoord;
 out vec2 v_texCoord;
 out vec3 v_worldPos;
 out vec3 v_worldPos;
-out float v_armorLayer; // Distinguish armor pieces for Roman auxiliary archer
+out float v_armorLayer;
+out float v_bodyHeight;
+out float v_helmetDetail;
+out float v_chainmailPhase;
+out float v_leatherWear;
+out float v_curvature;
+
+float hash13(vec3 p) {
+  return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
+}
+
+vec3 fallbackUp(vec3 n) {
+  return (abs(n.y) > 0.92) ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
+}
 
 
 void main() {
 void main() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  vec3 position = a_position;
+  vec3 normal = a_normal;
+  
+  // Shield curving: bend flat rectangle into scutum curve (materialId=4)
+  if (u_materialId == 4) {
+    float curveRadius = 0.55;
+    float curveAmount = 0.45;
+    float angle = position.x * curveAmount;
+    
+    float curved_x = sin(angle) * curveRadius;
+    float curved_z = position.z + (1.0 - cos(angle)) * curveRadius;
+    position = vec3(curved_x, position.y, curved_z);
+    
+    normal = vec3(sin(angle) * normal.z + cos(angle) * normal.x,
+                  normal.y,
+                  cos(angle) * normal.z - sin(angle) * normal.x);
+  }
+  
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * normal);
+
+  // Build tangent space
+  vec3 t = normalize(cross(fallbackUp(worldNormal), worldNormal));
+  if (length(t) < 1e-4)
+    t = vec3(1.0, 0.0, 0.0);
+  t = normalize(t - worldNormal * dot(worldNormal, t));
+  vec3 b = normalize(cross(worldNormal, t));
+
+  vec4 modelPos = u_model * vec4(position, 1.0);
+  vec3 worldPos = modelPos.xyz;
+
+  // Procedural denting and battle damage
+  float dentSeed = hash13(worldPos * 0.75 + worldNormal * 0.22);
+  float hammerNoise = sin(worldPos.y * 14.3 + dentSeed * 12.56);
+  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0095);
+  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.18, -worldNormal.x));
+  vec3 shearOffset = shearAxis * hammerNoise * 0.0032;
+
+  vec3 batteredPos = worldPos + dentOffset + shearOffset;
+  vec3 offsetPos = batteredPos + worldNormal * 0.0055;
+
+  mat4 invModel = inverse(u_model);
+  vec4 localBattered = invModel * vec4(batteredPos, 1.0);
+  gl_Position = u_mvp * localBattered;
+
+  v_worldPos = offsetPos;
   v_texCoord = a_texCoord;
   v_texCoord = a_texCoord;
-  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for Roman equipment
-  // Upper body (helmet) = 0, Torso (chainmail/scale) = 1, Lower (pteruges/belt)
-  // = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Roman auxiliary helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Lorica hamata/squamata region
+  v_normal = worldNormal;
+  v_worldNormal = worldNormal;
+  v_tangent = t;
+  v_bitangent = b;
+
+  float height = offsetPos.y;
+  
+  // Armor layer detection - STRICT ranges to avoid applying to wrong body parts
+  if (height > 1.45) {
+    v_armorLayer = 0.0; // Helmet region (helmet mesh only)
+  } else if (height > 0.85 && height <= 1.45) {
+    v_armorLayer = 1.0; // Chainmail torso (armor mesh only)
   } else {
   } else {
-    v_armorLayer = 2.0; // Pteruges/cingulum belt region
+    v_armorLayer = 2.0; // Legs, pteruges, belt (non-armor)
   }
   }
 
 
-  gl_Position = u_mvp * vec4(a_position, 1.0);
+  // Body height normalization
+  float torsoMin = 0.55;
+  float torsoMax = 1.65;
+  v_bodyHeight = clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
+
+  // Light helmet detail attributes
+  float conicalHeight = smoothstep(1.55, 1.75, height);
+  float browBandRegion = smoothstep(1.48, 1.52, height) * smoothstep(1.56, 1.52, height);
+  v_helmetDetail = conicalHeight * 0.6 + browBandRegion * 0.4;
+
+  // Chainmail ring phase
+  v_chainmailPhase = fract(offsetPos.x * 32.0 + offsetPos.z * 32.0);
+
+  // Leather wear and tension
+  float tensionSeed = hash13(offsetPos * 0.42 + worldNormal * 1.5);
+  v_leatherWear = tensionSeed * (0.7 + v_bodyHeight * 0.3);
+
+  // Surface curvature indicator
+  v_curvature = length(vec2(worldNormal.x, worldNormal.z));
 }
 }

+ 141 - 277
assets/shaders/spearman_roman_republic.frag

@@ -1,22 +1,27 @@
 #version 330 core
 #version 330 core
 
 
-// === Inputs preserved (do not change) ===
 in vec3 v_normal;
 in vec3 v_normal;
+in vec3 v_worldNormal;
+in vec3 v_tangent;
+in vec3 v_bitangent;
 in vec2 v_texCoord;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
 in vec3 v_worldPos;
-in float v_armorLayer; // Armor layer from vertex shader
+in float v_armorLayer;
+in float v_bodyHeight;
+in float v_helmetDetail;
+in float v_steelWear;
+in float v_chainmailPhase;
+in float v_rivetPattern;
+in float v_leatherWear;
 
 
 uniform sampler2D u_texture;
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform vec3 u_color;
 uniform bool u_useTexture;
 uniform bool u_useTexture;
 uniform float u_alpha;
 uniform float u_alpha;
+uniform int u_materialId;
 
 
 out vec4 FragColor;
 out vec4 FragColor;
 
 
-// === Utility ===
-float saturate(float x) { return clamp(x, 0.0, 1.0); }
-vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
-
 float hash(vec2 p) {
 float hash(vec2 p) {
   vec3 p3 = fract(vec3(p.xyx) * 0.1031);
   vec3 p3 = fract(vec3(p.xyx) * 0.1031);
   p3 += dot(p3, p3.yzx + 33.33);
   p3 += dot(p3, p3.yzx + 33.33);
@@ -34,292 +39,151 @@ float noise(vec2 p) {
   return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
   return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
 }
 }
 
 
-float leatherGrain(vec2 p) {
-  float grain = noise(p * 10.0) * 0.16;
-  float pores = noise(p * 22.0) * 0.08;
-  return grain + pores;
-}
-
-// Fixed bug: use 2D input (was referencing p.z).
-float fabricWeave(vec2 p) {
-  float weaveU = sin(p.x * 60.0);
-  float weaveV = sin(p.y * 60.0);
-  return weaveU * weaveV * 0.05;
-}
-
-// Hemispheric ambient (simple IBL feel without extra uniforms)
-vec3 hemiAmbient(vec3 n) {
-  float up = saturate(n.y * 0.5 + 0.5);
-  vec3 sky = vec3(0.60, 0.70, 0.80) * 0.35;
-  vec3 ground = vec3(0.20, 0.18, 0.16) * 0.25;
-  return mix(ground, sky, up);
-}
-
-// Schlick Fresnel
-vec3 fresnelSchlick(float cosTheta, vec3 F0) {
-  return F0 + (1.0 - F0) * pow(1.0 - saturate(cosTheta), 5.0);
-}
-
-// GGX / Trowbridge-Reitz
-float distributionGGX(float NdotH, float a) {
-  float a2 = a * a;
-  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
-  return a2 / max(3.14159265 * d * d, 1e-6);
+float chainmailRings(vec2 p) {
+  vec2 grid = fract(p * 32.0) - 0.5;
+  float ring = length(grid);
+  float ringPattern = smoothstep(0.38, 0.32, ring) - smoothstep(0.28, 0.22, ring);
+  vec2 offsetGrid = fract(p * 32.0 + vec2(0.5, 0.0)) - 0.5;
+  float offsetRing = length(offsetGrid);
+  float offsetPattern = smoothstep(0.38, 0.32, offsetRing) - smoothstep(0.28, 0.22, offsetRing);
+  return (ringPattern + offsetPattern) * 0.14;
 }
 }
 
 
-// Smith's Schlick-G for GGX
-float geometrySchlickGGX(float NdotX, float k) {
-  return NdotX / max(NdotX * (1.0 - k) + k, 1e-6);
-}
-float geometrySmith(float NdotV, float NdotL, float roughness) {
-  float r = roughness + 1.0;
-  float k = (r * r) / 8.0; // Schlick approximation
-  float ggx1 = geometrySchlickGGX(NdotV, k);
-  float ggx2 = geometrySchlickGGX(NdotL, k);
-  return ggx1 * ggx2;
-}
-
-// Screen-space curvature (edge detector) from normal derivatives
-float edgeWearMask(vec3 n) {
-  vec3 nx = dFdx(n);
-  vec3 ny = dFdy(n);
-  float curvature = length(nx) + length(ny);
-  return saturate(smoothstep(0.10, 0.70, curvature));
-}
-
-// Build an approximate TBN from derivatives (no new inputs needed)
-void buildTBN(out vec3 T, out vec3 B, out vec3 N, vec3 n, vec3 pos, vec2 uv) {
-  vec3 dp1 = dFdx(pos);
-  vec3 dp2 = dFdy(pos);
-  vec2 duv1 = dFdx(uv);
-  vec2 duv2 = dFdy(uv);
-
-  float det = duv1.x * duv2.y - duv1.y * duv2.x;
-  vec3 t = (dp1 * duv2.y - dp2 * duv1.y) * (det == 0.0 ? 1.0 : sign(det));
-  T = normalize(t - n * dot(n, t));
-  B = normalize(cross(n, T));
-  N = normalize(n);
-}
-
-// Cheap bump from a procedural height map in UV space
-vec3 perturbNormalFromHeight(vec3 n, vec3 pos, vec2 uv, float height,
-                             float scale, float strength) {
-  vec3 T, B, N;
-  buildTBN(T, B, N, n, pos, uv);
-
-  // Finite-difference heights in UV for gradient
-  float h0 = height;
-  float hx = noise((uv + vec2(0.002, 0.0)) * scale) - h0;
-  float hy = noise((uv + vec2(0.0, 0.002)) * scale) - h0;
-
-  vec3 bump = normalize(N + (T * hx + B * hy) * strength);
-  return bump;
+float pterugesStrips(vec2 p, float y) {
+  float stripX = fract(p.x * 9.0);
+  float strip = smoothstep(0.15, 0.20, stripX) - smoothstep(0.80, 0.85, stripX);
+  float leatherTex = noise(p * 18.0) * 0.35;
+  float hang = smoothstep(0.65, 0.45, y);
+  return strip * leatherTex * hang;
 }
 }
 
 
 void main() {
 void main() {
-  // Base color
   vec3 color = u_color;
   vec3 color = u_color;
   if (u_useTexture) {
   if (u_useTexture) {
     color *= texture(u_texture, v_texCoord).rgb;
     color *= texture(u_texture, v_texCoord).rgb;
   }
   }
 
 
-  // Inputs & coordinate prep
-  vec3 N = normalize(v_normal);
-  vec2 uvW = v_worldPos.xz * 4.5;
-  vec2 uv = v_texCoord * 4.5;
-
-  float avgColor = (color.r + color.g + color.b) / 3.0;
-  float colorHue =
-      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
-
-  // Material classification preserved
-  bool isMetal = (avgColor > 0.45 && avgColor <= 0.65 && colorHue < 0.15);
-  bool isLeather = (avgColor > 0.30 && avgColor <= 0.50 && colorHue < 0.20);
-  bool isFabric = (avgColor > 0.25 && !isMetal && !isLeather);
-
-  // Lighting basis (kept compatible with prior shader)
-  vec3 L = normalize(vec3(1.0, 1.15, 1.0));
-  // Approximate view vector from world origin; nudged to avoid degenerate
-  // normalization
-  vec3 V = normalize(-v_worldPos + N * 0.001);
-  vec3 H = normalize(L + V);
-
-  float NdotL = saturate(dot(N, L));
-  float NdotV = saturate(dot(N, V));
-  float NdotH = saturate(dot(N, H));
-  float VdotH = saturate(dot(V, H));
-
-  // Ambient
-  vec3 ambient = hemiAmbient(N);
-
-  // Shared wrap diffuse (preserved behavior, slight tweak via saturate)
-  float wrapAmount = isMetal ? 0.12 : (isLeather ? 0.25 : 0.35);
-  float diffWrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.20);
-  if (isMetal)
-    diffWrap = pow(diffWrap, 0.88);
-
-  // Edge & cavity masks (for wear/rust/shine)
-  float edgeMask = edgeWearMask(N);  // bright edges
-  float cavityMask = 1.0 - edgeMask; // crevices
-  // Gravity bias: downward-facing areas collect more dirt/rust
-  float downBias = saturate((-N.y) * 0.6 + 0.4);
-  cavityMask *= downBias;
-
-  // === Material models ===
-  vec3 F0 = vec3(0.04);  // default dielectric reflectance
-  float roughness = 0.6; // default roughness
-  float cavityAO = 1.0;  // occlusion multiplier
-  vec3 albedo = color;   // base diffuse/albedo
-  vec3 specular = vec3(0.0);
-
-  if (isMetal) {
-    // Use texture UVs for stability (as in original)
-    vec2 metalUV = v_texCoord * 4.5;
-
-    // Brushed/anisotropic micro-lines & microdents
-    float brushed = abs(sin(v_texCoord.y * 85.0)) * 0.022;
-    float dents = noise(metalUV * 6.0) * 0.035;
-    float rustTex = noise(metalUV * 8.0) * 0.10;
-
-    // Small directional scratches
-    float scratchLines = smoothstep(
-        0.97, 1.0, abs(sin(metalUV.x * 160.0 + noise(metalUV * 3.0) * 2.0)));
-    scratchLines *= 0.08;
-
-    // Procedural height for bumping (kept subtle to avoid shimmer)
-    float height = noise(metalUV * 12.0) * 0.5 + brushed * 2.0 + scratchLines;
-    vec3 Np =
-        perturbNormalFromHeight(N, v_worldPos, v_texCoord, height, 12.0, 0.55);
-    N = mix(N, Np, 0.65); // blend to keep stable
-
-    // Physically-based specular with GGX
-    roughness = clamp(0.18 + brushed * 0.35 + dents * 0.25 + rustTex * 0.30 -
-                          edgeMask * 0.12,
-                      0.05, 0.9);
-    float a = max(0.001, roughness * roughness);
-
-    // Metals take F0 from their base color
-    F0 = saturate(color);
-
-    // Rust/dirt reduce albedo and boost roughness in cavities
-    float rustMask = smoothstep(0.55, 0.85, noise(metalUV * 5.5)) * cavityMask;
-    vec3 rustTint = vec3(0.35, 0.18, 0.08); // warm iron-oxide tint
-    albedo = mix(albedo, albedo * 0.55 + rustTint * 0.35, rustMask);
-    roughness = clamp(mix(roughness, 0.85, rustMask), 0.05, 0.95);
-
-    // Edge wear: brighten edges with lower roughness (polished)
-    albedo = mix(albedo, albedo * 1.12 + vec3(0.05), edgeMask * 0.7);
-    roughness = clamp(mix(roughness, 0.10, edgeMask * 0.5), 0.05, 0.95);
-
-    // Recompute lighting terms with updated normal
-    H = normalize(L + V);
-    NdotL = saturate(dot(N, L));
-    NdotV = saturate(dot(N, V));
-    NdotH = saturate(dot(N, H));
-    VdotH = saturate(dot(V, H));
-
-    float D = distributionGGX(NdotH, a);
-    float G = geometrySmith(NdotV, NdotL, roughness);
-    vec3 F = fresnelSchlick(VdotH, F0);
-
-    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
-
-    // Clearcoat sparkle (very subtle tight lobe)
-    float aCoat = 0.04; // ~roughness 0.2
-    float Dcoat = distributionGGX(NdotH, aCoat);
-    float Gcoat = geometrySmith(NdotV, NdotL, sqrt(aCoat));
-    vec3 Fcoat = fresnelSchlick(VdotH, vec3(0.04));
-    specular += 0.06 * (Dcoat * Gcoat * Fcoat) / max(4.0 * NdotL * NdotV, 1e-4);
-
-    // Metals have almost no diffuse term
-    float kD = 0.0;
-    vec3 diffuse = vec3(kD);
-
-    // AO from cavities
-    cavityAO = 1.0 - rustMask * 0.6;
-
-    // Final combine (ambient + wrapped diffuse + specular)
-    vec3 lit = ambient * albedo * cavityAO + diffWrap * albedo * diffuse +
-               specular * NdotL;
-
-    // Small addition of brushed sheen from the original
-    lit += vec3(brushed) * 0.8;
-
-    color = lit;
-
-  } else if (isLeather) {
-    // Leather microstructure & wear
-    float leather = leatherGrain(uvW);
-    float wear = noise(uvW * 4.0) * 0.12 - 0.06;
-
-    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
-    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
-
-    albedo *= 1.0 + leather - 0.08 + wear;
-    albedo += vec3(leatherSheen);
-
-    // Leather: dielectric
-    roughness = clamp(0.55 + leather * 0.25, 0.2, 0.95);
-    float a = roughness * roughness;
-
-    float D = distributionGGX(NdotH, a);
-    float G = geometrySmith(NdotV, NdotL, roughness);
-    vec3 F = fresnelSchlick(VdotH, F0);
-    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
-
-    float kD = 1.0 - max(max(F.r, F.g), F.b);
-    vec3 diffuse = kD * albedo;
-
-    cavityAO = 1.0 - (noise(uvW * 3.0) * 0.15) * cavityMask;
-
-    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
-
-  } else if (isFabric) {
-    float weave = fabricWeave(v_worldPos.xz);
-    float fabricFuzz = noise(uvW * 18.0) * 0.08;
-    float folds = noise(uvW * 5.0) * 0.10 - 0.05;
-
-    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
-    float fabricSheen = pow(1.0 - viewAngle, 7.0) * 0.10;
-
-    albedo *= 1.0 + fabricFuzz - 0.04 + folds;
-    albedo += vec3(weave + fabricSheen);
-
-    roughness = clamp(0.65 + fabricFuzz * 0.25, 0.3, 0.98);
-    float a = roughness * roughness;
-
-    float D = distributionGGX(NdotH, a);
-    float G = geometrySmith(NdotV, NdotL, roughness);
-    vec3 F = fresnelSchlick(VdotH, F0);
-    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
-
-    float kD = 1.0 - max(max(F.r, F.g), F.b);
-    vec3 diffuse = kD * albedo;
-
-    cavityAO = 1.0 - (noise(uvW * 2.5) * 0.10) * cavityMask;
-
-    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
-
-  } else {
-    // Generic matte
-    float detail = noise(uvW * 8.0) * 0.14;
-    albedo *= 1.0 + detail - 0.07;
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 4.5;
+  
+  // 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);
+  
+  // Fallback to old layer system for non-armor meshes
+  if (u_materialId == 0) {
+    isHelmet = (v_armorLayer < 0.5);
+    isArmor = false;  // Body mesh should not get armor effects
+  }
+  
+  bool isLegs = (v_armorLayer >= 1.5);
+
+  // === ROMAN SPEARMAN (HASTATUS) MATERIALS ===
+
+  // HEAVY STEEL HELMET (cool blue-grey steel)
+  if (isHelmet) {
+    // Steel wear patterns from vertex shader
+    float brushed = abs(sin(v_worldPos.y * 95.0)) * 0.020;
+    float dents = noise(uv * 6.5) * 0.032 * v_steelWear;
+    float rustTex = noise(uv * 9.0) * 0.11 * v_steelWear;
+    
+    // Use vertex-computed helmet detail (reinforcement bands, brow band, cheek guards)
+    float bands = v_helmetDetail * 0.12;
+    float rivets = v_rivetPattern * 0.10;
+    
+    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+    float viewAngle = max(dot(normalize(v_worldNormal), V), 0.0);
+    float steelSheen = pow(viewAngle, 9.0) * 0.30;
+    float steelFresnel = pow(1.0 - viewAngle, 2.0) * 0.22;
+
+    color += vec3(steelSheen + steelFresnel + bands + rivets);
+    color -= vec3(rustTex * 0.3);
+    color += vec3(brushed * 0.6);
+  }
+  // LIGHT CHAINMAIL ARMOR (lorica hamata - pectorale reinforcement optional)
+  else if (isArmor) {
+    // FORCE grey metal base - chainmail is CLEARLY not skin
+    color = color * vec3(0.42, 0.46, 0.50);  // Darker grey base
+    
+    vec2 chainUV = v_worldPos.xz * 22.0;  // Larger rings
+    
+    // PECTORALE CHEST PLATE - highly visible steel plate overlay
+    float chestPlate = smoothstep(1.15, 1.25, v_bodyHeight) * 
+                       smoothstep(1.55, 1.45, v_bodyHeight) *
+                       smoothstep(0.25, 0.15, abs(v_worldPos.x));  // Center chest
+    
+    // STRONG distinction between plate and chainmail
+    if (chestPlate > 0.3) {
+      // PECTORALE - polished steel plate
+      color = vec3(0.72, 0.76, 0.82);  // Bright steel
+      
+      // Plate edges and rivets
+      float plateEdge = smoothstep(0.88, 0.92, chestPlate) * 0.25;
+      float rivets = step(0.92, fract(v_worldPos.x * 18.0)) * 
+                     step(0.92, fract(v_worldPos.y * 12.0)) * 0.30;
+      
+      vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+      float viewAngle = max(dot(normalize(v_worldNormal), V), 0.0);
+      float plateSheen = pow(viewAngle, 8.0) * 0.75;  // Strong reflection
+      
+      color += vec3(plateSheen + rivets);
+      color -= vec3(plateEdge);
+    } else {
+      // CHAINMAIL - much more visible rings
+      float rings = chainmailRings(chainUV) * 2.2;
+      float ringGaps = (1.0 - chainmailRings(chainUV)) * 0.50;
+      
+      // Iron oxidation and rust
+      float oxidation = noise(chainUV * 7.5) * 0.28 * v_steelWear;
+      vec3 rustColor = vec3(0.38, 0.28, 0.22);
+      
+      // Battle damage
+      float damage = step(0.86, noise(chainUV * 0.8)) * 0.40;
+      
+      vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+      float viewAngle = max(dot(normalize(v_worldNormal), V), 0.0);
+      float chainSheen = pow(viewAngle, 4.5) * 0.55;
+      
+      float shimmer = abs(sin(chainUV.x * 32.0) * sin(chainUV.y * 32.0)) * 0.22;
+      
+      color += vec3(rings + chainSheen + shimmer);
+      color -= vec3(ringGaps + damage);
+      color = mix(color, rustColor, oxidation * 0.40);
+    }
+    
+    // Ensure armor is ALWAYS clearly visible
+    color = clamp(color, vec3(0.32), vec3(0.88));
+  }
+  // LEATHER PTERUGES & BELT
+  else if (isLegs) {
+    float leatherGrain = noise(uv * 10.0) * 0.16 * (0.5 + v_leatherWear * 0.5);
+    float leatherPores = noise(uv * 22.0) * 0.08;
+    float strips = pterugesStrips(v_worldPos.xz, v_bodyHeight);
+    float wear = noise(uv * 4.0) * v_leatherWear * 0.10 - 0.05;
+    
+    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+    float viewAngle = max(dot(normalize(v_worldNormal), V), 0.0);
+    float leatherSheen = pow(1.0 - viewAngle, 4.5) * 0.10;
+
+    color *= 1.0 + leatherGrain + leatherPores - 0.08 + wear;
+    color += vec3(strips * 0.15 + leatherSheen);
+  }
 
 
-    roughness = 0.7;
-    float a = roughness * roughness;
+  color = clamp(color, 0.0, 1.0);
 
 
-    float D = distributionGGX(NdotH, a);
-    float G = geometrySmith(NdotV, NdotL, roughness);
-    vec3 F = fresnelSchlick(VdotH, F0);
-    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+  // Lighting per material
+  vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
+  float nDotL = dot(normalize(v_worldNormal), lightDir);
 
 
-    float kD = 1.0 - max(max(F.r, F.g), F.b);
-    vec3 diffuse = kD * albedo;
+  float wrapAmount = isHelmet ? 0.12 : (isArmor ? 0.22 : 0.35);
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.20);
 
 
-    color = ambient * albedo + diffWrap * diffuse + specular * NdotL;
+  if (isHelmet) {
+    diff = pow(diff, 0.88);
   }
   }
 
 
-  // Final color clamp and alpha preserved
-  color = saturate(color);
+  color *= diff;
   FragColor = vec4(color, u_alpha);
   FragColor = vec4(color, u_alpha);
 }
 }

+ 100 - 13
assets/shaders/spearman_roman_republic.vert

@@ -6,27 +6,114 @@ layout(location = 2) in vec2 a_texCoord;
 
 
 uniform mat4 u_mvp;
 uniform mat4 u_mvp;
 uniform mat4 u_model;
 uniform mat4 u_model;
+uniform int u_materialId;
 
 
 out vec3 v_normal;
 out vec3 v_normal;
+out vec3 v_worldNormal;
+out vec3 v_tangent;
+out vec3 v_bitangent;
 out vec2 v_texCoord;
 out vec2 v_texCoord;
 out vec3 v_worldPos;
 out vec3 v_worldPos;
-out float v_armorLayer; // Distinguish armor pieces for Roman hastatus spearman
+out float v_armorLayer;
+out float v_bodyHeight;
+out float v_helmetDetail;
+out float v_steelWear;
+out float v_chainmailPhase;
+out float v_rivetPattern;
+out float v_leatherWear;
+
+float hash13(vec3 p) {
+  return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
+}
+
+vec3 fallbackUp(vec3 n) {
+  return (abs(n.y) > 0.92) ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
+}
 
 
 void main() {
 void main() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  vec3 position = a_position;
+  vec3 normal = a_normal;
+  
+  // Shield curving: bend flat rectangle into scutum curve (materialId=4)
+  if (u_materialId == 4) {
+    float curveRadius = 0.55;
+    float curveAmount = 0.45;
+    float angle = position.x * curveAmount;
+    
+    float curved_x = sin(angle) * curveRadius;
+    float curved_z = position.z + (1.0 - cos(angle)) * curveRadius;
+    position = vec3(curved_x, position.y, curved_z);
+    
+    normal = vec3(sin(angle) * normal.z + cos(angle) * normal.x,
+                  normal.y,
+                  cos(angle) * normal.z - sin(angle) * normal.x);
+  }
+  
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * normal);
+
+  // Build tangent space for normal mapping
+  vec3 t = normalize(cross(fallbackUp(worldNormal), worldNormal));
+  if (length(t) < 1e-4)
+    t = vec3(1.0, 0.0, 0.0);
+  t = normalize(t - worldNormal * dot(worldNormal, t));
+  vec3 b = normalize(cross(worldNormal, t));
+
+  vec4 modelPos = u_model * vec4(position, 1.0);
+  vec3 worldPos = modelPos.xyz;
+
+  // Heavy steel battle damage simulation
+  float dentSeed = hash13(worldPos * 0.82 + worldNormal * 0.28);
+  float hammerImpact = sin(worldPos.y * 16.5 + dentSeed * 18.84);
+  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0115); // Deeper dents
+  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.22, -worldNormal.x));
+  vec3 shearOffset = shearAxis * hammerImpact * 0.0042;
+
+  vec3 batteredPos = worldPos + dentOffset + shearOffset;
+  vec3 offsetPos = batteredPos + worldNormal * 0.006;
+
+  mat4 invModel = inverse(u_model);
+  vec4 localBattered = invModel * vec4(batteredPos, 1.0);
+  gl_Position = u_mvp * localBattered;
+
+  v_worldPos = offsetPos;
   v_texCoord = a_texCoord;
   v_texCoord = a_texCoord;
-  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for Roman republican hastati
-  // Upper body (helmet) = 0, Torso (pectorale/mail) = 1, Lower
-  // (belt/pteruges) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Pectorale (heart guard)/lorica hamata region
+  v_normal = worldNormal;
+  v_worldNormal = worldNormal;
+  v_tangent = t;
+  v_bitangent = b;
+
+  float height = offsetPos.y;
+  
+  // Armor layer detection - STRICT torso range only
+  if (height > 1.50) {
+    v_armorLayer = 0.0; // Heavy steel helmet
+  } else if (height > 0.85 && height <= 1.50) {
+    v_armorLayer = 1.0; // Light chainmail (pectorale) - TORSO ONLY
   } else {
   } else {
-    v_armorLayer = 2.0; // Military belt/pteruges region
+    v_armorLayer = 2.0; // Leather pteruges
   }
   }
 
 
-  gl_Position = u_mvp * vec4(a_position, 1.0);
+  // Body height normalization
+  float torsoMin = 0.55;
+  float torsoMax = 1.68;
+  v_bodyHeight = clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
+
+  // Heavy helmet detail attributes
+  float reinforcementBands = fract(height * 14.0);
+  float browBandRegion = smoothstep(1.48, 1.52, height) * smoothstep(1.56, 1.52, height);
+  float cheekGuardArea = smoothstep(1.45, 1.55, height) * smoothstep(1.65, 1.55, height);
+  v_helmetDetail = reinforcementBands * 0.4 + browBandRegion * 0.4 + cheekGuardArea * 0.2;
+
+  // Steel wear patterns (rust, scratches)
+  v_steelWear = dentSeed * (1.0 - v_bodyHeight * 0.3); // More wear on lower parts
+
+  // Chainmail ring phase for light armor
+  v_chainmailPhase = fract(offsetPos.x * 32.0 + offsetPos.z * 32.0 + offsetPos.y * 0.5);
+
+  // Rivet pattern for helmet
+  v_rivetPattern = step(0.96, fract(offsetPos.x * 22.0)) * step(0.94, fract(offsetPos.z * 18.0));
+
+  // Leather wear
+  v_leatherWear = hash13(offsetPos * 0.45 + worldNormal * 1.8) * (0.6 + v_bodyHeight * 0.4);
 }
 }

+ 131 - 104
assets/shaders/swordsman_roman_republic.frag

@@ -1,14 +1,24 @@
 #version 330 core
 #version 330 core
 
 
 in vec3 v_normal;
 in vec3 v_normal;
+in vec3 v_worldNormal;
+in vec3 v_tangent;
+in vec3 v_bitangent;
 in vec2 v_texCoord;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
 in vec3 v_worldPos;
-in float v_armorLayer; // Armor layer from vertex shader
+in float v_armorLayer;
+in float v_bodyHeight;
+in float v_helmetDetail;
+in float v_platePhase;
+in float v_segmentStress;
+in float v_rivetPattern;
+in float v_polishLevel;
 
 
 uniform sampler2D u_texture;
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform vec3 u_color;
 uniform bool u_useTexture;
 uniform bool u_useTexture;
 uniform float u_alpha;
 uniform float u_alpha;
+uniform int u_materialId;
 
 
 out vec4 FragColor;
 out vec4 FragColor;
 
 
@@ -29,34 +39,22 @@ float noise(vec2 p) {
   return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
   return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
 }
 }
 
 
-// Medieval plate armor articulation lines
+// Segmented armor plates (lorica segmentata)
 float armorPlates(vec2 p, float y) {
 float armorPlates(vec2 p, float y) {
-  // Horizontal articulation lines (overlapping plates)
   float plateY = fract(y * 6.5);
   float plateY = fract(y * 6.5);
   float plateLine = smoothstep(0.90, 0.98, plateY) * 0.12;
   float plateLine = smoothstep(0.90, 0.98, plateY) * 0.12;
-
-  // Brass rivet decorations
   float rivetX = fract(p.x * 18.0);
   float rivetX = fract(p.x * 18.0);
   float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
   float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
-  float rivetPattern = rivet * step(0.92, plateY) * 0.25; // Brass is brighter
-
+  float rivetPattern = rivet * step(0.92, plateY) * 0.25;
   return plateLine + rivetPattern;
   return plateLine + rivetPattern;
 }
 }
 
 
-// Chainmail texture pattern
-float chainmailRings(vec2 p) {
-  vec2 grid = fract(p * 35.0) - 0.5;
-  float ring = length(grid);
-  float ringPattern =
-      smoothstep(0.35, 0.30, ring) - smoothstep(0.25, 0.20, ring);
-
-  // Offset every other row for interlinked appearance
-  vec2 offsetGrid = fract(p * 35.0 + vec2(0.5, 0.0)) - 0.5;
-  float offsetRing = length(offsetGrid);
-  float offsetPattern =
-      smoothstep(0.35, 0.30, offsetRing) - smoothstep(0.25, 0.20, offsetRing);
-
-  return (ringPattern + offsetPattern) * 0.15;
+float pterugesStrips(vec2 p, float y) {
+  float stripX = fract(p.x * 9.0);
+  float strip = smoothstep(0.15, 0.20, stripX) - smoothstep(0.80, 0.85, stripX);
+  float leatherTex = noise(p * 18.0) * 0.35;
+  float hang = smoothstep(0.65, 0.45, y);
+  return strip * leatherTex * hang;
 }
 }
 
 
 void main() {
 void main() {
@@ -67,110 +65,139 @@ void main() {
 
 
   vec3 normal = normalize(v_normal);
   vec3 normal = normalize(v_normal);
   vec2 uv = v_worldPos.xz * 5.0;
   vec2 uv = v_worldPos.xz * 5.0;
-  float avgColor = (color.r + color.g + color.b) / 3.0;
-
-  // Detect material type by color tone
-  float colorHue =
-      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
-  bool isBrass =
-      (color.r > color.g * 1.15 && color.r > color.b * 1.2 && avgColor > 0.55);
+  
+  // 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);
+  
+  // Fallback to old layer system for non-armor meshes
+  if (u_materialId == 0) {
+    isHelmet = (v_armorLayer < 0.5);
+    isArmor = false;  // Body mesh should not get armor effects
+  }
+  
+  bool isLegs = (v_armorLayer >= 1.5);
 
 
-  // === MEDIEVAL KNIGHT MATERIALS ===
+  // === ROMAN SWORDSMAN (LEGIONARY) MATERIALS ===
 
 
-  // POLISHED STEEL PLATE (Great Helm, cuirass, pauldrons, rerebraces) - bright
-  // silvery
-  if (avgColor > 0.60 && !isBrass) {
-    // Mirror-polished steel finish
+  // HEAVY STEEL HELMET (galea - cool blue-grey steel)
+  if (isHelmet) {
+    // Polished steel finish with vertex polish level
     float brushedMetal = abs(sin(v_worldPos.y * 95.0)) * 0.02;
     float brushedMetal = abs(sin(v_worldPos.y * 95.0)) * 0.02;
-
-    // Battle wear: scratches and dents
-    float scratches = noise(uv * 35.0) * 0.018;
+    float scratches = noise(uv * 35.0) * 0.018 * (1.0 - v_polishLevel * 0.5);
     float dents = noise(uv * 8.0) * 0.025;
     float dents = noise(uv * 8.0) * 0.025;
-
-    // Plate articulation lines and rivets
-    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
-
-    // Strong specular reflections (polished metal)
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float fresnel = pow(1.0 - viewAngle, 1.8) * 0.35; // Bright rim lighting
-    float specular = pow(viewAngle, 12.0) * 0.55;     // Sharp mirror highlights
-
-    // Environmental reflections (sky dome)
-    float skyReflection = (normal.y * 0.5 + 0.5) * 0.12;
-
-    color += vec3(fresnel + skyReflection + specular * 1.8);
-    color += vec3(plates);
+    
+    // Use vertex-computed helmet detail
+    float bands = v_helmetDetail * 0.12;
+    float rivets = v_rivetPattern * 0.12;
+    
+    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+    float viewAngle = max(dot(normalize(v_worldNormal), V), 0.0);
+    float fresnel = pow(1.0 - viewAngle, 1.8) * 0.35;
+    float specular = pow(viewAngle, 12.0) * 0.55 * v_polishLevel;
+    float skyReflection = (v_worldNormal.y * 0.5 + 0.5) * 0.12;
+
+    color += vec3(fresnel + skyReflection + specular * 1.8 + bands + rivets);
     color += vec3(brushedMetal);
     color += vec3(brushedMetal);
     color -= vec3(scratches + dents * 0.4);
     color -= vec3(scratches + dents * 0.4);
   }
   }
-  // BRASS ACCENTS (rivets, buckles, crosses, decorations) - golden
-  else if (isBrass) {
-    // Warm metallic brass
-    float brassNoise = noise(uv * 22.0) * 0.025;
-    float patina = noise(uv * 6.0) * 0.08; // Age darkening
-
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float brassSheen = pow(viewAngle, 8.0) * 0.35;
-    float brassFresnel = pow(1.0 - viewAngle, 2.5) * 0.20;
-
-    color += vec3(brassSheen + brassFresnel);
-    color += vec3(brassNoise);
-    color -= vec3(patina * 0.5); // Darker in recesses
-  }
-  // CHAINMAIL AVENTAIL (hanging neck protection) - grey steel rings
-  else if (avgColor > 0.40 && avgColor <= 0.60) {
-    // Interlocked ring texture
-    float rings = chainmailRings(v_worldPos.xz);
-
-    // Chainmail has less shine than plate
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float chainSheen = pow(viewAngle, 6.0) * 0.18;
-
-    // Individual ring highlights
-    float ringHighlights = noise(uv * 30.0) * 0.12;
-
-    color += vec3(rings + chainSheen + ringHighlights);
-    color *= 1.0 - noise(uv * 12.0) * 0.08; // Slight darkening between rings
-  }
-  // HERALDIC SURCOAT (team-colored tabard over armor) - bright cloth
-  else if (avgColor > 0.25) {
-    // Rich fabric weave texture
-    float weaveX = sin(v_worldPos.x * 70.0);
-    float weaveZ = sin(v_worldPos.z * 70.0);
-    float weave = weaveX * weaveZ * 0.04;
-
-    // Embroidered cross emblem texture
-    float embroidery = noise(uv * 12.0) * 0.06;
-
-    // Fabric has soft sheen
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float fabricSheen = pow(1.0 - viewAngle, 6.0) * 0.08;
-
-    // Heraldic colors are vibrant
-    color *= 1.0 + noise(uv * 5.0) * 0.10 - 0.05;
-    color += vec3(weave + embroidery + fabricSheen);
+  // HEAVY SEGMENTED ARMOR (lorica segmentata - iconic Roman plate armor)
+  else if (isArmor) {
+    // FORCE polished steel base - segmentata is BRIGHT, REFLECTIVE armor
+    color = vec3(0.72, 0.78, 0.88);  // Bright steel base - NOT skin tone!
+    
+    vec2 armorUV = v_worldPos.xz * 5.5;
+    
+    // === HORIZONTAL PLATE BANDS - MUST BE OBVIOUS ===
+    // 6-7 clearly visible bands wrapping torso
+    float bandPattern = fract(v_platePhase);
+    
+    // STRONG band edges (plate separations)
+    float bandEdge = step(0.92, bandPattern) + step(bandPattern, 0.08);
+    float plateLine = bandEdge * 0.55;  // Much stronger
+    
+    // DEEP shadows between overlapping plates
+    float overlapShadow = smoothstep(0.90, 0.98, bandPattern) * 0.65;
+    
+    // Alternating plate brightness (polishing variation)
+    float plateBrightness = step(0.5, fract(v_platePhase * 0.5)) * 0.15;
+    
+    // === RIVETS - CLEARLY VISIBLE ===
+    // Large brass rivets along each band edge
+    float rivetX = fract(v_worldPos.x * 16.0);
+    float rivetY = fract(v_platePhase * 6.5);  // Align with bands
+    float rivet = smoothstep(0.45, 0.50, rivetX) * 
+                  smoothstep(0.55, 0.50, rivetX) *
+                  (step(0.92, rivetY) + step(rivetY, 0.08));
+    float brassRivets = rivet * 0.45;  // Much more visible
+    vec3 brassColor = vec3(0.95, 0.82, 0.45);  // Bright brass
+    
+    // === METALLIC FINISH ===
+    // Polished steel with strong reflections
+    float brushedMetal = abs(sin(v_worldPos.y * 75.0)) * 0.12;
+    
+    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+    float viewAngle = max(dot(normalize(v_worldNormal), V), 0.0);
+    
+    // VERY STRONG specular - legionary armor was highly polished
+    float plateSpecular = pow(viewAngle, 9.0) * 0.85 * v_polishLevel;
+    
+    // Metallic fresnel
+    float plateFresnel = pow(1.0 - viewAngle, 2.2) * 0.45;
+    
+    // Sky reflection
+    float skyReflect = (v_worldNormal.y * 0.5 + 0.5) * 0.35 * v_polishLevel;
+    
+    // === WEAR & DAMAGE ===
+    // Battle scratches
+    float scratches = noise(armorUV * 42.0) * 0.08 * (1.0 - v_polishLevel * 0.7);
+    
+    // Impact dents (front armor takes hits)
+    float frontFacing = smoothstep(-0.2, 0.7, v_worldNormal.z);
+    float dents = noise(armorUV * 6.0) * 0.12 * frontFacing;
+    
+    // Joint wear between plates
+    float jointWear = v_segmentStress * 0.25;
+
+    // Apply all plate effects - STRONG VISIBILITY
+    color += vec3(plateBrightness + plateSpecular + plateFresnel + 
+                  skyReflect + brushedMetal);
+    color -= vec3(plateLine * 0.4 + overlapShadow + scratches + dents * 0.5 + jointWear);
+    
+    // Add brass rivets with color
+    color = mix(color, brassColor, brassRivets);
+    
+    // Ensure segmentata is ALWAYS bright and visible
+    color = clamp(color, vec3(0.45), vec3(0.95));
   }
   }
-  // LEATHER/DARK ELEMENTS (straps, gloves, scabbard) - dark brown
-  else {
+  // LEATHER PTERUGES & BELT
+  else if (isLegs) {
     float leatherGrain = noise(uv * 10.0) * 0.15;
     float leatherGrain = noise(uv * 10.0) * 0.15;
+    float strips = pterugesStrips(v_worldPos.xz, v_bodyHeight);
     float wearMarks = noise(uv * 3.0) * 0.10;
     float wearMarks = noise(uv * 3.0) * 0.10;
+    
+    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
+    float viewAngle = max(dot(normalize(v_worldNormal), V), 0.0);
+    float leatherSheen = pow(1.0 - viewAngle, 4.5) * 0.10;
 
 
     color *= 1.0 + leatherGrain - 0.08 + wearMarks - 0.05;
     color *= 1.0 + leatherGrain - 0.08 + wearMarks - 0.05;
+    color += vec3(strips * 0.15 + leatherSheen);
   }
   }
 
 
   color = clamp(color, 0.0, 1.0);
   color = clamp(color, 0.0, 1.0);
 
 
-  // Lighting model - hard shadows for metal, soft for fabric
+  // Lighting per material
   vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
   vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
-  float nDotL = dot(normal, lightDir);
+  float nDotL = dot(normalize(v_worldNormal), lightDir);
 
 
-  // Metal = hard shadows, Fabric = soft wrap
-  float wrapAmount = (avgColor > 0.50) ? 0.08 : 0.30;
+  float wrapAmount = isHelmet ? 0.08 : (isArmor ? 0.10 : 0.30);
   float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.18);
   float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.18);
 
 
   // Extra contrast for polished steel
   // Extra contrast for polished steel
-  if (avgColor > 0.60 && !isBrass) {
-    diff = pow(diff, 0.85); // Sharper lighting falloff
+  if (isHelmet || isArmor) {
+    diff = pow(diff, 0.85);
   }
   }
 
 
   color *= diff;
   color *= diff;

+ 108 - 13
assets/shaders/swordsman_roman_republic.vert

@@ -6,27 +6,122 @@ layout(location = 2) in vec2 a_texCoord;
 
 
 uniform mat4 u_mvp;
 uniform mat4 u_mvp;
 uniform mat4 u_model;
 uniform mat4 u_model;
+uniform int u_materialId;
 
 
 out vec3 v_normal;
 out vec3 v_normal;
+out vec3 v_worldNormal;
+out vec3 v_tangent;
+out vec3 v_bitangent;
 out vec2 v_texCoord;
 out vec2 v_texCoord;
 out vec3 v_worldPos;
 out vec3 v_worldPos;
-out float
-    v_armorLayer; // Distinguish armor pieces for Roman legionary swordsman
+out float v_armorLayer;
+out float v_bodyHeight;
+out float v_helmetDetail;
+out float v_platePhase;
+out float v_segmentStress;
+out float v_rivetPattern;
+out float v_leatherWear;
+out float v_polishLevel;
+
+float hash13(vec3 p) {
+  return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
+}
+
+vec3 fallbackUp(vec3 n) {
+  return (abs(n.y) > 0.92) ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
+}
 
 
 void main() {
 void main() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  vec3 position = a_position;
+  vec3 normal = a_normal;
+  
+  // Shield curving: bend flat rectangle into scutum curve (materialId=4)
+  if (u_materialId == 4) {
+    float curveRadius = 0.55;  // Curve radius relative to shield width
+    float curveAmount = 0.45;  // How much to curve (±25 degrees)
+    float angle = position.x * curveAmount;  // X position drives curve angle
+    
+    // Bend position around Y axis (vertical shield)
+    float curved_x = sin(angle) * curveRadius;
+    float curved_z = position.z + (1.0 - cos(angle)) * curveRadius;
+    position = vec3(curved_x, position.y, curved_z);
+    
+    // Rotate normal to follow curved surface
+    normal = vec3(sin(angle) * normal.z + cos(angle) * normal.x, 
+                  normal.y,
+                  cos(angle) * normal.z - sin(angle) * normal.x);
+  }
+  
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * normal);
+
+  // Build tangent space
+  vec3 t = normalize(cross(fallbackUp(worldNormal), worldNormal));
+  if (length(t) < 1e-4)
+    t = vec3(1.0, 0.0, 0.0);
+  t = normalize(t - worldNormal * dot(worldNormal, t));
+  vec3 b = normalize(cross(worldNormal, t));
+
+  vec4 modelPos = u_model * vec4(position, 1.0);
+  vec3 worldPos = modelPos.xyz;
+
+  // Heavy battle damage for elite legionary equipment
+  float dentSeed = hash13(worldPos * 0.88 + worldNormal * 0.32);
+  float combatStress = sin(worldPos.y * 18.2 + dentSeed * 22.61);
+  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0105);
+  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.25, -worldNormal.x));
+  vec3 shearOffset = shearAxis * combatStress * 0.0038;
+
+  vec3 batteredPos = worldPos + dentOffset + shearOffset;
+  vec3 offsetPos = batteredPos + worldNormal * 0.0062;
+
+  mat4 invModel = inverse(u_model);
+  vec4 localBattered = invModel * vec4(batteredPos, 1.0);
+  gl_Position = u_mvp * localBattered;
+
+  v_worldPos = offsetPos;
   v_texCoord = a_texCoord;
   v_texCoord = a_texCoord;
-  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for Roman legionary equipment
-  // Upper body (galea) = 0, Torso (lorica segmentata) = 1, Lower (pteruges) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Galea (Roman helmet) region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Lorica segmentata (banded armor) region
+  v_normal = worldNormal;
+  v_worldNormal = worldNormal;
+  v_tangent = t;
+  v_bitangent = b;
+
+  float height = offsetPos.y;
+  
+  // Armor layer detection - segmentata ONLY on torso
+  if (height > 1.50) {
+    v_armorLayer = 0.0; // Heavy steel galea helmet
+  } else if (height > 0.90 && height <= 1.50) {
+    v_armorLayer = 1.0; // Heavy lorica segmentata - TORSO ONLY
   } else {
   } else {
-    v_armorLayer = 2.0; // Pteruges (leather strips)/cingulum region
+    v_armorLayer = 2.0; // Leather pteruges
   }
   }
 
 
-  gl_Position = u_mvp * vec4(a_position, 1.0);
+  // Body height normalization
+  float torsoMin = 0.55;
+  float torsoMax = 1.70;
+  v_bodyHeight = clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
+
+  // Heavy helmet attributes (galea)
+  float reinforcementBands = fract(height * 14.0);
+  float browBandRegion = smoothstep(1.48, 1.52, height) * smoothstep(1.56, 1.52, height);
+  float cheekGuardArea = smoothstep(1.42, 1.52, height) * smoothstep(1.68, 1.58, height);
+  v_helmetDetail = reinforcementBands * 0.35 + browBandRegion * 0.45 + cheekGuardArea * 0.2;
+
+  // Segmented armor plate phase (lorica segmentata)
+  v_platePhase = fract(height * 6.5); // Horizontal bands
+  
+  // Segment articulation stress
+  float localX = (invModel * vec4(offsetPos, 1.0)).x;
+  v_segmentStress = combatStress * (0.6 + 0.4 * sin(localX * 12.0));
+
+  // Rivet pattern (visible on both helmet and armor)
+  v_rivetPattern = step(0.96, fract(offsetPos.x * 18.0)) * 
+                   step(0.94, fract(v_platePhase * 1.0));
+
+  // Leather wear for pteruges
+  v_leatherWear = hash13(offsetPos * 0.48 + worldNormal * 2.1) * (0.55 + v_bodyHeight * 0.45);
+
+  // Polish level (higher on helmet and exposed plates)
+  v_polishLevel = 1.0 - dentSeed * 0.4 - v_bodyHeight * 0.2;
 }
 }

+ 66 - 1
game/units/troop_catalog.cpp

@@ -145,7 +145,7 @@ void TroopCatalog::register_defaults() {
 
 
   horse_swordsman.combat.health = 200;
   horse_swordsman.combat.health = 200;
   horse_swordsman.combat.max_health = 200;
   horse_swordsman.combat.max_health = 200;
-  horse_swordsman.combat.speed = 8.0F;
+  horse_swordsman.combat.speed = 4.F;
   horse_swordsman.combat.vision_range = 16.0F;
   horse_swordsman.combat.vision_range = 16.0F;
   horse_swordsman.combat.ranged_range = 1.5F;
   horse_swordsman.combat.ranged_range = 1.5F;
   horse_swordsman.combat.ranged_damage = 5;
   horse_swordsman.combat.ranged_damage = 5;
@@ -165,6 +165,39 @@ void TroopCatalog::register_defaults() {
   horse_swordsman.individuals_per_unit = 9;
   horse_swordsman.individuals_per_unit = 9;
   horse_swordsman.max_units_per_row = 3;
   horse_swordsman.max_units_per_row = 3;
 
 
+
+  TroopClass horse_archer{};
+  horse_archer.unit_type = Game::Units::TroopType::HorseArcher;
+  horse_archer.display_name = "Horse Archer";
+  horse_archer.production.cost = 120;
+  horse_archer.production.build_time = 9.0F;
+  horse_archer.production.priority = 12;
+  horse_archer.production.is_melee = false;
+
+  horse_archer.combat.health = 160;
+  horse_archer.combat.max_health = 160;
+  horse_archer.combat.speed = 3.0F;
+  horse_archer.combat.vision_range = 15.0F;
+  horse_archer.combat.ranged_range = 7.0F;
+  horse_archer.combat.ranged_damage = 12;
+  horse_archer.combat.ranged_cooldown = 2.2F;
+  horse_archer.combat.melee_range = 1.5F;
+  horse_archer.combat.melee_damage = 10;
+  horse_archer.combat.melee_cooldown = 1.0F;
+  horse_archer.combat.can_ranged = true;
+  horse_archer.combat.can_melee = true;
+
+  horse_archer.visuals.render_scale = 0.8F;
+  horse_archer.visuals.selection_ring_size = 2.0F;
+  horse_archer.visuals.selection_ring_ground_offset = 1.35F;
+  horse_archer.visuals.selection_ring_y_offset = 0.0F;
+  horse_archer.visuals.renderer_id = "troops/kingdom/horse_archer";
+
+  horse_archer.individuals_per_unit = 8;
+  horse_archer.max_units_per_row = 3;
+
+  register_class(std::move(horse_archer));
+
   register_class(std::move(horse_swordsman));
   register_class(std::move(horse_swordsman));
 
 
   TroopClass healer{};
   TroopClass healer{};
@@ -198,6 +231,38 @@ void TroopCatalog::register_defaults() {
   healer.max_units_per_row = 1;
   healer.max_units_per_row = 1;
 
 
   register_class(std::move(healer));
   register_class(std::move(healer));
+
+  TroopClass horse_spearman{};
+  horse_spearman.unit_type = Game::Units::TroopType::HorseSpearman;
+  horse_spearman.display_name = "Horse Spearman";
+  horse_spearman.production.cost = 130;
+  horse_spearman.production.build_time = 9.5F;
+  horse_spearman.production.priority = 13;
+  horse_spearman.production.is_melee = true;
+
+  horse_spearman.combat.health = 180;
+  horse_spearman.combat.max_health = 180;
+  horse_spearman.combat.speed = 3.0F;
+  horse_spearman.combat.vision_range = 15.0F;
+  horse_spearman.combat.ranged_range = 2.5F;
+  horse_spearman.combat.ranged_damage = 9;
+  horse_spearman.combat.ranged_cooldown = 1.8F;
+  horse_spearman.combat.melee_range = 2.2F;
+  horse_spearman.combat.melee_damage = 20;
+  horse_spearman.combat.melee_cooldown = 0.9F;
+  horse_spearman.combat.can_ranged = false;
+  horse_spearman.combat.can_melee = true;
+
+  horse_spearman.visuals.render_scale = 0.8F;
+  horse_spearman.visuals.selection_ring_size = 2.0F;
+  horse_spearman.visuals.selection_ring_ground_offset = 1.35F;
+  horse_spearman.visuals.selection_ring_y_offset = 0.0F;
+  horse_spearman.visuals.renderer_id = "troops/kingdom/horse_spearman";
+
+  horse_spearman.individuals_per_unit = 8;
+  horse_spearman.max_units_per_row = 3;
+
+  register_class(std::move(horse_spearman));
 }
 }
 
 
 } // namespace Game::Units
 } // namespace Game::Units

+ 1 - 0
render/draw_queue.h

@@ -32,6 +32,7 @@ struct MeshCmd {
   QMatrix4x4 mvp;
   QMatrix4x4 mvp;
   QVector3D color{1, 1, 1};
   QVector3D color{1, 1, 1};
   float alpha = 1.0F;
   float alpha = 1.0F;
+  int materialId = 0;  // 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
   class Shader *shader = nullptr;
   class Shader *shader = nullptr;
 };
 };
 
 

+ 0 - 16
render/entity/nations/carthage/swordsman_renderer.cpp

@@ -383,22 +383,6 @@ void registerKnightRenderer(Render::GL::EntityRendererRegistry &registry) {
           scene_renderer->setCurrentShader(nullptr);
           scene_renderer->setCurrentShader(nullptr);
         }
         }
       });
       });
-  registry.register_renderer(
-      "troops/carthage/swordsman", [](const DrawContext &ctx, ISubmitter &out) {
-        static KnightRenderer const static_renderer;
-        Shader *swordsman_shader = nullptr;
-        if (ctx.backend != nullptr) {
-          swordsman_shader = ctx.backend->shader(QStringLiteral("swordsman"));
-        }
-        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) && (swordsman_shader != nullptr)) {
-          scene_renderer->setCurrentShader(swordsman_shader);
-        }
-        static_renderer.render(ctx, out);
-        if (scene_renderer != nullptr) {
-          scene_renderer->setCurrentShader(nullptr);
-        }
-      });
 }
 }
 
 
 } // namespace Render::GL::Carthage
 } // namespace Render::GL::Carthage

+ 0 - 16
render/entity/nations/kingdom/swordsman_renderer.cpp

@@ -383,22 +383,6 @@ void registerKnightRenderer(Render::GL::EntityRendererRegistry &registry) {
           scene_renderer->setCurrentShader(nullptr);
           scene_renderer->setCurrentShader(nullptr);
         }
         }
       });
       });
-  registry.register_renderer(
-      "troops/kingdom/swordsman", [](const DrawContext &ctx, ISubmitter &out) {
-        static KnightRenderer const static_renderer;
-        Shader *swordsman_shader = nullptr;
-        if (ctx.backend != nullptr) {
-          swordsman_shader = ctx.backend->shader(QStringLiteral("swordsman"));
-        }
-        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) && (swordsman_shader != nullptr)) {
-          scene_renderer->setCurrentShader(swordsman_shader);
-        }
-        static_renderer.render(ctx, out);
-        if (scene_renderer != nullptr) {
-          scene_renderer->setCurrentShader(nullptr);
-        }
-      });
 }
 }
 
 
 } // namespace Render::GL::Kingdom
 } // namespace Render::GL::Kingdom

+ 1 - 1
render/entity/nations/roman/spearman_renderer.cpp

@@ -239,7 +239,7 @@ public:
                   const HumanoidAnimationContext &anim,
                   const HumanoidAnimationContext &anim,
                   ISubmitter &out) const override {
                   ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
     auto &registry = EquipmentRegistry::instance();
-    auto armor = registry.get(EquipmentCategory::Armor, "roman_heavy_armor");
+    auto armor = registry.get(EquipmentCategory::Armor, "roman_light_armor");
     if (armor) {
     if (armor) {
       armor->render(ctx, pose.body_frames, v.palette, anim, out);
       armor->render(ctx, pose.body_frames, v.palette, anim, out);
     }
     }

+ 7 - 17
render/entity/nations/roman/swordsman_renderer.cpp

@@ -97,8 +97,14 @@ struct KnightExtras {
 
 
 class KnightRenderer : public HumanoidRendererBase {
 class KnightRenderer : public HumanoidRendererBase {
 public:
 public:
+  // Swordsman-specific proportions
+  static constexpr float kShoulderWidth = 1.15F; // Lower than default
+  static constexpr float kTorsoScale = 0.98F;    // Slimmer torso
+  static constexpr float kArmScale = 0.92F;      // Slimmer arms
+
   auto get_proportion_scaling() const -> QVector3D override {
   auto get_proportion_scaling() const -> QVector3D override {
-    return {1.40F, 1.05F, 1.10F};
+    // Use swordsman-specific parameters
+    return {kShoulderWidth, kTorsoScale, kArmScale};
   }
   }
 
 
 private:
 private:
@@ -383,22 +389,6 @@ void registerKnightRenderer(Render::GL::EntityRendererRegistry &registry) {
           scene_renderer->setCurrentShader(nullptr);
           scene_renderer->setCurrentShader(nullptr);
         }
         }
       });
       });
-  registry.register_renderer(
-      "troops/roman/swordsman", [](const DrawContext &ctx, ISubmitter &out) {
-        static KnightRenderer const static_renderer;
-        Shader *swordsman_shader = nullptr;
-        if (ctx.backend != nullptr) {
-          swordsman_shader = ctx.backend->shader(QStringLiteral("swordsman"));
-        }
-        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) && (swordsman_shader != nullptr)) {
-          scene_renderer->setCurrentShader(swordsman_shader);
-        }
-        static_renderer.render(ctx, out);
-        if (scene_renderer != nullptr) {
-          scene_renderer->setCurrentShader(nullptr);
-        }
-      });
 }
 }
 
 
 } // namespace Render::GL::Roman
 } // namespace Render::GL::Roman

+ 123 - 233
render/equipment/armor/roman_armor.cpp

@@ -26,173 +26,88 @@ void RomanHeavyArmorRenderer::render(const DrawContext &ctx,
   (void)anim;
   (void)anim;
 
 
   const AttachmentFrame &torso = frames.torso;
   const AttachmentFrame &torso = frames.torso;
+  const AttachmentFrame &waist = frames.waist;
+  const AttachmentFrame &head = frames.head;
+
   if (torso.radius <= 0.0F) {
   if (torso.radius <= 0.0F) {
     return;
     return;
   }
   }
 
 
   using HP = HumanProportions;
   using HP = HumanProportions;
 
 
+  // Lorica Segmentata - polished steel with cool blue-grey tint
   QVector3D const steel_color =
   QVector3D const steel_color =
-      saturate_color(palette.metal * QVector3D(0.92F, 0.94F, 0.98F));
+      saturate_color(palette.metal * QVector3D(0.88F, 0.92F, 1.08F));
   QVector3D const brass_color =
   QVector3D const brass_color =
-      saturate_color(palette.metal * QVector3D(1.2F, 1.0F, 0.6F));
+      saturate_color(palette.metal * QVector3D(1.25F, 1.05F, 0.62F));
   QVector3D const leather_color =
   QVector3D const leather_color =
-      saturate_color(palette.leather * QVector3D(0.6F, 0.4F, 0.3F));
-
-  const QVector3D &origin = torso.origin;
-  const QVector3D &right = torso.right;
-  const QVector3D &up = torso.up;
-  const QVector3D &forward = torso.forward;
-
-  float const torso_r = torso.radius * 1.08F;
-  float const shoulder_width = torso_r * 1.18F;
-  float const chest_depth_front = torso_r * 1.15F;
-  float const chest_depth_back = torso_r * 0.82F;
-
-  constexpr int num_bands = 8;
-  float const y_top = HP::SHOULDER_Y;
-  float const y_bottom = HP::WAIST_Y + 0.05F;
-  float const band_height = (y_top - y_bottom) / static_cast<float>(num_bands);
-
-  constexpr int segments = 20;
-  constexpr float pi = std::numbers::pi_v<float>;
-
-  for (int band = 0; band < num_bands; ++band) {
-    float const y_band_top = y_top - static_cast<float>(band) * band_height;
-    float const y_band_bottom = y_band_top - band_height * 0.92F;
-
-    float const t =
-        static_cast<float>(band) / static_cast<float>(num_bands - 1);
-    float const width_scale = shoulder_width * (1.0F - t * 0.18F);
-
-    QVector3D band_color =
-        steel_color * (1.0F - static_cast<float>(band % 2) * 0.05F);
-
-    auto getRadius = [&](float angle) -> float {
-      float const cos_a = std::cos(angle);
-      float depth = (cos_a > 0.0F) ? chest_depth_front : chest_depth_back;
-      return width_scale * depth * (std::abs(cos_a) * 0.25F + 0.75F);
-    };
-
-    for (int i = 0; i < segments; ++i) {
-      float const angle1 = (static_cast<float>(i) / segments) * 2.0F * pi;
-      float const angle2 = (static_cast<float>(i + 1) / segments) * 2.0F * pi;
-
-      float const cos1 = std::cos(angle1);
-      float const sin1 = std::sin(angle1);
-      float const cos2 = std::cos(angle2);
-      float const sin2 = std::sin(angle2);
-
-      float const r1 = getRadius(angle1);
-      float const r2 = getRadius(angle2);
-
-      QVector3D const p1_top = origin + right * (r1 * sin1) +
-                               forward * (r1 * cos1) +
-                               up * (y_band_top - origin.y());
-      QVector3D const p2_top = origin + right * (r2 * sin2) +
-                               forward * (r2 * cos2) +
-                               up * (y_band_top - origin.y());
-
-      QVector3D const p1_bot = origin + right * (r1 * sin1) +
-                               forward * (r1 * cos1) +
-                               up * (y_band_bottom - origin.y());
-      QVector3D const p2_bot = origin + right * (r2 * sin2) +
-                               forward * (r2 * cos2) +
-                               up * (y_band_bottom - origin.y());
-
-      float const seg_r = (r1 + r2) * 0.5F * 0.04F;
-      submitter.mesh(getUnitCylinder(),
-                     cylinderBetween(ctx.model, p1_top, p1_bot, seg_r),
-                     band_color, nullptr, 1.0F);
-      submitter.mesh(getUnitCylinder(),
-                     cylinderBetween(ctx.model, p2_top, p2_bot, seg_r),
-                     band_color, nullptr, 1.0F);
-      submitter.mesh(getUnitCylinder(),
-                     cylinderBetween(ctx.model, p1_top, p2_top, seg_r),
-                     band_color, nullptr, 1.0F);
-      submitter.mesh(getUnitCylinder(),
-                     cylinderBetween(ctx.model, p1_bot, p2_bot, seg_r),
-                     band_color, nullptr, 1.0F);
-    }
-
-    if (band % 2 == 0) {
-      for (int i = 0; i < 6; ++i) {
-        float const angle = (static_cast<float>(i) / 6.0F) * 2.0F * pi;
-        float const r = getRadius(angle) * 0.95F;
-        float const y_rivet = (y_band_top + y_band_bottom) * 0.5F;
-        QVector3D rivet_pos = origin + right * (r * std::sin(angle)) +
-                              forward * (r * std::cos(angle)) +
-                              up * (y_rivet - origin.y());
-
-        QMatrix4x4 m = ctx.model;
-        m.translate(rivet_pos);
-        m.scale(0.01F);
-        submitter.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
-      }
-    }
+      saturate_color(palette.leather * QVector3D(0.58F, 0.38F, 0.26F));
+
+  auto safeNormal = [](const QVector3D &v, const QVector3D &fallback) {
+    return (v.lengthSquared() > 1e-6F) ? v.normalized() : fallback;
+  };
+
+  QVector3D up = safeNormal(torso.up, QVector3D(0.0F, 1.0F, 0.0F));
+  QVector3D right = safeNormal(torso.right, QVector3D(1.0F, 0.0F, 0.0F));
+  QVector3D forward = safeNormal(torso.forward, QVector3D(0.0F, 0.0F, 1.0F));
+  QVector3D waist_up = safeNormal(waist.up, up);
+  QVector3D head_up = safeNormal(head.up, up);
+
+  float const torso_r = torso.radius;
+  float const torso_depth =
+      (torso.depth > 0.0F) ? torso.depth : torso_r * 0.75F;
+  auto depth_scale_for = [&](float base) {
+    float const ratio = torso_depth / std::max(0.001F, torso_r);
+    return std::max(0.08F, base * ratio);
+  };
+  float const waist_r =
+      waist.radius > 0.0F ? waist.radius : torso.radius * 0.88F;
+  float const head_r = head.radius > 0.0F ? head.radius : torso.radius * 0.58F;
+
+  // Lorica segmentata extends from shoulders to waist
+  QVector3D top = torso.origin + up * (torso_r * 0.48F);
+  QVector3D head_guard = head.origin - head_up * (head_r * 1.30F);
+  if (QVector3D::dotProduct(top - head_guard, up) > 0.0F) {
+    top = head_guard - up * (torso_r * 0.05F);
   }
   }
 
 
+  QVector3D bottom =
+      waist.origin + waist_up * (waist_r * 0.08F) - forward * (torso_r * 0.01F);
+
+  // MAIN SEGMENTED PLATE ARMOR - follows torso contours
+  QMatrix4x4 plates = cylinderBetween(ctx.model, top, bottom, torso_r * 1.02F);
+  plates.scale(1.05F, 1.0F, depth_scale_for(0.86F));
+  submitter.mesh(getUnitTorso(), plates, steel_color, nullptr, 1.0F, 1);  // materialId=1 (armor)
+
+  // Shoulder guards (pteruges) - 2 overlapping plates per side
   auto renderShoulderGuard = [&](const QVector3D &shoulder_pos,
   auto renderShoulderGuard = [&](const QVector3D &shoulder_pos,
                                  const QVector3D &outward) {
                                  const QVector3D &outward) {
-    constexpr int shoulder_segments = 4;
-    float const upper_arm_r = HP::UPPER_ARM_R;
-
-    for (int i = 0; i < shoulder_segments; ++i) {
-      float const seg_y = shoulder_pos.y() - static_cast<float>(i) * 0.04F;
-      float const seg_r = upper_arm_r * (2.3F - static_cast<float>(i) * 0.15F);
-      QVector3D seg_pos =
-          shoulder_pos + outward * (0.03F + static_cast<float>(i) * 0.01F);
-      seg_pos.setY(seg_y);
-
-      submitter.mesh(getUnitSphere(), sphereAt(ctx.model, seg_pos, seg_r),
-                     steel_color * (1.0F - static_cast<float>(i) * 0.04F),
-                     nullptr, 1.0F);
-
-      if (i < 3) {
-        QMatrix4x4 m = ctx.model;
-        m.translate(seg_pos + QVector3D(0, 0.02F, 0.04F));
-        m.scale(0.008F);
-        submitter.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
-      }
-    }
+    // Upper shoulder plate
+    QVector3D upper_pos = shoulder_pos + outward * 0.03F + forward * 0.01F;
+    QMatrix4x4 upper = ctx.model;
+    upper.translate(upper_pos);
+    upper.scale(HP::UPPER_ARM_R * 1.90F, HP::UPPER_ARM_R * 0.42F,
+                HP::UPPER_ARM_R * 1.65F);
+    submitter.mesh(getUnitSphere(), upper, steel_color * 0.98F, nullptr, 1.0F, 1);  // materialId=1
+
+    // Lower shoulder plate with brass trim
+    QVector3D lower_pos = upper_pos - up * 0.06F + outward * 0.02F;
+    QMatrix4x4 lower = ctx.model;
+    lower.translate(lower_pos);
+    lower.scale(HP::UPPER_ARM_R * 1.68F, HP::UPPER_ARM_R * 0.38F,
+                HP::UPPER_ARM_R * 1.48F);
+    submitter.mesh(getUnitSphere(), lower, steel_color * 0.94F, nullptr, 1.0F, 1);  // materialId=1
+
+    // Brass rivet on shoulder
+    QMatrix4x4 rivet = ctx.model;
+    rivet.translate(upper_pos + forward * 0.04F);
+    rivet.scale(0.012F);
+    submitter.mesh(getUnitSphere(), rivet, brass_color, nullptr, 1.0F);
   };
   };
 
 
   renderShoulderGuard(frames.shoulder_l.origin, -right);
   renderShoulderGuard(frames.shoulder_l.origin, -right);
   renderShoulderGuard(frames.shoulder_r.origin, right);
   renderShoulderGuard(frames.shoulder_r.origin, right);
 
 
-  const AttachmentFrame &waist = frames.waist;
-  auto safeDir = [](const QVector3D &axis, const QVector3D &fallback) {
-    if (axis.lengthSquared() > 1e-6F) {
-      return axis.normalized();
-    }
-    QVector3D fb = fallback;
-    if (fb.lengthSquared() < 1e-6F) {
-      fb = QVector3D(0.0F, 1.0F, 0.0F);
-    }
-    return fb.normalized();
-  };
-
-  QVector3D const waist_center =
-      (waist.radius > 0.0F) ? waist.origin
-                            : QVector3D(origin.x(), HP::WAIST_Y, origin.z());
-  QVector3D const waist_up = safeDir(waist.up, up);
-  float const belt_height =
-      (waist.radius > 0.0F ? waist.radius : torso.radius) * 0.24F;
-  QVector3D const belt_top = waist_center + waist_up * (0.5F * belt_height);
-  QVector3D const belt_bot = waist_center - waist_up * (0.5F * belt_height);
-  float const belt_radius =
-      (waist.radius > 0.0F ? waist.radius : torso.radius * 0.95F) * 1.12F;
-
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, belt_bot, belt_top, belt_radius),
-                 leather_color * 0.92F, nullptr, 1.0F);
-
-  QVector3D const trim_top = belt_top + waist_up * 0.012F;
-  QVector3D const trim_bot = belt_bot - waist_up * 0.012F;
-  submitter.mesh(
-      getUnitCylinder(),
-      cylinderBetween(ctx.model, trim_bot, trim_top, belt_radius * 1.03F),
-      brass_color * 0.95F, nullptr, 1.0F);
 }
 }
 
 
 void RomanLightArmorRenderer::render(const DrawContext &ctx,
 void RomanLightArmorRenderer::render(const DrawContext &ctx,
@@ -203,100 +118,75 @@ void RomanLightArmorRenderer::render(const DrawContext &ctx,
   (void)anim;
   (void)anim;
 
 
   const AttachmentFrame &torso = frames.torso;
   const AttachmentFrame &torso = frames.torso;
+  const AttachmentFrame &waist = frames.waist;
+  const AttachmentFrame &head = frames.head;
+
   if (torso.radius <= 0.0F) {
   if (torso.radius <= 0.0F) {
     return;
     return;
   }
   }
 
 
   using HP = HumanProportions;
   using HP = HumanProportions;
 
 
-  QVector3D const steel_color =
-      saturate_color(palette.metal * QVector3D(0.9F, 0.93F, 0.97F));
+  // Chainmail (lorica hamata) - dull steel/iron color
+  QVector3D const chainmail_color =
+      saturate_color(palette.metal * QVector3D(0.68F, 0.72F, 0.78F));
   QVector3D const leather_color =
   QVector3D const leather_color =
-      saturate_color(palette.leather * QVector3D(0.55F, 0.38F, 0.28F));
-
-  const QVector3D &origin = torso.origin;
-  const QVector3D &right = torso.right;
-  const QVector3D &up = torso.up;
-  const QVector3D &forward = torso.forward;
-
-  float const torso_r = torso.radius * 1.04F;
-  float const shoulder_width = torso_r * 1.12F;
-  float const chest_depth_front = torso_r * 1.10F;
-  float const chest_depth_back = torso_r * 0.86F;
-
-  constexpr int num_bands = 4;
-  float const y_top = HP::SHOULDER_Y - 0.02F;
-  float const y_bottom = HP::CHEST_Y;
-  float const band_height = (y_top - y_bottom) / static_cast<float>(num_bands);
-
-  constexpr int segments = 16;
-  constexpr float pi = std::numbers::pi_v<float>;
-
-  for (int band = 0; band < num_bands; ++band) {
-    float const y_band_top = y_top - static_cast<float>(band) * band_height;
-    float const y_band_bottom = y_band_top - band_height * 0.90F;
-
-    float const t =
-        static_cast<float>(band) / static_cast<float>(num_bands - 1);
-    float const width_scale = shoulder_width * (1.0F - t * 0.12F);
-
-    QVector3D band_color =
-        steel_color * (1.0F - static_cast<float>(band % 2) * 0.04F);
-
-    for (int i = 0; i < segments; ++i) {
-      float const angle1 = (static_cast<float>(i) / segments) * 2.0F * pi;
-      float const angle2 = (static_cast<float>(i + 1) / segments) * 2.0F * pi;
-
-      auto getRadius = [&](float angle) -> float {
-        float const cos_a = std::cos(angle);
-        float depth = (cos_a > 0.0F) ? chest_depth_front : chest_depth_back;
-        return width_scale * depth * (std::abs(cos_a) * 0.3F + 0.7F);
-      };
-
-      float const r1 = getRadius(angle1);
-      float const r2 = getRadius(angle2);
-
-      QVector3D const p1_top = origin + right * (r1 * std::sin(angle1)) +
-                               forward * (r1 * std::cos(angle1)) +
-                               up * (y_band_top - origin.y());
-      QVector3D const p1_bot = origin + right * (r1 * std::sin(angle1)) +
-                               forward * (r1 * std::cos(angle1)) +
-                               up * (y_band_bottom - origin.y());
-
-      float const seg_r = r1 * 0.035F;
-      submitter.mesh(getUnitCylinder(),
-                     cylinderBetween(ctx.model, p1_top, p1_bot, seg_r),
-                     band_color, nullptr, 1.0F);
-    }
-  }
+      saturate_color(palette.leather * QVector3D(0.52F, 0.36F, 0.24F));
+  QVector3D const steel_color =
+      saturate_color(palette.metal * QVector3D(0.82F, 0.86F, 0.94F));
 
 
-  const AttachmentFrame &waist = frames.waist;
-  auto safeDir = [](const QVector3D &axis, const QVector3D &fallback) {
-    if (axis.lengthSquared() > 1e-6F) {
-      return axis.normalized();
-    }
-    QVector3D fb = fallback;
-    if (fb.lengthSquared() < 1e-6F) {
-      fb = QVector3D(0.0F, 1.0F, 0.0F);
-    }
-    return fb.normalized();
+  auto safeNormal = [](const QVector3D &v, const QVector3D &fallback) {
+    return (v.lengthSquared() > 1e-6F) ? v.normalized() : fallback;
+  };
+
+  QVector3D up = safeNormal(torso.up, QVector3D(0.0F, 1.0F, 0.0F));
+  QVector3D right = safeNormal(torso.right, QVector3D(1.0F, 0.0F, 0.0F));
+  QVector3D forward = safeNormal(torso.forward, QVector3D(0.0F, 0.0F, 1.0F));
+  QVector3D waist_up = safeNormal(waist.up, up);
+  QVector3D head_up = safeNormal(head.up, up);
+
+  float const torso_r = torso.radius;
+  float const torso_depth =
+      (torso.depth > 0.0F) ? torso.depth : torso_r * 0.75F;
+  auto depth_scale_for = [&](float base) {
+    float const ratio = torso_depth / std::max(0.001F, torso_r);
+    return std::max(0.08F, base * ratio);
   };
   };
+  float const waist_r =
+      waist.radius > 0.0F ? waist.radius : torso.radius * 0.86F;
+  float const head_r = head.radius > 0.0F ? head.radius : torso.radius * 0.58F;
+
+  // Chainmail extends from shoulders to mid-thigh
+  QVector3D top = torso.origin + up * (torso_r * 0.42F);
+  QVector3D head_guard = head.origin - head_up * (head_r * 1.35F);
+  if (QVector3D::dotProduct(top - head_guard, up) > 0.0F) {
+    top = head_guard - up * (torso_r * 0.06F);
+  }
+
+  // Chainmail hangs lower than segmentata
+  QVector3D bottom = waist.origin - waist_up * (waist_r * 0.15F);
+
+  // MAIN CHAINMAIL LAYER - loose-fitting, follows body contours
+  QMatrix4x4 chainmail =
+      cylinderBetween(ctx.model, top, bottom, torso_r * 0.98F);
+  chainmail.scale(1.02F, 1.0F, depth_scale_for(0.82F));
+  submitter.mesh(getUnitTorso(), chainmail, chainmail_color, nullptr, 1.0F, 1);  // materialId=1 (armor)
+
+  // PECTORALE (chest reinforcement plate) - distinguishing feature
+  // Rectangular steel/bronze plate worn over chainmail on chest
+  QVector3D chest_center = torso.origin + up * (torso_r * 0.12F) +
+                          forward * (torso_depth * 0.48F);
+  float const plate_width = torso_r * 0.85F;
+  float const plate_height = torso_r * 0.65F;
+  float const plate_depth = torso_r * 0.18F;
+
+  QMatrix4x4 pectorale = ctx.model;
+  pectorale.translate(chest_center);
+  QQuaternion chest_rot = QQuaternion::fromDirection(forward, up);
+  pectorale.rotate(chest_rot.conjugated());
+  pectorale.scale(plate_width, plate_height, plate_depth);
+  submitter.mesh(getUnitSphere(), pectorale, steel_color, nullptr, 1.0F, 1);  // materialId=1 (armor plate)
 
 
-  QVector3D const waist_center =
-      (waist.radius > 0.0F)
-          ? waist.origin
-          : QVector3D(origin.x(), HP::WAIST_Y + 0.02F, origin.z());
-  QVector3D const waist_up = safeDir(waist.up, up);
-  float const belt_height =
-      (waist.radius > 0.0F ? waist.radius : torso.radius) * 0.18F;
-  QVector3D const belt_top = waist_center + waist_up * (0.5F * belt_height);
-  QVector3D const belt_bot = waist_center - waist_up * (0.5F * belt_height);
-  float const belt_r =
-      (waist.radius > 0.0F ? waist.radius : torso.radius) * 1.02F;
-
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, belt_top, belt_bot, belt_r),
-                 leather_color, nullptr, 1.0F);
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 36 - 119
render/equipment/helmets/roman_heavy_helmet.cpp

@@ -32,141 +32,58 @@ void RomanHeavyHelmetRenderer::render(const DrawContext &ctx,
     return HumanoidRendererBase::frameLocalPosition(head, normalized);
     return HumanoidRendererBase::frameLocalPosition(head, normalized);
   };
   };
 
 
+  // Steel with cooler blue-grey tint for heavy helmet (distinguished from light bronze)
   QVector3D const steel_color =
   QVector3D const steel_color =
-      saturate_color(palette.metal * QVector3D(0.95F, 0.96F, 1.0F));
-  QVector3D const brass_color =
-      saturate_color(palette.metal * QVector3D(1.3F, 1.1F, 0.7F));
-  QVector3D const visor_color(0.1F, 0.1F, 0.1F);
-
-  float const helm_r = head_r * 1.15F;
-  float const helm_ratio = helm_r / head_r;
-
-  QVector3D const helm_bot = headPoint(QVector3D(0.0F, -0.20F, 0.0F));
-  QVector3D const helm_top = headPoint(QVector3D(0.0F, 1.40F, 0.0F));
-
+      saturate_color(palette.metal * QVector3D(0.88F, 0.92F, 1.08F));
+  QVector3D const brass_accent =
+      saturate_color(palette.metal * QVector3D(1.4F, 1.15F, 0.65F));
+  
+  float const helm_r = head_r * 1.18F;  // Slightly larger than light helmet
+
+  // Main helmet bowl - shader will add detail
+  QVector3D const helm_bot = headPoint(QVector3D(0.0F, -0.25F, 0.0F));
+  QVector3D const helm_top = headPoint(QVector3D(0.0F, 1.42F, 0.0F));
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
                  cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
                  cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
-                 steel_color, nullptr, 1.0F);
-
-  QVector3D const cap_top = headPoint(QVector3D(0.0F, 1.48F, 0.0F));
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
-                 steel_color * 1.05F, nullptr, 1.0F);
-
-  auto ring = [&](float y_offset, const QVector3D &col) {
-    QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
-    float const height = head_r * 0.015F;
-    QVector3D const a = center + head.up * (height * 0.5F);
-    QVector3D const b = center - head.up * (height * 0.5F);
-    submitter.mesh(getUnitCylinder(),
-                   cylinderBetween(ctx.model, a, b, helm_r * 1.02F), col,
-                   nullptr, 1.0F);
-  };
-
-  ring(1.25F, steel_color * 1.08F);
-  ring(0.50F, steel_color * 1.08F);
-  ring(-0.05F, steel_color * 1.08F);
-
-  QVector3D const brow_center = headPoint(QVector3D(0.0F, 0.15F, 0.0F));
-  QVector3D const brow_top = brow_center + head.up * 0.03F;
-  QVector3D const brow_bot = brow_center - head.up * 0.02F;
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, brow_bot, brow_top, helm_r * 1.08F),
-                 brass_color * 0.95F, nullptr, 1.0F);
-
-  float const visor_y = 0.15F;
-  float const visor_forward = helm_r * 0.72F;
-  float const visor_forward_norm = visor_forward / head_r;
-  QVector3D const visor_center =
-      headPoint(QVector3D(0.0F, visor_y, visor_forward_norm));
-
-  QVector3D const visor_hl = visor_center - head.right * (helm_r * 0.35F);
-  QVector3D const visor_hr = visor_center + head.right * (helm_r * 0.35F);
-  submitter.mesh(
-      getUnitCylinder(),
-      cylinderBetween(ctx.model, visor_hl, visor_hr, head_r * 0.012F),
-      visor_color, nullptr, 1.0F);
-
-  QVector3D const visor_vt = visor_center + head.up * (helm_r * 0.25F);
-  QVector3D const visor_vb = visor_center - head.up * (helm_r * 0.25F);
-  submitter.mesh(
-      getUnitCylinder(),
-      cylinderBetween(ctx.model, visor_vb, visor_vt, head_r * 0.012F),
-      visor_color, nullptr, 1.0F);
-
-  auto draw_breathing_hole = [&](float x_sign, float y_offset) {
-    QVector3D const pos = headPoint(QVector3D(
-        x_sign * 0.50F * helm_ratio, y_offset, visor_forward_norm * 0.97F));
-    QMatrix4x4 m = ctx.model;
-    m.translate(pos);
-    m.scale(0.010F);
-    submitter.mesh(getUnitSphere(), m, visor_color, nullptr, 1.0F);
-  };
-
-  for (int i = 0; i < 4; ++i) {
-    draw_breathing_hole(1.0F, 0.05F - i * 0.10F);
-    draw_breathing_hole(-1.0F, 0.05F - i * 0.10F);
-  }
-
-  QVector3D const cross_center =
-      headPoint(QVector3D(0.0F, 0.60F, (helm_r * 0.75F) / head_r));
-
-  QVector3D const cross_h1 = cross_center - head.right * 0.04F;
-  QVector3D const cross_h2 = cross_center + head.right * 0.04F;
-  submitter.mesh(
-      getUnitCylinder(),
-      cylinderBetween(ctx.model, cross_h1, cross_h2, head_r * 0.008F),
-      brass_color, nullptr, 1.0F);
-
-  QVector3D const cross_v1 = cross_center - head.up * 0.04F;
-  QVector3D const cross_v2 = cross_center + head.up * 0.04F;
-  submitter.mesh(
-      getUnitCylinder(),
-      cylinderBetween(ctx.model, cross_v1, cross_v2, head_r * 0.008F),
-      brass_color, nullptr, 1.0F);
-
-  float const cheek_w = head_r * 0.52F;
-  QVector3D const cheek_top = headPoint(QVector3D(0.0F, 0.18F, 0.0F));
-  QVector3D const cheek_bot = headPoint(QVector3D(0.0F, -0.45F, 0.0F));
+                 steel_color, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
-  QVector3D const cheek_ltop =
-      cheek_top + head.right * (-cheek_w / head_r) + head.forward * 0.42F;
-  QVector3D const cheek_lbot = cheek_bot +
-                               head.right * (-cheek_w * 0.85F / head_r) +
-                               head.forward * 0.32F;
+  // Dome cap
+  QVector3D const cap_top = headPoint(QVector3D(0.0F, 1.52F, 0.0F));
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, cheek_lbot, cheek_ltop, 0.032F),
-                 steel_color * 0.94F, nullptr, 1.0F);
+                 cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.96F),
+                 steel_color * 1.06F, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
-  QVector3D const cheek_rtop =
-      cheek_top + head.right * (cheek_w / head_r) + head.forward * 0.42F;
-  QVector3D const cheek_rbot = cheek_bot +
-                               head.right * (cheek_w * 0.85F / head_r) +
-                               head.forward * 0.32F;
+  // Single brass brow band - shader will add rivets and detail
+  QVector3D const brow_center = headPoint(QVector3D(0.0F, 0.12F, 0.0F));
+  QVector3D const brow_top = brow_center + head.up * 0.035F;
+  QVector3D const brow_bot = brow_center - head.up * 0.025F;
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, cheek_rbot, cheek_rtop, 0.032F),
-                 steel_color * 0.94F, nullptr, 1.0F);
+                 cylinderBetween(ctx.model, brow_bot, brow_top, helm_r * 1.10F),
+                 brass_accent * 0.92F, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
-  QVector3D const neck_top = headPoint(QVector3D(0.0F, -0.10F, -1.02F));
-  QVector3D const neck_bot = headPoint(QVector3D(0.0F, -0.28F, -0.95F));
+  // Neck guard - shader adds segmented lamellae appearance
+  QVector3D const neck_top = headPoint(QVector3D(0.0F, -0.12F, -1.08F));
+  QVector3D const neck_bot = headPoint(QVector3D(0.0F, -0.35F, -1.02F));
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, neck_bot, neck_top, helm_r * 0.95F),
-                 steel_color * 0.92F, nullptr, 1.0F);
+                 cylinderBetween(ctx.model, neck_bot, neck_top, helm_r * 0.98F),
+                 steel_color * 0.88F, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
+  // Crest mount - minimal geometry, shader adds detail
   QVector3D const crest_base = cap_top;
   QVector3D const crest_base = cap_top;
-  QVector3D const crest_mid = crest_base + head.up * 0.08F;
-  QVector3D const crest_top = crest_mid + head.up * 0.15F;
+  QVector3D const crest_mid = crest_base + head.up * 0.10F;
+  QVector3D const crest_top = crest_mid + head.up * 0.18F;
 
 
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, crest_base, crest_mid, 0.020F),
-                 brass_color, nullptr, 1.0F);
+                 cylinderBetween(ctx.model, crest_base, crest_mid, 0.022F),
+                 brass_accent, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
+  // Red horsehair crest
   submitter.mesh(getUnitCone(),
   submitter.mesh(getUnitCone(),
-                 coneFromTo(ctx.model, crest_mid, crest_top, 0.048F),
-                 QVector3D(0.92F, 0.15F, 0.15F), nullptr, 1.0F);
+                 coneFromTo(ctx.model, crest_mid, crest_top, 0.052F),
+                 QVector3D(0.96F, 0.12F, 0.12F), nullptr, 1.0F, 0);  // materialId=0 (decoration)
 
 
-  submitter.mesh(getUnitSphere(), sphereAt(ctx.model, crest_top, 0.022F),
-                 brass_color, nullptr, 1.0F);
+  submitter.mesh(getUnitSphere(), sphereAt(ctx.model, crest_top, 0.024F),
+                 brass_accent, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 21 - 40
render/equipment/helmets/roman_light_helmet.cpp

@@ -32,80 +32,61 @@ void RomanLightHelmetRenderer::render(const DrawContext &ctx,
     return HumanoidRendererBase::frameLocalPosition(head, normalized);
     return HumanoidRendererBase::frameLocalPosition(head, normalized);
   };
   };
 
 
+  // Bronze with warm golden tint for light helmet (distinguished from heavy steel)
   QVector3D const helmet_color =
   QVector3D const helmet_color =
-      saturate_color(palette.metal * QVector3D(1.08F, 0.98F, 0.78F));
-  QVector3D const helmet_accent = helmet_color * 1.12F;
+      saturate_color(palette.metal * QVector3D(1.15F, 0.92F, 0.68F));
+  QVector3D const helmet_accent = helmet_color * 1.14F;
 
 
   QVector3D const helmet_top = headPoint(QVector3D(0.0F, 1.28F, 0.0F));
   QVector3D const helmet_top = headPoint(QVector3D(0.0F, 1.28F, 0.0F));
   QVector3D const helmet_bot = headPoint(QVector3D(0.0F, 0.08F, 0.0F));
   QVector3D const helmet_bot = headPoint(QVector3D(0.0F, 0.08F, 0.0F));
-  float const helmet_r = head_r * 1.10F;
+  float const helmet_r = head_r * 1.08F;  // Slightly smaller than heavy helmet
 
 
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
                  cylinderBetween(ctx.model, helmet_bot, helmet_top, helmet_r),
                  cylinderBetween(ctx.model, helmet_bot, helmet_top, helmet_r),
-                 helmet_color, nullptr, 1.0F);
+                 helmet_color, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
   QVector3D const apex_pos = headPoint(QVector3D(0.0F, 1.48F, 0.0F));
   QVector3D const apex_pos = headPoint(QVector3D(0.0F, 1.48F, 0.0F));
   submitter.mesh(getUnitCone(),
   submitter.mesh(getUnitCone(),
                  coneFromTo(ctx.model, helmet_top, apex_pos, helmet_r * 0.97F),
                  coneFromTo(ctx.model, helmet_top, apex_pos, helmet_r * 0.97F),
-                 helmet_accent, nullptr, 1.0F);
+                 helmet_accent, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
-  auto ring = [&](float y_offset, float r_scale, float h,
-                  const QVector3D &col) {
+  // Simplified decoration bands - shader will add fine detail
+  auto ring = [&](float y_offset, float r_scale, const QVector3D &col) {
     QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
     QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
+    float const h = head_r * 0.018F;
     QVector3D const a = center + head.up * (h * 0.5F);
     QVector3D const a = center + head.up * (h * 0.5F);
     QVector3D const b = center - head.up * (h * 0.5F);
     QVector3D const b = center - head.up * (h * 0.5F);
     submitter.mesh(getUnitCylinder(),
     submitter.mesh(getUnitCylinder(),
                    cylinderBetween(ctx.model, a, b, helmet_r * r_scale), col,
                    cylinderBetween(ctx.model, a, b, helmet_r * r_scale), col,
-                   nullptr, 1.0F);
+                   nullptr, 1.0F, 2);  // materialId=2 (helmet)
   };
   };
 
 
-  ring(0.35F, 1.07F, 0.020F, helmet_accent);
-  ring(0.65F, 1.03F, 0.015F, helmet_color * 1.05F);
-  ring(0.95F, 1.01F, 0.012F, helmet_color * 1.03F);
+  ring(0.35F, 1.06F, helmet_accent);
+  ring(0.95F, 1.02F, helmet_color * 1.04F);
 
 
-  float const cheek_w = head_r * 0.48F;
-  QVector3D const cheek_top = headPoint(QVector3D(0.0F, 0.22F, 0.0F));
-  QVector3D const cheek_bot = headPoint(QVector3D(0.0F, -0.42F, 0.0F));
-
-  QVector3D const cheek_ltop =
-      cheek_top + head.right * (-cheek_w / head_r) + head.forward * 0.38F;
-  QVector3D const cheek_lbot = cheek_bot +
-                               head.right * (-cheek_w * 0.82F / head_r) +
-                               head.forward * 0.28F;
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, cheek_lbot, cheek_ltop, 0.028F),
-                 helmet_color * 0.96F, nullptr, 1.0F);
-
-  QVector3D const cheek_rtop =
-      cheek_top + head.right * (cheek_w / head_r) + head.forward * 0.38F;
-  QVector3D const cheek_rbot = cheek_bot +
-                               head.right * (cheek_w * 0.82F / head_r) +
-                               head.forward * 0.28F;
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, cheek_rbot, cheek_rtop, 0.028F),
-                 helmet_color * 0.96F, nullptr, 1.0F);
-
-  QVector3D const neck_guard_top = headPoint(QVector3D(0.0F, 0.03F, -0.82F));
-  QVector3D const neck_guard_bot = headPoint(QVector3D(0.0F, -0.32F, -0.88F));
+  // Neck guard - shader will add scale/lamellae texture
+  QVector3D const neck_guard_top = headPoint(QVector3D(0.0F, 0.03F, -0.85F));
+  QVector3D const neck_guard_bot = headPoint(QVector3D(0.0F, -0.32F, -0.92F));
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
                  cylinderBetween(ctx.model, neck_guard_bot, neck_guard_top,
                  cylinderBetween(ctx.model, neck_guard_bot, neck_guard_top,
-                                 helmet_r * 0.88F),
-                 helmet_color * 0.93F, nullptr, 1.0F);
+                                 helmet_r * 0.86F),
+                 helmet_color * 0.90F, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
+  // Simple crest - shader adds horsehair texture
   QVector3D const crest_base = apex_pos;
   QVector3D const crest_base = apex_pos;
   QVector3D const crest_mid = crest_base + head.up * 0.09F;
   QVector3D const crest_mid = crest_base + head.up * 0.09F;
   QVector3D const crest_top = crest_mid + head.up * 0.12F;
   QVector3D const crest_top = crest_mid + head.up * 0.12F;
 
 
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
                  cylinderBetween(ctx.model, crest_base, crest_mid, 0.018F),
                  cylinderBetween(ctx.model, crest_base, crest_mid, 0.018F),
-                 helmet_accent, nullptr, 1.0F);
+                 helmet_accent, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 
 
   submitter.mesh(getUnitCone(),
   submitter.mesh(getUnitCone(),
                  coneFromTo(ctx.model, crest_mid, crest_top, 0.042F),
                  coneFromTo(ctx.model, crest_mid, crest_top, 0.042F),
-                 QVector3D(0.88F, 0.18F, 0.18F), nullptr, 1.0F);
+                 QVector3D(0.88F, 0.18F, 0.18F), nullptr, 1.0F, 0);  // materialId=0 (decoration)
 
 
   submitter.mesh(getUnitSphere(), sphereAt(ctx.model, crest_top, 0.020F),
   submitter.mesh(getUnitSphere(), sphereAt(ctx.model, crest_top, 0.020F),
-                 helmet_accent, nullptr, 1.0F);
+                 helmet_accent, nullptr, 1.0F, 2);  // materialId=2 (helmet)
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 43 - 78
render/equipment/weapons/shield_roman.cpp

@@ -32,6 +32,7 @@ void RomanShieldRenderer::render(const DrawContext &ctx,
                                  const HumanoidPalette &palette,
                                  const HumanoidPalette &palette,
                                  const HumanoidAnimationContext &,
                                  const HumanoidAnimationContext &,
                                  ISubmitter &submitter) {
                                  ISubmitter &submitter) {
+  // Roman scutum - large curved rectangular shield
   constexpr float k_shield_yaw_degrees = -70.0F;
   constexpr float k_shield_yaw_degrees = -70.0F;
 
 
   QMatrix4x4 rot;
   QMatrix4x4 rot;
@@ -41,91 +42,55 @@ void RomanShieldRenderer::render(const DrawContext &ctx,
   const QVector3D axis_x = rot.map(QVector3D(1.0F, 0.0F, 0.0F));
   const QVector3D axis_x = rot.map(QVector3D(1.0F, 0.0F, 0.0F));
   const QVector3D axis_y = rot.map(QVector3D(0.0F, 1.0F, 0.0F));
   const QVector3D axis_y = rot.map(QVector3D(0.0F, 1.0F, 0.0F));
 
 
-  float const shield_width = 0.18F * 2.5F;
-  float const shield_height = shield_width * 1.3F;
+  // Scutum dimensions - LARGE rectangular shield
+  float const shield_width = 0.45F;
+  float const shield_height = 1.0F;
 
 
+  // Position on left hand - same pattern as Carthage shield
   QVector3D shield_center = frames.hand_l.origin +
   QVector3D shield_center = frames.hand_l.origin +
                             axis_x * (-shield_width * 0.35F) +
                             axis_x * (-shield_width * 0.35F) +
-                            axis_y * (-0.05F) + n * (0.06F);
-
-  QVector3D const shield_color{0.65F, 0.15F, 0.15F};
-  QVector3D const trim_color{0.78F, 0.70F, 0.45F};
-  QVector3D const metal_color{0.72F, 0.73F, 0.78F};
-
-  constexpr int width_segments = 8;
-  constexpr int height_segments = 12;
-
-  for (int h = 0; h < height_segments; ++h) {
-    for (int w = 0; w < width_segments; ++w) {
-      float const h_t =
-          static_cast<float>(h) / static_cast<float>(height_segments - 1);
-      float const w_t =
-          static_cast<float>(w) / static_cast<float>(width_segments - 1);
-
-      float const y_local = (h_t - 0.5F) * shield_height;
-      float const x_local = (w_t - 0.5F) * shield_width;
-
-      QVector3D segment_pos =
-          shield_center + axis_y * y_local + axis_x * x_local;
-
-      QMatrix4x4 m = ctx.model;
-      m.translate(segment_pos);
-      m.scale(0.028F, 0.032F, 0.008F);
-
-      submitter.mesh(getUnitSphere(), m, shield_color, nullptr, 1.0F);
-    }
-  }
-
-  for (int i = 0; i < width_segments + 1; ++i) {
-    float const t = static_cast<float>(i) / static_cast<float>(width_segments);
-    float const x_local = (t - 0.5F) * shield_width;
-
-    QVector3D top_pos =
-        shield_center + axis_y * (shield_height * 0.5F) + axis_x * x_local;
-    QVector3D bot_pos =
-        shield_center + axis_y * (-shield_height * 0.5F) + axis_x * x_local;
-
-    QMatrix4x4 m_top = ctx.model;
-    m_top.translate(top_pos);
-    m_top.scale(0.015F);
-    submitter.mesh(getUnitSphere(), m_top, trim_color, nullptr, 1.0F);
-
-    QMatrix4x4 m_bot = ctx.model;
-    m_bot.translate(bot_pos);
-    m_bot.scale(0.015F);
-    submitter.mesh(getUnitSphere(), m_bot, trim_color, nullptr, 1.0F);
-  }
-
-  for (int i = 0; i < height_segments + 1; ++i) {
-    float const t = static_cast<float>(i) / static_cast<float>(height_segments);
-    float const y_local = (t - 0.5F) * shield_height;
-
-    QVector3D left_pos =
-        shield_center + axis_y * y_local + axis_x * (-shield_width * 0.5F);
-    QVector3D right_pos =
-        shield_center + axis_y * y_local + axis_x * (shield_width * 0.5F);
-
-    QMatrix4x4 m_left = ctx.model;
-    m_left.translate(left_pos);
-    m_left.scale(0.015F);
-    submitter.mesh(getUnitSphere(), m_left, trim_color, nullptr, 1.0F);
-
-    QMatrix4x4 m_right = ctx.model;
-    m_right.translate(right_pos);
-    m_right.scale(0.015F);
-    submitter.mesh(getUnitSphere(), m_right, trim_color, nullptr, 1.0F);
-  }
-
-  float const boss_radius = shield_width * 0.25F;
+                            axis_y * 0.15F + n * 0.06F;
+
+  QVector3D const shield_color{0.68F, 0.14F, 0.12F};  // Deep red
+  QVector3D const trim_color{0.88F, 0.75F, 0.42F};     // Brass trim
+  QVector3D const metal_color{0.82F, 0.84F, 0.88F};    // Steel boss
+
+  // Main scutum body - flat rectangular surface (shader will curve it)
+  QMatrix4x4 shield_body = ctx.model;
+  shield_body.translate(shield_center);
+  shield_body.rotate(90.0F, 0.0F, 1.0F, 0.0F); // Flip shield by 90 degrees along Y axis
+  shield_body.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+  shield_body.scale(shield_width * 0.005F, shield_height * 0.5F, 0.24F); // Broader and thinner
+  
+  submitter.mesh(getUnitCube(), shield_body, shield_color, nullptr, 1.0F, 4);  // materialId=4 (shield)
+
+  // Brass rim trim - top and bottom edges
+  float const rim_thickness = 0.020F;
+  
+  QVector3D top_left = shield_center + axis_y * (shield_height * 0.5F) - axis_x * (shield_width * 0.5F);
+  QVector3D top_right = shield_center + axis_y * (shield_height * 0.5F) + axis_x * (shield_width * 0.5F);
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, top_left, top_right, rim_thickness),
+                 trim_color, nullptr, 1.0F, 4);
+  
+  QVector3D bot_left = shield_center - axis_y * (shield_height * 0.5F) - axis_x * (shield_width * 0.5F);
+  QVector3D bot_right = shield_center - axis_y * (shield_height * 0.5F) + axis_x * (shield_width * 0.5F);
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, bot_left, bot_right, rim_thickness),
+                 trim_color, nullptr, 1.0F, 4);
+
+  // Steel umbo (boss) - large central dome
+  float const boss_radius = 0.08F;
   submitter.mesh(getUnitSphere(),
   submitter.mesh(getUnitSphere(),
                  sphereAt(ctx.model, shield_center + n * 0.05F, boss_radius),
                  sphereAt(ctx.model, shield_center + n * 0.05F, boss_radius),
-                 metal_color, nullptr, 1.0F);
+                 metal_color, nullptr, 1.0F, 4);
 
 
-  QVector3D const grip_a = shield_center - axis_x * 0.035F - n * 0.030F;
-  QVector3D const grip_b = shield_center + axis_x * 0.035F - n * 0.030F;
+  // Horizontal grip bar behind boss
+  QVector3D const grip_a = shield_center - axis_x * 0.06F - n * 0.03F;
+  QVector3D const grip_b = shield_center + axis_x * 0.06F - n * 0.03F;
   submitter.mesh(getUnitCylinder(),
   submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, grip_a, grip_b, 0.010F),
-                 palette.leather, nullptr, 1.0F);
+                 cylinderBetween(ctx.model, grip_a, grip_b, 0.012F),
+                 palette.leather, nullptr, 1.0F, 0);
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 1 - 0
render/gl/backend.cpp

@@ -1068,6 +1068,7 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
       active_shader->setUniform(uniforms->useTexture, it.texture != nullptr);
       active_shader->setUniform(uniforms->useTexture, it.texture != nullptr);
       active_shader->setUniform(uniforms->color, it.color);
       active_shader->setUniform(uniforms->color, it.color);
       active_shader->setUniform(uniforms->alpha, it.alpha);
       active_shader->setUniform(uniforms->alpha, it.alpha);
+      active_shader->setUniform(uniforms->materialId, it.materialId);
       it.mesh->draw();
       it.mesh->draw();
       break;
       break;
     }
     }

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

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

+ 1 - 0
render/gl/backend/character_pipeline.h

@@ -28,6 +28,7 @@ public:
     GL::Shader::UniformHandle useTexture{GL::Shader::InvalidUniform};
     GL::Shader::UniformHandle useTexture{GL::Shader::InvalidUniform};
     GL::Shader::UniformHandle color{GL::Shader::InvalidUniform};
     GL::Shader::UniformHandle color{GL::Shader::InvalidUniform};
     GL::Shader::UniformHandle alpha{GL::Shader::InvalidUniform};
     GL::Shader::UniformHandle alpha{GL::Shader::InvalidUniform};
+    GL::Shader::UniformHandle materialId{GL::Shader::InvalidUniform};
   };
   };
 
 
   GL::Shader *m_basicShader = nullptr;
   GL::Shader *m_basicShader = nullptr;

+ 5 - 0
render/gl/primitives.cpp

@@ -580,6 +580,11 @@ auto getUnitCylinder(int radialSegments) -> Mesh * {
   return s_mesh.get();
   return s_mesh.get();
 }
 }
 
 
+auto getUnitCube() -> Mesh * {
+  static std::unique_ptr<Mesh> const s_mesh(createCubeMesh());
+  return s_mesh.get();
+}
+
 auto getUnitSphere(int latSegments, int lonSegments) -> Mesh * {
 auto getUnitSphere(int latSegments, int lonSegments) -> Mesh * {
   static std::unique_ptr<Mesh> const s_mesh(
   static std::unique_ptr<Mesh> const s_mesh(
       createUnitSphereMesh(latSegments, lonSegments));
       createUnitSphereMesh(latSegments, lonSegments));

+ 1 - 0
render/gl/primitives.h

@@ -11,6 +11,7 @@ inline constexpr int kDefaultCapsuleHeightSegments = 1;
 inline constexpr int kDefaultTorsoHeightSegments = 8;
 inline constexpr int kDefaultTorsoHeightSegments = 8;
 
 
 auto getUnitCylinder(int radialSegments = kDefaultRadialSegments) -> Mesh *;
 auto getUnitCylinder(int radialSegments = kDefaultRadialSegments) -> Mesh *;
+auto getUnitCube() -> Mesh *;
 
 
 auto getUnitSphere(int latSegments = kDefaultLatitudeSegments,
 auto getUnitSphere(int latSegments = kDefaultLatitudeSegments,
                    int lonSegments = kDefaultRadialSegments) -> Mesh *;
                    int lonSegments = kDefaultRadialSegments) -> Mesh *;

+ 194 - 194
render/horse/rig.cpp

@@ -113,6 +113,21 @@ inline void drawCone(ISubmitter &out, const QMatrix4x4 &model,
            alpha);
            alpha);
 }
 }
 
 
+inline void drawRoundedSegment(ISubmitter &out, const QMatrix4x4 &model,
+                               const QVector3D &start, const QVector3D &end,
+                               float start_radius, float end_radius,
+                               const QVector3D &start_color,
+                               const QVector3D &end_color, float alpha = 1.0F) {
+  float const mid_radius = 0.5F * (start_radius + end_radius);
+  QVector3D const tint = lerp(start_color, end_color, 0.5F);
+  out.mesh(getUnitCylinder(), cylinderBetween(model, start, end, mid_radius),
+           tint, nullptr, alpha);
+  out.mesh(getUnitSphere(), Render::Geom::sphereAt(model, start, start_radius),
+           start_color, nullptr, alpha);
+  out.mesh(getUnitSphere(), Render::Geom::sphereAt(model, end, end_radius),
+           end_color, nullptr, alpha);
+}
+
 inline auto bezier(const QVector3D &p0, const QVector3D &p1,
 inline auto bezier(const QVector3D &p0, const QVector3D &p1,
                    const QVector3D &p2, float t) -> QVector3D {
                    const QVector3D &p2, float t) -> QVector3D {
   float const u = 1.0F - t;
   float const u = 1.0F - t;
@@ -484,18 +499,6 @@ void HorseRendererBase::render(const DrawContext &ctx,
     out.mesh(getUnitSphere(), belly, belly_color, nullptr, 1.0F);
     out.mesh(getUnitSphere(), belly, belly_color, nullptr, 1.0F);
   }
   }
 
 
-  for (int i = 0; i < 2; ++i) {
-    float const side = (i == 0) ? 1.0F : -1.0F;
-    QMatrix4x4 ribs = horse_ctx.model;
-    ribs.translate(barrel_center + QVector3D(side * d.bodyWidth * 0.90F,
-                                             -d.bodyHeight * 0.10F,
-                                             -d.bodyLength * 0.05F));
-    ribs.scale(d.bodyWidth * 0.38F, d.bodyHeight * 0.42F, d.bodyLength * 0.30F);
-    QVector3D const rib_color =
-        coatGradient(v.coatColor, 0.45F, 0.05F, coat_seed_d + side * 0.05F);
-    out.mesh(getUnitSphere(), ribs, rib_color, nullptr, 1.0F);
-  }
-
   {
   {
     QMatrix4x4 rump = horse_ctx.model;
     QMatrix4x4 rump = horse_ctx.model;
     rump.translate(rump_center);
     rump.translate(rump_center);
@@ -543,29 +546,7 @@ void HorseRendererBase::render(const DrawContext &ctx,
     out.mesh(getUnitSphere(), spine, spine_color, nullptr, 1.0F);
     out.mesh(getUnitSphere(), spine, spine_color, nullptr, 1.0F);
   }
   }
 
 
-  for (int i = 0; i < 2; ++i) {
-    float const side = (i == 0) ? 1.0F : -1.0F;
-    QVector3D const scapula_top =
-        withers_peak + QVector3D(side * d.bodyWidth * 0.52F,
-                                 d.bodyHeight * 0.08F, d.bodyLength * 0.06F);
-    QVector3D const scapula_base =
-        chest_center + QVector3D(side * d.bodyWidth * 0.70F,
-                                 -d.bodyHeight * 0.02F, d.bodyLength * 0.06F);
-    QVector3D const scapula_mid = lerp(scapula_top, scapula_base, 0.55F);
-    draw_cylinder(
-        out, horse_ctx.model, scapula_top, scapula_mid, d.bodyWidth * 0.18F,
-        coatGradient(v.coatColor, 0.82F, 0.16F, coat_seed_a + side * 0.05F));
-
-    QMatrix4x4 shoulder_cap = horse_ctx.model;
-    shoulder_cap.translate(scapula_base + QVector3D(0.0F, d.bodyHeight * 0.04F,
-                                                    d.bodyLength * 0.02F));
-    shoulder_cap.scale(QVector3D(d.bodyWidth * 0.32F, d.bodyHeight * 0.24F,
-                                 d.bodyLength * 0.18F));
-    out.mesh(
-        getUnitSphere(), shoulder_cap,
-        coatGradient(v.coatColor, 0.66F, 0.12F, coat_seed_b + side * 0.07F),
-        nullptr, 1.0F);
-  }
+  // Shoulder cap accent removed for cleaner silhouette.
 
 
   {
   {
     QMatrix4x4 sternum = horse_ctx.model;
     QMatrix4x4 sternum = horse_ctx.model;
@@ -861,33 +842,39 @@ void HorseRendererBase::render(const DrawContext &ctx,
              tail_color * (0.96F + 0.02F * i), 0.88F);
              tail_color * (0.96F + 0.02F * i), 0.88F);
   }
   }
 
 
-  auto render_hoof = [&](const QVector3D &hoof_top,
-                         const QVector3D &hoof_bottom, float wallRadius,
+  auto render_hoof = [&](const QVector3D &hoof_top, float hoof_height,
+                         float half_width, float half_depth,
                          const QVector3D &hoof_color, bool is_rear) {
                          const QVector3D &hoof_color, bool is_rear) {
-    QVector3D const wall_tint = lighten(hoof_color, is_rear ? 1.04F : 1.0F);
-    out.mesh(
-        getUnitCylinder(),
-        cylinderBetween(horse_ctx.model, hoof_top, hoof_bottom, wallRadius),
-        wall_tint, nullptr, 1.0F);
-
-    QVector3D const toe =
-        hoof_bottom + QVector3D(0.0F, -d.hoofHeight * 0.14F, 0.0F);
-    out.mesh(getUnitCone(),
-             coneFromTo(horse_ctx.model, toe, hoof_bottom, wallRadius * 0.90F),
-             wall_tint * 0.96F, nullptr, 1.0F);
+    QVector3D const hoof_center =
+        hoof_top + QVector3D(0.0F, -hoof_height * 0.5F, 0.0F);
+    QVector3D const wall_tint =
+        lighten(hoof_color, is_rear ? 1.02F : 1.05F);
+    QMatrix4x4 hoof_block = horse_ctx.model;
+    hoof_block.translate(hoof_center);
+    hoof_block.scale(
+        QVector3D(half_width, hoof_height * 0.5F, half_depth));
+    out.mesh(getUnitCylinder(), hoof_block, wall_tint, nullptr, 1.0F);
 
 
     QMatrix4x4 sole = horse_ctx.model;
     QMatrix4x4 sole = horse_ctx.model;
-    sole.translate(lerp(hoof_top, hoof_bottom, 0.88F) +
-                   QVector3D(0.0F, -d.hoofHeight * 0.05F, 0.0F));
-    sole.scale(
-        QVector3D(wallRadius * 1.08F, wallRadius * 0.28F, wallRadius * 1.02F));
-    out.mesh(getUnitSphere(), sole, lighten(hoof_color, 1.12F), nullptr, 1.0F);
+    sole.translate(hoof_center + QVector3D(0.0F, -hoof_height * 0.45F, 0.0F));
+    sole.scale(QVector3D(half_width * 0.92F, hoof_height * 0.08F,
+                         half_depth * 0.95F));
+    out.mesh(getUnitCylinder(), sole, darken(hoof_color, 0.72F), nullptr,
+             1.0F);
+
+    QMatrix4x4 toe = horse_ctx.model;
+    toe.translate(hoof_center + QVector3D(0.0F, -hoof_height * 0.10F,
+                                          is_rear ? -half_depth * 0.35F
+                                                  : half_depth * 0.30F));
+    toe.scale(QVector3D(half_width * 0.85F, hoof_height * 0.20F,
+                        half_depth * 0.70F));
+    out.mesh(getUnitSphere(), toe, lighten(hoof_color, 1.10F), nullptr, 1.0F);
 
 
     QMatrix4x4 coronet = horse_ctx.model;
     QMatrix4x4 coronet = horse_ctx.model;
-    coronet.translate(lerp(hoof_top, hoof_bottom, 0.12F));
-    coronet.scale(
-        QVector3D(wallRadius * 1.05F, wallRadius * 0.24F, wallRadius * 1.05F));
-    out.mesh(getUnitSphere(), coronet, lighten(hoof_color, 1.06F), nullptr,
+    coronet.translate(hoof_top + QVector3D(0.0F, -hoof_height * 0.10F, 0.0F));
+    coronet.scale(QVector3D(half_width * 0.95F, half_width * 0.60F,
+                            half_depth * 1.05F));
+    out.mesh(getUnitSphere(), coronet, lighten(hoof_color, 1.16F), nullptr,
              1.0F);
              1.0F);
   };
   };
 
 
@@ -909,11 +896,26 @@ void HorseRendererBase::render(const DrawContext &ctx,
       lift = idle * d.idleBobAmplitude * 2.0F;
       lift = idle * d.idleBobAmplitude * 2.0F;
     }
     }
 
 
-    bool const tighten_legs = is_moving;
-    float const shoulder_out = d.bodyWidth * (tighten_legs ? 0.44F : 0.58F);
-    QVector3D shoulder = anchor + QVector3D(lateralSign * shoulder_out,
-                                            0.05F + lift * 0.05F, stride);
     bool const is_rear = (forwardBias < 0.0F);
     bool const is_rear = (forwardBias < 0.0F);
+    if (!is_rear) {
+      stride = std::clamp(stride, -d.bodyLength * 0.02F,
+                          d.bodyLength * 0.18F);
+    }
+
+    bool const tighten_legs = is_moving;
+    float const shoulder_out =
+        d.bodyWidth * (tighten_legs ? 0.42F : 0.56F) *
+        (is_rear ? 0.96F : 1.0F);
+    float const shoulder_height = (is_rear ? 0.02F : 0.05F);
+    float const stance_pull =
+        is_rear ? -d.bodyLength * 0.04F : d.bodyLength * 0.05F;
+    float const stance_stagger =
+        lateralSign * (is_rear ? -d.bodyLength * 0.020F
+                               : d.bodyLength * 0.030F);
+    QVector3D shoulder =
+        anchor + QVector3D(lateralSign * shoulder_out,
+                           shoulder_height + lift * 0.04F,
+                           stride + stance_pull + stance_stagger);
 
 
     float const gallop_angle = leg_phase * 2.0F * k_pi;
     float const gallop_angle = leg_phase * 2.0F * k_pi;
     float const hip_swing = is_moving ? std::sin(gallop_angle) : 0.0F;
     float const hip_swing = is_moving ? std::sin(gallop_angle) : 0.0F;
@@ -923,160 +925,154 @@ void HorseRendererBase::render(const DrawContext &ctx,
                        std::sin(gallop_angle + (is_rear ? 0.35F : -0.25F)))
                        std::sin(gallop_angle + (is_rear ? 0.35F : -0.25F)))
             : 0.0F;
             : 0.0F;
 
 
-    shoulder.setZ(shoulder.z() + hip_swing * (is_rear ? -0.12F : 0.10F));
+    shoulder.setZ(shoulder.z() + hip_swing * (is_rear ? -0.10F : 0.08F));
     if (tighten_legs) {
     if (tighten_legs) {
-      shoulder.setX(shoulder.x() - lateralSign * lift_factor * 0.05F);
+      shoulder.setX(shoulder.x() - lateralSign * lift_factor * 0.04F);
     }
     }
 
 
-    float const thigh_length = d.legLength * (is_rear ? 0.62F : 0.56F);
-    float const hip_pitch = hip_swing * (is_rear ? 0.62F : 0.50F);
-    float const inward_lean =
-        tighten_legs ? (-0.06F - lift_factor * 0.045F) : -0.012F;
-    QVector3D thigh_dir(lateralSign * inward_lean, -std::cos(hip_pitch) * 0.90F,
-                        (is_rear ? -1.0F : 1.0F) * std::sin(hip_pitch) * 0.65F);
-    if (thigh_dir.lengthSquared() > 1e-6F) {
-      thigh_dir.normalize();
-    }
-
-    QVector3D knee = shoulder + thigh_dir * thigh_length;
-    knee.setY(knee.y() + lift_factor * thigh_length * 0.28F);
-
     QVector3D girdle_top =
     QVector3D girdle_top =
         (is_rear ? croup_peak : withers_peak) +
         (is_rear ? croup_peak : withers_peak) +
         QVector3D(lateralSign * d.bodyWidth * (is_rear ? 0.44F : 0.48F),
         QVector3D(lateralSign * d.bodyWidth * (is_rear ? 0.44F : 0.48F),
                   is_rear ? -d.bodyHeight * 0.06F : d.bodyHeight * 0.04F,
                   is_rear ? -d.bodyHeight * 0.06F : d.bodyHeight * 0.04F,
-                  (is_rear ? -d.bodyLength * 0.06F : d.bodyLength * 0.05F));
+                  (is_rear ? -d.bodyLength * 0.08F : d.bodyLength * 0.07F));
     girdle_top.setZ(girdle_top.z() + hip_swing * (is_rear ? -0.08F : 0.05F));
     girdle_top.setZ(girdle_top.z() + hip_swing * (is_rear ? -0.08F : 0.05F));
     girdle_top.setX(girdle_top.x() - lateralSign * lift_factor * 0.03F);
     girdle_top.setX(girdle_top.x() - lateralSign * lift_factor * 0.03F);
 
 
     QVector3D const socket =
     QVector3D const socket =
         shoulder +
         shoulder +
         QVector3D(0.0F, d.bodyWidth * 0.12F,
         QVector3D(0.0F, d.bodyWidth * 0.12F,
-                  is_rear ? -d.bodyLength * 0.03F : d.bodyLength * 0.02F);
-    draw_cylinder(out, horse_ctx.model, girdle_top, socket,
-                  d.bodyWidth * (is_rear ? 0.20F : 0.18F),
-                  coatGradient(v.coatColor, is_rear ? 0.70F : 0.80F,
-                               is_rear ? -0.20F : 0.22F,
-                               coat_seed_b + lateralSign * 0.03F));
-
-    QMatrix4x4 socket_cap = horse_ctx.model;
-    socket_cap.translate(socket + QVector3D(0.0F, -d.bodyWidth * 0.04F,
-                                            is_rear ? -d.bodyLength * 0.02F
-                                                    : d.bodyLength * 0.03F));
-    socket_cap.scale(QVector3D(d.bodyWidth * (is_rear ? 0.36F : 0.32F),
-                               d.bodyWidth * 0.28F, d.bodyLength * 0.18F));
-    out.mesh(getUnitSphere(), socket_cap,
-             coatGradient(v.coatColor, is_rear ? 0.60F : 0.68F,
-                          is_rear ? -0.24F : 0.18F,
-                          coat_seed_c + lateralSign * 0.02F),
-             nullptr, 1.0F);
-
-    float const knee_flex =
-        is_moving
-            ? clamp01(std::sin(gallop_angle + (is_rear ? 0.65F : -0.45F)) *
-                          0.55F +
-                      0.42F)
-            : 0.32F;
-
-    float const forearm_length = d.legLength * 0.30F;
-    float const bend_cos = std::cos(knee_flex * k_pi * 0.5F);
-    float const bend_sin = std::sin(knee_flex * k_pi * 0.5F);
-    QVector3D forearm_dir(0.0F, -bend_cos,
-                          (is_rear ? -1.0F : 1.0F) * bend_sin * 0.85F);
-    if (forearm_dir.lengthSquared() < 1e-6F) {
-      forearm_dir = QVector3D(0.0F, -1.0F, 0.0F);
-    } else {
-      forearm_dir.normalize();
+                  is_rear ? -d.bodyLength * 0.05F : d.bodyLength * 0.04F);
+    if (is_rear) {
+      draw_cylinder(out, horse_ctx.model, girdle_top, socket,
+                    d.bodyWidth * (is_rear ? 0.20F : 0.18F),
+                    coatGradient(v.coatColor, is_rear ? 0.70F : 0.80F,
+                                 is_rear ? -0.20F : 0.22F,
+                                 coat_seed_b + lateralSign * 0.03F));
     }
     }
-    QVector3D const cannon = knee + forearm_dir * forearm_length;
-
-    float const pastern_length = d.legLength * 0.12F;
-    QVector3D const fetlock = cannon + QVector3D(0.0F, -pastern_length, 0.0F);
-
-    float const hoof_pitch =
-        is_moving ? (-0.20F + std::sin(leg_phase * 2.0F * k_pi +
-                                       (is_rear ? 0.2F : -0.1F)) *
-                                  0.10F)
-                  : 0.0F;
-    QVector3D const hoof_dir =
-        rotateAroundZ(QVector3D(0.0F, -1.0F, 0.0F), hoof_pitch);
-    QVector3D const hoof_top = fetlock;
-    QVector3D const hoof_bottom = hoof_top + hoof_dir * d.hoofHeight;
 
 
-    float const thigh_belly_r = d.bodyWidth * (is_rear ? 0.58F : 0.50F);
-    float const knee_r = d.bodyWidth * (is_rear ? 0.22F : 0.20F);
-    float const cannon_r = d.bodyWidth * 0.16F;
-    float const pastern_r = d.bodyWidth * 0.11F;
+    if (is_rear) {
+      QMatrix4x4 socket_cap = horse_ctx.model;
+      socket_cap.translate(socket + QVector3D(0.0F, -d.bodyWidth * 0.04F,
+                                              -d.bodyLength * 0.02F));
+      socket_cap.scale(QVector3D(d.bodyWidth * 0.36F, d.bodyWidth * 0.28F,
+                                 d.bodyLength * 0.18F));
+      out.mesh(getUnitSphere(), socket_cap,
+               coatGradient(v.coatColor, 0.60F, -0.24F,
+                            coat_seed_c + lateralSign * 0.02F),
+               nullptr, 1.0F);
+    }
 
 
-    QVector3D const thigh_belly = shoulder + (knee - shoulder) * 0.62F;
+    float const upper_length = d.legLength * (is_rear ? 0.48F : 0.46F);
+    float const lower_length = d.legLength * (is_rear ? 0.43F : 0.49F);
+    float const pastern_length = d.legLength * (is_rear ? 0.12F : 0.14F);
+
+    float const backward_bias = is_rear ? -0.42F : -0.18F;
+    float const hip_drive =
+        (is_rear ? -1.0F : 1.0F) * hip_swing * 0.20F;
+    QVector3D upper_dir(lateralSign * (tighten_legs ? -0.05F : -0.02F),
+                        -0.90F - lift_factor * 0.08F,
+                        backward_bias + hip_drive);
+    if (upper_dir.lengthSquared() < 1e-6F) {
+      upper_dir = QVector3D(0.0F, -1.0F, backward_bias);
+    }
+    upper_dir.normalize();
 
 
-    QVector3D const thigh_color = coatGradient(
-        v.coatColor, is_rear ? 0.48F : 0.58F, is_rear ? -0.22F : 0.18F,
-        coat_seed_a + lateralSign * 0.07F);
-    out.mesh(getUnitCone(),
-             coneFromTo(horse_ctx.model, thigh_belly, shoulder, thigh_belly_r),
-             thigh_color, nullptr, 1.0F);
+    QVector3D knee = shoulder + upper_dir * upper_length;
+    knee.setY(knee.y() + lift_factor * upper_length * 0.32F);
+    float const knee_out = d.bodyWidth * (is_rear ? 0.08F : 0.06F);
+    knee.setX(knee.x() + lateralSign * knee_out);
 
 
-    {
-      QMatrix4x4 muscle = horse_ctx.model;
-      muscle.translate(thigh_belly +
-                       QVector3D(0.0F, 0.0F, is_rear ? -0.015F : 0.020F));
-      muscle.scale(thigh_belly_r * QVector3D(1.05F, 0.85F, 0.92F));
-      out.mesh(getUnitSphere(), muscle, lighten(thigh_color, 1.03F), nullptr,
-               1.0F);
+    float const joint_drive =
+        is_moving
+            ? clamp01(std::sin(gallop_angle + (is_rear ? 0.50F : -0.35F)) *
+                          0.55F +
+                      0.45F)
+            : 0.35F;
+
+    float const lower_forward =
+        (is_rear ? 0.44F : 0.20F) +
+        (is_rear ? 0.30F : 0.18F) * (joint_drive - 0.5F);
+    QVector3D lower_dir(lateralSign * (tighten_legs ? -0.02F : -0.01F), -0.95F,
+                        lower_forward);
+    if (lower_dir.lengthSquared() < 1e-6F) {
+      lower_dir = QVector3D(0.0F, -1.0F, lower_forward);
     }
     }
+    lower_dir.normalize();
 
 
-    QVector3D const knee_color = darken(thigh_color, 0.96F);
-    out.mesh(getUnitCone(),
-             coneFromTo(horse_ctx.model, knee, thigh_belly, knee_r), knee_color,
-             nullptr, 1.0F);
+    QVector3D cannon = knee + lower_dir * lower_length;
+    cannon.setY(cannon.y() - lift_factor * lower_length * 0.12F);
 
 
-    {
-      QMatrix4x4 joint = horse_ctx.model;
-      joint.translate(knee + QVector3D(0.0F, 0.0F, is_rear ? -0.028F : 0.034F));
-      joint.scale(QVector3D(knee_r * 1.18F, knee_r * 1.06F, knee_r * 1.36F));
-      out.mesh(getUnitSphere(), joint, darken(knee_color, 0.90F), nullptr,
-               1.0F);
+    float const pastern_bias = is_rear ? -0.30F : 0.08F;
+    float const pastern_dyn =
+        (is_rear ? -0.10F : 0.05F) * (joint_drive - 0.5F);
+    QVector3D pastern_dir(0.0F, -1.0F, pastern_bias + pastern_dyn);
+    if (pastern_dir.lengthSquared() < 1e-6F) {
+      pastern_dir = QVector3D(0.0F, -1.0F, pastern_bias);
     }
     }
+    pastern_dir.normalize();
 
 
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(horse_ctx.model, knee, cannon, cannon_r),
-             darken(thigh_color, 0.93F), nullptr, 1.0F);
+    QVector3D fetlock = cannon + pastern_dir * pastern_length;
+    fetlock.setY(fetlock.y() - lift_factor * pastern_length * 0.25F);
+    QVector3D hoof_top = fetlock;
 
 
-    {
-      QMatrix4x4 tendon = horse_ctx.model;
-      tendon.translate(
-          lerp(knee, cannon, 0.55F) +
-          QVector3D(0.0F, 0.0F,
-                    is_rear ? -cannon_r * 0.35F : cannon_r * 0.35F));
-      tendon.scale(
-          QVector3D(cannon_r * 0.45F, cannon_r * 0.95F, cannon_r * 0.55F));
-      out.mesh(getUnitSphere(), tendon,
-               darken(thigh_color, is_rear ? 0.88F : 0.90F), nullptr, 1.0F);
-    }
+    float const shoulder_r = d.bodyWidth * (is_rear ? 0.35F : 0.32F);
+    float const upper_r = shoulder_r * (is_rear ? 0.88F : 0.84F);
+    float const knee_r = upper_r * 0.96F;
+    float const cannon_r = knee_r * 0.92F;
+    float const pastern_r = cannon_r * 0.84F;
 
 
-    {
-      QMatrix4x4 joint = horse_ctx.model;
-      joint.translate(fetlock);
-      joint.scale(
-          QVector3D(pastern_r * 1.12F, pastern_r * 1.05F, pastern_r * 1.26F));
-      out.mesh(getUnitSphere(), joint, darken(thigh_color, 0.92F), nullptr,
-               1.0F);
-    }
+    QVector3D const thigh_color = coatGradient(
+        v.coatColor, is_rear ? 0.48F : 0.58F, is_rear ? -0.22F : 0.18F,
+        coat_seed_a + lateralSign * 0.07F);
+    QVector3D const shin_color =
+        darken(thigh_color, is_rear ? 0.90F : 0.92F);
+
+    drawRoundedSegment(out, horse_ctx.model, shoulder, knee, shoulder_r, upper_r,
+                       thigh_color, darken(thigh_color, 0.94F));
+
+    out.mesh(getUnitSphere(),
+             Render::Geom::sphereAt(horse_ctx.model, knee, knee_r * 1.08F),
+             darken(thigh_color, 0.90F), nullptr, 1.0F);
+
+    QVector3D const calf_mid = lerp(knee, cannon, 0.40F);
+    float const calf_upper_r = knee_r * 0.98F;
+    float const calf_mid_r = calf_upper_r * (is_rear ? 0.95F : 0.92F);
+    drawRoundedSegment(out, horse_ctx.model, knee, calf_mid, calf_upper_r,
+                       calf_mid_r, shin_color, darken(shin_color, 0.90F));
+    drawRoundedSegment(out, horse_ctx.model, calf_mid, cannon, calf_mid_r,
+                       cannon_r, darken(shin_color, 0.90F),
+                       darken(shin_color, 0.96F));
+
+    QVector3D const hoof_joint_color =
+        darken(shin_color, is_rear ? 0.92F : 0.94F);
+    out.mesh(getUnitSphere(),
+             Render::Geom::sphereAt(horse_ctx.model, cannon,
+                                    cannon_r * (is_rear ? 1.02F : 0.95F)),
+             hoof_joint_color, nullptr, 1.0F);
 
 
     float const sock =
     float const sock =
         sockChance > 0.78F ? 1.0F : (sockChance > 0.58F ? 0.55F : 0.0F);
         sockChance > 0.78F ? 1.0F : (sockChance > 0.58F ? 0.55F : 0.0F);
     QVector3D const distal_color =
     QVector3D const distal_color =
         (sock > 0.0F) ? lighten(v.coatColor, 1.18F) : v.coatColor * 0.92F;
         (sock > 0.0F) ? lighten(v.coatColor, 1.18F) : v.coatColor * 0.92F;
     float const t_sock = smoothstep(0.0F, 1.0F, sock);
     float const t_sock = smoothstep(0.0F, 1.0F, sock);
+    QVector3D const pastern_color =
+        lerp(hoof_joint_color, distal_color, t_sock * 0.8F);
+
+    drawRoundedSegment(out, horse_ctx.model, cannon, fetlock, cannon_r * 0.90F,
+                       pastern_r, hoof_joint_color, pastern_color);
 
 
-    out.mesh(
-        getUnitCylinder(),
-        cylinderBetween(horse_ctx.model, cannon, fetlock, pastern_r * 1.05F),
-        lerp(v.coatColor * 0.94F, distal_color, t_sock * 0.8F), nullptr, 1.0F);
+    QVector3D const fetlock_color =
+        lerp(pastern_color, distal_color, 0.25F);
+    out.mesh(getUnitSphere(),
+             Render::Geom::sphereAt(horse_ctx.model, fetlock,
+                                    pastern_r * 1.15F),
+             fetlock_color, nullptr, 1.0F);
 
 
     QVector3D const hoof_color = v.hoof_color;
     QVector3D const hoof_color = v.hoof_color;
-    render_hoof(hoof_top, hoof_bottom, pastern_r * 0.96F, hoof_color, is_rear);
+    float const hoof_width = pastern_r * (is_rear ? 1.55F : 1.45F);
+    float const hoof_depth = hoof_width * (is_rear ? 0.90F : 1.05F);
+    render_hoof(hoof_top, d.hoofHeight, hoof_width, hoof_depth, hoof_color,
+                is_rear);
 
 
     if (sock > 0.0F) {
     if (sock > 0.0F) {
       QVector3D const feather_tip = lerp(fetlock, hoof_top, 0.35F) +
       QVector3D const feather_tip = lerp(fetlock, hoof_top, 0.35F) +
@@ -1088,19 +1084,23 @@ void HorseRendererBase::render(const DrawContext &ctx,
 
 
   QVector3D const front_anchor =
   QVector3D const front_anchor =
       barrel_center +
       barrel_center +
-      QVector3D(0.0F, d.bodyHeight * 0.05F, d.bodyLength * 0.28F);
+      QVector3D(0.0F, d.bodyHeight * 0.05F, d.bodyLength * 0.32F);
   QVector3D const rear_anchor =
   QVector3D const rear_anchor =
       barrel_center +
       barrel_center +
-      QVector3D(0.0F, d.bodyHeight * 0.02F, -d.bodyLength * 0.32F);
-
-  draw_leg(front_anchor, 1.0F, d.bodyLength * 0.30F, g.frontLegPhase,
-           sock_chance_fl);
-  draw_leg(front_anchor, -1.0F, d.bodyLength * 0.30F, g.frontLegPhase + 0.5F,
-           sock_chance_fr);
-  draw_leg(rear_anchor, 1.0F, -d.bodyLength * 0.28F, g.rearLegPhase,
-           sock_chance_rl);
-  draw_leg(rear_anchor, -1.0F, -d.bodyLength * 0.28F, g.rearLegPhase + 0.5F,
-           sock_chance_rr);
+      QVector3D(0.0F, d.bodyHeight * 0.02F, -d.bodyLength * 0.30F);
+
+  float const front_forward_bias = d.bodyLength * 0.16F;
+  float const front_bias_offset = d.bodyLength * 0.035F;
+  draw_leg(front_anchor, 1.0F, front_forward_bias + front_bias_offset,
+           g.frontLegPhase, sock_chance_fl);
+  draw_leg(front_anchor, -1.0F, front_forward_bias - front_bias_offset,
+           g.frontLegPhase + 0.48F, sock_chance_fr);
+  float const rear_forward_bias = -d.bodyLength * 0.16F;
+  float const rear_bias_offset = d.bodyLength * 0.032F;
+  draw_leg(rear_anchor, 1.0F, rear_forward_bias - rear_bias_offset,
+           g.rearLegPhase, sock_chance_rl);
+  draw_leg(rear_anchor, -1.0F, rear_forward_bias + rear_bias_offset,
+           g.rearLegPhase + 0.52F, sock_chance_rr);
 
 
   QVector3D const bit_left =
   QVector3D const bit_left =
       muzzle_center + QVector3D(d.headWidth * 0.55F, -d.headHeight * 0.08F,
       muzzle_center + QVector3D(d.headWidth * 0.55F, -d.headHeight * 0.08F,
@@ -1162,9 +1162,9 @@ void HorseRendererBase::render(const DrawContext &ctx,
   body_frames.rump.up = up;
   body_frames.rump.up = up;
   body_frames.rump.forward = forward;
   body_frames.rump.forward = forward;
 
 
-  QVector3D const tail_base_pos =
+    QVector3D const tail_base_pos =
       rump_center +
       rump_center +
-      QVector3D(0.0F, d.bodyHeight * 0.20F, -d.bodyLength * 0.40F);
+      QVector3D(0.0F, d.bodyHeight * 0.20F, -100.05F);
   body_frames.tail_base.origin = tail_base_pos;
   body_frames.tail_base.origin = tail_base_pos;
   body_frames.tail_base.right = right;
   body_frames.tail_base.right = right;
   body_frames.tail_base.up = up;
   body_frames.tail_base.up = up;

+ 16 - 0
render/humanoid/rig.cpp

@@ -955,6 +955,16 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
         ctx.entity->getComponent<Engine::Core::TransformComponent>();
         ctx.entity->getComponent<Engine::Core::TransformComponent>();
   }
   }
 
 
+  float entity_ground_offset = 0.0F;
+  if (unit_comp != nullptr) {
+    entity_ground_offset =
+        Game::Units::TroopConfig::instance().getSelectionRingGroundOffset(
+            unit_comp->spawn_type);
+    if (transform_comp != nullptr) {
+      entity_ground_offset *= transform_comp->scale.y;
+    }
+  }
+
   uint32_t seed = 0U;
   uint32_t seed = 0U;
   if (unit_comp != nullptr) {
   if (unit_comp != nullptr) {
     seed ^= uint32_t(unit_comp->owner_id * 2654435761U);
     seed ^= uint32_t(unit_comp->owner_id * 2654435761U);
@@ -1024,11 +1034,17 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
       m.scale(transform_comp->scale.x, transform_comp->scale.y,
       m.scale(transform_comp->scale.x, transform_comp->scale.y,
               transform_comp->scale.z);
               transform_comp->scale.z);
       m.translate(offset_x, vertical_jitter, offset_z);
       m.translate(offset_x, vertical_jitter, offset_z);
+      if (entity_ground_offset != 0.0F) {
+        m.translate(0.0F, -entity_ground_offset, 0.0F);
+      }
       inst_model = m;
       inst_model = m;
     } else {
     } else {
       inst_model = ctx.model;
       inst_model = ctx.model;
       inst_model.rotate(applied_yaw, 0.0F, 1.0F, 0.0F);
       inst_model.rotate(applied_yaw, 0.0F, 1.0F, 0.0F);
       inst_model.translate(offset_x, vertical_jitter, offset_z);
       inst_model.translate(offset_x, vertical_jitter, offset_z);
+      if (entity_ground_offset != 0.0F) {
+        inst_model.translate(0.0F, -entity_ground_offset, 0.0F);
+      }
     }
     }
 
 
     DrawContext inst_ctx{ctx.resources, ctx.entity, ctx.world, inst_model};
     DrawContext inst_ctx{ctx.resources, ctx.entity, ctx.world, inst_model};

+ 2 - 1
render/scene_renderer.cpp

@@ -102,7 +102,7 @@ void Renderer::setViewport(int width, int height) {
   }
   }
 }
 }
 void Renderer::mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
 void Renderer::mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
-                    Texture *texture, float alpha) {
+                    Texture *texture, float alpha, int materialId) {
   if (mesh == nullptr) {
   if (mesh == nullptr) {
     return;
     return;
   }
   }
@@ -124,6 +124,7 @@ void Renderer::mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
   cmd.mvp = m_view_proj * model;
   cmd.mvp = m_view_proj * model;
   cmd.color = color;
   cmd.color = color;
   cmd.alpha = alpha;
   cmd.alpha = alpha;
+  cmd.materialId = materialId;
   cmd.shader = m_currentShader;
   cmd.shader = m_currentShader;
   if (m_activeQueue != nullptr) {
   if (m_activeQueue != nullptr) {
     m_activeQueue->submit(cmd);
     m_activeQueue->submit(cmd);

+ 1 - 1
render/scene_renderer.h

@@ -111,7 +111,7 @@ public:
   auto isPaused() const -> bool { return m_paused; }
   auto isPaused() const -> bool { return m_paused; }
 
 
   void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
   void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
-            Texture *texture = nullptr, float alpha = 1.0F) override;
+            Texture *texture = nullptr, float alpha = 1.0F, int materialId = 0) override;
   void cylinder(const QVector3D &start, const QVector3D &end, float radius,
   void cylinder(const QVector3D &start, const QVector3D &end, float radius,
                 const QVector3D &color, float alpha = 1.0F) override;
                 const QVector3D &color, float alpha = 1.0F) override;
   void selectionRing(const QMatrix4x4 &model, float alphaInner,
   void selectionRing(const QMatrix4x4 &model, float alphaInner,

+ 3 - 2
render/submitter.h

@@ -16,7 +16,7 @@ class ISubmitter {
 public:
 public:
   virtual ~ISubmitter() = default;
   virtual ~ISubmitter() = default;
   virtual void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
   virtual void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
-                    Texture *tex = nullptr, float alpha = 1.0F) = 0;
+                    Texture *tex = nullptr, float alpha = 1.0F, int materialId = 0) = 0;
   virtual void cylinder(const QVector3D &start, const QVector3D &end,
   virtual void cylinder(const QVector3D &start, const QVector3D &end,
                         float radius, const QVector3D &color,
                         float radius, const QVector3D &color,
                         float alpha = 1.0F) = 0;
                         float alpha = 1.0F) = 0;
@@ -47,7 +47,7 @@ public:
   void setShader(Shader *shader) { m_shader = shader; }
   void setShader(Shader *shader) { m_shader = shader; }
 
 
   void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
   void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
-            Texture *tex = nullptr, float alpha = 1.0F) override {
+            Texture *tex = nullptr, float alpha = 1.0F, int materialId = 0) override {
     if ((m_queue == nullptr) || (mesh == nullptr)) {
     if ((m_queue == nullptr) || (mesh == nullptr)) {
       return;
       return;
     }
     }
@@ -74,6 +74,7 @@ public:
     cmd.model = model;
     cmd.model = model;
     cmd.color = color;
     cmd.color = color;
     cmd.alpha = alpha;
     cmd.alpha = alpha;
+    cmd.materialId = materialId;
     cmd.shader = m_shader;
     cmd.shader = m_shader;
     m_queue->submit(cmd);
     m_queue->submit(cmd);
   }
   }

+ 1 - 1
tests/render/helmet_renderers_test.cpp

@@ -15,7 +15,7 @@ class MockSubmitter : public ISubmitter {
 public:
 public:
   void mesh(Mesh * /*mesh*/, const QMatrix4x4 & /*transform*/,
   void mesh(Mesh * /*mesh*/, const QMatrix4x4 & /*transform*/,
             const QVector3D & /*color*/, Texture * /*texture*/,
             const QVector3D & /*color*/, Texture * /*texture*/,
-            float /*alpha*/) override {
+            float /*alpha*/, int /*materialId*/) override {
     mesh_count++;
     mesh_count++;
   }
   }
 
 

+ 1 - 1
tests/render/horse_equipment_renderers_test.cpp

@@ -27,7 +27,7 @@ class MockSubmitter : public ISubmitter {
 public:
 public:
   void mesh(Mesh * /*mesh*/, const QMatrix4x4 & /*model*/,
   void mesh(Mesh * /*mesh*/, const QMatrix4x4 & /*model*/,
             const QVector3D & /*color*/, Texture * /*tex*/ = nullptr,
             const QVector3D & /*color*/, Texture * /*tex*/ = nullptr,
-            float /*alpha*/ = 1.0F) override {
+            float /*alpha*/ = 1.0F, int /*materialId*/ = 0) override {
     mesh_count++;
     mesh_count++;
   }
   }