Bläddra i källkod

Add separate Carthaginian archer armor files with shared helmet support

djeada 1 månad sedan
förälder
incheckning
16d4b07643
63 ändrade filer med 2911 tillägg och 1654 borttagningar
  1. 12 0
      CMakeLists.txt
  2. 6 0
      assets.qrc
  3. 351 142
      assets/shaders/archer_carthage.frag
  4. 59 15
      assets/shaders/archer_carthage.vert
  5. 51 44
      assets/shaders/carthage_light_helmet.frag
  6. 4 4
      assets/shaders/carthage_light_helmet.vert
  7. 64 67
      assets/shaders/chainmail_armor.frag
  8. 3 3
      assets/shaders/chainmail_armor.vert
  9. 172 267
      assets/shaders/spearman_carthage.frag
  10. 67 17
      assets/shaders/spearman_carthage.vert
  11. 255 136
      assets/shaders/swordsman_carthage.frag
  12. 79 13
      assets/shaders/swordsman_carthage.vert
  13. 5 1
      render/CMakeLists.txt
  14. 4 4
      render/entity/barracks_flag_renderer.h
  15. 30 10
      render/entity/nations/carthage/archer_renderer.cpp
  16. 1 0
      render/entity/nations/carthage/archer_style.cpp
  17. 1 0
      render/entity/nations/carthage/archer_style.h
  18. 72 66
      render/entity/nations/carthage/barracks_renderer.cpp
  19. 8 7
      render/entity/nations/carthage/horse_swordsman_renderer.cpp
  20. 61 11
      render/entity/nations/carthage/spearman_renderer.cpp
  21. 7 6
      render/entity/nations/carthage/spearman_style.cpp
  22. 1 0
      render/entity/nations/carthage/spearman_style.h
  23. 9 14
      render/entity/nations/carthage/swordsman_renderer.cpp
  24. 7 6
      render/entity/nations/kingdom/archer_renderer.cpp
  25. 52 52
      render/entity/nations/kingdom/barracks_renderer.cpp
  26. 7 6
      render/entity/nations/kingdom/horse_swordsman_renderer.cpp
  27. 48 8
      render/entity/nations/kingdom/spearman_renderer.cpp
  28. 7 12
      render/entity/nations/kingdom/swordsman_renderer.cpp
  29. 7 6
      render/entity/nations/roman/archer_renderer.cpp
  30. 49 44
      render/entity/nations/roman/barracks_renderer.cpp
  31. 7 6
      render/entity/nations/roman/horse_swordsman_renderer.cpp
  32. 48 8
      render/entity/nations/roman/spearman_renderer.cpp
  33. 7 12
      render/entity/nations/roman/swordsman_renderer.cpp
  34. 1 1
      render/entity/registry.cpp
  35. 155 0
      render/equipment/armor/CARTHAGE_ARCHER_ARMOR.md
  36. 78 0
      render/equipment/armor/armor_heavy_carthage.cpp
  37. 19 0
      render/equipment/armor/armor_heavy_carthage.h
  38. 100 0
      render/equipment/armor/armor_light_carthage.cpp
  39. 19 0
      render/equipment/armor/armor_light_carthage.h
  40. 35 0
      render/equipment/armor/armor_utils.h
  41. 0 101
      render/equipment/armor/carthage_armor.cpp
  42. 0 29
      render/equipment/armor/carthage_armor.h
  43. 105 129
      render/equipment/armor/chainmail_armor.cpp
  44. 5 5
      render/equipment/armor/chainmail_armor.h
  45. 56 18
      render/equipment/armor/roman_armor.cpp
  46. 36 23
      render/equipment/armor/tunic_renderer.cpp
  47. 194 0
      render/equipment/helmets/carthage_heavy_helmet.cpp
  48. 47 0
      render/equipment/helmets/carthage_heavy_helmet.h
  49. 266 202
      render/equipment/helmets/carthage_light_helmet.cpp
  50. 11 11
      render/equipment/helmets/carthage_light_helmet.h
  51. 21 13
      render/equipment/register_equipment.cpp
  52. 5 8
      render/gl/backend.cpp
  53. 57 26
      render/gl/backend/character_pipeline.cpp
  54. 6 0
      render/gl/backend/character_pipeline.h
  55. 11 11
      render/horse/rig.cpp
  56. 18 17
      render/humanoid/humanoid_specs.h
  57. 17 22
      render/humanoid/rig.cpp
  58. 10 7
      render/humanoid/rig.h
  59. 21 5
      tests/render/armor_renderer_test.cpp
  60. 22 15
      tests/render/body_frames_test.cpp
  61. 4 2
      tests/render/helmet_renderers_test.cpp
  62. 12 8
      tests/render/pose_controller_compatibility_test.cpp
  63. 19 14
      tests/render/pose_controller_test.cpp

+ 12 - 0
CMakeLists.txt

@@ -174,6 +174,12 @@ if(QT_VERSION_MAJOR EQUAL 6)
         RESOURCES
             assets/shaders/archer.frag
             assets/shaders/archer.vert
+            assets/shaders/archer_kingdom_of_iron.frag
+            assets/shaders/archer_kingdom_of_iron.vert
+            assets/shaders/archer_roman_republic.frag
+            assets/shaders/archer_roman_republic.vert
+            assets/shaders/archer_carthage.frag
+            assets/shaders/archer_carthage.vert
             assets/shaders/basic.frag
             assets/shaders/basic.vert
             assets/shaders/bridge.frag
@@ -203,6 +209,12 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/shaders/riverbank.vert
             assets/shaders/spearman.frag
             assets/shaders/spearman.vert
+            assets/shaders/spearman_kingdom_of_iron.frag
+            assets/shaders/spearman_kingdom_of_iron.vert
+            assets/shaders/spearman_roman_republic.frag
+            assets/shaders/spearman_roman_republic.vert
+            assets/shaders/spearman_carthage.frag
+            assets/shaders/spearman_carthage.vert
             assets/shaders/stone_instanced.frag
             assets/shaders/stone_instanced.vert
             assets/shaders/terrain_chunk.frag

+ 6 - 0
assets.qrc

@@ -4,8 +4,11 @@
         <file>assets/shaders/archer.frag</file>
         <file>assets/shaders/archer.vert</file>
         <file>assets/shaders/archer_kingdom_of_iron.frag</file>
+        <file>assets/shaders/archer_kingdom_of_iron.vert</file>
         <file>assets/shaders/archer_roman_republic.frag</file>
+        <file>assets/shaders/archer_roman_republic.vert</file>
         <file>assets/shaders/archer_carthage.frag</file>
+        <file>assets/shaders/archer_carthage.vert</file>
         <file>assets/shaders/basic.frag</file>
         <file>assets/shaders/basic.vert</file>
         <file>assets/shaders/bridge.frag</file>
@@ -42,8 +45,11 @@
         <file>assets/shaders/spearman.frag</file>
         <file>assets/shaders/spearman.vert</file>
         <file>assets/shaders/spearman_kingdom_of_iron.frag</file>
+        <file>assets/shaders/spearman_kingdom_of_iron.vert</file>
         <file>assets/shaders/spearman_roman_republic.frag</file>
+        <file>assets/shaders/spearman_roman_republic.vert</file>
         <file>assets/shaders/spearman_carthage.frag</file>
+        <file>assets/shaders/spearman_carthage.vert</file>
         <file>assets/shaders/stone_instanced.frag</file>
         <file>assets/shaders/stone_instanced.vert</file>
         <file>assets/shaders/terrain_chunk.frag</file>

+ 351 - 142
assets/shaders/archer_carthage.frag

@@ -1,17 +1,27 @@
 #version 330 core
 
 in vec3 v_normal;
+in vec3 v_worldNormal;
+in vec3 v_tangent;
+in vec3 v_bitangent;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
-in float v_armorLayer; // NEW: Armor layer from vertex shader
+in float v_armorLayer;
+in float v_leatherTension;
+in float v_bodyHeight;
 
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform bool u_useTexture;
 uniform float u_alpha;
+uniform float u_time;
+uniform float u_rainIntensity;
 
 out vec4 FragColor;
 
+// ----------------------------- Utils & Noise -----------------------------
+const float PI = 3.14159265359;
+
 float hash(vec2 p) {
   vec3 p3 = fract(vec3(p.xyx) * 0.1031);
   p3 += dot(p3, p3.yzx + 33.33);
@@ -29,168 +39,367 @@ float noise(vec2 p) {
   return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
 }
 
-// Roman chainmail (lorica hamata) ring pattern
-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);
+float triplanarNoise(vec3 pos, vec3 normal, float scale) {
+  vec3 b = abs(normal);
+  b = max(b, vec3(0.0001));
+  b /= (b.x + b.y + b.z);
+  float xy = noise(pos.xy * scale);
+  float yz = noise(pos.yz * scale);
+  float zx = noise(pos.zx * scale);
+  return xy * b.z + yz * b.x + zx * b.y;
+}
 
-  // Offset rows for interlocking
-  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);
+float fbm(vec3 pos, vec3 normal, float scale) {
+  float total = 0.0;
+  float amp = 0.5;
+  float freq = 1.0;
+  for (int i = 0; i < 5; ++i) {
+    total += amp * triplanarNoise(pos * freq, normal, scale * freq);
+    freq *= 2.02;
+    amp *= 0.45;
+  }
+  return total;
+}
 
-  return (ringPattern + offsetPattern) * 0.14;
+// Luma / chroma helpers
+float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }
+float sat(vec3 c) {
+  float mx = max(max(c.r, c.g), c.b);
+  float mn = min(min(c.r, c.g), c.b);
+  return (mx - mn) / max(mx, 1e-5);
 }
 
-// Leather pteruges strips (hanging skirt/shoulder guards)
-float pterugesStrips(vec2 p, float y) {
-  // Vertical leather strips
-  float stripX = fract(p.x * 9.0);
-  float strip = smoothstep(0.15, 0.20, stripX) - smoothstep(0.80, 0.85, stripX);
+// ----------------------------- Geometry helpers -----------------------------
+float fresnelSchlick(float cosTheta, float F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
 
-  // Add leather texture to strips
-  float leatherTex = noise(p * 18.0) * 0.35;
+float computeCurvature(vec3 normal) {
+  vec3 dx = dFdx(normal);
+  vec3 dy = dFdy(normal);
+  return length(dx) + length(dy);
+}
 
-  // Strips hang and curve
-  float hang = smoothstep(0.65, 0.45, y);
+// Microfacet (GGX) – isotropic (we’ll tint/spec weight per material)
+float D_GGX(float NdotH, float a) {
+  float a2 = a * a;
+  float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
+  return a2 / max(PI * d * d, 1e-6);
+}
 
-  return strip * leatherTex * hang;
+float G_Smith(float NdotV, float NdotL, float a) {
+  // Schlick-GGX
+  float k = (a + 1.0);
+  k = (k * k) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
 }
 
-void main() {
-  vec3 color = u_color;
-  if (u_useTexture) {
-    color *= texture(u_texture, v_texCoord).rgb;
-  }
+// Simple wrap diffuse (Oren–Nayar would need sigma; we keep interface)
+float wrapDiffuse(vec3 normal, vec3 lightDir, float wrap) {
+  float nl = dot(normal, lightDir);
+  return max(nl * (1.0 - wrap) + wrap, 0.0);
+}
 
-  vec3 normal = normalize(v_normal);
-  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.02 && color.r > color.b * 1.10 && avgColor > 0.48);
-  bool isSeaCloak = (color.g > color.r * 1.2 && color.b > color.r * 1.3);
-  bool isLinothorax =
-      (v_armorLayer == 1.0 && avgColor > 0.55 && avgColor < 0.78);
-  bool isLeatherCap = (v_armorLayer == 0.0 && !isBronze);
-
-  // === CARTHAGINIAN LIGHT ARCHER MATERIALS (North African/Mercenary style) ===
-
-  // LEATHER CAP/HEADBAND (instead of heavy bronze helmet)
-  if (isLeatherCap) {
-    // Thick tanned leather with Numidian/Libyan styling
-    float leatherGrain = noise(uv * 14.0) * 0.20;
-    float leatherPores = noise(uv * 28.0) * 0.10;
-    float tooledPattern =
-        sin(v_worldPos.x * 40.0) * sin(v_worldPos.y * 35.0) * 0.06;
-
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
-    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
-
-    color *= 1.0 + leatherGrain + leatherPores - 0.08;
-    color += vec3(tooledPattern + leatherSheen);
-  }
-  // LINOTHORAX (LAYERED LINEN ARMOR) - Carthaginian specialty
-  else if (isLinothorax) {
-    // Multiple layers of glued linen - very distinctive texture
-    float linenWeaveX = sin(v_worldPos.x * 65.0);
-    float linenWeaveZ = sin(v_worldPos.z * 68.0);
-    float weave = linenWeaveX * linenWeaveZ * 0.08;
+// Perturb normal procedurally to add micro detail per material
+vec3 perturbNormalLeather(vec3 N, vec3 T, vec3 B, vec3 P) {
+  float g1 = fbm(P * 6.0, N, 8.0);
+  float g2 = fbm(P * 18.0 + vec3(2.1), N, 24.0);
+  vec3 nT = T * (g1 * 0.06 + g2 * 0.02);
+  vec3 nB = B * (g1 * 0.03 + g2 * 0.03);
+  vec3 pN = normalize(N + nT + nB);
+  return pN;
+}
 
-    // Visible layering from edge-on angles
-    float layers = abs(sin(v_worldPos.y * 22.0)) * 0.12;
+vec3 perturbNormalLinen(vec3 N, vec3 T, vec3 B, vec3 P) {
+  // warp/weft weave
+  float warp = sin(P.x * 140.0) * 0.06;
+  float weft = sin(P.z * 146.0) * 0.06;
+  float slub = fbm(P * 7.0, N, 10.0) * 0.04;
+  vec3 pN = normalize(N + T * (warp + slub) + B * (weft + slub * 0.5));
+  return pN;
+}
 
-    // Glue/resin stiffening (darker patches)
-    float resinStains = noise(uv * 8.0) * 0.10;
+vec3 perturbNormalBronze(vec3 N, vec3 T, vec3 B, vec3 P) {
+  float hammer = fbm(P * 16.0, N, 22.0) * 0.10;
+  float ripple = fbm(P * 40.0 + vec3(3.7), N, 55.0) * 0.03;
+  vec3 pN = normalize(N + T * hammer + B * ripple);
+  return pN;
+}
 
-    // Soft matte finish (not shiny like metal)
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.2))));
-    float linenSheen = pow(1.0 - viewAngle, 12.0) * 0.05;
+// Clearcoat (water film) lobe for rain
+vec3 clearcoatSpec(vec3 N, vec3 L, vec3 V, float coatStrength,
+                   float coatRough) {
+  vec3 H = normalize(L + V);
+  float NdotV = max(dot(N, V), 0.0);
+  float NdotL = max(dot(N, L), 0.0);
+  float NdotH = max(dot(N, H), 0.0);
+  float a = max(coatRough, 0.02);
+  float D = D_GGX(NdotH, a);
+  float G = G_Smith(NdotV, NdotL, a);
+  // IOR ~1.33 → F0 ≈ 0.02
+  float F = fresnelSchlick(max(dot(H, V), 0.0), 0.02);
+  float spec = (D * G * F) / max(4.0 * NdotV * NdotL + 1e-5, 1e-5);
+  return vec3(spec * coatStrength);
+}
 
-    color *= 1.0 + weave + layers - resinStains * 0.5;
-    color += vec3(linenSheen);
-  }
-  // SUN-DULLED BRONZE (minimal use - only decorative pieces)
-  else if (isBronze) {
-    float saltPatina = noise(uv * 9.0) * 0.16;
-    float verdigris = noise(uv * 12.0) * 0.10;
-    float viewAngle = abs(dot(normal, normalize(vec3(0.1, 1.0, 0.2))));
-    float bronzeSheen = pow(viewAngle, 6.5) * 0.22;
-    float bronzeFresnel = pow(1.0 - viewAngle, 2.0) * 0.20;
-    color += vec3(bronzeSheen + bronzeFresnel);
-    color -= vec3(saltPatina * 0.4 + verdigris * 0.35);
-  }
-  // LIGHT LEATHER ARMOR (not chainmail - mercenary style)
-  else if (avgColor > 0.35 && avgColor <= 0.58 && !isSeaCloak) {
-    // Hardened leather cuirass instead of heavy mail
-    float hardenedGrain = noise(uv * 16.0) * 0.18;
-    float cracks = noise(uv * 32.0) * 0.08;
-    float oilSheen =
-        pow(abs(dot(normal, normalize(vec3(0.2, 1.0, 0.3)))), 8.0) * 0.14;
-
-    color += vec3(hardenedGrain + oilSheen);
-    color -= vec3(cracks * 0.4);
-  }
-  // TEAL SEA CLOAK
-  else if (isSeaCloak) {
-    float weaveX = sin(v_worldPos.x * 48.0);
-    float weaveZ = sin(v_worldPos.z * 52.0);
-    float weave = weaveX * weaveZ * 0.040;
-    float woolFuzz = noise(uv * 19.0) * 0.08;
-    float folds = noise(uv * 7.0) * 0.10 - 0.05;
-    float capeSheen =
-        pow(1.0 - abs(dot(normal, vec3(0.0, 1.0, 0.2))), 7.5) * 0.07;
-
-    color *= 1.0 + woolFuzz - 0.05 + folds;
-    color += vec3(weave + capeSheen);
+// ----------------------------- Main -----------------------------
+void main() {
+  // Base color (historically-inspired calibration: leather/linen/bronze)
+  vec3 baseColor = u_color;
+  if (u_useTexture) {
+    baseColor *= texture(u_texture, v_texCoord).rgb;
   }
-  // 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;
-    float leatherPores = noise(uv * 22.0) * 0.08;
-
-    // Pteruges strip pattern
-    float strips = pterugesStrips(v_worldPos.xz, v_worldPos.y);
-
-    // Worn leather edges
-    float wear = noise(uv * 4.0) * 0.10 - 0.05;
 
-    // Leather has subtle sheen
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float leatherSheen = pow(1.0 - viewAngle, 4.5) * 0.10;
-
-    color *= 1.0 + leatherGrain + leatherPores - 0.08 + wear;
-    color += vec3(strips * 0.15 + leatherSheen);
+  // Material classification (robust by luma/sat, keeps your heuristics spirit)
+  float Y = luma(baseColor);
+  float S = sat(baseColor);
+  bool likelyLeather =
+      (Y > 0.18 && Y < 0.65 && baseColor.r > baseColor.g * 1.03);
+  bool likelyLinen = (Y > 0.65 && S < 0.22); // undyed ecru/off-white
+  bool likelyBronze = (baseColor.r > baseColor.g * 1.03 &&
+                       baseColor.r > baseColor.b * 1.10 && Y > 0.42);
+
+  // Canonical normal/TBN
+  vec3 Nw = normalize(v_worldNormal);
+  vec3 Tw = normalize(v_tangent);
+  vec3 Bw = normalize(v_bitangent);
+
+  // Lighting (keep your single sun, plus very light ambient model)
+  vec3 L = normalize(vec3(0.55, 1.15, 0.35));
+  vec3 V = normalize(
+      vec3(0.0, 0.0, 1.0)); // no camera pos available; preserve interface
+  vec3 H = normalize(L + V);
+
+  float rain = clamp(u_rainIntensity, 0.0, 1.0);
+  float curvature = computeCurvature(Nw);
+
+  // Sky/ground ambient: cool from above, warm bounce from below
+  float up = clamp(Nw.y, 0.0, 1.0);
+  float down = clamp(-Nw.y, 0.0, 1.0);
+  vec3 skyAmbient = vec3(0.60, 0.70, 0.85) * (0.20 + 0.35 * up);
+  vec3 groundBounce = vec3(0.40, 0.34, 0.28) * (0.06 + 0.20 * down);
+  vec3 ambient = skyAmbient + groundBounce;
+
+  // Raindrop streak mask (flows down -Y with time, gathers in hollows)
+  float streak = smoothstep(0.65, 1.0,
+                            sin(v_worldPos.y * 22.0 - u_time * 4.0 +
+                                fbm(v_worldPos * 0.8, Nw, 1.2) * 6.283));
+  float wetGather = (1.0 - clamp(Nw.y, 0.0, 1.0)) *
+                    (0.4 + 0.6 * fbm(v_worldPos * 2.0, Nw, 3.0));
+  float wetMask =
+      clamp(rain * mix(0.5 * wetGather, 1.0 * wetGather, streak), 0.0, 1.0);
+
+  // Start with the incoming color, we’ll push toward historically plausible
+  // targets
+  vec3 color = baseColor;
+  vec3 N = Nw;
+
+  // Accumulated specular
+  vec3 specularAccum = vec3(0.0);
+
+  // Common N·L/V/H
+  float NdotL, NdotV, NdotH;
+
+  // ----------------------------- Leather -----------------------------
+  if (likelyLeather) {
+    // Historical vegetable-tanned leather: warm brown; sun-bleached upper
+    // torso, sweat/dirt lower
+    vec3 targetLeather = vec3(0.42, 0.30, 0.20);
+    color = mix(targetLeather, color, 0.25);
+
+    // Subtle vertical sun-bleach gradient (upper torso brighter)
+    float sunKissed = mix(0.92, 1.06, clamp(v_bodyHeight, 0.0, 1.0));
+    color *= vec3(sunKissed, mix(0.90, 0.98, v_bodyHeight),
+                  mix(0.87, 0.95, v_bodyHeight));
+
+    // Procedural micro normal for grain/pores
+    N = perturbNormalLeather(Nw, Tw, Bw, v_worldPos);
+
+    // Grain/aging modulation
+    float coarse = fbm(v_worldPos * 3.2, Nw, 4.0);
+    float medium = fbm(v_worldPos * 7.6, Nw, 8.0);
+    float fine = fbm(v_worldPos * 16.0, Nw, 20.0);
+    float pores = fbm(v_worldPos * 38.0 + vec3(3.7), Nw, 48.0);
+    float grain = coarse * 0.40 + medium * 0.32 + fine * 0.20 + pores * 0.08;
+
+    // Stretch/crease based on tension and local grain
+    float stretch = mix(-0.05, 0.12, clamp(v_leatherTension, 0.0, 1.0));
+    float creaseWaves = sin(v_worldPos.x * 10.5 + grain * 2.1) *
+                        sin(v_worldPos.z * 9.2 + grain * 2.6);
+    float creaseMask = smoothstep(0.55, 0.87, creaseWaves) * 0.20;
+    color = mix(color, color * 0.72, creaseMask);
+    color += color * stretch * 0.5;
+
+    // Edge wear via curvature, salt/sweat halos
+    float edgeWear = smoothstep(0.34, 1.10, curvature);
+    color = mix(color, color + vec3(0.14, 0.12, 0.08), edgeWear * 0.28);
+    float salt = smoothstep(0.62, 0.95,
+                            fbm(v_worldPos * vec3(12.0, 6.0, 12.0), Nw, 14.0));
+    color = mix(color, color * vec3(0.80, 0.78, 0.75), salt * 0.12);
+
+    // Dirt accumulation lower on body
+    float dirt =
+        fbm(v_worldPos * vec3(2.5, 1.1, 2.5), Nw, 3.5) * (1.0 - v_bodyHeight);
+    color = mix(color, color * vec3(0.70, 0.58, 0.42),
+                smoothstep(0.45, 0.75, dirt) * 0.20);
+
+    // Wetness darkens leather and adds clearcoat
+    color = mix(color, color * 0.55, wetMask * 0.85);
+
+    // Specular: dielectric leather F0 ~ 0.035, roughness from grain/tension
+    float roughness =
+        clamp(0.55 - v_leatherTension * 0.18 + grain * 0.12, 0.28, 0.75);
+    float a = max(0.04, roughness * roughness);
+    NdotL = max(dot(N, L), 0.0);
+    NdotV = max(dot(N, V), 0.0);
+    NdotH = max(dot(N, normalize(L + V)), 0.0);
+    float D = D_GGX(NdotH, a);
+    float G = G_Smith(NdotV, NdotL, a);
+    float F = fresnelSchlick(max(dot(normalize(L + V), V), 0.0), 0.035);
+    float spec = (D * G * F) / max(4.0 * NdotV * NdotL + 1e-5, 1e-5);
+
+    // Add water clearcoat when raining
+    vec3 coat = clearcoatSpec(N, L, V, wetMask * 0.9, mix(0.10, 0.03, wetMask));
+
+    specularAccum += vec3(spec) * 0.55 + coat;
+
+    // ----------------------------- Linen -----------------------------
+  } else if (likelyLinen) {
+    // Undyed linen (ecru) target; historically sun-yellowing & sweat near
+    // neck/upper torso
+    vec3 targetLinen = vec3(0.88, 0.85, 0.78);
+    color = mix(targetLinen, color, 0.35);
+
+    // Procedural weave normal
+    N = perturbNormalLinen(Nw, Tw, Bw, v_worldPos);
+
+    // Subtle weave tint + size variation (glue/sizing + fray)
+    float weave = sin(v_worldPos.x * 62.0) * sin(v_worldPos.z * 66.0) * 0.08;
+    float sizing =
+        fbm(v_worldPos * 3.0, Nw, 4.5) * 0.10; // starch/glue darkening
+    float fray =
+        fbm(v_worldPos * 9.0, Nw, 10.0) * clamp(1.4 - Nw.y, 0.0, 1.0) * 0.12;
+    color += vec3(weave * 0.5);
+    color -= vec3(sizing * 0.6);
+    color -= vec3(fray * 0.15);
+
+    // Dust from below, perspiration from above
+    float dust = clamp(1.0 - Nw.y, 0.0, 1.0) * fbm(v_worldPos * 1.1, Nw, 2.0);
+    float sweat =
+        smoothstep(0.6, 1.0, v_bodyHeight) * fbm(v_worldPos * 2.4, Nw, 3.1);
+    color = mix(color, color * (1.0 - dust * 0.35), 0.7);
+    color = mix(color, color * vec3(0.96, 0.93, 0.88),
+                1.0 - clamp(sweat * 0.5, 0.0, 1.0));
+
+    // Damp linen darkens and gets slightly more specular
+    float damp = rain * (0.18 + 0.32 * fbm(v_worldPos * 2.0, Nw, 3.5));
+    color *= (1.0 - damp * 0.35);
+
+    // Cloth specular (dielectric, low F0; slight sheen at grazing)
+    NdotL = max(dot(N, L), 0.0);
+    NdotV = max(dot(N, V), 0.0);
+    float roughness = 0.85; // matte
+    float a = roughness * roughness;
+    float D = D_GGX(max(dot(N, normalize(L + V)), 0.0), a);
+    float G = G_Smith(NdotV, NdotL, a);
+    float F = fresnelSchlick(max(dot(normalize(L + V), V), 0.0), 0.028);
+    float spec = (D * G * F) / max(4.0 * NdotV * NdotL + 1e-5, 1e-5);
+
+    // Wet clearcoat is minimal on absorbent cloth
+    vec3 coat = clearcoatSpec(N, L, V, wetMask * 0.25, 0.06);
+    specularAccum += vec3(spec * 0.25) + coat;
+
+    // ----------------------------- Bronze -----------------------------
+  } else if (likelyBronze) {
+    // Warm hammered bronze with cuprite (reddish) and malachite (green) patina
+    vec3 bronzeWarm = vec3(0.58, 0.44, 0.20);
+    vec3 cuprite = vec3(0.36, 0.18, 0.10);
+    vec3 malachite = vec3(0.18, 0.45, 0.36);
+
+    // Procedural hammered normal
+    N = perturbNormalBronze(Nw, Tw, Bw, v_worldPos);
+
+    float hammer = fbm(v_worldPos * 14.0, Nw, 20.0) * 0.18;
+    float patina = fbm(v_worldPos * 6.0 + vec3(5.0), Nw, 8.0) * 0.14;
+    float runOff = fbm(v_worldPos * vec3(1.2, 3.4, 1.2), Nw, 2.2) *
+                   (1.0 - Nw.y); // forms under gravity
+
+    // Color layering: base → cuprite pits → malachite edges/runoffs
+    vec3 bronzeBase = mix(bronzeWarm, color, 0.35) + vec3(hammer);
+    vec3 withCuprite =
+        mix(bronzeBase, cuprite,
+            smoothstep(0.70, 0.95, fbm(v_worldPos * 9.0, Nw, 12.0)));
+    color = mix(withCuprite, malachite,
+                clamp(patina * 0.5 + runOff * 0.6, 0.0, 1.0));
+
+    // Wetness: darker diffuse, stronger sharp spec
+    color = mix(color, color * 0.65, wetMask * 0.6);
+
+    // Metallic specular (energy conserving)
+    float metallic = 0.92;
+    float roughness = clamp(0.25 + hammer * 0.25 + patina * 0.15, 0.15, 0.65);
+    float a = roughness * roughness;
+
+    NdotL = max(dot(N, L), 0.0);
+    NdotV = max(dot(N, V), 0.0);
+    NdotH = max(dot(N, normalize(L + V)), 0.0);
+
+    float D = D_GGX(NdotH, a);
+    float G = G_Smith(NdotV, NdotL, a);
+
+    // F0 from metallic base color
+    vec3 F0 = mix(vec3(0.06), clamp(bronzeWarm, 0.0, 1.0), metallic);
+    float FH = max(dot(normalize(L + V), V), 0.0);
+    vec3 F = F0 + (1.0 - F0) * pow(1.0 - FH, 5.0);
+
+    vec3 spec = (D * G * F) / max(4.0 * NdotV * NdotL + 1e-5, 1e-5);
+    specularAccum += spec * 0.95;
+
+    // Clearcoat water film
+    specularAccum +=
+        clearcoatSpec(N, L, V, wetMask * 0.8, mix(0.10, 0.04, wetMask));
+
+    // ----------------------------- Generic / fallback
+    // -----------------------------
+  } else {
+    // Subtle material noise/ambient and light wetness handling
+    float subtle = fbm(v_worldPos * 4.0, Nw, 5.5) * 0.12;
+    color *= (1.0 + subtle);
+    color = mix(color, color * 0.7, wetMask * 0.5);
+    N = Nw;
+    NdotL = max(dot(N, L), 0.0);
+    NdotV = max(dot(N, V), 0.0);
+    float roughness = 0.6;
+    float a = roughness * roughness;
+    float D = D_GGX(max(dot(N, normalize(L + V)), 0.0), a);
+    float G = G_Smith(NdotV, NdotL, a);
+    float F = fresnelSchlick(max(dot(normalize(L + V), V), 0.0), 0.04);
+    float spec = (D * G * F) / max(4.0 * NdotV * NdotL + 1e-5, 1e-5);
+    specularAccum += vec3(spec * 0.25);
   }
-  // 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;
-  }
+  // Diffuse (wrapped to soften terminator; bronze uses a bit tighter wrap)
+  float wrap = likelyBronze ? 0.22 : (likelyLeather ? 0.35 : 0.42);
+  float diff = wrapDiffuse(N, L, wrap);
 
-  color = clamp(color, 0.0, 1.0);
+  // Cavity/curvature occlusion to sit details down
+  float cavity = 1.0 - smoothstep(0.2, 1.1, curvature);
+  float ao = mix(0.85, 1.0, cavity);
 
-  // Lighting model - soft wrap for leather/fabric, harder for metal
-  vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
-  float nDotL = dot(normal, lightDir);
+  // Final light mix, simple energy conservation for dielectrics
+  float metallicTerm = likelyBronze ? 0.92 : 0.0;
+  float Favg = 0.04; // average dielectric reflectance
+  float kd = (1.0 - metallicTerm) * (1.0 - Favg);
 
-  // Metal = harder shadows, Fabric/leather = soft wrap
-  float wrapAmount = isBronze ? 0.18 : 0.40;
-  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.22);
+  vec3 lit = color * kd * diff + specularAccum;
+  lit += ambient * color * kd * 0.5; // ambient contribution
 
-  // Enhance contrast for bronze
-  if (isBronze) {
-    diff = pow(diff, 0.90);
-  }
+  // Slight rain darkening in creases
+  lit = mix(lit, lit * 0.85, wetMask * 0.2);
 
-  color *= diff;
-  FragColor = vec4(color, u_alpha);
+  // Clamp and out
+  vec3 finalColor = clamp(lit * ao, 0.0, 1.0);
+  FragColor = vec4(finalColor, u_alpha);
 }

+ 59 - 15
assets/shaders/archer_carthage.vert

@@ -8,25 +8,69 @@ uniform mat4 u_mvp;
 uniform mat4 u_model;
 
 out vec3 v_normal;
+out vec3 v_worldNormal;
+out vec3 v_tangent;
+out vec3 v_bitangent;
 out vec2 v_texCoord;
 out vec3 v_worldPos;
-out float
-    v_armorLayer; // NEW: Distinguish armor pieces for Carthaginian light armor
+out float v_armorLayer;
+out float v_leatherTension;
+out float v_bodyHeight;
+
+float hash13(vec3 p) {
+  return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
+}
+
+vec3 fallbackUp(vec3 normal) {
+  return (abs(normal.y) > 0.92) ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
+}
 
 void main() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
-  v_texCoord = a_texCoord;
-  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for lighter Carthaginian equipment
-  // Upper body (helmet/head) = 0, Torso = 1, Lower = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Leather cap/light helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Linothorax (linen) torso region
-  } else {
-    v_armorLayer = 2.0; // Leather skirt/pteruges region
-  }
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * a_normal);
+
+  // Build a stable TBN without needing extra attributes
+  vec3 t = normalize(cross(fallbackUp(worldNormal), worldNormal));
+  if (length(t) < 1e-4)
+    t = vec3(1.0, 0.0, 0.0);
+  // Gram–Schmidt to ensure orthonormality
+  t = normalize(t - worldNormal * dot(worldNormal, t));
+  vec3 b = normalize(cross(worldNormal, t));
+
+  vec4 modelPos = u_model * vec4(a_position, 1.0);
+  vec3 worldPos = modelPos.xyz;
+
+  // Small normal push to avoid self-shadow acne in forward pipelines
+  vec3 offsetPos = worldPos + worldNormal * 0.008;
 
   gl_Position = u_mvp * vec4(a_position, 1.0);
+
+  v_worldPos = offsetPos;
+  v_texCoord = a_texCoord;
+  v_normal = worldNormal;
+  v_worldNormal = worldNormal;
+  v_tangent = t;
+  v_bitangent = b;
+
+  // Layer bands (kept identical thresholds to preserve your gameplay logic)
+  float height = offsetPos.y;
+  float layer = 2.0;
+  if (height > 1.28)
+    layer = 0.0;
+  else if (height > 0.86)
+    layer = 1.0;
+  v_armorLayer = layer;
+
+  // Leather tension: variation + curvature bias + height influence
+  float tensionSeed = hash13(offsetPos * 0.35 + worldNormal);
+  float heightFactor = smoothstep(0.5, 1.5, height);
+  float curvatureFactor = length(vec2(worldNormal.x, worldNormal.z));
+  v_leatherTension = mix(tensionSeed, 1.0 - tensionSeed, layer * 0.33) *
+                     (0.7 + curvatureFactor * 0.3) * (0.8 + heightFactor * 0.2);
+
+  // Normalized torso height for gradient effects
+  float torsoMin = 0.58;
+  float torsoMax = 1.36;
+  v_bodyHeight =
+      clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
 }

+ 51 - 44
assets/shaders/carthage_light_helmet.frag

@@ -26,19 +26,20 @@ float distributionGGX(vec3 N, vec3 H, float roughness) {
   float a2 = a * a;
   float NdotH = max(dot(N, H), 0.0);
   float NdotH2 = NdotH * NdotH;
-  
+
   float denom = (NdotH2 * (a2 - 1.0) + 1.0);
   denom = 3.14159265 * denom * denom;
-  
+
   return a2 / max(denom, 0.0001);
 }
 
 // Procedural bronze oxidation/patina
 vec3 bronzePatina(vec3 baseColor, vec3 worldPos) {
   // Green-blue patina in crevices and lower areas
-  float patina_noise = fract(sin(dot(worldPos.xz, vec2(12.9898, 78.233))) * 43758.5453);
+  float patina_noise =
+      fract(sin(dot(worldPos.xz, vec2(12.9898, 78.233))) * 43758.5453);
   float patina_amount = smoothstep(1.5, 1.7, worldPos.y) * 0.3 * patina_noise;
-  
+
   vec3 patina_color = vec3(0.2, 0.55, 0.45); // Verdigris green
   return mix(baseColor, patina_color, patina_amount);
 }
@@ -47,10 +48,10 @@ vec3 bronzePatina(vec3 baseColor, vec3 worldPos) {
 float hammeredTexture(vec2 uv) {
   vec2 id = floor(uv * 32.0);
   float n = fract(sin(dot(id, vec2(12.9898, 78.233))) * 43758.5453);
-  
+
   vec2 local = fract(uv * 32.0) - 0.5;
   float dist = length(local);
-  
+
   // Circular hammer marks
   float hammer = smoothstep(0.4, 0.3, dist) * (0.5 + n * 0.5);
   return hammer * 0.15;
@@ -60,10 +61,10 @@ float hammeredTexture(vec2 uv) {
 float rivetDetail(vec2 uv) {
   vec2 rivet_grid = fract(uv * 6.0) - 0.5;
   float rivet_dist = length(rivet_grid);
-  
+
   float rivet = smoothstep(0.08, 0.05, rivet_dist);
   rivet *= smoothstep(0.12, 0.10, rivet_dist); // Ring around rivet
-  
+
   return rivet * 0.25;
 }
 
@@ -72,47 +73,53 @@ void main() {
   vec3 V = normalize(v_viewDir);
   vec3 L = normalize(u_lightDir);
   vec3 H = normalize(V + L);
-  
-  // Base bronze color with procedural weathering
-  vec3 baseColor = bronzePatina(u_color, v_worldPos);
-  
-  // Add hammered texture detail
-  float hammer = hammeredTexture(v_texCoord);
-  baseColor = mix(baseColor, baseColor * 0.85, hammer);
-  
-  // Add rivet highlights
-  float rivets = rivetDetail(v_texCoord);
-  baseColor = mix(baseColor, baseColor * 1.3, rivets);
-  
-  // PBR lighting calculation
-  vec3 F0 = mix(vec3(0.04), baseColor, v_metallic);
+
+  // High-quality bronze material
+  vec3 baseColor = u_color;
+
+  // Subtle patina only in deep recesses
+  float ambient_factor = max(dot(N, vec3(0.0, 1.0, 0.0)), 0.0);
+  vec3 patina_color = vec3(0.25, 0.45, 0.35);
+  baseColor = mix(baseColor, patina_color, (1.0 - ambient_factor) * 0.12);
+
+  // Advanced PBR
+  vec3 F0 = vec3(0.95, 0.64, 0.54); // Bronze F0
   vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
-  
-  // Specular component
+
   float NDF = distributionGGX(N, H, v_roughness);
   float NdotL = max(dot(N, L), 0.0);
   float NdotV = max(dot(N, V), 0.0);
-  
-  vec3 specular = NDF * F / max(4.0 * NdotV * NdotL, 0.001);
-  
-  // Diffuse component
-  vec3 kD = (vec3(1.0) - F) * (1.0 - v_metallic);
+
+  // Cook-Torrance specular
+  float G = min(1.0, min(2.0 * NdotV * NdotL / max(dot(V, H), 0.001),
+                         2.0 * NdotV * NdotL / max(dot(V, H), 0.001)));
+
+  vec3 specular = (NDF * F * G) / max(4.0 * NdotV * NdotL, 0.001);
+  specular = clamp(specular, 0.0, 10.0);
+
+  // Metallic doesn't have diffuse
+  vec3 kD = vec3(0.0);
   vec3 diffuse = kD * baseColor / 3.14159265;
-  
-  // Combine lighting
-  vec3 ambient = u_ambientColor * baseColor * 0.3;
-  vec3 radiance = u_lightColor * NdotL;
-  vec3 color = ambient + (diffuse + specular) * radiance;
-  
-  // Edge wear (brighter on edges from polishing)
-  float edge_factor = pow(1.0 - NdotV, 3.0);
-  color = mix(color, color * 1.4, edge_factor * 0.3);
-  
-  // Tone mapping
-  color = color / (color + vec3(1.0));
-  
-  // Gamma correction
+
+  // Strong ambient for visibility
+  vec3 ambient = u_ambientColor * baseColor * 0.5;
+
+  // Main lighting
+  vec3 radiance = u_lightColor * NdotL * 1.8;
+  vec3 color = ambient + (diffuse + specular * 2.5) * radiance;
+
+  // Strong rim light for definition
+  float rim = pow(1.0 - NdotV, 2.5) * NdotL;
+  color += baseColor * rim * 0.6;
+
+  // Brighten overall
+  color *= 1.3;
+
+  // Gentle tone mapping
+  color = color / (color + vec3(0.5));
+
+  // Gamma
   color = pow(color, vec3(1.0 / 2.2));
-  
+
   FragColor = vec4(color, u_alpha);
 }

+ 4 - 4
assets/shaders/carthage_light_helmet.vert

@@ -20,15 +20,15 @@ void main() {
   v_worldPos = worldPos.xyz;
   v_normal = normalize(u_normalMatrix * a_normal);
   v_texCoord = a_texCoord;
-  
+
   // Camera at approximate position for view direction
   vec3 cameraPos = vec3(0.0, 1.5, 5.0);
   v_viewDir = normalize(cameraPos - v_worldPos);
-  
+
   // Material properties based on position
   // Bronze helmet: high metallic, medium roughness
   if (v_worldPos.y > 1.6) {
-    v_metallic = 0.85; // Bronze
+    v_metallic = 0.85;  // Bronze
     v_roughness = 0.35; // Polished but weathered
   } else if (v_worldPos.y > 1.5) {
     v_metallic = 0.75; // Crest mount
@@ -37,6 +37,6 @@ void main() {
     v_metallic = 0.65; // Cheek guards
     v_roughness = 0.5;
   }
-  
+
   gl_Position = u_mvp * vec4(a_position, 1.0);
 }

+ 64 - 67
assets/shaders/chainmail_armor.frag

@@ -27,67 +27,69 @@ float noise(vec2 p) {
   vec2 i = floor(p);
   vec2 f = fract(p);
   f = f * f * (3.0 - 2.0 * f);
-  
+
   float a = hash(i);
   float b = hash(i + vec2(1.0, 0.0));
   float c = hash(i + vec2(0.0, 1.0));
   float d = hash(i + vec2(1.0, 1.0));
-  
+
   return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
 }
 
 // Chainmail ring pattern - interlocking rings
 float chainmailRingPattern(vec2 uv, float phase) {
   vec2 uv_scaled = uv * 64.0; // Ring density
-  
+
   // Apply row offset for interlocking
   uv_scaled.x += phase * 0.5;
-  
+
   vec2 grid = fract(uv_scaled) - 0.5;
   float ring_dist = length(grid);
-  
+
   // Outer ring
-  float outer_ring = smoothstep(0.45, 0.40, ring_dist) - smoothstep(0.35, 0.30, ring_dist);
-  
+  float outer_ring =
+      smoothstep(0.45, 0.40, ring_dist) - smoothstep(0.35, 0.30, ring_dist);
+
   // Inner ring (creates depth)
-  float inner_ring = smoothstep(0.32, 0.28, ring_dist) - smoothstep(0.25, 0.20, ring_dist);
-  
+  float inner_ring =
+      smoothstep(0.32, 0.28, ring_dist) - smoothstep(0.25, 0.20, ring_dist);
+
   // Combine rings
   float ring = outer_ring + inner_ring * 0.5;
-  
+
   // Add ring overlap highlights (where rings interlock)
   vec2 overlap_grid = fract(uv_scaled + vec2(0.5, 0.0)) - 0.5;
   float overlap_dist = length(overlap_grid);
   float overlap = smoothstep(0.25, 0.22, overlap_dist) * 0.3;
-  
+
   return clamp(ring + overlap, 0.0, 1.0);
 }
 
 // Rust and weathering
 vec3 applyRust(vec3 baseColor, vec3 worldPos, float rustAmount) {
   // Rust color palette
-  vec3 rust_dark = vec3(0.35, 0.15, 0.10); // Dark rust/brown
+  vec3 rust_dark = vec3(0.35, 0.15, 0.10);   // Dark rust/brown
   vec3 rust_bright = vec3(0.65, 0.35, 0.20); // Orange rust
-  vec3 rust_green = vec3(0.25, 0.40, 0.35); // Green oxidation
-  
+  vec3 rust_green = vec3(0.25, 0.40, 0.35);  // Green oxidation
+
   // Procedural rust distribution
   float rust_noise1 = noise(worldPos.xz * 20.0);
   float rust_noise2 = noise(worldPos.xy * 15.0);
   float rust_noise3 = noise(worldPos.yz * 18.0);
-  
+
   float combined_noise = (rust_noise1 + rust_noise2 + rust_noise3) / 3.0;
-  
+
   // More rust in lower areas (gravity/water accumulation)
   float height_factor = clamp(1.0 - worldPos.y * 0.6, 0.0, 1.0);
-  
+
   // Total rust amount
   float total_rust = combined_noise * rustAmount * height_factor;
   total_rust = clamp(total_rust, 0.0, 1.0);
-  
+
   // Mix rust colors
   vec3 rust_mix = mix(rust_dark, rust_bright, rust_noise1);
   rust_mix = mix(rust_mix, rust_green, rust_noise2 * 0.3);
-  
+
   return mix(baseColor, rust_mix, total_rust);
 }
 
@@ -95,10 +97,10 @@ vec3 applyRust(vec3 baseColor, vec3 worldPos, float rustAmount) {
 float ringAO(vec2 uv, float phase) {
   vec2 uv_scaled = uv * 64.0;
   uv_scaled.x += phase * 0.5;
-  
+
   vec2 grid = fract(uv_scaled) - 0.5;
   float dist = length(grid);
-  
+
   // Ambient occlusion in ring valleys
   float ao = smoothstep(0.15, 0.35, dist);
   return ao;
@@ -114,56 +116,51 @@ void main() {
   vec3 V = normalize(v_viewDir);
   vec3 L = normalize(u_lightDir);
   vec3 H = normalize(V + L);
-  
-  // Base steel/iron color
-  vec3 baseColor = u_color;
-  
-  // Apply chainmail ring pattern
+
+  // Brighter steel base
+  vec3 baseColor = u_color * 1.2;
+
+  // Subtle chainmail pattern
   float ringPattern = chainmailRingPattern(v_texCoord, v_ringPhase);
-  vec3 ringColor = mix(baseColor * 0.6, baseColor * 1.2, ringPattern);
-  
-  // Apply ambient occlusion from ring structure
-  float ao = ringAO(v_texCoord, v_ringPhase);
-  ringColor *= mix(0.7, 1.0, ao);
-  
-  // Apply rust/weathering
-  ringColor = applyRust(ringColor, v_worldPos, u_rustAmount);
-  
-  // Lighting calculation
+  vec3 ringColor = mix(baseColor * 0.85, baseColor * 1.15, ringPattern * 0.6);
+
+  // Light rust only
+  ringColor = applyRust(ringColor, v_worldPos, u_rustAmount * 0.5);
+
+  // Strong metallic lighting
   float NdotL = max(dot(N, L), 0.0);
   float NdotH = max(dot(N, H), 0.0);
-  
-  // Diffuse
-  vec3 diffuse = ringColor * NdotL;
-  
-  // Specular (metallic)
-  float roughness = mix(0.3, 0.8, u_rustAmount); // More rust = rougher
-  float specular_power = mix(128.0, 16.0, roughness);
-  float specular = pow(NdotH, specular_power) * (1.0 - u_rustAmount * 0.7);
-  
-  // Fresnel rim lighting
-  float rim = fresnel(V, N, 3.0) * 0.4;
-  
-  // Ambient
-  vec3 ambient = u_ambientColor * ringColor * 0.4;
-  
-  // Combine
-  vec3 color = ambient + (diffuse + vec3(specular) * 0.8) * u_lightColor;
+  float NdotV = max(dot(N, V), 0.0);
+
+  // Bright diffuse
+  vec3 diffuse = ringColor * NdotL * 1.5;
+
+  // Strong specular
+  float spec_power = 64.0;
+  float specular = pow(NdotH, spec_power) * 1.8;
+
+  // Bright ambient
+  vec3 ambient = u_ambientColor * ringColor * 0.6;
+
+  // Fresnel rim
+  float rim = pow(1.0 - NdotV, 3.0) * 0.5;
+
+  // Combine with boosted brightness
+  vec3 color = ambient + (diffuse + vec3(specular)) * u_lightColor;
   color += vec3(rim) * u_lightColor;
-  
-  // Ring edge highlights
-  float edge_highlight = ringPattern * specular * 0.5;
-  color += vec3(edge_highlight);
-  
-  // Subtle chainmail shimmer (from ring overlaps)
-  float shimmer = noise(v_texCoord * 80.0 + v_worldPos.xy) * ringPattern * 0.15;
-  color += vec3(shimmer);
-  
-  // Tone mapping
-  color = color / (color + vec3(1.0));
-  
-  // Gamma correction
+
+  // Add sparkle from rings
+  float sparkle = ringPattern * NdotH * 0.4;
+  color += vec3(sparkle);
+
+  // Brighten overall
+  color *= 1.4;
+
+  // Gentle tone mapping
+  color = color / (color + vec3(0.5));
+
+  // Gamma
   color = pow(color, vec3(1.0 / 2.2));
-  
+
   FragColor = vec4(color, u_alpha);
 }

+ 3 - 3
assets/shaders/chainmail_armor.vert

@@ -20,15 +20,15 @@ void main() {
   v_worldPos = worldPos.xyz;
   v_normal = normalize(u_normalMatrix * a_normal);
   v_texCoord = a_texCoord;
-  
+
   // Camera position
   vec3 cameraPos = vec3(0.0, 1.5, 5.0);
   v_viewDir = normalize(cameraPos - v_worldPos);
-  
+
   // Ring phase for interlocking pattern
   // Creates alternating offset rows for realistic chainmail
   float rowIndex = floor(v_worldPos.y * 50.0);
   v_ringPhase = mod(rowIndex, 2.0);
-  
+
   gl_Position = u_mvp * vec4(a_position, 1.0);
 }

+ 172 - 267
assets/shaders/spearman_carthage.frag

@@ -1,327 +1,232 @@
 #version 330 core
 
-// === Inputs preserved (do not change) ===
 in vec3 v_normal;
+in vec3 v_worldNormal;
+in vec3 v_tangent;
+in vec3 v_bitangent;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
-in float v_armorLayer; // Armor layer from vertex shader
+in float v_armorLayer;
+in float v_leatherTension;
+in float v_bodyHeight;
+in float v_layerNoise;
+in float v_bendAmount;
 
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform bool u_useTexture;
 uniform float u_alpha;
+uniform float u_time;
+uniform float u_rainIntensity;
 
 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); }
+const vec3 kLeatherBase = vec3(0.42, 0.30, 0.20);
+const vec3 kLinenBase = vec3(0.88, 0.83, 0.74);
+const vec3 kBronzeBase = vec3(0.58, 0.44, 0.20);
 
-float hash(vec2 p) {
-  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
-  p3 += dot(p3, p3.yzx + 33.33);
-  return fract((p3.x + p3.y) * p3.z);
+float hash21(vec2 p) {
+  p = fract(p * vec2(234.34, 435.345));
+  p += dot(p, p + 34.45);
+  return fract(p.x * p.y);
 }
 
-float noise(vec2 p) {
-  vec2 i = floor(p);
-  vec2 f = fract(p);
+float noise3(vec3 p) {
+  vec3 i = floor(p);
+  vec3 f = fract(p);
   f = f * f * (3.0 - 2.0 * f);
-  float a = hash(i);
-  float b = hash(i + vec2(1.0, 0.0));
-  float c = hash(i + vec2(0.0, 1.0));
-  float d = hash(i + vec2(1.0, 1.0));
-  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+  float n = i.x + i.y * 57.0 + i.z * 113.0;
+  return mix(
+      mix(mix(hash21(vec2(n, n + 1.0)), hash21(vec2(n + 57.0, n + 58.0)), f.x),
+          mix(hash21(vec2(n + 113.0, n + 114.0)),
+              hash21(vec2(n + 170.0, n + 171.0)), f.x),
+          f.y),
+      mix(mix(hash21(vec2(n + 226.0, n + 227.0)),
+              hash21(vec2(n + 283.0, n + 284.0)), f.x),
+          mix(hash21(vec2(n + 339.0, n + 340.0)),
+              hash21(vec2(n + 396.0, n + 397.0)), f.x),
+          f.y),
+      f.z);
 }
 
-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;
+float fbm(vec3 p) {
+  float value = 0.0;
+  float amp = 0.5;
+  float freq = 1.0;
+  for (int i = 0; i < 5; ++i) {
+    value += amp * noise3(p * freq);
+    freq *= 1.9;
+    amp *= 0.5;
+  }
+  return value;
 }
 
-// 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.46, 0.66, 0.78) * 0.36;
-  vec3 ground = vec3(0.18, 0.16, 0.14) * 0.28;
+  float up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
+  vec3 sky = vec3(0.58, 0.67, 0.78);
+  vec3 ground = vec3(0.25, 0.21, 0.18);
   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 D_GGX(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);
 }
 
-// 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;
+  float k = (r * r) / 8.0;
+  return geometrySchlickGGX(NdotV, k) * geometrySchlickGGX(NdotL, k);
 }
 
-// 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));
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
 }
 
-// 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);
+vec3 perturb(vec3 N, vec3 T, vec3 B, vec3 amount) {
+  return normalize(N + T * amount.x + B * amount.y);
 }
 
-// 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);
+struct MaterialSample {
+  vec3 color;
+  vec3 normal;
+  float roughness;
+  vec3 F0;
+};
+
+MaterialSample sampleLeather(vec3 baseColor, vec3 pos, vec3 N, vec3 T, vec3 B) {
+  MaterialSample m;
+  float grain = fbm(pos * 3.1 + vec3(v_layerNoise));
+  float crease = fbm(pos * vec3(1.2, 4.0, 1.8));
+  vec2 swell = vec2(grain - 0.5, crease - 0.5) * 0.15;
+  vec3 Np = perturb(N, T, B, vec3(swell, v_bendAmount * 0.1));
+
+  vec3 tint = mix(kLeatherBase, baseColor, 0.4);
+  tint *= 1.0 - 0.18 * grain;
+  tint += vec3(0.06) * smoothstep(0.4, 0.9, v_bodyHeight);
+  tint = mix(tint, tint * vec3(0.7, 0.6, 0.5), smoothstep(0.65, 0.95, crease));
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.55 + grain * 0.3 - v_leatherTension * 0.2, 0.32, 0.95);
+  m.F0 = vec3(0.035);
+  return m;
+}
+
+MaterialSample sampleLinen(vec3 baseColor, vec3 pos, vec3 N, vec3 T, vec3 B) {
+  MaterialSample m;
+  float weaveU = sin(pos.x * 68.0);
+  float weaveV = sin(pos.z * 72.0);
+  float weft = weaveU * weaveV;
+  float fray = fbm(pos * 5.0 + vec3(v_layerNoise));
+  vec3 Np = perturb(N, T, B, vec3(weft * 0.05, fray * 0.04, 0.0));
+
+  vec3 tint = mix(kLinenBase, baseColor, 0.5);
+  tint *= 1.0 - 0.12 * fray;
+  tint = mix(tint, tint * vec3(0.92, 0.9, 0.85),
+             smoothstep(0.5, 1.0, v_bodyHeight));
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.78 + fray * 0.25, 0.35, 0.95);
+  m.F0 = vec3(0.028);
+  return m;
+}
 
-  // 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;
+MaterialSample sampleBronze(vec3 baseColor, vec3 pos, vec3 N, vec3 T, vec3 B) {
+  MaterialSample m;
+  float hammer = fbm(pos * 12.5);
+  float patina = fbm(pos * 6.0 + vec3(3.1, 0.2, 5.5));
+  vec3 Np =
+      perturb(N, T, B, vec3((hammer - 0.5) * 0.12, (patina - 0.5) * 0.08, 0.0));
+
+  vec3 tint = mix(kBronzeBase, baseColor, 0.35);
+  vec3 patinaColor = vec3(0.22, 0.5, 0.42);
+  tint = mix(tint, patinaColor, clamp(patina * 0.55, 0.0, 0.6));
+  tint += vec3(0.08) * pow(max(dot(Np, vec3(0.0, 1.0, 0.1)), 0.0), 6.0);
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.32 + hammer * 0.25 + patina * 0.2, 0.18, 0.72);
+  m.F0 = mix(vec3(0.06), vec3(0.95, 0.68, 0.48), 0.85);
+  return m;
+}
 
-  vec3 bump = normalize(N + (T * hx + B * hy) * strength);
-  return bump;
+vec3 applyWetDarkening(vec3 color, float wetMask) {
+  return mix(color, color * 0.6, wetMask);
 }
 
 void main() {
-  // Base color
-  vec3 color = u_color;
+  vec3 baseColor = u_color;
   if (u_useTexture) {
-    color *= texture(u_texture, v_texCoord).rgb;
+    baseColor *= 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.14 : (isLeather ? 0.28 : 0.38);
-  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.045, 0.05, 0.055);
-  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);
+  bool helmetRegion = (v_armorLayer < 0.5);
+  bool upperRegion = (v_armorLayer >= 0.5 && v_armorLayer < 1.5);
+
+  vec3 Nw = normalize(v_worldNormal);
+  vec3 Tw = normalize(v_tangent);
+  vec3 Bw = normalize(v_bitangent);
+
+  MaterialSample mat;
+  if (helmetRegion) {
+    mat = sampleBronze(baseColor, v_worldPos, Nw, Tw, Bw);
+  } else if (upperRegion) {
+    // Torso mixes linen and leather patches
+    MaterialSample linen = sampleLinen(baseColor, v_worldPos, Nw, Tw, Bw);
+    MaterialSample leather =
+        sampleLeather(baseColor, v_worldPos * 1.3, Nw, Tw, Bw);
+    float blend = smoothstep(0.3, 0.9, v_layerNoise);
+    mat.color = mix(linen.color, leather.color, blend * 0.6);
+    mat.normal = normalize(mix(linen.normal, leather.normal, blend * 0.5));
+    mat.roughness = mix(linen.roughness, leather.roughness, blend);
+    mat.F0 = mix(linen.F0, leather.F0, blend);
+  } else {
+    mat = sampleLeather(baseColor, v_worldPos * 1.1, Nw, Tw, Bw);
+  }
 
-    float kD = 1.0 - max(max(F.r, F.g), F.b);
-    vec3 diffuse = kD * albedo;
+  vec3 L = normalize(vec3(0.45, 1.15, 0.35));
+  vec3 V = normalize(vec3(0.0, 0.0, 1.0));
+  vec3 H = normalize(L + V);
 
-    cavityAO = 1.0 - (noise(uvW * 2.5) * 0.10) * cavityMask;
+  float NdotL = max(dot(mat.normal, L), 0.0);
+  float NdotV = max(dot(mat.normal, V), 0.0);
+  float NdotH = max(dot(mat.normal, H), 0.0);
+  float VdotH = max(dot(V, H), 0.0);
 
-    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
+  float wrap = helmetRegion ? 0.18 : 0.34;
+  float diff = max(NdotL * (1.0 - wrap) + wrap, 0.0);
 
-  } else {
-    // Generic matte
-    float detail = noise(uvW * 8.0) * 0.14;
-    albedo *= 1.0 + detail - 0.07;
+  float a = max(0.01, mat.roughness * mat.roughness);
+  float D = D_GGX(NdotH, a);
+  float G = geometrySmith(NdotV, NdotL, mat.roughness);
+  vec3 F = fresnelSchlick(VdotH, mat.F0);
+  vec3 spec = (D * G * F) / max(4.0 * NdotL * NdotV + 1e-5, 1e-5);
 
-    roughness = 0.7;
-    float a = roughness * roughness;
+  float kd = 1.0 - max(max(F.r, F.g), F.b);
+  if (helmetRegion) {
+    kd *= 0.2;
+  }
 
-    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 rain = clamp(u_rainIntensity, 0.0, 1.0);
+  float wetMask = rain * (1.0 - clamp(mat.normal.y, 0.0, 1.0)) *
+                  (0.4 + 0.6 * fbm(v_worldPos * vec3(1.4, 0.8, 1.2)));
 
-    float kD = 1.0 - max(max(F.r, F.g), F.b);
-    vec3 diffuse = kD * albedo;
+  vec3 ambient = hemiAmbient(mat.normal);
+  vec3 color = applyWetDarkening(mat.color, wetMask);
 
-    color = ambient * albedo + diffWrap * diffuse + specular * NdotL;
-  }
+  vec3 lighting =
+      ambient * color * 0.55 + color * kd * diff + spec * max(NdotL, 0.0);
 
-  color = mix(color, vec3(0.30, 0.55, 0.65), edgeMask * 0.18);
+  float grime = fbm(v_worldPos * 2.6 + vec3(v_layerNoise, v_bendAmount, 0.0));
+  lighting = mix(lighting, lighting * vec3(0.78, 0.74, 0.70),
+                 smoothstep(0.5, 0.95, grime));
 
-  // Final color clamp and alpha preserved
-  color = saturate(color);
-  FragColor = vec4(color, u_alpha);
+  FragColor = vec4(clamp(lighting, 0.0, 1.0), u_alpha);
 }

+ 67 - 17
assets/shaders/spearman_carthage.vert

@@ -8,26 +8,76 @@ uniform mat4 u_mvp;
 uniform mat4 u_model;
 
 out vec3 v_normal;
+out vec3 v_worldNormal;
+out vec3 v_tangent;
+out vec3 v_bitangent;
 out vec2 v_texCoord;
 out vec3 v_worldPos;
-out float
-    v_armorLayer; // Distinguish armor pieces for Carthaginian Libyan spearman
+out float v_armorLayer;
+out float v_leatherTension;
+out float v_bodyHeight;
+out float v_layerNoise;
+out float v_bendAmount;
+
+float hash13(vec3 p) {
+  return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
+}
+
+vec3 fallbackUp(vec3 normal) {
+  return (abs(normal.y) > 0.92) ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
+}
 
 void main() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * a_normal);
+
+  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(a_position, 1.0);
+  vec3 worldPos = modelPos.xyz;
+
+  float dentNoise = hash13(worldPos * 0.85 + worldNormal * 0.25);
+  float torsion = sin(worldPos.y * 11.5 + dentNoise * 6.28318);
+  vec3 dentOffset = worldNormal * ((dentNoise - 0.5) * 0.012);
+  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.15, -worldNormal.x));
+  vec3 shearOffset = shearAxis * torsion * 0.004;
+  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_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for Carthaginian Libyan infantry
-  // Upper body (helmet) = 0, Torso (linothorax/captured) = 1, Lower
-  // (greaves/skirt) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Captured helmet/Iberian style region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Linothorax/captured mail region
-  } else {
-    v_armorLayer = 2.0; // Leather greaves/pteruges region
-  }
-
-  gl_Position = u_mvp * vec4(a_position, 1.0);
+  v_normal = worldNormal;
+  v_worldNormal = worldNormal;
+  v_tangent = t;
+  v_bitangent = b;
+
+  float height = offsetPos.y;
+  float layer = 2.0;
+  if (height > 1.28)
+    layer = 0.0;
+  else if (height > 0.86)
+    layer = 1.0;
+  v_armorLayer = layer;
+
+  float tensionSeed = hash13(offsetPos * 0.35 + worldNormal * 1.7);
+  float heightFactor = smoothstep(0.5, 1.5, height);
+  float curvatureFactor = length(vec2(worldNormal.x, worldNormal.z));
+  v_leatherTension = mix(tensionSeed, 1.0 - tensionSeed, layer * 0.42) *
+                     (0.65 + curvatureFactor * 0.35) *
+                     (0.78 + heightFactor * 0.22);
+
+  float torsoMin = 0.58;
+  float torsoMax = 1.36;
+  v_bodyHeight =
+      clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
+  v_layerNoise = dentNoise;
+  v_bendAmount = torsion;
 }

+ 255 - 136
assets/shaders/swordsman_carthage.frag

@@ -1,9 +1,20 @@
 #version 330 core
 
 in vec3 v_normal;
+in vec3 v_worldNormal;
+in vec3 v_tangent;
+in vec3 v_bitangent;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
-in float v_armorLayer; // Armor layer from vertex shader
+in float v_armorLayer;
+in float v_bodyHeight;
+in float v_layerNoise;
+in float v_plateStress;
+in float v_lamellaPhase;
+in float v_latitudeMix;
+in float v_cuirassProfile;
+in float v_chainmailMix;
+in float v_frontMask;
 
 uniform sampler2D u_texture;
 uniform vec3 u_color;
@@ -12,169 +23,277 @@ uniform float u_alpha;
 
 out vec4 FragColor;
 
-float hash(vec2 p) {
-  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
-  p3 += dot(p3, p3.yzx + 33.33);
-  return fract((p3.x + p3.y) * p3.z);
+const vec3 kBronzeBase = vec3(0.60, 0.42, 0.18);
+const vec3 kLinenBase = vec3(0.88, 0.82, 0.72);
+const vec3 kLeatherBase = vec3(0.38, 0.25, 0.15);
+
+float hash21(vec2 p) {
+  p = fract(p * vec2(234.34, 435.345));
+  p += dot(p, p + 34.45);
+  return fract(p.x * p.y);
 }
 
-float noise(vec2 p) {
-  vec2 i = floor(p);
-  vec2 f = fract(p);
+float noise3(vec3 p) {
+  vec3 i = floor(p);
+  vec3 f = fract(p);
   f = f * f * (3.0 - 2.0 * f);
-  float a = hash(i);
-  float b = hash(i + vec2(1.0, 0.0));
-  float c = hash(i + vec2(0.0, 1.0));
-  float d = hash(i + vec2(1.0, 1.0));
-  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+  float n = i.x + i.y * 57.0 + i.z * 113.0;
+  return mix(
+      mix(mix(hash21(vec2(n, n + 1.0)), hash21(vec2(n + 57.0, n + 58.0)), f.x),
+          mix(hash21(vec2(n + 113.0, n + 114.0)),
+              hash21(vec2(n + 170.0, n + 171.0)), f.x),
+          f.y),
+      mix(mix(hash21(vec2(n + 226.0, n + 227.0)),
+              hash21(vec2(n + 283.0, n + 284.0)), f.x),
+          mix(hash21(vec2(n + 339.0, n + 340.0)),
+              hash21(vec2(n + 396.0, n + 397.0)), f.x),
+          f.y),
+      f.z);
 }
 
-// Medieval plate armor articulation lines
-float armorPlates(vec2 p, float y) {
-  // Horizontal articulation lines (overlapping plates)
-  float plateY = fract(y * 6.5);
-  float plateLine = smoothstep(0.90, 0.98, plateY) * 0.12;
-
-  // Brass rivet decorations
-  float rivetX = fract(p.x * 18.0);
-  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
-
-  return plateLine + rivetPattern;
+float fbm(vec3 p) {
+  float value = 0.0;
+  float amp = 0.5;
+  float freq = 1.0;
+  for (int i = 0; i < 5; ++i) {
+    value += amp * noise3(p * freq);
+    freq *= 1.85;
+    amp *= 0.55;
+  }
+  return value;
 }
 
-// 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);
+vec3 hemiAmbient(vec3 n) {
+  float up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
+  vec3 sky = vec3(0.57, 0.66, 0.78);
+  vec3 ground = vec3(0.32, 0.26, 0.20);
+  return mix(ground, sky, up);
+}
 
-  return (ringPattern + offsetPattern) * 0.15;
+float D_GGX(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);
 }
 
-void main() {
-  vec3 color = u_color;
-  if (u_useTexture) {
-    color *= texture(u_texture, v_texCoord).rgb;
-  }
+float geometrySchlickGGX(float NdotX, float k) {
+  return NdotX / max(NdotX * (1.0 - k) + k, 1e-6);
+}
 
-  vec3 normal = normalize(v_normal);
-  vec2 uv = v_worldPos.xz * 5.0;
-  float avgColor = (color.r + color.g + color.b) / 3.0;
+float geometrySmith(float NdotV, float NdotL, float roughness) {
+  float r = roughness + 1.0;
+  float k = (r * r) / 8.0;
+  return geometrySchlickGGX(NdotV, k) * geometrySchlickGGX(NdotL, k);
+}
 
-  // 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.10 && color.r > color.b * 1.10 && avgColor > 0.48);
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
 
-  // === MEDIEVAL KNIGHT MATERIALS ===
+vec3 perturb(vec3 N, vec3 T, vec3 B, vec3 amount) {
+  return normalize(N + T * amount.x + B * amount.y);
+}
 
-  // POLISHED STEEL PLATE (Great Helm, cuirass, pauldrons, rerebraces) - bright
-  // silvery
-  if (avgColor > 0.60 && !isBrass) {
-    // Mirror-polished steel finish
-    float brushedMetal = abs(sin(v_worldPos.y * 88.0)) * 0.024;
+struct MaterialSample {
+  vec3 color;
+  vec3 normal;
+  float roughness;
+  vec3 F0;
+};
+
+MaterialSample sampleHammeredBronze(vec3 baseColor, vec3 pos, vec3 N, vec3 T,
+                                    vec3 B) {
+  MaterialSample m;
+  float hammer = fbm(pos * 12.0);
+  float patina = fbm(pos * 5.0 + vec3(2.7, 0.3, 5.5));
+  vec3 Np =
+      perturb(N, T, B, vec3((hammer - 0.5) * 0.12, (patina - 0.5) * 0.07, 0.0));
+
+  vec3 tint = mix(kBronzeBase, baseColor, 0.35);
+  tint = mix(tint, vec3(0.20, 0.47, 0.40), clamp(patina * 0.55, 0.0, 0.6));
+  tint += vec3(0.05) * pow(max(dot(Np, vec3(0.0, 1.0, 0.1)), 0.0), 5.0);
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.3 + hammer * 0.25 + patina * 0.18, 0.18, 0.72);
+  m.F0 = mix(vec3(0.06), vec3(0.92, 0.66, 0.46), 0.9);
+  return m;
+}
 
-    // Battle wear: scratches and dents
-    float scratches = noise(uv * 33.0) * 0.019;
-    float dents = noise(uv * 7.5) * 0.024;
+MaterialSample sampleMuscleBronze(vec3 baseColor, vec3 pos, vec3 N, vec3 T,
+                                  vec3 B, float sculpt, float frontMask) {
+  MaterialSample m;
+  float hammer = fbm(pos * 9.0);
+  float profile = sculpt * 2.0 - 1.0;
+  float chestLine = sin(pos.x * 6.0 + profile * 3.5);
+  float abDivide = sin(pos.y * 11.0 - profile * 4.0);
+  vec3 Np = perturb(N, T, B,
+                    vec3((chestLine + profile * 0.6) * 0.06,
+                         (abDivide + frontMask * 0.4) * 0.05, 0.0));
+
+  vec3 tint = mix(kBronzeBase, baseColor, 0.65);
+  tint = mix(tint, tint * vec3(1.05, 0.98, 0.90),
+             smoothstep(-0.2, 0.8, profile) * 0.35);
+  tint += vec3(0.08) * frontMask * smoothstep(0.45, 0.95, sculpt);
+  tint -= vec3(0.04) * hammer;
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.28 + hammer * 0.25 - frontMask * 0.05, 0.16, 0.70);
+  m.F0 = mix(vec3(0.08), vec3(0.94, 0.68, 0.44), 0.85);
+  return m;
+}
 
-    // Plate articulation lines and rivets
-    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+MaterialSample sampleChainmail(vec3 baseColor, vec3 pos, vec3 N, vec3 T, vec3 B,
+                               float bandMix) {
+  MaterialSample m;
+  vec2 uv = pos.xz * 12.0 + pos.yx * 6.0;
+  float ringA = sin(uv.x) * cos(uv.y);
+  float ringB = sin((uv.x + uv.y) * 0.5);
+  float ringPattern = mix(ringA, ringB, 0.5);
+  float weave = fbm(vec3(uv, 0.0) * 0.6 + v_layerNoise);
+  vec3 Np = perturb(
+      N, T, B, vec3((ringPattern - 0.5) * 0.05, (bandMix - 0.5) * 0.04, 0.0));
+
+  vec3 tint = mix(vec3(0.46, 0.48, 0.53), baseColor, 0.3);
+  tint *= 1.0 - weave * 0.12;
+  tint += vec3(0.05) * smoothstep(0.4, 0.9, bandMix);
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.42 - ringPattern * 0.08 + weave * 0.18, 0.2, 0.85);
+  m.F0 = vec3(0.14);
+  return m;
+}
 
-    // Strong specular reflections (polished metal)
-    float viewAngle = abs(dot(normal, normalize(vec3(0.1, 1.0, 0.3))));
-    float fresnel = pow(1.0 - viewAngle, 1.9) * 0.33;
-    float specular = pow(viewAngle, 11.5) * 0.50;
+MaterialSample sampleLamellarLinen(vec3 baseColor, vec3 pos, vec3 N, vec3 T,
+                                   vec3 B) {
+  MaterialSample m;
+  float slat = sin(pos.x * 32.0 + v_plateStress * 1.5);
+  float seam = v_lamellaPhase;
+  float edge = smoothstep(0.92, 1.0, seam) + smoothstep(0.0, 0.08, seam);
+  float weave = fbm(pos * 6.0 + vec3(v_layerNoise));
+  vec3 Np = perturb(N, T, B, vec3(slat * 0.04, weave * 0.03, 0.0));
+
+  vec3 tint = mix(kLinenBase, baseColor, 0.4);
+  tint *= 1.0 - 0.12 * weave;
+  tint = mix(tint, tint * vec3(0.82, 0.76, 0.70),
+             smoothstep(0.55, 1.0, v_bodyHeight));
+
+  float plateHighlight = edge * 0.10;
+  float rivetNoise = smoothstep(0.85, 1.0, seam) *
+                     hash21(vec2(pos.x * 9.0, pos.y * 7.0)) * 0.04;
+
+  m.color = tint + vec3(plateHighlight + rivetNoise);
+  m.normal = Np;
+  m.roughness = clamp(0.62 + weave * 0.18 - edge * 0.1, 0.35, 0.9);
+  m.F0 = vec3(0.028);
+  return m;
+}
 
-    // Environmental reflections (sky dome)
-    float skyReflection = (normal.y * 0.5 + 0.5) * 0.10;
+MaterialSample sampleDyedLeather(vec3 baseColor, vec3 pos, vec3 N, vec3 T,
+                                 vec3 B) {
+  MaterialSample m;
+  float grain = fbm(pos * 4.0);
+  float crack = fbm(pos * 9.0 + vec3(0.0, 1.7, 2.3));
+  vec3 Np =
+      perturb(N, T, B, vec3((grain - 0.5) * 0.08, (crack - 0.5) * 0.06, 0.0));
+
+  vec3 tint = mix(kLeatherBase, baseColor, 0.5);
+  tint *= 1.0 - 0.12 * grain;
+  tint += vec3(0.05) * smoothstep(0.4, 0.9, crack);
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.55 + grain * 0.25 - crack * 0.18, 0.25, 0.95);
+  m.F0 = vec3(0.035);
+  return m;
+}
 
-    color += vec3(fresnel + skyReflection + specular * 1.8);
-    color += vec3(plates);
-    color += vec3(brushedMetal);
-    color -= vec3(scratches + dents * 0.4);
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture) {
+    baseColor *= texture(u_texture, v_texCoord).rgb;
   }
-  // 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.5) * 0.32;
-    float brassFresnel = pow(1.0 - viewAngle, 2.6) * 0.18;
-
-    color += vec3(brassSheen + brassFresnel);
-    color += vec3(brassNoise);
-    color -= vec3(patina * 0.5); // Darker in recesses
+
+  bool helmetRegion = (v_armorLayer < 0.5);
+  bool torsoRegion = (v_armorLayer >= 0.5 && v_armorLayer < 1.5);
+
+  vec3 Nw = normalize(v_worldNormal);
+  vec3 Tw = normalize(v_tangent);
+  vec3 Bw = normalize(v_bitangent);
+
+  MaterialSample mat;
+  if (helmetRegion) {
+    mat = sampleHammeredBronze(baseColor, v_worldPos, Nw, Tw, Bw);
+  } else if (torsoRegion) {
+    MaterialSample bronze = sampleMuscleBronze(
+        baseColor, v_worldPos, Nw, Tw, Bw, v_cuirassProfile, v_frontMask);
+    MaterialSample mail = sampleChainmail(
+        baseColor, v_worldPos * 1.1, Nw, Tw, Bw,
+        clamp(v_chainmailMix + (1.0 - v_frontMask) * 0.25, 0.0, 1.0));
+    float mailBlend = smoothstep(0.3, 0.85, v_chainmailMix) *
+                      smoothstep(0.1, 0.85, 1.0 - v_frontMask);
+    float bronzeBlend = 1.0 - mailBlend;
+    mat.color = bronze.color * bronzeBlend + mail.color * mailBlend;
+    mat.normal =
+        normalize(bronze.normal * bronzeBlend + mail.normal * mailBlend);
+    mat.roughness = mix(bronze.roughness, mail.roughness, mailBlend);
+    mat.F0 = mix(bronze.F0, mail.F0, mailBlend);
+  } else {
+    MaterialSample greave =
+        sampleHammeredBronze(baseColor, v_worldPos * 1.2, Nw, Tw, Bw);
+    MaterialSample skirtMail =
+        sampleChainmail(baseColor, v_worldPos * 0.9, Nw, Tw, Bw,
+                        clamp(v_chainmailMix + 0.35, 0.0, 1.0));
+    MaterialSample skirtLeather =
+        sampleDyedLeather(baseColor, v_worldPos * 0.8, Nw, Tw, Bw);
+    float mailBias = smoothstep(0.25, 0.75, v_chainmailMix);
+    float bronzeMix = smoothstep(0.2, 0.6, v_layerNoise);
+    float skirtBlend = mix(mailBias, bronzeMix, 0.4);
+    float mailWeight = clamp(skirtBlend, 0.0, 1.0);
+    mat.color = mix(skirtLeather.color, greave.color, bronzeMix);
+    mat.color = mix(mat.color, skirtMail.color, mailWeight * 0.7);
+    mat.normal = normalize(mix(skirtLeather.normal, greave.normal, bronzeMix));
+    mat.normal = normalize(mix(mat.normal, skirtMail.normal, mailWeight * 0.7));
+    mat.roughness = mix(skirtLeather.roughness, greave.roughness, bronzeMix);
+    mat.roughness = mix(mat.roughness, skirtMail.roughness, mailWeight * 0.7);
+    mat.F0 = mix(skirtLeather.F0, greave.F0, bronzeMix);
+    mat.F0 = mix(mat.F0, skirtMail.F0, mailWeight * 0.7);
   }
-  // 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;
+  vec3 L = normalize(vec3(0.45, 1.12, 0.35));
+  vec3 V = normalize(vec3(0.0, 0.0, 1.0));
+  vec3 H = normalize(L + V);
 
-    // Individual ring highlights
-    float ringHighlights = noise(uv * 30.0) * 0.12;
+  float NdotL = max(dot(mat.normal, L), 0.0);
+  float NdotV = max(dot(mat.normal, V), 0.0);
+  float NdotH = max(dot(mat.normal, H), 0.0);
+  float VdotH = max(dot(V, H), 0.0);
 
-    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);
-  }
-  // LEATHER/DARK ELEMENTS (straps, gloves, scabbard) - dark brown
-  else {
-    float leatherGrain = noise(uv * 10.0) * 0.15;
-    float wearMarks = noise(uv * 3.0) * 0.10;
+  float wrap = helmetRegion ? 0.15 : (torsoRegion ? 0.28 : 0.32);
+  float diff = max(NdotL * (1.0 - wrap) + wrap, 0.18);
 
-    color *= 1.0 + leatherGrain - 0.08 + wearMarks - 0.05;
-  }
+  float a = max(0.01, mat.roughness * mat.roughness);
+  float D = D_GGX(NdotH, a);
+  float G = geometrySmith(NdotV, NdotL, mat.roughness);
+  vec3 F = fresnelSchlick(VdotH, mat.F0);
+  vec3 spec = (D * G * F) / max(4.0 * NdotL * NdotV + 1e-5, 1e-5);
 
-  float seaBlend = saturate((color.g + color.b) * 0.5 - color.r * 0.3) * 0.10;
-  color = mix(color, vec3(0.24, 0.58, 0.68), seaBlend);
-  color = clamp(color, 0.0, 1.0);
+  float kd = 1.0 - max(max(F.r, F.g), F.b);
+  if (helmetRegion) {
+    kd *= 0.25;
+  }
 
-  // Lighting model - hard shadows for metal, soft for fabric
-  vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
-  float nDotL = dot(normal, lightDir);
+  vec3 ambient = hemiAmbient(mat.normal);
+  vec3 color = mat.color;
 
-  // Metal = hard shadows, Fabric = soft wrap
-  float wrapAmount = (avgColor > 0.50) ? 0.08 : 0.30;
-  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.18);
+  float grime = fbm(v_worldPos * vec3(2.0, 1.4, 2.3));
+  color =
+      mix(color, color * vec3(0.78, 0.72, 0.68), smoothstep(0.45, 0.9, grime));
 
-  // Extra contrast for polished steel
-  if (avgColor > 0.60 && !isBrass) {
-    diff = pow(diff, 0.85); // Sharper lighting falloff
-  }
+  vec3 lighting =
+      ambient * color * 0.55 + color * kd * diff + spec * max(NdotL, 0.0);
 
-  color *= diff;
-  FragColor = vec4(color, u_alpha);
+  FragColor = vec4(clamp(lighting, 0.0, 1.0), u_alpha);
 }

+ 79 - 13
assets/shaders/swordsman_carthage.vert

@@ -8,25 +8,91 @@ uniform mat4 u_mvp;
 uniform mat4 u_model;
 
 out vec3 v_normal;
+out vec3 v_worldNormal;
+out vec3 v_tangent;
+out vec3 v_bitangent;
 out vec2 v_texCoord;
 out vec3 v_worldPos;
-out float v_armorLayer; // Distinguish armor pieces for Carthaginian sacred band
-                        // swordsman
+out float v_armorLayer;
+out float v_bodyHeight;
+out float v_layerNoise;
+out float v_plateStress;
+out float v_lamellaPhase;
+out float v_latitudeMix;
+out float v_cuirassProfile;
+out float v_chainmailMix;
+out float v_frontMask;
+
+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() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * a_normal);
+
+  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(a_position, 1.0);
+  vec3 worldPos = modelPos.xyz;
+
+  float dentSeed = hash13(worldPos * 0.8 + worldNormal * 0.3);
+  float torsion = sin(worldPos.y * 9.5 + dentSeed * 15.0);
+  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.01);
+  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.2, -worldNormal.x));
+  vec3 shearOffset = shearAxis * torsion * 0.0035;
+
+  vec3 batteredPos = worldPos + dentOffset + shearOffset;
+  vec3 offsetPos = batteredPos + worldNormal * 0.005;
+
+  mat4 invModel = inverse(u_model);
+  vec4 localPosition = invModel * vec4(batteredPos, 1.0);
+  gl_Position = u_mvp * localPosition;
+
+  v_worldPos = offsetPos;
   v_texCoord = a_texCoord;
-  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for Carthaginian elite infantry
-  // Upper body (helmet) = 0, Torso (linothorax/bronze) = 1, Lower (greaves) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Montefortino/Corinthian helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Bronze cuirass/linothorax region
+  v_normal = worldNormal;
+  v_worldNormal = worldNormal;
+  v_tangent = t;
+  v_bitangent = b;
+
+  vec3 localPos = localPosition.xyz;
+  float localHeight = clamp((localPos.y + 0.30) / 1.45, 0.0, 1.0);
+  float chestSplit = smoothstep(0.35, 0.78, localHeight);
+  float ribWave = sin((localPos.y + 0.15) * 8.5 + localPos.x * 2.1);
+  float abWave = sin((localHeight - 0.35) * 12.0 - localPos.x * 1.7);
+  float sculpt = mix(ribWave, abWave, chestSplit);
+  v_cuirassProfile = clamp(0.5 + 0.45 * sculpt, 0.0, 1.0);
+
+  float mailBands = smoothstep(0.15, 0.60, 1.0 - localHeight);
+  float mailPattern = 0.5 + 0.5 * sin(localPos.x * 9.0 + localPos.y * 6.2);
+  v_chainmailMix = clamp(mailBands * mailPattern, 0.0, 1.0);
+
+  v_frontMask = clamp(smoothstep(-0.18, 0.18, -localPos.z), 0.0, 1.0);
+
+  float height = offsetPos.y;
+  if (height > 1.5) {
+    v_armorLayer = 0.0;
+  } else if (height > 0.8) {
+    v_armorLayer = 1.0;
   } else {
-    v_armorLayer = 2.0; // Bronze greaves/leather skirt region
+    v_armorLayer = 2.0;
   }
 
-  gl_Position = u_mvp * vec4(a_position, 1.0);
+  float torsoMin = 0.58;
+  float torsoMax = 1.50;
+  v_bodyHeight =
+      clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
+  v_layerNoise = dentSeed;
+  v_plateStress = torsion;
+  v_lamellaPhase = fract(offsetPos.y * 4.25 + offsetPos.x * 0.12);
+  v_latitudeMix = clamp((offsetPos.z + 2.5) * 0.2, 0.0, 1.0);
 }

+ 5 - 1
render/CMakeLists.txt

@@ -74,7 +74,8 @@ add_library(render_gl STATIC
     equipment/armor/tunic_renderer.cpp
     equipment/armor/kingdom_armor.cpp
     equipment/armor/roman_armor.cpp
-    equipment/armor/carthage_armor.cpp
+    equipment/armor/armor_light_carthage.cpp
+    equipment/armor/armor_heavy_carthage.cpp
     equipment/weapons/bow_renderer.cpp
     equipment/weapons/quiver_renderer.cpp
     equipment/weapons/roman_scutum.cpp
@@ -87,6 +88,9 @@ add_library(render_gl STATIC
     equipment/helmets/roman_light_helmet.cpp
     equipment/helmets/kingdom_heavy_helmet.cpp
     equipment/helmets/kingdom_light_helmet.cpp
+    equipment/helmets/carthage_heavy_helmet.cpp
+    equipment/helmets/carthage_light_helmet.cpp
+    equipment/armor/chainmail_armor.cpp
 )
 
 target_include_directories(render_gl PUBLIC .)

+ 4 - 4
render/entity/barracks_flag_renderer.h

@@ -25,7 +25,7 @@ struct FlagColors {
 };
 
 inline void draw_rally_flag_if_any(const DrawContext &p, ISubmitter &out,
-                               Texture *white, const FlagColors &colors) {
+                                   Texture *white, const FlagColors &colors) {
   if (auto *prod =
           p.entity->getComponent<Engine::Core::ProductionComponent>()) {
     if (prod->rallySet && (p.resources != nullptr)) {
@@ -81,9 +81,9 @@ struct CaptureColors {
 };
 
 inline CaptureColors get_capture_colors(const DrawContext &p,
-                                      const QVector3D &baseTeamColor,
-                                      const QVector3D &baseTeamTrim,
-                                      float maxLowering = 0.0F) {
+                                        const QVector3D &baseTeamColor,
+                                        const QVector3D &baseTeamTrim,
+                                        float maxLowering = 0.0F) {
   CaptureColors result{baseTeamColor, baseTeamTrim, 0.0F};
 
   if (p.entity != nullptr) {

+ 30 - 10
render/entity/nations/carthage/archer_renderer.cpp

@@ -83,11 +83,19 @@ class ArcherRenderer : public HumanoidRendererBase {
 public:
   auto get_proportion_scaling() const -> QVector3D override {
 
-    return {1.15F, 1.02F, 0.75F};
+    return {0.82F, 1.08F, 0.82F};
+  }
+
+  void adjust_variation(const DrawContext &, uint32_t,
+                        VariationParams &variation) const override {
+    variation.height_scale *= 1.06F;
+    variation.bulkScale *= 0.72F;
+    variation.stanceWidth *= 0.80F;
+    variation.armSwingAmp *= 0.92F;
   }
 
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -162,8 +170,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -292,7 +300,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
     using HP = HumanProportions;
 
     auto const &style = resolve_style(ctx);
@@ -310,19 +318,24 @@ public:
       return;
     }
 
-    auto helmet = registry.get(EquipmentCategory::Helmet, "montefortino");
+    auto helmet = registry.get(EquipmentCategory::Helmet, "carthage_light");
     if (helmet) {
       helmet->render(ctx, pose.bodyFrames, v.palette, anim_ctx, out);
     }
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     if (resolve_style(ctx).show_armor) {
       auto &registry = EquipmentRegistry::instance();
-      auto armor =
-          registry.get(EquipmentCategory::Armor, "carthage_light_armor");
+      auto const &style = resolve_style(ctx);
+
+      std::string armor_key =
+          style.armor_id.empty() ? "armor_light_carthage" : style.armor_id;
+
+      auto armor = registry.get(EquipmentCategory::Armor, armor_key);
       if (armor) {
         armor->render(ctx, pose.bodyFrames, v.palette, anim, out);
       }
@@ -392,6 +405,13 @@ void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
         if (ctx.backend != nullptr) {
           QString shader_key = static_renderer.resolve_shader_key(ctx);
           archer_shader = ctx.backend->shader(shader_key);
+          if ((archer_shader == nullptr) &&
+              shader_key == QStringLiteral("archer_carthage")) {
+            archer_shader = ctx.backend->getOrLoadShader(
+                shader_key,
+                QStringLiteral(":/assets/shaders/archer_carthage.vert"),
+                QStringLiteral(":/assets/shaders/archer_carthage.frag"));
+          }
           if (archer_shader == nullptr) {
             archer_shader = ctx.backend->shader(QStringLiteral("archer"));
           }

+ 1 - 0
render/entity/nations/carthage/archer_style.cpp

@@ -33,6 +33,7 @@ void register_carthage_archer_style() {
   style.force_beard = true;
   style.attachment_profile.clear();
   style.shader_id = "archer_carthage";
+  style.armor_id = "armor_light_carthage";
 
   register_archer_style("carthage", style);
 }

+ 1 - 0
render/entity/nations/carthage/archer_style.h

@@ -25,6 +25,7 @@ struct ArcherStyleConfig {
 
   std::string attachment_profile;
   std::string shader_id;
+  std::string armor_id;
 };
 
 void register_carthage_archer_style();

+ 72 - 66
render/entity/nations/carthage/barracks_renderer.cpp

@@ -47,8 +47,8 @@ inline auto make_palette(const QVector3D &team) -> CarthagePalette {
 }
 
 inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
-                    const QMatrix4x4 &model, const QVector3D &pos,
-                    const QVector3D &size, const QVector3D &color) {
+                     const QMatrix4x4 &model, const QVector3D &pos,
+                     const QVector3D &size, const QVector3D &color) {
   QMatrix4x4 m = model;
   m.translate(pos);
   m.scale(size);
@@ -56,33 +56,33 @@ inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
 }
 
 inline void draw_cyl(ISubmitter &out, const QMatrix4x4 &model,
-                    const QVector3D &a, const QVector3D &b, float r,
-                    const QVector3D &color, Texture *white) {
+                     const QVector3D &a, const QVector3D &b, float r,
+                     const QVector3D &color, Texture *white) {
   out.mesh(getUnitCylinder(), model * cylinderBetween(a, b, r), color, white,
            1.0F);
 }
 
 void draw_platform(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                  Texture *white, const CarthagePalette &c) {
+                   Texture *white, const CarthagePalette &c) {
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.08F, 0.0F),
-          QVector3D(2.0F, 0.08F, 1.8F), c.limestone_dark);
+           QVector3D(2.0F, 0.08F, 1.8F), c.limestone_dark);
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.18F, 0.0F),
-          QVector3D(1.8F, 0.02F, 1.6F), c.limestone);
+           QVector3D(1.8F, 0.02F, 1.6F), c.limestone);
 
   for (float x = -1.5F; x <= 1.5F; x += 0.35F) {
     for (float z = -1.3F; z <= 1.3F; z += 0.35F) {
       if (fabsf(x) > 0.6F || fabsf(z) > 0.5F) {
         draw_box(out, unit, white, p.model, QVector3D(x, 0.21F, z),
-                QVector3D(0.15F, 0.01F, 0.15F), c.terracotta);
+                 QVector3D(0.15F, 0.01F, 0.15F), c.terracotta);
       }
     }
   }
 }
 
 void draw_colonnade(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                   Texture *white, const CarthagePalette &c) {
+                    Texture *white, const CarthagePalette &c) {
   float const col_height = 1.6F;
   float const col_radius = 0.10F;
 
@@ -91,18 +91,19 @@ void draw_colonnade(const DrawContext &p, ISubmitter &out, Mesh *unit,
     float const z = 1.4F;
 
     draw_box(out, unit, white, p.model, QVector3D(x, 0.25F, z),
-            QVector3D(col_radius * 1.2F, 0.05F, col_radius * 1.2F), c.marble);
+             QVector3D(col_radius * 1.2F, 0.05F, col_radius * 1.2F), c.marble);
 
     draw_cyl(out, p.model, QVector3D(x, 0.2F, z),
-            QVector3D(x, 0.2F + col_height, z), col_radius, c.limestone, white);
+             QVector3D(x, 0.2F + col_height, z), col_radius, c.limestone,
+             white);
 
     draw_box(out, unit, white, p.model,
-            QVector3D(x, 0.2F + col_height + 0.05F, z),
-            QVector3D(col_radius * 1.5F, 0.08F, col_radius * 1.5F), c.marble);
+             QVector3D(x, 0.2F + col_height + 0.05F, z),
+             QVector3D(col_radius * 1.5F, 0.08F, col_radius * 1.5F), c.marble);
 
     draw_box(out, unit, white, p.model,
-            QVector3D(x, 0.2F + col_height + 0.12F, z),
-            QVector3D(col_radius * 1.3F, 0.04F, col_radius * 1.3F), c.gold);
+             QVector3D(x, 0.2F + col_height + 0.12F, z),
+             QVector3D(col_radius * 1.3F, 0.04F, col_radius * 1.3F), c.gold);
   }
 
   for (int i = 0; i < 3; ++i) {
@@ -110,107 +111,107 @@ void draw_colonnade(const DrawContext &p, ISubmitter &out, Mesh *unit,
 
     float const x_left = -1.6F;
     draw_box(out, unit, white, p.model, QVector3D(x_left, 0.25F, z),
-            QVector3D(col_radius * 1.2F, 0.05F, col_radius * 1.2F), c.marble);
+             QVector3D(col_radius * 1.2F, 0.05F, col_radius * 1.2F), c.marble);
     draw_cyl(out, p.model, QVector3D(x_left, 0.2F, z),
-            QVector3D(x_left, 0.2F + col_height, z), col_radius, c.limestone,
-            white);
+             QVector3D(x_left, 0.2F + col_height, z), col_radius, c.limestone,
+             white);
     draw_box(out, unit, white, p.model,
-            QVector3D(x_left, 0.2F + col_height + 0.05F, z),
-            QVector3D(col_radius * 1.5F, 0.08F, col_radius * 1.5F), c.marble);
+             QVector3D(x_left, 0.2F + col_height + 0.05F, z),
+             QVector3D(col_radius * 1.5F, 0.08F, col_radius * 1.5F), c.marble);
 
     float const x_right = 1.6F;
     draw_box(out, unit, white, p.model, QVector3D(x_right, 0.25F, z),
-            QVector3D(col_radius * 1.2F, 0.05F, col_radius * 1.2F), c.marble);
+             QVector3D(col_radius * 1.2F, 0.05F, col_radius * 1.2F), c.marble);
     draw_cyl(out, p.model, QVector3D(x_right, 0.2F, z),
-            QVector3D(x_right, 0.2F + col_height, z), col_radius, c.limestone,
-            white);
+             QVector3D(x_right, 0.2F + col_height, z), col_radius, c.limestone,
+             white);
     draw_box(out, unit, white, p.model,
-            QVector3D(x_right, 0.2F + col_height + 0.05F, z),
-            QVector3D(col_radius * 1.5F, 0.08F, col_radius * 1.5F), c.marble);
+             QVector3D(x_right, 0.2F + col_height + 0.05F, z),
+             QVector3D(col_radius * 1.5F, 0.08F, col_radius * 1.5F), c.marble);
   }
 }
 
 void draw_central_courtyard(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                          Texture *white, const CarthagePalette &c) {
+                            Texture *white, const CarthagePalette &c) {
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.22F, 0.0F),
-          QVector3D(1.3F, 0.01F, 1.1F), c.limestone_shade);
+           QVector3D(1.3F, 0.01F, 1.1F), c.limestone_shade);
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.24F, 0.0F),
-          QVector3D(0.7F, 0.02F, 0.5F), c.blue_light);
+           QVector3D(0.7F, 0.02F, 0.5F), c.blue_light);
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.25F, -0.52F),
-          QVector3D(0.72F, 0.02F, 0.02F), c.blue_accent);
+           QVector3D(0.72F, 0.02F, 0.02F), c.blue_accent);
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.25F, 0.52F),
-          QVector3D(0.72F, 0.02F, 0.02F), c.blue_accent);
+           QVector3D(0.72F, 0.02F, 0.02F), c.blue_accent);
 
   draw_cyl(out, p.model, QVector3D(0.0F, 0.25F, 0.0F),
-          QVector3D(0.0F, 0.55F, 0.0F), 0.06F, c.marble, white);
+           QVector3D(0.0F, 0.55F, 0.0F), 0.06F, c.marble, white);
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.58F, 0.0F),
-          QVector3D(0.08F, 0.03F, 0.08F), c.blue_accent);
+           QVector3D(0.08F, 0.03F, 0.08F), c.blue_accent);
 }
 
 void draw_chamber(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                 Texture *white, const CarthagePalette &c) {
+                  Texture *white, const CarthagePalette &c) {
   float const wall_h = 1.4F;
 
   draw_box(out, unit, white, p.model,
-          QVector3D(0.0F, wall_h * 0.5F + 0.2F, -1.2F),
-          QVector3D(1.4F, wall_h * 0.5F, 0.1F), c.limestone);
+           QVector3D(0.0F, wall_h * 0.5F + 0.2F, -1.2F),
+           QVector3D(1.4F, wall_h * 0.5F, 0.1F), c.limestone);
 
   draw_box(out, unit, white, p.model,
-          QVector3D(-1.5F, wall_h * 0.5F + 0.2F, -0.5F),
-          QVector3D(0.1F, wall_h * 0.5F, 0.6F), c.limestone);
+           QVector3D(-1.5F, wall_h * 0.5F + 0.2F, -0.5F),
+           QVector3D(0.1F, wall_h * 0.5F, 0.6F), c.limestone);
   draw_box(out, unit, white, p.model,
-          QVector3D(1.5F, wall_h * 0.5F + 0.2F, -0.5F),
-          QVector3D(0.1F, wall_h * 0.5F, 0.6F), c.limestone);
+           QVector3D(1.5F, wall_h * 0.5F + 0.2F, -0.5F),
+           QVector3D(0.1F, wall_h * 0.5F, 0.6F), c.limestone);
 
   draw_box(out, unit, white, p.model, QVector3D(-0.6F, 0.65F, -1.15F),
-          QVector3D(0.25F, 0.35F, 0.03F), c.cedar_dark);
+           QVector3D(0.25F, 0.35F, 0.03F), c.cedar_dark);
   draw_box(out, unit, white, p.model, QVector3D(-0.6F, 0.98F, -1.15F),
-          QVector3D(0.25F, 0.05F, 0.03F), c.blue_accent);
+           QVector3D(0.25F, 0.05F, 0.03F), c.blue_accent);
 
   draw_box(out, unit, white, p.model, QVector3D(0.6F, 0.65F, -1.15F),
-          QVector3D(0.25F, 0.35F, 0.03F), c.cedar_dark);
+           QVector3D(0.25F, 0.35F, 0.03F), c.cedar_dark);
   draw_box(out, unit, white, p.model, QVector3D(0.6F, 0.98F, -1.15F),
-          QVector3D(0.25F, 0.05F, 0.03F), c.blue_accent);
+           QVector3D(0.25F, 0.05F, 0.03F), c.blue_accent);
 }
 
 void draw_terrace(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                 Texture *white, const CarthagePalette &c) {
+                  Texture *white, const CarthagePalette &c) {
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.05F, 0.0F),
-          QVector3D(1.7F, 0.08F, 1.5F), c.marble);
+           QVector3D(1.7F, 0.08F, 1.5F), c.marble);
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.12F, 1.45F),
-          QVector3D(1.65F, 0.05F, 0.05F), c.gold);
+           QVector3D(1.65F, 0.05F, 0.05F), c.gold);
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.18F, -0.2F),
-          QVector3D(1.5F, 0.04F, 1.0F), c.terracotta);
+           QVector3D(1.5F, 0.04F, 1.0F), c.terracotta);
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.28F, -0.65F),
-          QVector3D(1.45F, 0.06F, 0.05F), c.limestone);
+           QVector3D(1.45F, 0.06F, 0.05F), c.limestone);
 
   for (float x : {-1.4F, 1.4F}) {
     draw_box(out, unit, white, p.model, QVector3D(x, 2.35F, -0.65F),
-            QVector3D(0.08F, 0.08F, 0.08F), c.gold);
+             QVector3D(0.08F, 0.08F, 0.08F), c.gold);
   }
 }
 
 void draw_trading_goods(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                      Texture *white, const CarthagePalette &c) {
+                        Texture *white, const CarthagePalette &c) {
 
   draw_cyl(out, p.model, QVector3D(-1.2F, 0.2F, 1.1F),
-          QVector3D(-1.2F, 0.5F, 1.1F), 0.08F, c.terracotta_dark, white);
+           QVector3D(-1.2F, 0.5F, 1.1F), 0.08F, c.terracotta_dark, white);
   draw_cyl(out, p.model, QVector3D(-0.9F, 0.2F, 1.15F),
-          QVector3D(-0.9F, 0.45F, 1.15F), 0.07F, c.terracotta, white);
+           QVector3D(-0.9F, 0.45F, 1.15F), 0.07F, c.terracotta, white);
 
   draw_cyl(out, p.model, QVector3D(1.1F, 0.2F, -0.9F),
-          QVector3D(1.1F, 0.42F, -0.9F), 0.06F, c.blue_accent, white);
+           QVector3D(1.1F, 0.42F, -0.9F), 0.06F, c.blue_accent, white);
 }
 
 void draw_phoenician_banner(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                          Texture *white, const CarthagePalette &c) {
+                            Texture *white, const CarthagePalette &c) {
   float const pole_x = 0.0F;
   float const pole_z = -2.0F;
   float const pole_height = 2.4F;
@@ -274,12 +275,12 @@ void draw_phoenician_banner(const DrawContext &p, ISubmitter &out, Mesh *unit,
   out.mesh(unit, trimTop, captureColors.teamTrimColor, white, 1.0F);
 
   draw_box(out, unit, white, p.model,
-          QVector3D(pole_x + 0.2F, pole_height + 0.12F, pole_z + 0.03F),
-          QVector3D(0.26F, 0.02F, 0.01F), c.gold);
+           QVector3D(pole_x + 0.2F, pole_height + 0.12F, pole_z + 0.03F),
+           QVector3D(0.26F, 0.02F, 0.01F), c.gold);
 }
 
 void draw_rally_flag(const DrawContext &p, ISubmitter &out, Texture *white,
-                   const CarthagePalette &c) {
+                     const CarthagePalette &c) {
   BarracksFlagRenderer::FlagColors colors{.team = c.team,
                                           .teamTrim = c.team_trim,
                                           .timber = c.cedar,
@@ -289,27 +290,30 @@ void draw_rally_flag(const DrawContext &p, ISubmitter &out, Texture *white,
 }
 
 void draw_health_bar(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                   Texture *white) {
-  if (p.entity == nullptr)
+                     Texture *white) {
+  if (p.entity == nullptr) {
     return;
+  }
   auto *u = p.entity->getComponent<Engine::Core::UnitComponent>();
-  if (u == nullptr)
+  if (u == nullptr) {
     return;
+  }
 
   float const ratio =
       std::clamp(u->health / float(std::max(1, u->max_health)), 0.0F, 1.0F);
-  if (ratio <= 0.0F)
+  if (ratio <= 0.0F) {
     return;
+  }
 
   QVector3D const bg(0.06F, 0.06F, 0.06F);
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.65F, 0.0F),
-          QVector3D(0.9F, 0.04F, 0.06F), bg);
+           QVector3D(0.9F, 0.04F, 0.06F), bg);
 
   QVector3D const fg = QVector3D(0.22F, 0.78F, 0.22F) * ratio +
                        QVector3D(0.85F, 0.15F, 0.15F) * (1.0F - ratio);
   draw_box(out, unit, white, p.model,
-          QVector3D(-(0.9F * (1.0F - ratio)) * 0.5F, 2.66F, 0.0F),
-          QVector3D(0.9F * ratio * 0.5F, 0.035F, 0.055F), fg);
+           QVector3D(-(0.9F * (1.0F - ratio)) * 0.5F, 2.66F, 0.0F),
+           QVector3D(0.9F * ratio * 0.5F, 0.035F, 0.055F), fg);
 }
 
 void draw_selection(const DrawContext &p, ISubmitter &out) {
@@ -325,13 +329,15 @@ void draw_selection(const DrawContext &p, ISubmitter &out) {
 }
 
 void draw_barracks(const DrawContext &p, ISubmitter &out) {
-  if (!p.resources || !p.entity)
+  if (!p.resources || !p.entity) {
     return;
+  }
 
   auto *t = p.entity->getComponent<Engine::Core::TransformComponent>();
   auto *r = p.entity->getComponent<Engine::Core::RenderableComponent>();
-  if (!t || !r)
+  if (!t || !r) {
     return;
+  }
 
   Mesh *unit = p.resources->unit();
   Texture *white = p.resources->white();

+ 8 - 7
render/entity/nations/carthage/horse_swordsman_renderer.cpp

@@ -63,7 +63,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
   }
@@ -83,8 +83,8 @@ public:
   }
 
   void customize_pose(const DrawContext &ctx,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -298,7 +298,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "montefortino");
@@ -309,10 +309,11 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
-    auto armor = registry.get(EquipmentCategory::Armor, "carthage_heavy_armor");
+    auto armor = registry.get(EquipmentCategory::Armor, "armor_heavy_carthage");
     if (armor) {
       armor->render(ctx, pose.bodyFrames, v.palette, anim, out);
     }

+ 61 - 11
render/entity/nations/carthage/spearman_renderer.cpp

@@ -44,6 +44,31 @@ constexpr float k_spearman_style_mix_weight = 0.4F;
 constexpr float k_kneel_depth_multiplier = 0.875F;
 constexpr float k_lean_amount_multiplier = 0.67F;
 
+struct SpearmanShaderResourcePaths {
+  QString vertex;
+  QString fragment;
+};
+
+auto lookup_spearman_shader_resources(const QString &shader_key)
+    -> std::optional<SpearmanShaderResourcePaths> {
+  if (shader_key == QStringLiteral("spearman_carthage")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_carthage.vert"),
+        QStringLiteral(":/assets/shaders/spearman_carthage.frag")};
+  }
+  if (shader_key == QStringLiteral("spearman_kingdom_of_iron")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_kingdom_of_iron.vert"),
+        QStringLiteral(":/assets/shaders/spearman_kingdom_of_iron.frag")};
+  }
+  if (shader_key == QStringLiteral("spearman_roman_republic")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_roman_republic.vert"),
+        QStringLiteral(":/assets/shaders/spearman_roman_republic.frag")};
+  }
+  return std::nullopt;
+}
+
 auto spearman_style_registry()
     -> std::unordered_map<std::string, SpearmanStyleConfig> & {
   static std::unordered_map<std::string, SpearmanStyleConfig> styles;
@@ -86,7 +111,14 @@ struct SpearmanExtras {
 class SpearmanRenderer : public HumanoidRendererBase {
 public:
   auto get_proportion_scaling() const -> QVector3D override {
-    return {1.10F, 1.02F, 1.05F};
+
+    return {0.94F, 1.04F, 0.92F};
+  }
+
+  void adjust_variation(const DrawContext &, uint32_t,
+                        VariationParams &variation) const override {
+    variation.bulkScale *= 0.90F;
+    variation.stanceWidth *= 0.92F;
   }
 
 private:
@@ -94,7 +126,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -102,8 +134,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -198,10 +230,10 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
-    auto helmet = registry.get(EquipmentCategory::Helmet, "montefortino");
+    auto helmet = registry.get(EquipmentCategory::Helmet, "carthage_heavy");
     if (helmet) {
       HumanoidAnimationContext anim_ctx{};
       helmet->render(ctx, pose.bodyFrames, v.palette, anim_ctx, out);
@@ -209,10 +241,14 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
-    auto armor = registry.get(EquipmentCategory::Armor, "carthage_heavy_armor");
+    const SpearmanStyleConfig &style = resolve_style(ctx);
+    std::string armor_key =
+        style.armor_id.empty() ? "armor_light_carthage" : style.armor_id;
+    auto armor = registry.get(EquipmentCategory::Armor, armor_key);
     if (armor) {
       armor->render(ctx, pose.bodyFrames, v.palette, anim, out);
     }
@@ -315,11 +351,25 @@ void registerSpearmanRenderer(Render::GL::EntityRendererRegistry &registry) {
       "troops/carthage/spearman", [](const DrawContext &ctx, ISubmitter &out) {
         static SpearmanRenderer const static_renderer;
         Shader *spearman_shader = nullptr;
+        auto acquireShader = [&](const QString &shader_key) -> Shader * {
+          if (ctx.backend == nullptr || shader_key.isEmpty()) {
+            return nullptr;
+          }
+          Shader *shader = ctx.backend->shader(shader_key);
+          if (shader != nullptr) {
+            return shader;
+          }
+          if (auto resources = lookup_spearman_shader_resources(shader_key)) {
+            shader = ctx.backend->getOrLoadShader(shader_key, resources->vertex,
+                                                  resources->fragment);
+          }
+          return shader;
+        };
         if (ctx.backend != nullptr) {
           QString shader_key = static_renderer.resolve_shader_key(ctx);
-          spearman_shader = ctx.backend->shader(shader_key);
+          spearman_shader = acquireShader(shader_key);
           if (spearman_shader == nullptr) {
-            spearman_shader = ctx.backend->shader(QStringLiteral("spearman"));
+            spearman_shader = acquireShader(QStringLiteral("spearman"));
           }
         }
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);

+ 7 - 6
render/entity/nations/carthage/spearman_style.cpp

@@ -4,12 +4,12 @@
 #include <QVector3D>
 
 namespace {
-constexpr QVector3D k_carthage_cloth{0.15F, 0.36F, 0.55F};
-constexpr QVector3D k_carthage_leather{0.30F, 0.20F, 0.12F};
-constexpr QVector3D k_carthage_leather_dark{0.18F, 0.12F, 0.08F};
-constexpr QVector3D k_carthage_metal{0.68F, 0.66F, 0.52F};
-constexpr QVector3D k_carthage_spear_shaft{0.40F, 0.26F, 0.14F};
-constexpr QVector3D k_carthage_spearhead{0.74F, 0.72F, 0.60F};
+constexpr QVector3D k_carthage_cloth{0.12F, 0.36F, 0.52F};
+constexpr QVector3D k_carthage_leather{0.36F, 0.24F, 0.12F};
+constexpr QVector3D k_carthage_leather_dark{0.22F, 0.16F, 0.10F};
+constexpr QVector3D k_carthage_metal{0.75F, 0.66F, 0.42F};
+constexpr QVector3D k_carthage_spear_shaft{0.38F, 0.28F, 0.18F};
+constexpr QVector3D k_carthage_spearhead{0.90F, 0.88F, 0.70F};
 } // namespace
 
 namespace Render::GL::Carthage {
@@ -24,6 +24,7 @@ void register_carthage_spearman_style() {
   style.spearhead_color = k_carthage_spearhead;
   style.spear_length_scale = 1.08F;
   style.shader_id = "spearman_carthage";
+  style.armor_id = "armor_light_carthage";
 
   register_spearman_style("carthage", style);
 }

+ 1 - 0
render/entity/nations/carthage/spearman_style.h

@@ -16,6 +16,7 @@ struct SpearmanStyleConfig {
   std::optional<float> spear_length_scale;
   std::optional<float> spear_shaft_radius_scale;
   std::string shader_id;
+  std::string armor_id;
 };
 
 void register_carthage_spearman_style();

+ 9 - 14
render/entity/nations/carthage/swordsman_renderer.cpp

@@ -106,7 +106,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -114,8 +114,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -212,10 +212,10 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
-    auto helmet = registry.get(EquipmentCategory::Helmet, "montefortino");
+    auto helmet = registry.get(EquipmentCategory::Helmet, "carthage_heavy");
     if (helmet) {
       HumanoidAnimationContext anim_ctx{};
       helmet->render(ctx, pose.bodyFrames, v.palette, anim_ctx, out);
@@ -223,10 +223,11 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
-    auto armor = registry.get(EquipmentCategory::Armor, "carthage_heavy_armor");
+    auto armor = registry.get(EquipmentCategory::Armor, "armor_heavy_carthage");
     if (armor) {
       armor->render(ctx, pose.bodyFrames, v.palette, anim, out);
     }
@@ -284,12 +285,6 @@ private:
              coneFromTo(ctx.model, tip, tip + QVector3D(-0.02F, -0.02F, -0.02F),
                         sheath_r),
              extras.metalColor, nullptr, 1.0F);
-
-    QVector3D const strap_a = hip + QVector3D(0.00F, 0.03F, 0.00F);
-    QVector3D const belt = QVector3D(0.12F, HP::WAIST_Y + 0.01F, 0.02F);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, strap_a, belt, 0.006F),
-             v.palette.leather, nullptr, 1.0F);
   }
 
   auto

+ 7 - 6
render/entity/nations/kingdom/archer_renderer.cpp

@@ -86,7 +86,7 @@ public:
   }
 
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -94,8 +94,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -215,7 +215,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "kingdom_light");
@@ -226,8 +226,9 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     if (resolve_style(ctx).show_armor) {
       auto &registry = EquipmentRegistry::instance();
       auto armor =

+ 52 - 52
render/entity/nations/kingdom/barracks_renderer.cpp

@@ -79,8 +79,8 @@ inline auto make_palette(const QVector3D &team) -> BarracksPalette {
 }
 
 inline void draw_cylinder(ISubmitter &out, const QMatrix4x4 &model,
-                         const QVector3D &a, const QVector3D &b, float radius,
-                         const QVector3D &color, Texture *white) {
+                          const QVector3D &a, const QVector3D &b, float radius,
+                          const QVector3D &color, Texture *white) {
   out.mesh(getUnitCylinder(), model * cylinderBetween(a, b, radius), color,
            white, 1.0F);
 }
@@ -153,11 +153,11 @@ inline void drawWalls(const DrawContext &p, ISubmitter &out, Mesh *,
 
   auto log_x = [&](float y, float z, float x0, float x1, const QVector3D &col) {
     draw_cylinder(out, p.model, QVector3D(x0 - notch, y, z),
-                 QVector3D(x1 + notch, y, z), r, col, white);
+                  QVector3D(x1 + notch, y, z), r, col, white);
   };
   auto log_z = [&](float y, float x, float z0, float z1, const QVector3D &col) {
     draw_cylinder(out, p.model, QVector3D(x, y, z0 - notch),
-                 QVector3D(x, y, z1 + notch), r, col, white);
+                  QVector3D(x, y, z1 + notch), r, col, white);
   };
 
   const float door_w = BuildingProportions::door_width;
@@ -182,33 +182,33 @@ inline void drawWalls(const DrawContext &p, ISubmitter &out, Mesh *,
 
   QVector3D const post_col = C.woodDark;
   draw_cylinder(out, p.model, QVector3D(-gap_half, y0, front_z),
-               QVector3D(-gap_half, y0 + door_h, front_z), r * 0.95F, post_col,
-               white);
+                QVector3D(-gap_half, y0 + door_h, front_z), r * 0.95F, post_col,
+                white);
   draw_cylinder(out, p.model, QVector3D(+gap_half, y0, front_z),
-               QVector3D(+gap_half, y0 + door_h, front_z), r * 0.95F, post_col,
-               white);
+                QVector3D(+gap_half, y0 + door_h, front_z), r * 0.95F, post_col,
+                white);
   draw_cylinder(out, p.model, QVector3D(-gap_half, y0 + door_h, front_z),
-               QVector3D(+gap_half, y0 + door_h, front_z), r, C.timberLight,
-               white);
+                QVector3D(+gap_half, y0 + door_h, front_z), r, C.timberLight,
+                white);
 
   float const brace_y0 = h * 0.35F;
   float const brace_y1 = h * 0.95F;
   draw_cylinder(out, p.model,
-               QVector3D(left_x + 0.08F, brace_y0, back_z + 0.10F),
-               QVector3D(left_x + 0.38F, brace_y1, back_z + 0.10F), r * 0.6F,
-               C.woodDark, white);
+                QVector3D(left_x + 0.08F, brace_y0, back_z + 0.10F),
+                QVector3D(left_x + 0.38F, brace_y1, back_z + 0.10F), r * 0.6F,
+                C.woodDark, white);
   draw_cylinder(out, p.model,
-               QVector3D(right_x - 0.08F, brace_y0, back_z + 0.10F),
-               QVector3D(right_x - 0.38F, brace_y1, back_z + 0.10F), r * 0.6F,
-               C.woodDark, white);
+                QVector3D(right_x - 0.08F, brace_y0, back_z + 0.10F),
+                QVector3D(right_x - 0.38F, brace_y1, back_z + 0.10F), r * 0.6F,
+                C.woodDark, white);
   draw_cylinder(out, p.model,
-               QVector3D(left_x + 0.08F, brace_y0, front_z - 0.10F),
-               QVector3D(left_x + 0.38F, brace_y1, front_z - 0.10F), r * 0.6F,
-               C.woodDark, white);
+                QVector3D(left_x + 0.08F, brace_y0, front_z - 0.10F),
+                QVector3D(left_x + 0.38F, brace_y1, front_z - 0.10F), r * 0.6F,
+                C.woodDark, white);
   draw_cylinder(out, p.model,
-               QVector3D(right_x - 0.08F, brace_y0, front_z - 0.10F),
-               QVector3D(right_x - 0.38F, brace_y1, front_z - 0.10F), r * 0.6F,
-               C.woodDark, white);
+                QVector3D(right_x - 0.08F, brace_y0, front_z - 0.10F),
+                QVector3D(right_x - 0.38F, brace_y1, front_z - 0.10F), r * 0.6F,
+                C.woodDark, white);
 }
 
 struct ChimneyInfo {
@@ -294,15 +294,15 @@ inline void drawRoofs(const DrawContext &p, ISubmitter &out, Mesh *,
   const float ridge_y = h + rise;
 
   draw_cylinder(out, p.model, QVector3D(left_x - over, plate_y, front_z + over),
-               QVector3D(right_x + over, plate_y, front_z + over), r,
-               C.woodDark, white);
+                QVector3D(right_x + over, plate_y, front_z + over), r,
+                C.woodDark, white);
   draw_cylinder(out, p.model, QVector3D(left_x - over, plate_y, back_z - over),
-               QVector3D(right_x + over, plate_y, back_z - over), r, C.woodDark,
-               white);
+                QVector3D(right_x + over, plate_y, back_z - over), r,
+                C.woodDark, white);
 
   draw_cylinder(out, p.model, QVector3D(left_x - over * 0.5F, ridge_y, 0.0F),
-               QVector3D(right_x + over * 0.5F, ridge_y, 0.0F), r,
-               C.timberLight, white);
+                QVector3D(right_x + over * 0.5F, ridge_y, 0.0F), r,
+                C.timberLight, white);
 
   const int pairs = 7;
   for (int i = 0; i < pairs; ++i) {
@@ -311,10 +311,10 @@ inline void drawRoofs(const DrawContext &p, ISubmitter &out, Mesh *,
         (left_x - over * 0.5F) * (1.0F - t) + (right_x + over * 0.5F) * t;
 
     draw_cylinder(out, p.model, QVector3D(x, plate_y, back_z - over),
-                 QVector3D(x, ridge_y, 0.0F), r * 0.85F, C.woodDark, white);
+                  QVector3D(x, ridge_y, 0.0F), r * 0.85F, C.woodDark, white);
 
     draw_cylinder(out, p.model, QVector3D(x, plate_y, front_z + over),
-                 QVector3D(x, ridge_y, 0.0F), r * 0.85F, C.woodDark, white);
+                  QVector3D(x, ridge_y, 0.0F), r * 0.85F, C.woodDark, white);
   }
 
   auto purlin = [&](float tz, bool front) {
@@ -322,8 +322,8 @@ inline void drawRoofs(const DrawContext &p, ISubmitter &out, Mesh *,
                           : (back_z - over - tz * (back_z - over));
     float const y = plate_y + tz * (ridge_y - plate_y);
     draw_cylinder(out, p.model, QVector3D(left_x - over * 0.4F, y, z),
-                 QVector3D(right_x + over * 0.4F, y, z), r * 0.6F, C.timber,
-                 white);
+                  QVector3D(right_x + over * 0.4F, y, z), r * 0.6F, C.timber,
+                  white);
   };
   purlin(0.35F, true);
   purlin(0.70F, true);
@@ -334,9 +334,9 @@ inline void drawRoofs(const DrawContext &p, ISubmitter &out, Mesh *,
     float const gap_l = ch.x - ch.gapRadius;
     float const gap_r = ch.x + ch.gapRadius;
     draw_cylinder(out, p.model, QVector3D(left_x - over * 0.35F, y, z),
-                 QVector3D(gap_l, y, z), rad, col, white);
+                  QVector3D(gap_l, y, z), rad, col, white);
     draw_cylinder(out, p.model, QVector3D(gap_r, y, z),
-                 QVector3D(right_x + over * 0.35F, y, z), rad, col, white);
+                  QVector3D(right_x + over * 0.35F, y, z), rad, col, white);
   };
 
   auto thatch_row = [&](float tz, bool front, float radScale, float tint) {
@@ -391,14 +391,14 @@ inline void drawDoor(const DrawContext &p, ISubmitter &out, Mesh *unit,
   }
 
   draw_cylinder(out, p.model,
-               QVector3D(-d_w * 0.45F, y0 + d_h * 0.35F, zf + 0.03F),
-               QVector3D(+d_w * 0.45F, y0 + d_h * 0.35F, zf + 0.03F), 0.02F,
-               frame_col, white);
+                QVector3D(-d_w * 0.45F, y0 + d_h * 0.35F, zf + 0.03F),
+                QVector3D(+d_w * 0.45F, y0 + d_h * 0.35F, zf + 0.03F), 0.02F,
+                frame_col, white);
 
   draw_cylinder(out, p.model,
-               QVector3D(d_w * 0.32F, y0 + d_h * 0.45F, zf + 0.04F),
-               QVector3D(d_w * 0.42F, y0 + d_h * 0.45F, zf + 0.04F), 0.012F,
-               C.timberLight, white);
+                QVector3D(d_w * 0.32F, y0 + d_h * 0.45F, zf + 0.04F),
+                QVector3D(d_w * 0.42F, y0 + d_h * 0.45F, zf + 0.04F), 0.012F,
+                C.timberLight, white);
 
   unitBox(out, unit, white, p.model,
           QVector3D(0.0F, y0 + d_h + 0.10F, zf + 0.02F),
@@ -484,15 +484,15 @@ inline void drawAnnex(const DrawContext &p, ISubmitter &out, Mesh *unit,
   float const front_z = z + annex_d * 0.5F;
   float const back_z = z - annex_d * 0.5F;
   draw_cylinder(out, p.model,
-               QVector3D(x - annex_w * 0.52F, plate_y, back_z - 0.12F),
-               QVector3D(x + annex_w * 0.52F, plate_y, back_z - 0.12F), 0.05F,
-               C.woodDark, white);
+                QVector3D(x - annex_w * 0.52F, plate_y, back_z - 0.12F),
+                QVector3D(x + annex_w * 0.52F, plate_y, back_z - 0.12F), 0.05F,
+                C.woodDark, white);
 
   float const ridge_y = annex_h + BuildingProportions::annex_roof_height;
   draw_cylinder(out, p.model,
-               QVector3D(x - annex_w * 0.50F, ridge_y, back_z - 0.02F),
-               QVector3D(x + annex_w * 0.50F, ridge_y, back_z - 0.02F), 0.05F,
-               C.timberLight, white);
+                QVector3D(x - annex_w * 0.50F, ridge_y, back_z - 0.02F),
+                QVector3D(x + annex_w * 0.50F, ridge_y, back_z - 0.02F), 0.05F,
+                C.timberLight, white);
 
   int const rows = 6;
   for (int i = 0; i < rows; ++i) {
@@ -502,8 +502,8 @@ inline void drawAnnex(const DrawContext &p, ISubmitter &out, Mesh *unit,
     QVector3D const col =
         lerp(C.thatchDark, C.thatch, 0.5F + 0.4F * (1.0F - t));
     draw_cylinder(out, p.model, QVector3D(x - annex_w * 0.55F, y, zrow),
-                 QVector3D(x + annex_w * 0.55F, y, zrow),
-                 0.06F * (1.15F - 0.6F * t), col, white);
+                  QVector3D(x + annex_w * 0.55F, y, zrow),
+                  0.06F * (1.15F - 0.6F * t), col, white);
   }
 
   unitBox(out, unit, white, p.model,
@@ -564,12 +564,12 @@ inline void drawBannerAndPole(const DrawContext &p, ISubmitter &out, Mesh *unit,
   QVector3D const beam_start(pole_x + 0.02F, beam_y, pole_z);
   QVector3D const beam_end(pole_x + beam_length + 0.02F, beam_y, pole_z);
   draw_cylinder(out, p.model, beam_start, beam_end, pole_radius * 0.35F,
-               C.timber, white);
+                C.timber, white);
 
   QVector3D const connector_top(
       beam_end.x(), beam_end.y() - target_height * 0.35F, beam_end.z());
   draw_cylinder(out, p.model, beam_end, connector_top, pole_radius * 0.18F,
-               C.timberLight, white);
+                C.timberLight, white);
 
   float const panel_x = beam_end.x() + (target_width * 0.5F - beam_length);
   unitBox(out, unit, white, p.model, QVector3D(panel_x, flag_y, pole_z + 0.01F),
@@ -589,7 +589,7 @@ inline void drawBannerAndPole(const DrawContext &p, ISubmitter &out, Mesh *unit,
 }
 
 inline void draw_rally_flag_if_any(const DrawContext &p, ISubmitter &out,
-                               Texture *white, const BarracksPalette &C) {
+                                   Texture *white, const BarracksPalette &C) {
   BarracksFlagRenderer::FlagColors colors{.team = C.team,
                                           .teamTrim = C.teamTrim,
                                           .timber = C.timber,
@@ -599,7 +599,7 @@ inline void draw_rally_flag_if_any(const DrawContext &p, ISubmitter &out,
 }
 
 inline void draw_health_bar(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                          Texture *white) {
+                            Texture *white) {
   if (p.entity == nullptr) {
     return;
   }

+ 7 - 6
render/entity/nations/kingdom/horse_swordsman_renderer.cpp

@@ -64,7 +64,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
   }
@@ -84,8 +84,8 @@ public:
   }
 
   void customize_pose(const DrawContext &ctx,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -299,7 +299,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "kingdom_heavy");
@@ -310,8 +310,9 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
     auto armor = registry.get(EquipmentCategory::Armor, "kingdom_heavy_armor");
     if (armor) {

+ 48 - 8
render/entity/nations/kingdom/spearman_renderer.cpp

@@ -44,6 +44,31 @@ constexpr float k_spearman_style_mix_weight = 0.4F;
 constexpr float k_kneel_depth_multiplier = 0.875F;
 constexpr float k_lean_amount_multiplier = 0.67F;
 
+struct SpearmanShaderResourcePaths {
+  QString vertex;
+  QString fragment;
+};
+
+auto lookup_spearman_shader_resources(const QString &shader_key)
+    -> std::optional<SpearmanShaderResourcePaths> {
+  if (shader_key == QStringLiteral("spearman_carthage")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_carthage.vert"),
+        QStringLiteral(":/assets/shaders/spearman_carthage.frag")};
+  }
+  if (shader_key == QStringLiteral("spearman_kingdom_of_iron")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_kingdom_of_iron.vert"),
+        QStringLiteral(":/assets/shaders/spearman_kingdom_of_iron.frag")};
+  }
+  if (shader_key == QStringLiteral("spearman_roman_republic")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_roman_republic.vert"),
+        QStringLiteral(":/assets/shaders/spearman_roman_republic.frag")};
+  }
+  return std::nullopt;
+}
+
 auto spearman_style_registry()
     -> std::unordered_map<std::string, SpearmanStyleConfig> & {
   static std::unordered_map<std::string, SpearmanStyleConfig> styles;
@@ -94,7 +119,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -102,8 +127,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -198,7 +223,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "kingdom_heavy");
@@ -209,8 +234,9 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
     auto armor = registry.get(EquipmentCategory::Armor, "kingdom_heavy_armor");
     if (armor) {
@@ -315,11 +341,25 @@ void registerSpearmanRenderer(Render::GL::EntityRendererRegistry &registry) {
       "troops/kingdom/spearman", [](const DrawContext &ctx, ISubmitter &out) {
         static SpearmanRenderer const static_renderer;
         Shader *spearman_shader = nullptr;
+        auto acquireShader = [&](const QString &shader_key) -> Shader * {
+          if (ctx.backend == nullptr || shader_key.isEmpty()) {
+            return nullptr;
+          }
+          Shader *shader = ctx.backend->shader(shader_key);
+          if (shader != nullptr) {
+            return shader;
+          }
+          if (auto resources = lookup_spearman_shader_resources(shader_key)) {
+            shader = ctx.backend->getOrLoadShader(shader_key, resources->vertex,
+                                                  resources->fragment);
+          }
+          return shader;
+        };
         if (ctx.backend != nullptr) {
           QString shader_key = static_renderer.resolve_shader_key(ctx);
-          spearman_shader = ctx.backend->shader(shader_key);
+          spearman_shader = acquireShader(shader_key);
           if (spearman_shader == nullptr) {
-            spearman_shader = ctx.backend->shader(QStringLiteral("spearman"));
+            spearman_shader = acquireShader(QStringLiteral("spearman"));
           }
         }
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);

+ 7 - 12
render/entity/nations/kingdom/swordsman_renderer.cpp

@@ -106,7 +106,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -114,8 +114,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -212,7 +212,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "kingdom_heavy");
@@ -223,8 +223,9 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
     auto armor = registry.get(EquipmentCategory::Armor, "kingdom_heavy_armor");
     if (armor) {
@@ -284,12 +285,6 @@ private:
              coneFromTo(ctx.model, tip, tip + QVector3D(-0.02F, -0.02F, -0.02F),
                         sheath_r),
              extras.metalColor, nullptr, 1.0F);
-
-    QVector3D const strap_a = hip + QVector3D(0.00F, 0.03F, 0.00F);
-    QVector3D const belt = QVector3D(0.12F, HP::WAIST_Y + 0.01F, 0.02F);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, strap_a, belt, 0.006F),
-             v.palette.leather, nullptr, 1.0F);
   }
 
   auto

+ 7 - 6
render/entity/nations/roman/archer_renderer.cpp

@@ -86,7 +86,7 @@ public:
   }
 
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -94,8 +94,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -215,7 +215,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
     using HP = HumanProportions;
 
     auto const &style = resolve_style(ctx);
@@ -240,8 +240,9 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     if (resolve_style(ctx).show_armor) {
       auto &registry = EquipmentRegistry::instance();
       auto armor = registry.get(EquipmentCategory::Armor, "roman_light_armor");

+ 49 - 44
render/entity/nations/roman/barracks_renderer.cpp

@@ -45,8 +45,8 @@ inline auto make_palette(const QVector3D &team) -> RomanPalette {
 }
 
 inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
-                    const QMatrix4x4 &model, const QVector3D &pos,
-                    const QVector3D &size, const QVector3D &color) {
+                     const QMatrix4x4 &model, const QVector3D &pos,
+                     const QVector3D &size, const QVector3D &color) {
   QMatrix4x4 m = model;
   m.translate(pos);
   m.scale(size);
@@ -54,8 +54,8 @@ inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
 }
 
 inline void draw_cyl(ISubmitter &out, const QMatrix4x4 &model,
-                    const QVector3D &a, const QVector3D &b, float r,
-                    const QVector3D &color, Texture *white) {
+                     const QVector3D &a, const QVector3D &b, float r,
+                     const QVector3D &color, Texture *white) {
   out.mesh(getUnitCylinder(), model * cylinderBetween(a, b, r), color, white,
            1.0F);
 }
@@ -64,19 +64,19 @@ void drawFortressBase(const DrawContext &p, ISubmitter &out, Mesh *unit,
                       Texture *white, const RomanPalette &c) {
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.15F, 0.0F),
-          QVector3D(1.8F, 0.15F, 1.5F), c.stone_base);
+           QVector3D(1.8F, 0.15F, 1.5F), c.stone_base);
 
   for (float x = -1.6F; x <= 1.6F; x += 0.4F) {
     draw_box(out, unit, white, p.model, QVector3D(x, 0.35F, -1.4F),
-            QVector3D(0.18F, 0.08F, 0.08F), c.stone_dark);
+             QVector3D(0.18F, 0.08F, 0.08F), c.stone_dark);
     draw_box(out, unit, white, p.model, QVector3D(x, 0.35F, 1.4F),
-            QVector3D(0.18F, 0.08F, 0.08F), c.stone_dark);
+             QVector3D(0.18F, 0.08F, 0.08F), c.stone_dark);
   }
   for (float z = -1.3F; z <= 1.3F; z += 0.4F) {
     draw_box(out, unit, white, p.model, QVector3D(-1.7F, 0.35F, z),
-            QVector3D(0.08F, 0.08F, 0.18F), c.stone_dark);
+             QVector3D(0.08F, 0.08F, 0.18F), c.stone_dark);
     draw_box(out, unit, white, p.model, QVector3D(1.7F, 0.35F, z),
-            QVector3D(0.08F, 0.08F, 0.18F), c.stone_dark);
+             QVector3D(0.08F, 0.08F, 0.18F), c.stone_dark);
   }
 }
 
@@ -85,23 +85,23 @@ void drawFortressWalls(const DrawContext &p, ISubmitter &out, Mesh *unit,
   float const wall_height = 1.2F;
 
   draw_box(out, unit, white, p.model,
-          QVector3D(0.0F, wall_height * 0.5F + 0.3F, -1.3F),
-          QVector3D(1.5F, wall_height * 0.5F, 0.12F), c.stone_light);
+           QVector3D(0.0F, wall_height * 0.5F + 0.3F, -1.3F),
+           QVector3D(1.5F, wall_height * 0.5F, 0.12F), c.stone_light);
   draw_box(out, unit, white, p.model,
-          QVector3D(0.0F, wall_height * 0.5F + 0.3F, 1.3F),
-          QVector3D(1.5F, wall_height * 0.5F, 0.12F), c.stone_light);
+           QVector3D(0.0F, wall_height * 0.5F + 0.3F, 1.3F),
+           QVector3D(1.5F, wall_height * 0.5F, 0.12F), c.stone_light);
   draw_box(out, unit, white, p.model,
-          QVector3D(-1.6F, wall_height * 0.5F + 0.3F, 0.0F),
-          QVector3D(0.12F, wall_height * 0.5F, 1.2F), c.stone_light);
+           QVector3D(-1.6F, wall_height * 0.5F + 0.3F, 0.0F),
+           QVector3D(0.12F, wall_height * 0.5F, 1.2F), c.stone_light);
   draw_box(out, unit, white, p.model,
-          QVector3D(1.6F, wall_height * 0.5F + 0.3F, 0.0F),
-          QVector3D(0.12F, wall_height * 0.5F, 1.2F), c.stone_light);
+           QVector3D(1.6F, wall_height * 0.5F + 0.3F, 0.0F),
+           QVector3D(0.12F, wall_height * 0.5F, 1.2F), c.stone_light);
 
   for (int i = 0; i < 6; ++i) {
     float const x = -1.2F + float(i) * 0.5F;
     draw_box(out, unit, white, p.model,
-            QVector3D(x, wall_height + 0.35F, -1.25F),
-            QVector3D(0.2F, 0.05F, 0.05F), c.brick);
+             QVector3D(x, wall_height + 0.35F, -1.25F),
+             QVector3D(0.2F, 0.05F, 0.05F), c.brick);
   }
 }
 
@@ -114,20 +114,20 @@ void drawCornerTowers(const DrawContext &p, ISubmitter &out, Mesh *unit,
   for (int i = 0; i < 4; ++i) {
 
     draw_box(out, unit, white, p.model,
-            QVector3D(corners[i].x(), 0.65F, corners[i].z()),
-            QVector3D(0.25F, 0.65F, 0.25F), c.stone_dark);
+             QVector3D(corners[i].x(), 0.65F, corners[i].z()),
+             QVector3D(0.25F, 0.65F, 0.25F), c.stone_dark);
 
     draw_box(out, unit, white, p.model,
-            QVector3D(corners[i].x(), 1.45F, corners[i].z()),
-            QVector3D(0.28F, 0.15F, 0.28F), c.brick_dark);
+             QVector3D(corners[i].x(), 1.45F, corners[i].z()),
+             QVector3D(0.28F, 0.15F, 0.28F), c.brick_dark);
 
     for (int j = 0; j < 4; ++j) {
       float angle = float(j) * 1.57F;
       float ox = sinf(angle) * 0.18F;
       float oz = cosf(angle) * 0.18F;
       draw_box(out, unit, white, p.model,
-              QVector3D(corners[i].x() + ox, 1.68F, corners[i].z() + oz),
-              QVector3D(0.06F, 0.08F, 0.06F), c.stone_light);
+               QVector3D(corners[i].x() + ox, 1.68F, corners[i].z() + oz),
+               QVector3D(0.06F, 0.08F, 0.06F), c.stone_light);
     }
   }
 }
@@ -136,24 +136,24 @@ void drawCourtyard(const DrawContext &p, ISubmitter &out, Mesh *unit,
                    Texture *white, const RomanPalette &c) {
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.32F, 0.0F),
-          QVector3D(1.2F, 0.02F, 0.9F), c.stone_base);
+           QVector3D(1.2F, 0.02F, 0.9F), c.stone_base);
 
   draw_cyl(out, p.model, QVector3D(0.0F, 0.3F, 0.0F),
-          QVector3D(0.0F, 0.95F, 0.0F), 0.08F, c.stone_light, white);
+           QVector3D(0.0F, 0.95F, 0.0F), 0.08F, c.stone_light, white);
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.65F, -0.85F),
-          QVector3D(0.35F, 0.35F, 0.08F), c.brick);
+           QVector3D(0.35F, 0.35F, 0.08F), c.brick);
 }
 
 void drawRomanRoof(const DrawContext &p, ISubmitter &out, Mesh *unit,
                    Texture *white, const RomanPalette &c) {
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 1.58F, 0.0F),
-          QVector3D(1.55F, 0.05F, 1.25F), c.tile_red);
+           QVector3D(1.55F, 0.05F, 1.25F), c.tile_red);
 
   for (float z = -1.0F; z <= 1.0F; z += 0.3F) {
     draw_box(out, unit, white, p.model, QVector3D(0.0F, 1.62F, z),
-            QVector3D(1.5F, 0.02F, 0.08F), c.tile_dark);
+             QVector3D(1.5F, 0.02F, 0.08F), c.tile_dark);
   }
 }
 
@@ -161,12 +161,12 @@ void drawGate(const DrawContext &p, ISubmitter &out, Mesh *unit, Texture *white,
               const RomanPalette &c) {
 
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.6F, 1.35F),
-          QVector3D(0.5F, 0.6F, 0.08F), c.wood_dark);
+           QVector3D(0.5F, 0.6F, 0.08F), c.wood_dark);
 
   for (int i = 0; i < 3; ++i) {
     float y = 0.3F + float(i) * 0.3F;
     draw_box(out, unit, white, p.model, QVector3D(0.0F, y, 1.37F),
-            QVector3D(0.45F, 0.03F, 0.02F), c.iron);
+             QVector3D(0.45F, 0.03F, 0.02F), c.iron);
   }
 }
 
@@ -235,12 +235,12 @@ void drawStandards(const DrawContext &p, ISubmitter &out, Mesh *unit,
   out.mesh(unit, trimTop, captureColors.teamTrimColor, white, 1.0F);
 
   draw_box(out, unit, white, p.model,
-          QVector3D(pole_x, pole_height + 0.15F, pole_z),
-          QVector3D(0.08F, 0.06F, 0.08F), c.iron);
+           QVector3D(pole_x, pole_height + 0.15F, pole_z),
+           QVector3D(0.08F, 0.06F, 0.08F), c.iron);
 }
 
 void draw_rally_flag(const DrawContext &p, ISubmitter &out, Texture *white,
-                   const RomanPalette &c) {
+                     const RomanPalette &c) {
   BarracksFlagRenderer::FlagColors colors{.team = c.team,
                                           .teamTrim = c.team_trim,
                                           .timber = c.wood,
@@ -250,27 +250,30 @@ void draw_rally_flag(const DrawContext &p, ISubmitter &out, Texture *white,
 }
 
 void draw_health_bar(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                   Texture *white) {
-  if (p.entity == nullptr)
+                     Texture *white) {
+  if (p.entity == nullptr) {
     return;
+  }
   auto *u = p.entity->getComponent<Engine::Core::UnitComponent>();
-  if (u == nullptr)
+  if (u == nullptr) {
     return;
+  }
 
   float const ratio =
       std::clamp(u->health / float(std::max(1, u->max_health)), 0.0F, 1.0F);
-  if (ratio <= 0.0F)
+  if (ratio <= 0.0F) {
     return;
+  }
 
   QVector3D const bg(0.06F, 0.06F, 0.06F);
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.35F, 0.0F),
-          QVector3D(0.9F, 0.04F, 0.06F), bg);
+           QVector3D(0.9F, 0.04F, 0.06F), bg);
 
   QVector3D const fg = QVector3D(0.22F, 0.78F, 0.22F) * ratio +
                        QVector3D(0.85F, 0.15F, 0.15F) * (1.0F - ratio);
   draw_box(out, unit, white, p.model,
-          QVector3D(-(0.9F * (1.0F - ratio)) * 0.5F, 2.36F, 0.0F),
-          QVector3D(0.9F * ratio * 0.5F, 0.035F, 0.055F), fg);
+           QVector3D(-(0.9F * (1.0F - ratio)) * 0.5F, 2.36F, 0.0F),
+           QVector3D(0.9F * ratio * 0.5F, 0.035F, 0.055F), fg);
 }
 
 void draw_selection(const DrawContext &p, ISubmitter &out) {
@@ -286,13 +289,15 @@ void draw_selection(const DrawContext &p, ISubmitter &out) {
 }
 
 void draw_barracks(const DrawContext &p, ISubmitter &out) {
-  if (!p.resources || !p.entity)
+  if (!p.resources || !p.entity) {
     return;
+  }
 
   auto *t = p.entity->getComponent<Engine::Core::TransformComponent>();
   auto *r = p.entity->getComponent<Engine::Core::RenderableComponent>();
-  if (!t || !r)
+  if (!t || !r) {
     return;
+  }
 
   Mesh *unit = p.resources->unit();
   Texture *white = p.resources->white();

+ 7 - 6
render/entity/nations/roman/horse_swordsman_renderer.cpp

@@ -63,7 +63,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
   }
@@ -83,8 +83,8 @@ public:
   }
 
   void customize_pose(const DrawContext &ctx,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -298,7 +298,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "roman_heavy");
@@ -309,8 +309,9 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
     auto armor = registry.get(EquipmentCategory::Armor, "roman_heavy_armor");
     if (armor) {

+ 48 - 8
render/entity/nations/roman/spearman_renderer.cpp

@@ -44,6 +44,31 @@ constexpr float k_spearman_style_mix_weight = 0.4F;
 constexpr float k_kneel_depth_multiplier = 0.875F;
 constexpr float k_lean_amount_multiplier = 0.67F;
 
+struct SpearmanShaderResourcePaths {
+  QString vertex;
+  QString fragment;
+};
+
+auto lookup_spearman_shader_resources(const QString &shader_key)
+    -> std::optional<SpearmanShaderResourcePaths> {
+  if (shader_key == QStringLiteral("spearman_carthage")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_carthage.vert"),
+        QStringLiteral(":/assets/shaders/spearman_carthage.frag")};
+  }
+  if (shader_key == QStringLiteral("spearman_kingdom_of_iron")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_kingdom_of_iron.vert"),
+        QStringLiteral(":/assets/shaders/spearman_kingdom_of_iron.frag")};
+  }
+  if (shader_key == QStringLiteral("spearman_roman_republic")) {
+    return SpearmanShaderResourcePaths{
+        QStringLiteral(":/assets/shaders/spearman_roman_republic.vert"),
+        QStringLiteral(":/assets/shaders/spearman_roman_republic.frag")};
+  }
+  return std::nullopt;
+}
+
 auto spearman_style_registry()
     -> std::unordered_map<std::string, SpearmanStyleConfig> & {
   static std::unordered_map<std::string, SpearmanStyleConfig> styles;
@@ -94,7 +119,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -102,8 +127,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -198,7 +223,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "roman_heavy");
@@ -209,8 +234,9 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
     auto armor = registry.get(EquipmentCategory::Armor, "roman_heavy_armor");
     if (armor) {
@@ -315,11 +341,25 @@ void registerSpearmanRenderer(Render::GL::EntityRendererRegistry &registry) {
       "troops/roman/spearman", [](const DrawContext &ctx, ISubmitter &out) {
         static SpearmanRenderer const static_renderer;
         Shader *spearman_shader = nullptr;
+        auto acquireShader = [&](const QString &shader_key) -> Shader * {
+          if (ctx.backend == nullptr || shader_key.isEmpty()) {
+            return nullptr;
+          }
+          Shader *shader = ctx.backend->shader(shader_key);
+          if (shader != nullptr) {
+            return shader;
+          }
+          if (auto resources = lookup_spearman_shader_resources(shader_key)) {
+            shader = ctx.backend->getOrLoadShader(shader_key, resources->vertex,
+                                                  resources->fragment);
+          }
+          return shader;
+        };
         if (ctx.backend != nullptr) {
           QString shader_key = static_renderer.resolve_shader_key(ctx);
-          spearman_shader = ctx.backend->shader(shader_key);
+          spearman_shader = acquireShader(shader_key);
           if (spearman_shader == nullptr) {
-            spearman_shader = ctx.backend->shader(QStringLiteral("spearman"));
+            spearman_shader = acquireShader(QStringLiteral("spearman"));
           }
         }
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);

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

@@ -106,7 +106,7 @@ private:
 
 public:
   void get_variant(const DrawContext &ctx, uint32_t seed,
-                  HumanoidVariant &v) const override {
+                   HumanoidVariant &v) const override {
     QVector3D const team_tint = resolveTeamTint(ctx);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
@@ -114,8 +114,8 @@ public:
   }
 
   void customize_pose(const DrawContext &,
-                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
-                     HumanoidPose &pose) const override {
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
     using HP = HumanProportions;
 
     const AnimationInputs &anim = anim_ctx.inputs;
@@ -212,7 +212,7 @@ public:
   }
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                  const HumanoidPose &pose, ISubmitter &out) const override {
+                   const HumanoidPose &pose, ISubmitter &out) const override {
 
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "roman_heavy");
@@ -223,8 +223,9 @@ public:
   }
 
   void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                 const HumanoidPose &pose, const HumanoidAnimationContext &anim,
-                 ISubmitter &out) const override {
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
     auto &registry = EquipmentRegistry::instance();
     auto armor = registry.get(EquipmentCategory::Armor, "roman_heavy_armor");
     if (armor) {
@@ -284,12 +285,6 @@ private:
              coneFromTo(ctx.model, tip, tip + QVector3D(-0.02F, -0.02F, -0.02F),
                         sheath_r),
              extras.metalColor, nullptr, 1.0F);
-
-    QVector3D const strap_a = hip + QVector3D(0.00F, 0.03F, 0.00F);
-    QVector3D const belt = QVector3D(0.12F, HP::WAIST_Y + 0.01F, 0.02F);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, strap_a, belt, 0.006F),
-             v.palette.leather, nullptr, 1.0F);
   }
 
   auto

+ 1 - 1
render/entity/registry.cpp

@@ -19,7 +19,7 @@
 namespace Render::GL {
 
 void EntityRendererRegistry::register_renderer(const std::string &type,
-                                              RenderFunc func) {
+                                               RenderFunc func) {
   m_map[type] = std::move(func);
 }
 

+ 155 - 0
render/equipment/armor/CARTHAGE_ARCHER_ARMOR.md

@@ -0,0 +1,155 @@
+# Carthaginian Archer Armor System
+
+## Overview
+
+This implementation provides two distinct armor variants for Carthaginian archers:
+- **Light Armor** (`armor_light_carthage`) - Linen linothorax for standard archers
+- **Heavy Armor** (`armor_heavy_carthage`) - Leather linothorax for elite archers
+
+## Key Features
+
+### Torso-Following Geometry
+- Armor built from **cylinder segments** that wrap around the torso
+- **Elliptical cross-section** (front deeper than back) matching human anatomy
+- Multiple **horizontal rings** connected by vertical segments
+- Properly **hugs the archer's body** instead of appearing as a box
+
+### Shared Architecture
+- Both variants use the **same construction approach** (cylinder rings)
+- Both align to the **same attachment frames** (torso and waist)
+- Both share the **same helmet model** (`carthage_light` - leather cap)
+
+### Minimal C++ Implementation
+- Each armor variant has its own `.cpp/.h` file pair
+- C++ code focuses on:
+  - Building torso-conforming geometry from cylinders
+  - Transform calculation via attachment frames
+  - Material binding (color and roughness)
+- No complex mesh manipulation - simple procedural geometry
+
+### Shader-Driven Complexity
+All visual differentiation is handled by the existing `archer_carthage` shaders:
+
+#### Vertex Shader Features
+- **Depth offset**: 0.8cm normal offset to avoid z-fighting (line 39)
+- **Leather tension**: Noise-driven perturbations for fabric stretch (lines 59-60)
+- **Tangent/bitangent**: For anisotropic leather highlights (lines 31-35)
+- **Segment encoding**: `v_armorLayer` for torso vs waist differentiation (lines 50-57)
+
+#### Fragment Shader Features
+- **Material detection**: Automatic detection via color ranges:
+  - Leather: avgColor 0.25-0.55, r > g × 1.05
+  - Linen: avgColor 0.68-0.90, r > b × 1.04
+- **Procedural leather**: Triplanar FBM noise for grain (lines 115-116)
+- **Linen weave**: Warp/weft patterns for fabric (lines 135-150)
+- **Seam stitching**: Sinusoidal masks along UV seams (line 119)
+- **Environmental effects**:
+  - Rain darkening based on surface orientation (line 121)
+  - Edge wear via curvature approximation (line 122)
+- **Fresnel specular**: F₀ ≈ 0.04 for waxed leather (line 126)
+
+## Usage
+
+### Configuring Armor Variants
+
+Edit `archer_style.cpp` to set the armor variant:
+
+```cpp
+void register_carthage_archer_style() {
+  ArcherStyleConfig style;
+  // ... other style settings ...
+  
+  // Choose armor variant:
+  style.armor_id = "armor_light_carthage";  // Light linen linothorax
+  // OR
+  style.armor_id = "armor_heavy_carthage";  // Heavy leather
+  
+  register_archer_style("carthage", style);
+}
+```
+
+### Color Ranges for Shader Detection
+
+**Light Armor (Linen)**:
+```cpp
+QVector3D linen_color = QVector3D(0.85F, 0.80F, 0.72F);
+float roughness = 0.8F; // High roughness for fabric
+```
+
+**Heavy Armor (Leather)**:
+```cpp
+QVector3D leather_color = QVector3D(0.42F, 0.32F, 0.24F);
+float roughness = 0.6F; // Medium roughness for treated leather
+```
+
+## Technical Details
+
+### Torso-Following Construction
+
+The armor is built from horizontal cylinder rings that follow the torso's elliptical shape:
+
+```cpp
+// Create horizontal ring at specific height
+auto createArmorRing = [&](float y_pos, float width_scale, 
+                           float depth_front, float depth_back) {
+  for (int i = 0; i < segments; ++i) {
+    // Calculate positions around ellipse
+    float r = width_scale * (abs(cos(angle)) * 0.25 + 0.75 * depth);
+    QVector3D p = origin + right * (r * sin) + forward * (r * cos) + up * y;
+    
+    // Submit small cylinder segment
+    submitter.mesh(getUnitCylinder(), cylinderBetween(p1, p2, radius), ...);
+  }
+};
+```
+
+Rings are created at:
+- Shoulder level (y_top)
+- Mid-chest (y_mid)
+- Chest (y_chest)  
+- Waist (y_waist)
+
+Vertical connectors fill gaps between rings to create a solid shell.
+
+### Elliptical Cross-Section
+
+The armor follows human anatomy with deeper front (chest) than back:
+- **Front depth**: 1.08-1.12 × torso radius
+- **Back depth**: 0.85-0.88 × torso radius
+- **Width taper**: Shoulder (1.18×) → Chest (1.12×) → Waist (1.08×)
+
+### No Mesh Duplication
+
+- Cylinders calculated procedurally each frame based on attachment frames
+- No vertex buffer manipulation
+- Shader handles all visual complexity
+- Clean separation between geometry (C++) and appearance (shaders)
+
+## Testing
+
+Test coverage includes:
+- Registration verification for both armor variants
+- Shared helmet verification
+- Equipment category separation validation
+
+Run tests with:
+```bash
+./build/tests/render_tests --gtest_filter="ArmorRendererTest.*Carthage*"
+```
+
+## Files
+
+### Header Files
+- `render/equipment/armor/armor_light_carthage.h`
+- `render/equipment/armor/armor_heavy_carthage.h`
+
+### Implementation Files
+- `render/equipment/armor/armor_light_carthage.cpp`
+- `render/equipment/armor/armor_heavy_carthage.cpp`
+
+### Shaders (Shared)
+- `assets/shaders/archer_carthage.vert`
+- `assets/shaders/archer_carthage.frag`
+
+### Tests
+- `tests/render/armor_renderer_test.cpp`

+ 78 - 0
render/equipment/armor/armor_heavy_carthage.cpp

@@ -0,0 +1,78 @@
+#include "armor_heavy_carthage.h"
+#include "../../geom/transforms.h"
+#include "../../gl/primitives.h"
+#include "../../humanoid/humanoid_math.h"
+#include "../../humanoid/humanoid_specs.h"
+#include "../../humanoid/rig.h"
+#include "../../submitter.h"
+#include <QMatrix4x4>
+#include <QVector3D>
+
+namespace Render::GL {
+
+using Render::Geom::cylinderBetween;
+
+void ArmorHeavyCarthageRenderer::render(const DrawContext &ctx,
+                                        const BodyFrames &frames,
+                                        const HumanoidPalette &palette,
+                                        const HumanoidAnimationContext &anim,
+                                        ISubmitter &submitter) {
+  (void)anim;
+  (void)palette;
+
+  const AttachmentFrame &torso = frames.torso;
+  const AttachmentFrame &waist = frames.waist;
+  const AttachmentFrame &head = frames.head;
+
+  if (torso.radius <= 0.0F) {
+    return;
+  }
+
+  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 waist_r =
+      waist.radius > 0.0F ? waist.radius : torso.radius * 0.90F;
+  float const head_r = head.radius > 0.0F ? head.radius : torso.radius * 0.60F;
+
+  QVector3D top = torso.origin + up * (torso_r * 0.45F);
+  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);
+  }
+
+  QVector3D bottom =
+      waist.origin + waist_up * (waist_r * 0.04F) - forward * (torso_r * 0.02F);
+  QVector3D chainmail_bottom =
+      waist.origin + waist_up * (waist_r * 0.02F) - forward * (torso_r * 0.04F);
+
+  QVector3D bronze_color = QVector3D(0.72F, 0.53F, 0.28F);
+  QVector3D bronze_core = bronze_color * 0.92F;
+  QVector3D chainmail_color = QVector3D(0.50F, 0.52F, 0.58F);
+
+  auto drawTorso = [&](const QVector3D &a, const QVector3D &b, float radius,
+                       const QVector3D &color, float scaleX, float scaleZ) {
+    QMatrix4x4 m = cylinderBetween(ctx.model, a, b, radius);
+    m.scale(scaleX, 1.0F, scaleZ);
+    submitter.mesh(getUnitTorso(), m, color, nullptr, 1.0F);
+  };
+
+  drawTorso(top, chainmail_bottom, torso_r * 0.90F, chainmail_color, 1.00F,
+            0.88F);
+
+  drawTorso(top + forward * (torso_r * 0.02F),
+            bottom + forward * (torso_r * 0.02F), torso_r * 0.98F, bronze_color,
+            1.05F, 0.84F);
+
+  drawTorso(top, bottom, torso_r * 0.90F, bronze_core, 0.98F, 0.80F);
+}
+
+} // namespace Render::GL

+ 19 - 0
render/equipment/armor/armor_heavy_carthage.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include "../../humanoid/rig.h"
+#include "../../palette.h"
+#include "../i_equipment_renderer.h"
+
+namespace Render::GL {
+
+class ArmorHeavyCarthageRenderer : public IEquipmentRenderer {
+public:
+  ArmorHeavyCarthageRenderer() = default;
+
+  void render(const DrawContext &ctx, const BodyFrames &frames,
+              const HumanoidPalette &palette,
+              const HumanoidAnimationContext &anim,
+              ISubmitter &submitter) override;
+};
+
+} // namespace Render::GL

+ 100 - 0
render/equipment/armor/armor_light_carthage.cpp

@@ -0,0 +1,100 @@
+#include "armor_light_carthage.h"
+#include "../../geom/transforms.h"
+#include "../../gl/primitives.h"
+#include "../../humanoid/humanoid_math.h"
+#include "../../humanoid/humanoid_specs.h"
+#include "../../humanoid/rig.h"
+#include "../../submitter.h"
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <cmath>
+#include <numbers>
+
+namespace Render::GL {
+
+using Render::Geom::cylinderBetween;
+
+void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
+                                        const BodyFrames &frames,
+                                        const HumanoidPalette &palette,
+                                        const HumanoidAnimationContext &anim,
+                                        ISubmitter &submitter) {
+  (void)anim;
+  (void)palette;
+
+  const AttachmentFrame &torso = frames.torso;
+  const AttachmentFrame &waist = frames.waist;
+  const AttachmentFrame &head = frames.head;
+
+  if (torso.radius <= 0.0F) {
+    return;
+  }
+
+  QVector3D leather_color = QVector3D(0.44F, 0.30F, 0.19F);
+  QVector3D leather_shadow = leather_color * 0.90F;
+  QVector3D leather_highlight = leather_color * 1.08F;
+
+  QVector3D up = torso.up.normalized();
+  QVector3D right = torso.right.normalized();
+  QVector3D forward = torso.forward.normalized();
+
+  float const torso_r = torso.radius;
+  float const waist_r =
+      waist.radius > 0.0F ? waist.radius : torso.radius * 0.85F;
+  float const head_r = head.radius > 0.0F ? head.radius : torso.radius * 0.6F;
+
+  QVector3D head_up =
+      (head.up.lengthSquared() > 1e-6F) ? head.up.normalized() : up;
+  QVector3D waist_up =
+      (waist.up.lengthSquared() > 1e-6F) ? waist.up.normalized() : up;
+
+  QVector3D top = torso.origin + up * (torso_r * 0.35F);
+  QVector3D head_guard =
+      head.origin -
+      head_up * ((head_r > 0.0F ? head_r : torso_r * 0.6F) * 1.45F);
+  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.03F) - forward * (torso_r * 0.01F);
+
+  float main_radius = torso_r * 0.96F;
+
+  QMatrix4x4 cuirass = cylinderBetween(ctx.model, top, bottom, main_radius);
+  cuirass.scale(1.0F, 1.0F, 0.80F);
+  submitter.mesh(getUnitTorso(), cuirass, leather_highlight, nullptr, 1.0F);
+
+  auto strap = [&](float side) {
+    QVector3D shoulder_anchor =
+        top + right * (torso_r * 0.54F * side) - up * (torso_r * 0.04F);
+    QVector3D chest_anchor =
+        shoulder_anchor - up * (torso_r * 0.82F) + forward * (torso_r * 0.22F);
+    submitter.mesh(getUnitCylinder(),
+                   cylinderBetween(ctx.model, shoulder_anchor, chest_anchor,
+                                   torso_r * 0.10F),
+                   leather_highlight * 0.95F, nullptr, 1.0F);
+  };
+  strap(1.0F);
+  strap(-1.0F);
+
+  QVector3D front_panel_top =
+      top + forward * (torso_r * 0.18F) - up * (torso_r * 0.06F);
+  QVector3D front_panel_bottom =
+      bottom + forward * (torso_r * 0.20F) + up * (torso_r * 0.03F);
+  QMatrix4x4 front_panel = cylinderBetween(ctx.model, front_panel_top,
+                                           front_panel_bottom, torso_r * 0.48F);
+  front_panel.scale(0.95F, 1.0F, 0.32F);
+  submitter.mesh(getUnitTorso(), front_panel, leather_highlight, nullptr, 1.0F);
+
+  QVector3D back_panel_top =
+      top - forward * (torso_r * 0.24F) - up * (torso_r * 0.05F);
+  QVector3D back_panel_bottom =
+      bottom - forward * (torso_r * 0.26F) + up * (torso_r * 0.02F);
+  QMatrix4x4 back_panel = cylinderBetween(ctx.model, back_panel_top,
+                                          back_panel_bottom, torso_r * 0.50F);
+  back_panel.scale(0.96F, 1.0F, 0.30F);
+  submitter.mesh(getUnitTorso(), back_panel, leather_shadow, nullptr, 1.0F);
+}
+
+} // namespace Render::GL

+ 19 - 0
render/equipment/armor/armor_light_carthage.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include "../../humanoid/rig.h"
+#include "../../palette.h"
+#include "../i_equipment_renderer.h"
+
+namespace Render::GL {
+
+class ArmorLightCarthageRenderer : public IEquipmentRenderer {
+public:
+  ArmorLightCarthageRenderer() = default;
+
+  void render(const DrawContext &ctx, const BodyFrames &frames,
+              const HumanoidPalette &palette,
+              const HumanoidAnimationContext &anim,
+              ISubmitter &submitter) override;
+};
+
+} // namespace Render::GL

+ 35 - 0
render/equipment/armor/armor_utils.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include "../../gl/draw_context.h"
+#include <QMatrix4x4>
+#include <QVector3D>
+
+namespace Render::GL {
+
+inline QMatrix4x4 createArmorTransform(const DrawContext &ctx,
+                                       const QVector3D &center,
+                                       const QVector3D &up,
+                                       const QVector3D &right,
+                                       const QVector3D &forward, float width,
+                                       float height, float depth) {
+  QMatrix4x4 transform = ctx.model;
+  transform.translate(center);
+
+  QMatrix4x4 orientation;
+  orientation(0, 0) = right.x();
+  orientation(0, 1) = up.x();
+  orientation(0, 2) = forward.x();
+  orientation(1, 0) = right.y();
+  orientation(1, 1) = up.y();
+  orientation(1, 2) = forward.y();
+  orientation(2, 0) = right.z();
+  orientation(2, 1) = up.z();
+  orientation(2, 2) = forward.z();
+
+  transform = transform * orientation;
+  transform.scale(width, height, depth);
+
+  return transform;
+}
+
+} // namespace Render::GL

+ 0 - 101
render/equipment/armor/carthage_armor.cpp

@@ -1,101 +0,0 @@
-#include "carthage_armor.h"
-#include "../../geom/transforms.h"
-#include "../../gl/primitives.h"
-#include "../../humanoid/humanoid_math.h"
-#include "../../humanoid/humanoid_specs.h"
-#include "../../humanoid/rig.h"
-#include "../../humanoid/style_palette.h"
-#include "../../submitter.h"
-#include "tunic_renderer.h"
-#include <QMatrix4x4>
-#include <QVector3D>
-#include <cmath>
-#include <numbers>
-
-namespace Render::GL {
-
-using Render::Geom::cylinderBetween;
-using Render::GL::Humanoid::saturate_color;
-
-void CarthageHeavyArmorRenderer::render(const DrawContext &ctx,
-                                        const BodyFrames &frames,
-                                        const HumanoidPalette &palette,
-                                        const HumanoidAnimationContext &anim,
-                                        ISubmitter &submitter) {
-
-  TunicConfig config;
-  config.torso_scale = 1.07F;
-  config.shoulder_width_scale = 1.22F;
-  config.chest_depth_scale = 0.84F;
-  config.waist_taper = 0.91F;
-  config.include_pauldrons = true;
-  config.include_gorget = true;
-  config.include_belt = true;
-
-  TunicRenderer renderer(config);
-  renderer.render(ctx, frames, palette, anim, submitter);
-
-  using HP = HumanProportions;
-  QVector3D const bronze_color =
-      saturate_color(palette.metal * QVector3D(1.2F, 1.0F, 0.65F));
-  QVector3D const chainmail_color =
-      saturate_color(palette.metal * QVector3D(0.82F, 0.85F, 0.90F));
-
-  float const y_neck = frames.head.origin.y() - HP::HEAD_RADIUS * 0.6F;
-  for (int i = 0; i < 3; ++i) {
-    float const y = y_neck - static_cast<float>(i) * 0.025F;
-    float const r = HP::NECK_RADIUS * (1.80F + static_cast<float>(i) * 0.10F);
-    QVector3D const ring_pos(frames.torso.origin.x(), y,
-                             frames.torso.origin.z());
-    QVector3D const a = ring_pos + QVector3D(0, 0.012F, 0);
-    QVector3D const b = ring_pos - QVector3D(0, 0.012F, 0);
-    submitter.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
-                   chainmail_color * (1.0F - static_cast<float>(i) * 0.05F),
-                   nullptr, 1.0F);
-  }
-
-  auto draw_phalera = [&](const QVector3D &pos) {
-    QMatrix4x4 m = ctx.model;
-    m.translate(pos);
-    m.scale(0.025F);
-    submitter.mesh(getUnitSphere(), m, bronze_color, nullptr, 1.0F);
-  };
-
-  draw_phalera(frames.shoulderL.origin + QVector3D(0, 0.05F, 0.02F));
-  draw_phalera(frames.shoulderR.origin + QVector3D(0, 0.05F, 0.02F));
-}
-
-void CarthageLightArmorRenderer::render(const DrawContext &ctx,
-                                        const BodyFrames &frames,
-                                        const HumanoidPalette &palette,
-                                        const HumanoidAnimationContext &anim,
-                                        ISubmitter &submitter) {
-
-  TunicConfig config;
-  config.torso_scale = 1.03F;
-  config.shoulder_width_scale = 1.14F;
-  config.chest_depth_scale = 0.89F;
-  config.waist_taper = 0.95F;
-  config.include_pauldrons = false;
-  config.include_gorget = false;
-  config.include_belt = true;
-
-  TunicRenderer renderer(config);
-  renderer.render(ctx, frames, palette, anim, submitter);
-
-  using HP = HumanProportions;
-  QVector3D const bronze_color =
-      saturate_color(palette.metal * QVector3D(1.1F, 0.9F, 0.6F));
-
-  auto draw_ornament = [&](const QVector3D &pos) {
-    QMatrix4x4 m = ctx.model;
-    m.translate(pos);
-    m.scale(0.015F);
-    submitter.mesh(getUnitSphere(), m, bronze_color, nullptr, 1.0F);
-  };
-
-  draw_ornament(frames.shoulderL.origin + QVector3D(0, 0.04F, 0.02F));
-  draw_ornament(frames.shoulderR.origin + QVector3D(0, 0.04F, 0.02F));
-}
-
-} // namespace Render::GL

+ 0 - 29
render/equipment/armor/carthage_armor.h

@@ -1,29 +0,0 @@
-#pragma once
-
-#include "../../humanoid/rig.h"
-#include "../../palette.h"
-#include "../i_equipment_renderer.h"
-
-namespace Render::GL {
-
-class CarthageHeavyArmorRenderer : public IEquipmentRenderer {
-public:
-  CarthageHeavyArmorRenderer() = default;
-
-  void render(const DrawContext &ctx, const BodyFrames &frames,
-              const HumanoidPalette &palette,
-              const HumanoidAnimationContext &anim,
-              ISubmitter &submitter) override;
-};
-
-class CarthageLightArmorRenderer : public IEquipmentRenderer {
-public:
-  CarthageLightArmorRenderer() = default;
-
-  void render(const DrawContext &ctx, const BodyFrames &frames,
-              const HumanoidPalette &palette,
-              const HumanoidAnimationContext &anim,
-              ISubmitter &submitter) override;
-};
-
-} // namespace Render::GL

+ 105 - 129
render/equipment/armor/chainmail_armor.cpp

@@ -1,5 +1,6 @@
 #include "chainmail_armor.h"
 #include "../../geom/transforms.h"
+#include "../../gl/backend.h"
 #include "../../gl/primitives.h"
 #include "../../humanoid/humanoid_math.h"
 #include "../../humanoid/rig.h"
@@ -14,17 +15,17 @@ namespace Render::GL {
 using Render::Geom::cylinderBetween;
 
 auto ChainmailArmorRenderer::calculateRingColor(float x, float y,
-                                                 float z) const -> QVector3D {
-  // Procedural rust/weathering based on position
-  float rust_noise = std::sin(x * 127.3F) * std::cos(y * 97.1F) * 
-                     std::sin(z * 83.7F);
-  rust_noise = (rust_noise + 1.0F) * 0.5F; // Normalize to [0,1]
-  
-  // More rust in lower areas (gravity effect)
+                                                float z) const -> QVector3D {
+
+  float rust_noise =
+      std::sin(x * 127.3F) * std::cos(y * 97.1F) * std::sin(z * 83.7F);
+  rust_noise = (rust_noise + 1.0F) * 0.5F;
+
   float gravity_rust = std::clamp(1.0F - y * 0.8F, 0.0F, 1.0F);
-  float total_rust = (rust_noise * 0.6F + gravity_rust * 0.4F) * m_config.rust_amount;
-  
-  return m_config.metal_color * (1.0F - total_rust) + 
+  float total_rust =
+      (rust_noise * 0.6F + gravity_rust * 0.4F) * m_config.rust_amount;
+
+  return m_config.metal_color * (1.0F - total_rust) +
          m_config.rust_tint * total_rust;
 }
 
@@ -33,16 +34,16 @@ void ChainmailArmorRenderer::render(const DrawContext &ctx,
                                     const HumanoidPalette &palette,
                                     const HumanoidAnimationContext &anim,
                                     ISubmitter &submitter) {
-  (void)palette;
   (void)anim;
+  (void)palette;
 
   renderTorsoMail(ctx, frames, submitter);
-  
+
   if (m_config.has_shoulder_guards) {
     renderShoulderGuards(ctx, frames, submitter);
   }
-  
-  if (m_config.has_arm_coverage && m_config.coverage > 0.5F) {
+
+  if (m_config.has_arm_coverage) {
     renderArmMail(ctx, frames, submitter);
   }
 }
@@ -52,66 +53,42 @@ void ChainmailArmorRenderer::renderTorsoMail(const DrawContext &ctx,
                                              ISubmitter &submitter) {
   const AttachmentFrame &torso = frames.torso;
   const AttachmentFrame &waist = frames.waist;
-  
+
   if (torso.radius <= 0.0F) {
     return;
   }
 
   float const torso_r = torso.radius;
-  float const coverage_height = m_config.coverage;
-  
-  // Main chainmail hauberk body - layered construction
-  int const vertical_segments = m_config.detail_level >= 2 ? 16 : 8;
-  
-  for (int seg = 0; seg < vertical_segments; ++seg) {
-    float t0 = static_cast<float>(seg) / static_cast<float>(vertical_segments);
-    float t1 = static_cast<float>(seg + 1) / static_cast<float>(vertical_segments);
-    
-    // Interpolate between torso and waist
-    QVector3D pos0 = torso.origin * (1.0F - t0) + waist.origin * t0;
-    QVector3D pos1 = torso.origin * (1.0F - t1) + waist.origin * t1;
-    
-    float r0 = torso_r * (1.0F + t0 * 0.15F); // Slight flare at waist
-    float r1 = torso_r * (1.0F + t1 * 0.15F);
-    
-    QVector3D ring_color = calculateRingColor(pos0.x(), pos0.y(), pos0.z());
-    
-    // If high detail, render actual ring structure
-    if (m_config.detail_level >= 2) {
-      renderRingDetails(ctx, pos0, r0, (pos1 - pos0).length(),
-                        torso.up, torso.right, submitter);
-    } else {
-      // Lower detail: solid segments with chainmail texture hint
-      submitter.mesh(getUnitCylinder(),
-                     cylinderBetween(ctx.model, pos0, pos1, (r0 + r1) * 0.5F * 1.02F),
-                     ring_color, nullptr, 0.75F);
-    }
-  }
-  
-  // Bottom edge rings (hanging mail at waist)
-  if (coverage_height > 0.7F) {
-    int const edge_rings = m_config.detail_level >= 1 ? 16 : 8;
-    
-    for (int i = 0; i < edge_rings; ++i) {
-      float angle = (static_cast<float>(i) / static_cast<float>(edge_rings)) *
-                    2.0F * std::numbers::pi_v<float>;
-      
-      float x = std::cos(angle);
-      float z = std::sin(angle);
-      
-      QVector3D ring_pos = waist.origin + 
-                           waist.right * (x * torso_r * 1.15F) +
-                           waist.forward * (z * torso_r * 1.15F) +
-                           waist.up * (-0.05F);
-      
-      QMatrix4x4 ring_m = ctx.model;
-      ring_m.translate(ring_pos);
-      ring_m.scale(m_config.ring_size * 1.5F);
-      
-      QVector3D edge_color = calculateRingColor(ring_pos.x(), ring_pos.y(), ring_pos.z());
-      submitter.mesh(getUnitSphere(), ring_m, edge_color, nullptr, 0.8F);
-    }
-  }
+
+  QVector3D top = torso.origin + torso.up * (torso_r * 0.20F);
+  QVector3D bottom = waist.origin - waist.up * (torso_r * 0.35F);
+
+  QMatrix4x4 mail_transform = ctx.model;
+  mail_transform.translate((top + bottom) * 0.5F);
+
+  QVector3D up_dir = torso.up.normalized();
+  QVector3D right_dir = torso.right.normalized();
+  QVector3D fwd_dir = torso.forward.normalized();
+
+  QMatrix4x4 orientation;
+  orientation(0, 0) = right_dir.x();
+  orientation(0, 1) = up_dir.x();
+  orientation(0, 2) = fwd_dir.x();
+  orientation(1, 0) = right_dir.y();
+  orientation(1, 1) = up_dir.y();
+  orientation(1, 2) = fwd_dir.y();
+  orientation(2, 0) = right_dir.z();
+  orientation(2, 1) = up_dir.z();
+  orientation(2, 2) = fwd_dir.z();
+
+  mail_transform = mail_transform * orientation;
+
+  float height = (top - bottom).length();
+  mail_transform.scale(torso_r * 1.12F, height * 0.5F, torso_r * 1.08F);
+
+  QVector3D steel_color = QVector3D(0.65F, 0.67F, 0.70F);
+
+  submitter.mesh(getUnitTorso(), mail_transform, steel_color, nullptr, 1.0F);
 }
 
 void ChainmailArmorRenderer::renderShoulderGuards(const DrawContext &ctx,
@@ -120,44 +97,45 @@ void ChainmailArmorRenderer::renderShoulderGuards(const DrawContext &ctx,
   const AttachmentFrame &shoulder_l = frames.shoulderL;
   const AttachmentFrame &shoulder_r = frames.shoulderR;
   const AttachmentFrame &torso = frames.torso;
-  
-  // Left shoulder pauldron
+
   QVector3D left_base = shoulder_l.origin;
   QVector3D left_tip = left_base + torso.up * 0.08F + torso.right * (-0.05F);
-  
-  float const shoulder_r = 0.08F;
-  
-  QVector3D left_color = calculateRingColor(left_base.x(), left_base.y(), left_base.z());
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, left_base, left_tip, shoulder_r),
-                 left_color, nullptr, 0.8F);
-  
-  // Right shoulder pauldron
+
+  float const shoulder_radius = 0.08F;
+
+  QVector3D left_color =
+      calculateRingColor(left_base.x(), left_base.y(), left_base.z());
+  submitter.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, left_base, left_tip, shoulder_radius),
+      left_color, nullptr, 0.8F);
+
   QVector3D right_base = shoulder_r.origin;
   QVector3D right_tip = right_base + torso.up * 0.08F + torso.right * 0.05F;
-  
-  QVector3D right_color = calculateRingColor(right_base.x(), right_base.y(), right_base.z());
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, right_base, right_tip, shoulder_r),
-                 right_color, nullptr, 0.8F);
-  
-  // Layered shoulder protection (multiple overlapping ring rows)
+
+  QVector3D right_color =
+      calculateRingColor(right_base.x(), right_base.y(), right_base.z());
+  submitter.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, right_base, right_tip, shoulder_radius),
+      right_color, nullptr, 0.8F);
+
   if (m_config.detail_level >= 1) {
     for (int layer = 0; layer < 3; ++layer) {
       float layer_offset = static_cast<float>(layer) * 0.025F;
-      
+
       QVector3D left_layer = left_base + torso.up * (-layer_offset);
       QVector3D right_layer = right_base + torso.up * (-layer_offset);
-      
+
       QMatrix4x4 left_m = ctx.model;
       left_m.translate(left_layer);
-      left_m.scale(shoulder_r * 1.3F);
+      left_m.scale(shoulder_radius * 1.3F);
       submitter.mesh(getUnitSphere(), left_m,
                      left_color * (1.0F - layer_offset), nullptr, 0.75F);
-      
+
       QMatrix4x4 right_m = ctx.model;
       right_m.translate(right_layer);
-      right_m.scale(shoulder_r * 1.3F);
+      right_m.scale(shoulder_radius * 1.3F);
       submitter.mesh(getUnitSphere(), right_m,
                      right_color * (1.0F - layer_offset), nullptr, 0.75F);
     }
@@ -167,83 +145,81 @@ void ChainmailArmorRenderer::renderShoulderGuards(const DrawContext &ctx,
 void ChainmailArmorRenderer::renderArmMail(const DrawContext &ctx,
                                            const BodyFrames &frames,
                                            ISubmitter &submitter) {
-  // Arm coverage (sleeves) extending from shoulders to elbows
+
   const AttachmentFrame &shoulder_l = frames.shoulderL;
   const AttachmentFrame &shoulder_r = frames.shoulderR;
   const AttachmentFrame &hand_l = frames.handL;
   const AttachmentFrame &hand_r = frames.handR;
-  
-  // Left arm mail sleeve
+
   QVector3D left_shoulder = shoulder_l.origin;
-  QVector3D left_elbow = (left_shoulder + hand_l.origin) * 0.5F; // Approximate elbow
-  
+  QVector3D left_elbow = (left_shoulder + hand_l.origin) * 0.5F;
+
   int const arm_segments = m_config.detail_level >= 2 ? 6 : 3;
-  
+
   for (int i = 0; i < arm_segments; ++i) {
     float t0 = static_cast<float>(i) / static_cast<float>(arm_segments);
     float t1 = static_cast<float>(i + 1) / static_cast<float>(arm_segments);
-    
+
     QVector3D pos0 = left_shoulder * (1.0F - t0) + left_elbow * t0;
     QVector3D pos1 = left_shoulder * (1.0F - t1) + left_elbow * t1;
-    
-    float radius = 0.05F * (1.0F - t0 * 0.2F); // Taper toward elbow
-    
+
+    float radius = 0.05F * (1.0F - t0 * 0.2F);
+
     QVector3D color = calculateRingColor(pos0.x(), pos0.y(), pos0.z());
     submitter.mesh(getUnitCylinder(),
-                   cylinderBetween(ctx.model, pos0, pos1, radius),
-                   color, nullptr, 0.75F);
+                   cylinderBetween(ctx.model, pos0, pos1, radius), color,
+                   nullptr, 0.75F);
   }
-  
-  // Right arm mail sleeve
+
   QVector3D right_shoulder = shoulder_r.origin;
   QVector3D right_elbow = (right_shoulder + hand_r.origin) * 0.5F;
-  
+
   for (int i = 0; i < arm_segments; ++i) {
     float t0 = static_cast<float>(i) / static_cast<float>(arm_segments);
     float t1 = static_cast<float>(i + 1) / static_cast<float>(arm_segments);
-    
+
     QVector3D pos0 = right_shoulder * (1.0F - t0) + right_elbow * t0;
     QVector3D pos1 = right_shoulder * (1.0F - t1) + right_elbow * t1;
-    
+
     float radius = 0.05F * (1.0F - t0 * 0.2F);
-    
+
     QVector3D color = calculateRingColor(pos0.x(), pos0.y(), pos0.z());
     submitter.mesh(getUnitCylinder(),
-                   cylinderBetween(ctx.model, pos0, pos1, radius),
-                   color, nullptr, 0.75F);
+                   cylinderBetween(ctx.model, pos0, pos1, radius), color,
+                   nullptr, 0.75F);
   }
 }
 
 void ChainmailArmorRenderer::renderRingDetails(
-    const DrawContext &ctx, const QVector3D &center, float radius,
-    float height, const QVector3D &up, const QVector3D &right,
-    ISubmitter &submitter) {
-  // Render individual interlocking rings for high detail mode
+    const DrawContext &ctx, const QVector3D &center, float radius, float height,
+    const QVector3D &up, const QVector3D &right, ISubmitter &submitter) {
+
   int const rings_around = 24;
   int const rings_vertical = 4;
-  
+
   for (int row = 0; row < rings_vertical; ++row) {
-    float y = (static_cast<float>(row) / static_cast<float>(rings_vertical)) * height;
-    
-    // Offset alternating rows for interlocking pattern
+    float y =
+        (static_cast<float>(row) / static_cast<float>(rings_vertical)) * height;
+
     float row_offset = (row % 2) * 0.5F;
-    
+
     for (int col = 0; col < rings_around; ++col) {
-      float angle = ((static_cast<float>(col) + row_offset) / 
-                     static_cast<float>(rings_around)) * 
+      float angle = ((static_cast<float>(col) + row_offset) /
+                     static_cast<float>(rings_around)) *
                     2.0F * std::numbers::pi_v<float>;
-      
+
       float x = std::cos(angle) * radius;
       float z = std::sin(angle) * radius;
-      
-      QVector3D ring_pos = center + up * y + right * x + 
+
+      QVector3D ring_pos = center + up * y + right * x +
                            QVector3D::crossProduct(up, right).normalized() * z;
-      
+
       QMatrix4x4 ring_m = ctx.model;
       ring_m.translate(ring_pos);
       ring_m.scale(m_config.ring_size);
-      
-      QVector3D color = calculateRingColor(ring_pos.x(), ring_pos.y(), ring_pos.z());
+
+      QVector3D color =
+          calculateRingColor(ring_pos.x(), ring_pos.y(), ring_pos.z());
       submitter.mesh(getUnitSphere(), ring_m, color, nullptr, 0.85F);
     }
   }

+ 5 - 5
render/equipment/armor/chainmail_armor.h

@@ -8,14 +8,14 @@
 namespace Render::GL {
 
 struct ChainmailArmorConfig {
-  QVector3D metal_color = QVector3D(0.65F, 0.67F, 0.70F); // Steel gray
+  QVector3D metal_color = QVector3D(0.65F, 0.67F, 0.70F);
   QVector3D rust_tint = QVector3D(0.52F, 0.35F, 0.25F);
-  float coverage = 1.0F; // 0.0 = minimal, 1.0 = full coverage
-  float rust_amount = 0.15F; // 0.0 = pristine, 1.0 = heavily rusted
+  float coverage = 1.0F;
+  float rust_amount = 0.15F;
   float ring_size = 0.008F;
   bool has_shoulder_guards = true;
   bool has_arm_coverage = true;
-  int detail_level = 2; // 0=low (simple), 1=medium, 2=high (individual rings)
+  int detail_level = 2;
 };
 
 class ChainmailArmorRenderer : public IEquipmentRenderer {
@@ -41,7 +41,7 @@ private:
   void renderRingDetails(const DrawContext &ctx, const QVector3D &center,
                          float radius, float height, const QVector3D &up,
                          const QVector3D &right, ISubmitter &submitter);
-  
+
   auto calculateRingColor(float x, float y, float z) const -> QVector3D;
 };
 

+ 56 - 18
render/equipment/armor/roman_armor.cpp

@@ -160,18 +160,39 @@ void RomanHeavyArmorRenderer::render(const DrawContext &ctx,
   renderShoulderGuard(frames.shoulderL.origin, -right);
   renderShoulderGuard(frames.shoulderR.origin, right);
 
-  QVector3D const waist_center(origin.x(), HP::WAIST_Y, origin.z());
-  for (int i = 0; i < 3; ++i) {
-    float const y0 = HP::WAIST_Y - static_cast<float>(i) * 0.035F;
-    float const y1 = y0 - 0.03F;
-    float const r = torso.radius * (1.05F + static_cast<float>(i) * 0.02F);
-
-    submitter.mesh(
-        getUnitCone(),
-        coneFromTo(ctx.model, QVector3D(waist_center.x(), y0, waist_center.z()),
-                   QVector3D(waist_center.x(), y1, waist_center.z()), r),
-        leather_color * (0.95F - static_cast<float>(i) * 0.05F), nullptr, 1.0F);
-  }
+  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,
@@ -249,12 +270,29 @@ void RomanLightArmorRenderer::render(const DrawContext &ctx,
     }
   }
 
-  QVector3D const waist_center(origin.x(), HP::WAIST_Y + 0.02F, origin.z());
-  float const belt_r = torso.radius * 1.02F;
-  QVector3D const belt_top(waist_center.x(), waist_center.y() + 0.02F,
-                           waist_center.z());
-  QVector3D const belt_bot(waist_center.x(), waist_center.y() - 0.02F,
-                           waist_center.z());
+  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 + 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),

+ 36 - 23
render/equipment/armor/tunic_renderer.cpp

@@ -40,7 +40,12 @@ void TunicRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
       saturate_color(palette.metal * QVector3D(1.3F, 1.1F, 0.7F));
 
   using HP = HumanProportions;
-  float const y_top = HP::SHOULDER_Y + 0.02F;
+  auto torsoY = [&](float spec_y) {
+    float const delta = spec_y - HP::SHOULDER_Y;
+    return torso.origin.y() + delta;
+  };
+
+  float const y_top = torsoY(HP::SHOULDER_Y + 0.02F);
 
   renderTorsoArmor(ctx, torso, steel_color, brass_color, submitter);
 
@@ -70,10 +75,15 @@ void TunicRenderer::renderTorsoArmor(const DrawContext &ctx,
   const QVector3D &forward = torso.forward;
   float const torso_r = torso.radius * m_config.torso_scale;
 
-  float const y_top = HP::SHOULDER_Y + 0.02F;
-  float const y_mid_chest = (HP::SHOULDER_Y + HP::CHEST_Y) * 0.5F;
-  float const y_bottom_chest = HP::CHEST_Y;
-  float const y_waist = HP::WAIST_Y + 0.06F;
+  auto mapTorsoY = [&](float spec_y) {
+    float const delta = spec_y - HP::SHOULDER_Y;
+    return origin.y() + delta;
+  };
+
+  float const y_top = mapTorsoY(HP::SHOULDER_Y + 0.02F);
+  float const y_mid_chest = mapTorsoY((HP::SHOULDER_Y + HP::CHEST_Y) * 0.5F);
+  float const y_bottom_chest = mapTorsoY(HP::CHEST_Y);
+  float const y_waist = mapTorsoY(HP::WAIST_Y + 0.06F);
 
   float const shoulder_width = torso_r * m_config.shoulder_width_scale;
   float const chest_width = torso_r * 1.15F;
@@ -258,25 +268,28 @@ void TunicRenderer::renderBelt(const DrawContext &ctx,
   using HP = HumanProportions;
 
   float const waist_r = waist.radius * m_config.waist_taper;
+  auto waistY = [&](float spec_y) {
+    float const delta = spec_y - HP::WAIST_Y;
+    return waist.origin.y() + delta;
+  };
 
-  for (int i = 0; i < 4; ++i) {
-    float const y0 = HP::WAIST_Y + 0.04F - static_cast<float>(i) * 0.038F;
-    float const y1 = y0 - 0.032F;
-    float const r0 = waist_r * (1.06F + static_cast<float>(i) * 0.025F);
-
-    submitter.mesh(
-        getUnitCone(),
-        coneFromTo(ctx.model, QVector3D(waist.origin.x(), y0, waist.origin.z()),
-                   QVector3D(waist.origin.x(), y1, waist.origin.z()), r0),
-        steel_color * (0.96F - static_cast<float>(i) * 0.02F), nullptr, 1.0F);
-
-    if (i < 3) {
-      QMatrix4x4 m = ctx.model;
-      m.translate(QVector3D(r0 * 0.90F, y0 - 0.016F, 0));
-      m.scale(0.012F);
-      submitter.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
-    }
-  }
+  float const y_center = waistY(HP::WAIST_Y + 0.02F);
+  QVector3D const belt_top(waist.origin.x(), y_center + 0.02F,
+                           waist.origin.z());
+  QVector3D const belt_bot(waist.origin.x(), y_center - 0.02F,
+                           waist.origin.z());
+
+  submitter.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, belt_bot, belt_top, waist_r * 1.08F),
+      steel_color * 0.94F, nullptr, 1.0F);
+
+  QVector3D const trim_top = belt_top + QVector3D(0, 0.005F, 0);
+  QVector3D const trim_bot = belt_bot - QVector3D(0, 0.005F, 0);
+  submitter.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, trim_bot, trim_top, waist_r * 1.12F),
+      brass_color * 0.95F, nullptr, 1.0F);
 }
 
 } // namespace Render::GL

+ 194 - 0
render/equipment/helmets/carthage_heavy_helmet.cpp

@@ -0,0 +1,194 @@
+#include "carthage_heavy_helmet.h"
+#include "../../geom/transforms.h"
+#include "../../gl/primitives.h"
+#include "../../humanoid/humanoid_math.h"
+#include "../../submitter.h"
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <algorithm>
+
+namespace {
+auto mixColor(const QVector3D &a, const QVector3D &b, float t) -> QVector3D {
+  return a * (1.0F - t) + b * t;
+}
+} // namespace
+
+namespace Render::GL {
+
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+
+void CarthageHeavyHelmetRenderer::render(const DrawContext &ctx,
+                                         const BodyFrames &frames,
+                                         const HumanoidPalette &palette,
+                                         const HumanoidAnimationContext &anim,
+                                         ISubmitter &submitter) {
+  (void)anim;
+  (void)palette;
+
+  const AttachmentFrame &head = frames.head;
+  if (head.radius <= 0.0F) {
+    return;
+  }
+
+  render_bowl(ctx, head, submitter);
+
+  if (m_config.has_neck_guard) {
+    render_neck_guard(ctx, head, submitter);
+  }
+
+  if (m_config.has_face_plate) {
+    render_face_plate(ctx, head, submitter);
+  }
+
+  if (m_config.has_hair_crest) {
+    render_crest(ctx, head, submitter);
+  }
+
+  if (m_config.has_cheek_guards) {
+    render_cheek_guards(ctx, head, submitter);
+  }
+}
+
+void CarthageHeavyHelmetRenderer::render_bowl(const DrawContext &ctx,
+                                              const AttachmentFrame &head,
+                                              ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
+  };
+
+  QVector3D c0 = headPoint(QVector3D(0.0f, 0.92f, 0.0f));
+  QMatrix4x4 m0 = ctx.model;
+  m0.translate(c0);
+  m0.scale(R * 1.12f, R * 0.68f, R * 1.08f);
+
+  QVector3D luminousBronze =
+      mixColor(m_config.bronze_color, m_config.glow_color, 0.35F);
+  submitter.mesh(getUnitSphere(), m0, luminousBronze, nullptr, 0.3f);
+
+  QVector3D rimCenter = headPoint(QVector3D(0.0f, 0.62f, 0.0f));
+  QMatrix4x4 rim = ctx.model;
+  rim.translate(rimCenter);
+  rim.scale(R * 1.28f, R * 0.16f, R * 1.25f);
+  submitter.mesh(getUnitSphere(), rim, m_config.glow_color, nullptr, 0.16f);
+}
+
+void CarthageHeavyHelmetRenderer::render_cheek_guards(
+    const DrawContext &ctx, const AttachmentFrame &head,
+    ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
+  };
+
+  QVector3D left_cheek = headPoint(QVector3D(-0.58f, 0.18f, 0.42f));
+  QVector3D right_cheek = headPoint(QVector3D(0.58f, 0.18f, 0.42f));
+
+  QMatrix4x4 m_left = ctx.model;
+  m_left.translate(left_cheek);
+  m_left.scale(R * 0.32f, R * 0.48f, R * 0.18f);
+  m_left.rotate(-6.0f, QVector3D(0.0f, 0.0f, 1.0f));
+
+  QMatrix4x4 m_right = ctx.model;
+  m_right.translate(right_cheek);
+  m_right.scale(R * 0.32f, R * 0.48f, R * 0.18f);
+  m_right.rotate(6.0f, QVector3D(0.0f, 0.0f, 1.0f));
+
+  submitter.mesh(getUnitSphere(), m_left, m_config.bronze_color, nullptr, 0.6f);
+  submitter.mesh(getUnitSphere(), m_right, m_config.bronze_color, nullptr,
+                 0.6f);
+}
+
+void CarthageHeavyHelmetRenderer::render_face_plate(const DrawContext &ctx,
+                                                    const AttachmentFrame &head,
+                                                    ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
+  };
+
+  QVector3D brow = headPoint(QVector3D(0.0f, 0.70f, 0.60f));
+  QVector3D chin = headPoint(QVector3D(0.0f, -0.08f, 0.34f));
+  QMatrix4x4 mask =
+      cylinderBetween(ctx.model, chin, brow, std::max(0.10f, R * 0.26f));
+  QVector3D plateColor =
+      mixColor(m_config.bronze_color, m_config.glow_color, 0.25F);
+  submitter.mesh(getUnitCylinder(), mask, plateColor, nullptr, 0.45f);
+
+  QVector3D noseTop = headPoint(QVector3D(0.0f, 0.58f, 0.70f));
+  QVector3D noseBottom = headPoint(QVector3D(0.0f, -0.02f, 0.46f));
+  QMatrix4x4 nose = cylinderBetween(ctx.model, noseBottom, noseTop,
+                                    std::max(0.05f, R * 0.12f));
+  submitter.mesh(getUnitCylinder(), nose, m_config.glow_color, nullptr, 0.65f);
+
+  render_brow_arch(ctx, head, submitter);
+}
+
+void CarthageHeavyHelmetRenderer::render_neck_guard(const DrawContext &ctx,
+                                                    const AttachmentFrame &head,
+                                                    ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
+  };
+
+  QVector3D guardCenter = headPoint(QVector3D(0.0f, 0.15f, -0.65f));
+  QMatrix4x4 guard = ctx.model;
+  guard.translate(guardCenter);
+  guard.scale(R * 1.25f, R * 0.52f, R * 0.58f);
+  QVector3D guardColor =
+      mixColor(m_config.bronze_color, m_config.glow_color, 0.15F);
+  submitter.mesh(getUnitSphere(), guard, guardColor, nullptr, 0.28f);
+}
+
+void CarthageHeavyHelmetRenderer::render_brow_arch(const DrawContext &ctx,
+                                                   const AttachmentFrame &head,
+                                                   ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
+  };
+
+  QVector3D left = headPoint(QVector3D(-0.62f, 0.66f, 0.60f));
+  QVector3D right = headPoint(QVector3D(0.62f, 0.66f, 0.60f));
+  float archRadius = std::max(0.04f, R * 0.10f);
+  QMatrix4x4 arch = cylinderBetween(ctx.model, left, right, archRadius);
+  QVector3D archColor =
+      mixColor(m_config.glow_color, m_config.bronze_color, 0.5F);
+  submitter.mesh(getUnitCylinder(), arch, archColor, nullptr, 0.52f);
+
+  QVector3D ridgeTop = headPoint(QVector3D(0.0f, 0.82f, 0.58f));
+  QMatrix4x4 ridge = ctx.model;
+  ridge.translate(ridgeTop);
+  ridge.scale(R * 0.22f, R * 0.10f, R * 0.26f);
+  submitter.mesh(getUnitSphere(), ridge, m_config.glow_color, nullptr, 0.58f);
+}
+
+void CarthageHeavyHelmetRenderer::render_crest(const DrawContext &ctx,
+                                               const AttachmentFrame &head,
+                                               ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
+  };
+
+  QVector3D crestBack = headPoint(QVector3D(0.0f, 1.18f, -0.28f));
+  QVector3D crestFront = headPoint(QVector3D(0.0f, 1.18f, 0.28f));
+  float crestRadius = std::max(0.06f, R * 0.26f);
+  QMatrix4x4 crestBridge =
+      cylinderBetween(ctx.model, crestBack, crestFront, crestRadius);
+  submitter.mesh(getUnitCylinder(), crestBridge, m_config.crest_color, nullptr,
+                 0.52f);
+
+  QVector3D plumeTop = headPoint(QVector3D(0.0f, 1.70f, 0.0f));
+  QVector3D plumeBase = headPoint(QVector3D(0.0f, 1.08f, 0.0f));
+  float plumeRadius = std::max(0.05f, R * 0.18f);
+  QMatrix4x4 plume =
+      cylinderBetween(ctx.model, plumeBase, plumeTop, plumeRadius);
+  QVector3D plumeColor =
+      mixColor(m_config.crest_color, m_config.glow_color, 0.40F);
+  submitter.mesh(getUnitCylinder(), plume, plumeColor, nullptr, 0.70f);
+}
+
+} // namespace Render::GL

+ 47 - 0
render/equipment/helmets/carthage_heavy_helmet.h

@@ -0,0 +1,47 @@
+#pragma once
+#include "../../humanoid/rig.h"
+#include "../i_equipment_renderer.h"
+#include <QVector3D>
+
+namespace Render::GL {
+
+struct CarthageHeavyHelmetConfig {
+  QVector3D bronze_color = QVector3D(0.72F, 0.45F, 0.20F);
+  QVector3D crest_color = QVector3D(0.95F, 0.95F, 0.90F);
+  QVector3D glow_color = QVector3D(1.0F, 0.98F, 0.92F);
+  bool has_cheek_guards = true;
+  bool has_face_plate = true;
+  bool has_neck_guard = true;
+  bool has_hair_crest = true;
+  int detail_level = 2;
+};
+
+class CarthageHeavyHelmetRenderer : public IEquipmentRenderer {
+public:
+  explicit CarthageHeavyHelmetRenderer(
+      const CarthageHeavyHelmetConfig &cfg = {})
+      : m_config(cfg) {}
+
+  void render(const DrawContext &ctx, const BodyFrames &frames,
+              const HumanoidPalette &palette,
+              const HumanoidAnimationContext &anim,
+              ISubmitter &submitter) override;
+
+private:
+  CarthageHeavyHelmetConfig m_config;
+
+  void render_bowl(const DrawContext &ctx, const AttachmentFrame &head,
+                   ISubmitter &submitter);
+  void render_cheek_guards(const DrawContext &ctx, const AttachmentFrame &head,
+                           ISubmitter &submitter);
+  void render_face_plate(const DrawContext &ctx, const AttachmentFrame &head,
+                         ISubmitter &submitter);
+  void render_neck_guard(const DrawContext &ctx, const AttachmentFrame &head,
+                         ISubmitter &submitter);
+  void render_brow_arch(const DrawContext &ctx, const AttachmentFrame &head,
+                        ISubmitter &submitter);
+  void render_crest(const DrawContext &ctx, const AttachmentFrame &head,
+                    ISubmitter &submitter);
+};
+
+} // namespace Render::GL

+ 266 - 202
render/equipment/helmets/carthage_light_helmet.cpp

@@ -1,5 +1,6 @@
 #include "carthage_light_helmet.h"
 #include "../../geom/transforms.h"
+#include "../../gl/backend.h"
 #include "../../gl/primitives.h"
 #include "../../humanoid/humanoid_math.h"
 #include "../../humanoid/rig.h"
@@ -15,10 +16,47 @@ namespace Render::GL {
 using Render::Geom::cylinderBetween;
 using Render::Geom::sphereAt;
 
-void CarthageLightHelmetRenderer::render(
-    const DrawContext &ctx, const BodyFrames &frames,
-    const HumanoidPalette &palette, const HumanoidAnimationContext &anim,
-    ISubmitter &submitter) {
+static inline void submit_disk(ISubmitter &submitter, const DrawContext &ctx,
+                               const QVector3D &center,
+                               const QVector3D &normal_dir, float radius,
+                               float thickness, const QVector3D &color,
+                               float roughness) {
+  QVector3D n = normal_dir;
+  if (n.lengthSquared() < 1e-5f) {
+    n = QVector3D(0, 1, 0);
+  }
+  n.normalize();
+  QVector3D a = center - 0.5f * thickness * n;
+  QVector3D b = center + 0.5f * thickness * n;
+  submitter.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, radius),
+                 color, nullptr, roughness);
+}
+
+static inline void submit_spike(ISubmitter &submitter, const DrawContext &ctx,
+                                const QVector3D &base, const QVector3D &dir,
+                                float length, float base_radius,
+                                const QVector3D &color, float roughness) {
+  QVector3D d = dir;
+  if (d.lengthSquared() < 1e-5f) {
+    d = QVector3D(0, 1, 0);
+  }
+  d.normalize();
+  QVector3D tip = base + d * length;
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, base, tip, base_radius), color,
+                 nullptr, roughness);
+  QMatrix4x4 m;
+  m = ctx.model;
+  m.translate(tip);
+  m.scale(base_radius * 1.1f);
+  submitter.mesh(getUnitSphere(), m, color * 1.05f, nullptr, roughness);
+}
+
+void CarthageLightHelmetRenderer::render(const DrawContext &ctx,
+                                         const BodyFrames &frames,
+                                         const HumanoidPalette &palette,
+                                         const HumanoidAnimationContext &anim,
+                                         ISubmitter &submitter) {
   (void)anim;
   (void)palette;
 
@@ -27,237 +65,263 @@ void CarthageLightHelmetRenderer::render(
     return;
   }
 
-  renderBowl(ctx, head, submitter);
-  renderBrim(ctx, head, submitter);
-  renderCheekGuards(ctx, head, submitter);
-  
-  if (m_config.has_nasal_guard) {
-    renderNasalGuard(ctx, head, submitter);
-  }
-  
-  if (m_config.has_crest) {
-    renderCrest(ctx, head, submitter);
-  }
-  
-  if (m_config.detail_level >= 2) {
-    renderRivets(ctx, head, submitter);
-  }
+  render_bowl(ctx, head, submitter);
 }
 
-void CarthageLightHelmetRenderer::renderBowl(const DrawContext &ctx,
-                                             const AttachmentFrame &head,
-                                             ISubmitter &submitter) {
-  // Main helmet bowl - hemispherical bronze cap
-  float const head_r = head.radius;
-  float const bowl_height = m_config.helmet_height;
-  
-  auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
-    return HumanoidRendererBase::frameLocalPosition(head, normalized);
+void CarthageLightHelmetRenderer::render_bowl(const DrawContext &ctx,
+                                              const AttachmentFrame &head,
+                                              ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
   };
 
-  // Create graduated helmet bowl with multiple segments for realism
-  int const segments = m_config.detail_level >= 2 ? 16 : 8;
-  
-  for (int i = 0; i < segments; ++i) {
-    float t0 = static_cast<float>(i) / static_cast<float>(segments);
-    float t1 = static_cast<float>(i + 1) / static_cast<float>(segments);
-    
-    // Spherical cap profile
-    float angle0 = t0 * std::numbers::pi_v<float> * 0.5F;
-    float angle1 = t1 * std::numbers::pi_v<float> * 0.5F;
-    
-    float y0 = std::cos(angle0) * bowl_height;
-    float y1 = std::cos(angle1) * bowl_height;
-    float r0 = std::sin(angle0) * head_r * 1.08F;
-    float r1 = std::sin(angle1) * head_r * 1.08F;
-    
-    QVector3D bottom = headPoint(QVector3D(0.0F, 0.85F - t0 * 0.3F, 0.0F));
-    QVector3D top = headPoint(QVector3D(0.0F, 0.85F - t1 * 0.3F, 0.0F));
-    
-    // Vary bronze color slightly per segment for hammered metal look
-    float variation = 1.0F + (std::sin(static_cast<float>(i) * 1.7F) * 0.08F);
-    QVector3D segment_color = m_config.bronze_color * variation;
-    
-    submitter.mesh(getUnitCylinder(),
-                   cylinderBetween(ctx.model, bottom, top, (r0 + r1) * 0.5F),
-                   segment_color, nullptr, 0.85F);
+  QVector3D up = head.up;
+  if (up.lengthSquared() < 1e-6F) {
+    up = QVector3D(0.0F, 1.0F, 0.0F);
+  } else {
+    up.normalize();
   }
-  
-  // Top dome cap
-  QVector3D apex = headPoint(QVector3D(0.0F, 1.0F, 0.0F));
-  QMatrix4x4 apex_m = ctx.model;
-  apex_m.translate(apex);
-  apex_m.scale(head_r * 0.25F);
-  submitter.mesh(getUnitSphere(), apex_m, 
-                 m_config.bronze_color * 1.15F, nullptr, 0.9F);
+  QVector3D right = head.right;
+  if (right.lengthSquared() < 1e-6F) {
+    right = QVector3D(1.0F, 0.0F, 0.0F);
+  } else {
+    right.normalize();
+  }
+  QVector3D forward = head.forward;
+  if (forward.lengthSquared() < 1e-6F) {
+    forward = QVector3D(0.0F, 0.0F, 1.0F);
+  } else {
+    forward.normalize();
+  }
+
+  QVector3D cap_center = head.origin + up * (R * 0.62F);
+  QMatrix4x4 bowl = ctx.model;
+  bowl.translate(cap_center);
+  bowl.scale(R * 0.88F, R * 0.82F, R * 0.88F);
+  submitter.mesh(getUnitSphere(), bowl, m_config.leather_color * 0.94F, nullptr,
+                 0.9F);
+
+  QVector3D taper_top = head.origin + up * (R * 0.48F);
+  QVector3D taper_bot = head.origin + up * (R * 0.26F);
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, taper_top, taper_bot, R * 0.78F),
+                 m_config.leather_color * 0.86F, nullptr, 0.92F);
+
+  QVector3D band_top = head.origin + up * (R * 0.18F);
+  QVector3D band_bot = head.origin + up * (R * 0.06F);
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, band_top, band_bot, R * 0.92F),
+                 m_config.leather_color * 0.72F, nullptr, 0.95F);
+
+  QVector3D crest_base = head.origin + up * (R * 0.82F);
+  QVector3D crest_tip = crest_base + up * (R * 0.55F);
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, crest_base, crest_tip, R * 0.35F),
+                 m_config.bronze_color * 0.78F, nullptr, 0.92F);
+  QMatrix4x4 crest_cap = ctx.model;
+  crest_cap.translate(crest_tip);
+  crest_cap.scale(R * 0.42F, R * 0.32F, R * 0.42F);
+  submitter.mesh(getUnitSphere(), crest_cap, m_config.bronze_color * 0.88F,
+                 nullptr, 0.93F);
+
+  QVector3D strap_front_top =
+      head.origin + up * (R * 0.44F) + forward * (R * 0.60F);
+  QVector3D strap_front_bot =
+      head.origin + up * (R * 0.20F) + forward * (R * 0.70F);
+  submitter.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, strap_front_top, strap_front_bot, R * 0.20F),
+      m_config.bronze_color * 0.85F, nullptr, 0.92F);
+
+  QVector3D strap_left_top =
+      head.origin + up * (R * 0.38F) - right * (R * 0.66F);
+  QVector3D strap_left_bot =
+      head.origin + up * (R * 0.16F) - right * (R * 0.72F);
+  submitter.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, strap_left_top, strap_left_bot, R * 0.16F),
+      m_config.bronze_color * 0.90F, nullptr, 0.95F);
+
+  QVector3D strap_right_top =
+      head.origin + up * (R * 0.38F) + right * (R * 0.66F);
+  QVector3D strap_right_bot =
+      head.origin + up * (R * 0.16F) + right * (R * 0.72F);
+  submitter.mesh(
+      getUnitCylinder(),
+      cylinderBetween(ctx.model, strap_right_top, strap_right_bot, R * 0.16F),
+      m_config.bronze_color * 0.90F, nullptr, 0.95F);
 }
 
-void CarthageLightHelmetRenderer::renderBrim(const DrawContext &ctx,
-                                             const AttachmentFrame &head,
-                                             ISubmitter &submitter) {
-  float const head_r = head.radius;
-  float const brim_width = m_config.brim_width;
-  
-  auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
-    return HumanoidRendererBase::frameLocalPosition(head, normalized);
+void CarthageLightHelmetRenderer::render_brim(const DrawContext &ctx,
+                                              const AttachmentFrame &head,
+                                              ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
+  };
+
+  auto blade = [&](float sx) {
+    QVector3D center = headPoint(QVector3D(0.42f * sx, 0.58f, 0.83f));
+    QVector3D nrm =
+        (head.forward * 0.90f - head.right * sx * 0.25f - head.up * 0.15f)
+            .normalized();
+    submit_disk(submitter, ctx, center, nrm, R * 0.56f, R * 0.08f,
+                m_config.bronze_color * 1.02f, 0.92f);
   };
+  blade(-1.0f);
+  blade(+1.0f);
 
-  // Front brim protection
-  QVector3D brim_base = headPoint(QVector3D(0.0F, 0.35F, 0.65F));
-  QVector3D brim_tip = brim_base + head.forward * brim_width;
-  
-  QMatrix4x4 brim_m = ctx.model;
-  brim_m.translate((brim_base + brim_tip) * 0.5F);
-  
-  // Orient brim forward
-  QVector3D brim_vec = brim_tip - brim_base;
-  float brim_len = brim_vec.length();
-  if (brim_len > 0.001F) {
-    brim_vec.normalize();
-    QVector3D right = QVector3D::crossProduct(brim_vec, head.up).normalized();
-    QVector3D up = QVector3D::crossProduct(right, brim_vec);
-    
-    QMatrix4x4 rotation;
-    rotation.setColumn(0, QVector4D(right, 0.0F));
-    rotation.setColumn(1, QVector4D(up, 0.0F));
-    rotation.setColumn(2, QVector4D(brim_vec, 0.0F));
-    rotation.setColumn(3, QVector4D(0.0F, 0.0F, 0.0F, 1.0F));
-    
-    brim_m = brim_m * rotation;
-  }
-  
-  brim_m.scale(head_r * 1.1F, head_r * 0.15F, brim_len * 0.5F);
-  submitter.mesh(getUnitSphere(), brim_m, 
-                 m_config.bronze_color * 0.92F, nullptr, 0.85F);
+  QVector3D L = headPoint(QVector3D(-0.48f, 0.66f, 0.83f));
+  QVector3D Rg = headPoint(QVector3D(0.48f, 0.66f, 0.83f));
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, L, Rg, R * 0.09f),
+                 m_config.bronze_color * 1.08f, nullptr, 0.95f);
 }
 
-void CarthageLightHelmetRenderer::renderCheekGuards(
+void CarthageLightHelmetRenderer::render_cheek_guards(
     const DrawContext &ctx, const AttachmentFrame &head,
     ISubmitter &submitter) {
-  float const head_r = head.radius;
-  float const guard_len = m_config.cheek_guard_length;
-  
-  auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
-    return HumanoidRendererBase::frameLocalPosition(head, normalized);
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
   };
 
-  // Left cheek guard
-  QVector3D left_top = headPoint(QVector3D(-0.75F, 0.45F, 0.35F));
-  QVector3D left_bot = left_top + head.up * (-guard_len) + 
-                       head.forward * 0.02F;
-  
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, left_top, left_bot, head_r * 0.42F),
-                 m_config.bronze_color * 0.88F, nullptr, 0.8F);
-  
-  // Right cheek guard
-  QVector3D right_top = headPoint(QVector3D(0.75F, 0.45F, 0.35F));
-  QVector3D right_bot = right_top + head.up * (-guard_len) + 
-                        head.forward * 0.02F;
-  
-  submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, right_top, right_bot, head_r * 0.42F),
-                 m_config.bronze_color * 0.88F, nullptr, 0.8F);
-  
-  // Cheek guard attachment rivets
-  if (m_config.detail_level >= 1) {
-    for (auto pos : {left_top, right_top}) {
-      QMatrix4x4 rivet_m = ctx.model;
-      rivet_m.translate(pos);
-      rivet_m.scale(head_r * 0.08F);
-      submitter.mesh(getUnitSphere(), rivet_m,
-                     m_config.bronze_color * 1.3F, nullptr, 1.0F);
+  auto side = [&](float sx) {
+    for (int j = 0; j < 4; ++j) {
+      float v = (float)j / 3.0f;
+      QVector3D center = headPoint(
+          QVector3D(0.90f * sx, 0.54f - 0.24f * v, 0.22f + 0.18f * v));
+      QVector3D n =
+          (head.right * sx * 0.95f + head.forward * 0.25f - head.up * 0.08f)
+              .normalized();
+      float r_plate = R * (0.38f - 0.05f * v);
+      float thick = R * 0.055f;
+      submit_disk(submitter, ctx, center, n, r_plate, thick,
+                  m_config.bronze_color * 0.95f, 0.86f);
+
+      if (m_config.detail_level >= 1) {
+
+        QMatrix4x4 riv_m = ctx.model;
+        riv_m.translate(center + n * (thick * 0.55f));
+        riv_m.scale(R * 0.06f);
+        submitter.mesh(getUnitSphere(), riv_m, m_config.bronze_color * 1.28f,
+                       nullptr, 1.0f);
+      }
     }
-  }
+
+    QVector3D fang_base = headPoint(QVector3D(0.78f * sx, 0.20f, 0.36f));
+    submit_spike(submitter, ctx, fang_base,
+                 -head.up * 0.8f + head.forward * 0.2f, R * 0.22f, R * 0.05f,
+                 m_config.bronze_color * 1.1f, 0.95f);
+  };
+
+  side(-1.0f);
+  side(+1.0f);
 }
 
-void CarthageLightHelmetRenderer::renderNasalGuard(
+void CarthageLightHelmetRenderer::render_nasal_guard(
     const DrawContext &ctx, const AttachmentFrame &head,
     ISubmitter &submitter) {
-  float const head_r = head.radius;
-  
-  auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
-    return HumanoidRendererBase::frameLocalPosition(head, normalized);
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
   };
 
-  // Vertical nose guard strip
-  QVector3D nasal_top = headPoint(QVector3D(0.0F, 0.55F, 0.85F));
-  QVector3D nasal_bot = headPoint(QVector3D(0.0F, 0.0F, 0.92F));
-  
+  QVector3D top = headPoint(QVector3D(0.0f, 0.70f, 0.80f));
+  QVector3D bot = headPoint(QVector3D(0.0f, -0.04f, 0.95f));
   submitter.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, nasal_bot, nasal_top, head_r * 0.12F),
-                 m_config.bronze_color * 0.95F, nullptr, 0.9F);
-}
+                 cylinderBetween(ctx.model, bot, top, R * 0.14f),
+                 m_config.bronze_color * 0.98f, nullptr, 0.9f);
 
-void CarthageLightHelmetRenderer::renderCrest(const DrawContext &ctx,
-                                              const AttachmentFrame &head,
-                                              ISubmitter &submitter) {
-  float const head_r = head.radius;
-  
-  auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
-    return HumanoidRendererBase::frameLocalPosition(head, normalized);
-  };
+  QVector3D left = top + head.right * (R * 0.30f);
+  QVector3D right = top - head.right * (R * 0.30f);
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, left, right, R * 0.07f),
+                 m_config.bronze_color * 1.06f, nullptr, 0.93f);
 
-  // Horsehair crest plume (red/purple dyed)
-  QVector3D crest_color(0.65F, 0.15F, 0.18F); // Dark red
-  
-  // Crest base mount
-  QVector3D crest_base = headPoint(QVector3D(0.0F, 0.95F, -0.15F));
-  QMatrix4x4 base_m = ctx.model;
-  base_m.translate(crest_base);
-  base_m.scale(head_r * 0.18F, head_r * 0.08F, head_r * 0.3F);
-  submitter.mesh(getUnitSphere(), base_m,
-                 m_config.bronze_color * 1.1F, nullptr, 0.95F);
-  
-  // Horsehair plume strands (multiple cylinders)
-  int const hair_strands = m_config.detail_level >= 2 ? 7 : 4;
-  
-  for (int i = 0; i < hair_strands; ++i) {
-    float offset = (static_cast<float>(i) - static_cast<float>(hair_strands) * 0.5F) 
-                   / static_cast<float>(hair_strands);
-    
-    QVector3D strand_base = crest_base + head.forward * (offset * head_r * 0.15F);
-    QVector3D strand_tip = strand_base + head.up * (head_r * 0.35F) + 
-                           head.forward * (head_r * -0.12F);
-    
-    float wave = std::sin(offset * 3.14159F) * 0.02F;
-    strand_tip += head.right * (wave * head_r);
-    
-    QVector3D strand_color = crest_color * (1.0F + offset * 0.15F);
-    
+  for (int i = 0; i < 3; ++i) {
+    float yy = -0.02f - 0.08f * i;
+    QVector3D gl = headPoint(QVector3D(-0.32f, yy, 0.96f));
+    QVector3D gr = headPoint(QVector3D(0.32f, yy, 0.96f));
     submitter.mesh(getUnitCylinder(),
-                   cylinderBetween(ctx.model, strand_base, strand_tip, head_r * 0.045F),
-                   strand_color, nullptr, 0.7F);
+                   cylinderBetween(ctx.model, gl, gr, R * 0.045f),
+                   m_config.bronze_color * 1.02f, nullptr, 0.9f);
   }
 }
 
-void CarthageLightHelmetRenderer::renderRivets(const DrawContext &ctx,
+void CarthageLightHelmetRenderer::render_crest(const DrawContext &ctx,
                                                const AttachmentFrame &head,
                                                ISubmitter &submitter) {
-  float const head_r = head.radius;
-  
-  auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
-    return HumanoidRendererBase::frameLocalPosition(head, normalized);
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
+  };
+
+  QVector3D left = headPoint(QVector3D(-0.95f, 1.02f, 0.02f));
+  QVector3D right = headPoint(QVector3D(0.95f, 1.02f, 0.02f));
+  submitter.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, left, right, R * 0.12f),
+                 m_config.bronze_color * 1.12f, nullptr, 0.96f);
+
+  QVector3D crest_color(0.85f, 0.15f, 0.18f);
+  int strands = (m_config.detail_level >= 2) ? 24 : 14;
+  for (int i = 0; i < strands; ++i) {
+    float t = (float)i / std::max(1, strands - 1);
+    QVector3D base = left * (1.0f - t) + right * t;
+    base += head.up * (R * 0.06f);
+    float bow = std::sin(t * std::numbers::pi_v<float>) * 0.35f;
+    QVector3D tip = base +
+                    head.up * (R * (0.55f - 0.15f * std::abs(0.5f - t))) +
+                    head.forward * (R * (0.30f + bow));
+    float spread = (float((i % 5) - 2)) * R * 0.06f;
+    tip += head.forward * 0.0f + head.right * 0.0f;
+    QVector3D col = crest_color * (0.9f + 0.18f * ((i % 2) ? 1.0f : 0.0f));
+    submitter.mesh(getUnitCylinder(),
+                   cylinderBetween(ctx.model, base, tip, R * 0.04f), col,
+                   nullptr, 0.62f);
+  }
+}
+
+void CarthageLightHelmetRenderer::render_rivets(const DrawContext &ctx,
+                                                const AttachmentFrame &head,
+                                                ISubmitter &submitter) {
+  const float R = head.radius;
+  auto headPoint = [&](const QVector3D &n) {
+    return HumanoidRendererBase::frameLocalPosition(head, n);
   };
+  QVector3D col = m_config.bronze_color * 1.25f;
+
+  int n1 = 16;
+  for (int i = 0; i < n1; ++i) {
+    float a = (float)i / n1 * 2.0f * std::numbers::pi_v<float>;
+    float ca = std::cos(a), sa = std::sin(a);
+    QVector3D p1 = headPoint(QVector3D(0.98f * ca, 0.48f, 0.98f * sa));
+    QMatrix4x4 m = ctx.model;
+    m.translate(p1);
+    m.scale(R * 0.058f);
+    submitter.mesh(getUnitSphere(), m, col, nullptr, 1.0f);
+  }
+  int n2 = 12;
+  for (int i = 0; i < n2; ++i) {
+    float a = (float)i / n2 * 2.0f * std::numbers::pi_v<float>;
+    float ca = std::cos(a), sa = std::sin(a);
+    QVector3D p2 = headPoint(QVector3D(0.82f * ca, 0.86f, 0.82f * sa - 0.03f));
+    QMatrix4x4 m = ctx.model;
+    m.translate(p2);
+    m.scale(R * 0.05f);
+    submitter.mesh(getUnitSphere(), m, col * 0.98f, nullptr, 1.0f);
+  }
 
-  // Decorative bronze rivets around helmet bowl
-  QVector3D rivet_color = m_config.bronze_color * 1.25F;
-  
-  int const rivet_count = 12;
-  for (int i = 0; i < rivet_count; ++i) {
-    float angle = (static_cast<float>(i) / static_cast<float>(rivet_count)) 
-                  * 2.0F * std::numbers::pi_v<float>;
-    
-    float x = std::cos(angle) * 0.85F;
-    float z = std::sin(angle) * 0.85F;
-    
-    QVector3D rivet_pos = headPoint(QVector3D(x, 0.55F, z));
-    
-    QMatrix4x4 rivet_m = ctx.model;
-    rivet_m.translate(rivet_pos);
-    rivet_m.scale(head_r * 0.06F);
-    
-    submitter.mesh(getUnitSphere(), rivet_m, rivet_color, nullptr, 1.0F);
+  int sp = 7;
+  for (int i = 0; i < sp; ++i) {
+    float t = (float)i / (sp - 1);
+    QVector3D base = headPoint(
+        QVector3D(0.0f, 0.95f + 0.1f * (t - 0.5f), 0.50f - 1.05f * t));
+    QVector3D dir = (head.up * 0.85f - head.forward * 0.15f);
+    submit_spike(submitter, ctx, base, dir,
+                 R * (0.22f + 0.06f * std::sin(t * std::numbers::pi_v<float>)),
+                 R * 0.045f, m_config.bronze_color * 1.12f, 0.96f);
   }
 }
 

+ 11 - 11
render/equipment/helmets/carthage_light_helmet.h

@@ -15,7 +15,7 @@ struct CarthageLightHelmetConfig {
   float cheek_guard_length = 0.12F;
   bool has_crest = true;
   bool has_nasal_guard = true;
-  int detail_level = 2; // 0=low, 1=medium, 2=high
+  int detail_level = 2;
 };
 
 class CarthageLightHelmetRenderer : public IEquipmentRenderer {
@@ -32,18 +32,18 @@ public:
 private:
   CarthageLightHelmetConfig m_config;
 
-  void renderBowl(const DrawContext &ctx, const AttachmentFrame &head,
-                  ISubmitter &submitter);
-  void renderBrim(const DrawContext &ctx, const AttachmentFrame &head,
-                  ISubmitter &submitter);
-  void renderCheekGuards(const DrawContext &ctx, const AttachmentFrame &head,
-                         ISubmitter &submitter);
-  void renderNasalGuard(const DrawContext &ctx, const AttachmentFrame &head,
-                        ISubmitter &submitter);
-  void renderCrest(const DrawContext &ctx, const AttachmentFrame &head,
+  void render_bowl(const DrawContext &ctx, const AttachmentFrame &head,
                    ISubmitter &submitter);
-  void renderRivets(const DrawContext &ctx, const AttachmentFrame &head,
+  void render_brim(const DrawContext &ctx, const AttachmentFrame &head,
+                   ISubmitter &submitter);
+  void render_cheek_guards(const DrawContext &ctx, const AttachmentFrame &head,
+                           ISubmitter &submitter);
+  void render_nasal_guard(const DrawContext &ctx, const AttachmentFrame &head,
+                          ISubmitter &submitter);
+  void render_crest(const DrawContext &ctx, const AttachmentFrame &head,
                     ISubmitter &submitter);
+  void render_rivets(const DrawContext &ctx, const AttachmentFrame &head,
+                     ISubmitter &submitter);
 };
 
 } // namespace Render::GL

+ 21 - 13
render/equipment/register_equipment.cpp

@@ -1,7 +1,11 @@
-#include "armor/carthage_armor.h"
+#include "armor/armor_heavy_carthage.h"
+#include "armor/armor_light_carthage.h"
+#include "armor/chainmail_armor.h"
 #include "armor/kingdom_armor.h"
 #include "armor/roman_armor.h"
 #include "equipment_registry.h"
+#include "helmets/carthage_heavy_helmet.h"
+#include "helmets/carthage_light_helmet.h"
 #include "helmets/headwrap.h"
 #include "helmets/kingdom_heavy_helmet.h"
 #include "helmets/kingdom_light_helmet.h"
@@ -54,16 +58,20 @@ void registerBuiltInEquipment() {
   registry.registerEquipment(EquipmentCategory::Weapon, "roman_scutum",
                              roman_scutum);
 
-  auto montefortino_helmet = std::make_shared<MontefortinoHelmetRenderer>();
-  registry.registerEquipment(EquipmentCategory::Helmet, "montefortino",
-                             montefortino_helmet);
+  auto carthage_heavy_helmet = std::make_shared<CarthageHeavyHelmetRenderer>();
   registry.registerEquipment(EquipmentCategory::Helmet, "carthage_heavy",
-                             montefortino_helmet);
+                             carthage_heavy_helmet);
+
+  auto carthage_light_helmet = std::make_shared<CarthageLightHelmetRenderer>();
+  registry.registerEquipment(EquipmentCategory::Helmet, "carthage_light",
+                             carthage_light_helmet);
 
   auto headwrap = std::make_shared<HeadwrapRenderer>();
   registry.registerEquipment(EquipmentCategory::Helmet, "headwrap", headwrap);
-  registry.registerEquipment(EquipmentCategory::Helmet, "carthage_light",
-                             headwrap);
+
+  auto montefortino = std::make_shared<MontefortinoHelmetRenderer>();
+  registry.registerEquipment(EquipmentCategory::Helmet, "montefortino",
+                             montefortino);
 
   auto roman_heavy = std::make_shared<RomanHeavyHelmetRenderer>();
   registry.registerEquipment(EquipmentCategory::Helmet, "roman_heavy",
@@ -97,13 +105,13 @@ void registerBuiltInEquipment() {
   registry.registerEquipment(EquipmentCategory::Armor, "roman_light_armor",
                              roman_light_armor);
 
-  auto carthage_heavy_armor = std::make_shared<CarthageHeavyArmorRenderer>();
-  registry.registerEquipment(EquipmentCategory::Armor, "carthage_heavy_armor",
-                             carthage_heavy_armor);
+  auto armor_light_carthage = std::make_shared<ArmorLightCarthageRenderer>();
+  registry.registerEquipment(EquipmentCategory::Armor, "armor_light_carthage",
+                             armor_light_carthage);
 
-  auto carthage_light_armor = std::make_shared<CarthageLightArmorRenderer>();
-  registry.registerEquipment(EquipmentCategory::Armor, "carthage_light_armor",
-                             carthage_light_armor);
+  auto armor_heavy_carthage = std::make_shared<ArmorHeavyCarthageRenderer>();
+  registry.registerEquipment(EquipmentCategory::Armor, "armor_heavy_carthage",
+                             armor_heavy_carthage);
 
   auto sword = std::make_shared<SwordRenderer>();
   registry.registerEquipment(EquipmentCategory::Weapon, "sword", sword);

+ 5 - 8
render/gl/backend.cpp

@@ -1040,14 +1040,11 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
         break;
       }
 
-      BackendPipelines::CharacterPipeline::BasicUniforms *uniforms =
-          &m_characterPipeline->m_basicUniforms;
-      if (active_shader == m_characterPipeline->m_archerShader) {
-        uniforms = &m_characterPipeline->m_archerUniforms;
-      } else if (active_shader == m_characterPipeline->m_swordsmanShader) {
-        uniforms = &m_characterPipeline->m_swordsmanUniforms;
-      } else if (active_shader == m_characterPipeline->m_spearmanShader) {
-        uniforms = &m_characterPipeline->m_spearmanUniforms;
+      auto *uniforms = m_characterPipeline
+                           ? m_characterPipeline->resolveUniforms(active_shader)
+                           : nullptr;
+      if (uniforms == nullptr) {
+        break;
       }
 
       if (m_lastBoundShader != active_shader) {

+ 57 - 26
render/gl/backend/character_pipeline.cpp

@@ -2,7 +2,9 @@
 #include "../backend.h"
 #include "../shader_cache.h"
 #include <QDebug>
+#include <QStringList>
 #include <qglobal.h>
+#include <utility>
 
 namespace Render::GL::BackendPipelines {
 
@@ -43,6 +45,7 @@ void CharacterPipeline::shutdown() {
 }
 
 void CharacterPipeline::cacheUniforms() {
+  m_uniformCache.clear();
   cacheBasicUniforms();
   cacheArcherUniforms();
   cacheKnightUniforms();
@@ -59,12 +62,8 @@ void CharacterPipeline::cacheBasicUniforms() {
     return;
   }
 
-  m_basicUniforms.mvp = m_basicShader->uniformHandle("u_mvp");
-  m_basicUniforms.model = m_basicShader->uniformHandle("u_model");
-  m_basicUniforms.texture = m_basicShader->uniformHandle("u_texture");
-  m_basicUniforms.useTexture = m_basicShader->uniformHandle("u_useTexture");
-  m_basicUniforms.color = m_basicShader->uniformHandle("u_color");
-  m_basicUniforms.alpha = m_basicShader->uniformHandle("u_alpha");
+  m_basicUniforms = buildUniformSet(m_basicShader);
+  m_uniformCache[m_basicShader] = m_basicUniforms;
 }
 
 void CharacterPipeline::cacheArcherUniforms() {
@@ -72,12 +71,9 @@ void CharacterPipeline::cacheArcherUniforms() {
     return;
   }
 
-  m_archerUniforms.mvp = m_archerShader->uniformHandle("u_mvp");
-  m_archerUniforms.model = m_archerShader->uniformHandle("u_model");
-  m_archerUniforms.texture = m_archerShader->uniformHandle("u_texture");
-  m_archerUniforms.useTexture = m_archerShader->uniformHandle("u_useTexture");
-  m_archerUniforms.color = m_archerShader->uniformHandle("u_color");
-  m_archerUniforms.alpha = m_archerShader->uniformHandle("u_alpha");
+  m_archerUniforms = buildUniformSet(m_archerShader);
+  m_uniformCache[m_archerShader] = m_archerUniforms;
+  cacheNationVariants(QStringLiteral("archer"));
 }
 
 void CharacterPipeline::cacheKnightUniforms() {
@@ -85,13 +81,9 @@ void CharacterPipeline::cacheKnightUniforms() {
     return;
   }
 
-  m_swordsmanUniforms.mvp = m_swordsmanShader->uniformHandle("u_mvp");
-  m_swordsmanUniforms.model = m_swordsmanShader->uniformHandle("u_model");
-  m_swordsmanUniforms.texture = m_swordsmanShader->uniformHandle("u_texture");
-  m_swordsmanUniforms.useTexture =
-      m_swordsmanShader->uniformHandle("u_useTexture");
-  m_swordsmanUniforms.color = m_swordsmanShader->uniformHandle("u_color");
-  m_swordsmanUniforms.alpha = m_swordsmanShader->uniformHandle("u_alpha");
+  m_swordsmanUniforms = buildUniformSet(m_swordsmanShader);
+  m_uniformCache[m_swordsmanShader] = m_swordsmanUniforms;
+  cacheNationVariants(QStringLiteral("swordsman"));
 }
 
 void CharacterPipeline::cacheSpearmanUniforms() {
@@ -99,13 +91,52 @@ void CharacterPipeline::cacheSpearmanUniforms() {
     return;
   }
 
-  m_spearmanUniforms.mvp = m_spearmanShader->uniformHandle("u_mvp");
-  m_spearmanUniforms.model = m_spearmanShader->uniformHandle("u_model");
-  m_spearmanUniforms.texture = m_spearmanShader->uniformHandle("u_texture");
-  m_spearmanUniforms.useTexture =
-      m_spearmanShader->uniformHandle("u_useTexture");
-  m_spearmanUniforms.color = m_spearmanShader->uniformHandle("u_color");
-  m_spearmanUniforms.alpha = m_spearmanShader->uniformHandle("u_alpha");
+  m_spearmanUniforms = buildUniformSet(m_spearmanShader);
+  m_uniformCache[m_spearmanShader] = m_spearmanUniforms;
+  cacheNationVariants(QStringLiteral("spearman"));
+}
+
+auto CharacterPipeline::buildUniformSet(GL::Shader *shader) const
+    -> BasicUniforms {
+  BasicUniforms uniforms;
+  if (shader == nullptr) {
+    return uniforms;
+  }
+  uniforms.mvp = shader->uniformHandle("u_mvp");
+  uniforms.model = shader->uniformHandle("u_model");
+  uniforms.texture = shader->uniformHandle("u_texture");
+  uniforms.useTexture = shader->uniformHandle("u_useTexture");
+  uniforms.color = shader->uniformHandle("u_color");
+  uniforms.alpha = shader->uniformHandle("u_alpha");
+  return uniforms;
+}
+
+void CharacterPipeline::cacheNationVariants(const QString &baseKey) {
+  if (m_shaderCache == nullptr) {
+    return;
+  }
+  static const QStringList nations{QStringLiteral("kingdom_of_iron"),
+                                   QStringLiteral("roman_republic"),
+                                   QStringLiteral("carthage")};
+  for (const QString &nation : nations) {
+    const QString shaderName = baseKey + QStringLiteral("_") + nation;
+    if (GL::Shader *variant = m_shaderCache->get(shaderName)) {
+      m_uniformCache.emplace(variant, buildUniformSet(variant));
+    }
+  }
+}
+
+auto CharacterPipeline::resolveUniforms(GL::Shader *shader) -> BasicUniforms * {
+  if (shader == nullptr) {
+    return nullptr;
+  }
+  auto it = m_uniformCache.find(shader);
+  if (it != m_uniformCache.end()) {
+    return &it->second;
+  }
+  BasicUniforms uniforms = buildUniformSet(shader);
+  auto [inserted, success] = m_uniformCache.emplace(shader, uniforms);
+  return &inserted->second;
 }
 
 } // namespace Render::GL::BackendPipelines

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

@@ -2,6 +2,7 @@
 
 #include "../shader.h"
 #include "pipeline_interface.h"
+#include <unordered_map>
 
 namespace Render::GL {
 class ShaderCache;
@@ -39,14 +40,19 @@ public:
   BasicUniforms m_swordsmanUniforms;
   BasicUniforms m_spearmanUniforms;
 
+  BasicUniforms *resolveUniforms(GL::Shader *shader);
+
 private:
   GL::Backend *m_backend = nullptr;
   GL::ShaderCache *m_shaderCache = nullptr;
+  std::unordered_map<GL::Shader *, BasicUniforms> m_uniformCache;
 
   void cacheBasicUniforms();
   void cacheArcherUniforms();
   void cacheKnightUniforms();
   void cacheSpearmanUniforms();
+  BasicUniforms buildUniformSet(GL::Shader *shader) const;
+  void cacheNationVariants(const QString &baseKey);
 };
 
 } // namespace BackendPipelines

+ 11 - 11
render/horse/rig.cpp

@@ -99,8 +99,8 @@ inline auto scaledSphere(const QMatrix4x4 &model, const QVector3D &center,
 }
 
 inline void draw_cylinder(ISubmitter &out, const QMatrix4x4 &model,
-                         const QVector3D &a, const QVector3D &b, float radius,
-                         const QVector3D &color, float alpha = 1.0F) {
+                          const QVector3D &a, const QVector3D &b, float radius,
+                          const QVector3D &color, float alpha = 1.0F) {
   out.mesh(getUnitCylinder(), cylinderBetween(model, a, b, radius), color,
            nullptr, alpha);
 }
@@ -506,7 +506,7 @@ void HorseRendererBase::render(const DrawContext &ctx,
         jugular_start +
         QVector3D(0.0F, -d.bodyHeight * 0.24F, d.bodyLength * 0.06F);
     draw_cylinder(out, ctx.model, jugular_start, jugular_end,
-                 neck_radius * 0.18F, lighten(neck_color_base, 1.08F), 0.85F);
+                  neck_radius * 0.18F, lighten(neck_color_base, 1.08F), 0.85F);
   }
 
   const int mane_sections = 8;
@@ -671,13 +671,13 @@ void HorseRendererBase::render(const DrawContext &ctx,
                                                  -d.headLength * 0.28F);
   QVector3D const tack_color = lighten(v.tack_color, 0.9F);
   draw_cylinder(out, ctx.model, bridle_base, cheek_anchor_left,
-               d.headWidth * 0.07F, tack_color);
+                d.headWidth * 0.07F, tack_color);
   draw_cylinder(out, ctx.model, bridle_base, cheek_anchor_right,
-               d.headWidth * 0.07F, tack_color);
+                d.headWidth * 0.07F, tack_color);
   draw_cylinder(out, ctx.model, cheek_anchor_left, brow, d.headWidth * 0.05F,
-               tack_color);
+                tack_color);
   draw_cylinder(out, ctx.model, cheek_anchor_right, brow, d.headWidth * 0.05F,
-               tack_color);
+                tack_color);
 
   QVector3D const mane_root =
       neck_top + QVector3D(0.0F, d.headHeight * 0.20F, -d.headLength * 0.20F);
@@ -851,10 +851,10 @@ void HorseRendererBase::render(const DrawContext &ctx,
         QVector3D(0.0F, d.bodyWidth * 0.12F,
                   is_rear ? -d.bodyLength * 0.03F : d.bodyLength * 0.02F);
     draw_cylinder(out, 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));
+                  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 = ctx.model;
     socket_cap.translate(socket + QVector3D(0.0F, -d.bodyWidth * 0.04F,

+ 18 - 17
render/humanoid/humanoid_specs.h

@@ -7,33 +7,34 @@ namespace Render::GL {
 struct HumanProportions {
 
   static constexpr float TOTAL_HEIGHT = 1.80F;
-  static constexpr float HEAD_HEIGHT = 0.23F;
+  static constexpr float HEADS_TALL = 7.5F;
+  static constexpr float HEAD_HEIGHT = TOTAL_HEIGHT / HEADS_TALL;
 
   static constexpr float GROUND_Y = 0.0F;
   static constexpr float HEAD_TOP_Y = GROUND_Y + TOTAL_HEIGHT;
   static constexpr float CHIN_Y = HEAD_TOP_Y - HEAD_HEIGHT;
-  static constexpr float NECK_BASE_Y = CHIN_Y - 0.08F;
-  static constexpr float SHOULDER_Y = NECK_BASE_Y - 0.04F;
-  static constexpr float CHEST_Y = SHOULDER_Y - 0.31F;
-  static constexpr float WAIST_Y = CHEST_Y - 0.25F;
+  static constexpr float NECK_BASE_Y = CHIN_Y - 0.09F;
+  static constexpr float SHOULDER_Y = NECK_BASE_Y - 0.045F;
+  static constexpr float CHEST_Y = SHOULDER_Y - 0.27F;
+  static constexpr float WAIST_Y = CHEST_Y - 0.18F;
 
-  static constexpr float UPPER_LEG_LEN = 0.46F;
-  static constexpr float LOWER_LEG_LEN = 0.44F;
+  static constexpr float UPPER_LEG_LEN = 0.50F;
+  static constexpr float LOWER_LEG_LEN = 0.47F;
   static constexpr float KNEE_Y = WAIST_Y - UPPER_LEG_LEN;
 
-  static constexpr float SHOULDER_WIDTH = HEAD_HEIGHT * 1.85F;
-  static constexpr float HEAD_RADIUS = HEAD_HEIGHT * 0.42F;
-  static constexpr float NECK_RADIUS = HEAD_RADIUS * 0.38F;
-  static constexpr float TORSO_TOP_R = HEAD_RADIUS * 1.15F;
-  static constexpr float TORSO_BOT_R = HEAD_RADIUS * 1.05F;
+  static constexpr float SHOULDER_WIDTH = HEAD_HEIGHT * 2.05F;
+  static constexpr float HEAD_RADIUS = HEAD_HEIGHT * 0.41F;
+  static constexpr float NECK_RADIUS = HEAD_RADIUS * 0.39F;
+  static constexpr float TORSO_TOP_R = HEAD_RADIUS * 1.20F;
+  static constexpr float TORSO_BOT_R = HEAD_RADIUS * 1.08F;
   static constexpr float UPPER_ARM_R = HEAD_RADIUS * 0.38F;
-  static constexpr float FORE_ARM_R = HEAD_RADIUS * 0.30F;
+  static constexpr float FORE_ARM_R = HEAD_RADIUS * 0.32F;
   static constexpr float HAND_RADIUS = HEAD_RADIUS * 0.28F;
-  static constexpr float UPPER_LEG_R = HEAD_RADIUS * 0.50F;
-  static constexpr float LOWER_LEG_R = HEAD_RADIUS * 0.42F;
+  static constexpr float UPPER_LEG_R = HEAD_RADIUS * 0.54F;
+  static constexpr float LOWER_LEG_R = HEAD_RADIUS * 0.44F;
 
-  static constexpr float UPPER_ARM_LEN = 0.28F;
-  static constexpr float FORE_ARM_LEN = 0.30F;
+  static constexpr float UPPER_ARM_LEN = 0.32F;
+  static constexpr float FORE_ARM_LEN = 0.27F;
 };
 
 enum class MaterialType : uint8_t {

+ 17 - 22
render/humanoid/rig.cpp

@@ -78,14 +78,14 @@ auto HumanoidRendererBase::makeHeadLocalTransform(
 }
 
 void HumanoidRendererBase::get_variant(const DrawContext &ctx, uint32_t seed,
-                                      HumanoidVariant &v) const {
+                                       HumanoidVariant &v) const {
   QVector3D const team_tint = resolveTeamTint(ctx);
   v.palette = makeHumanoidPalette(team_tint, seed);
 }
 
 void HumanoidRendererBase::customize_pose(const DrawContext &,
-                                         const HumanoidAnimationContext &,
-                                         uint32_t, HumanoidPose &) const {}
+                                          const HumanoidAnimationContext &,
+                                          uint32_t, HumanoidPose &) const {}
 
 void HumanoidRendererBase::addAttachments(const DrawContext &,
                                           const HumanoidVariant &,
@@ -250,10 +250,11 @@ void HumanoidRendererBase::computeLocomotionPose(
   float const b_scale = variation.bulkScale;
   float const s_width = variation.stanceWidth;
 
-  pose.shoulderL = QVector3D(-HP::TORSO_TOP_R * 0.98F * b_scale,
-                             HP::SHOULDER_Y * h_scale, 0.0F);
-  pose.shoulderR = QVector3D(HP::TORSO_TOP_R * 0.98F * b_scale,
-                             HP::SHOULDER_Y * h_scale, 0.0F);
+  float const half_shoulder_span = 0.5F * HP::SHOULDER_WIDTH * b_scale;
+  pose.shoulderL =
+      QVector3D(-half_shoulder_span, HP::SHOULDER_Y * h_scale, 0.0F);
+  pose.shoulderR =
+      QVector3D(half_shoulder_span, HP::SHOULDER_Y * h_scale, 0.0F);
 
   pose.pelvisPos = QVector3D(0.0F, HP::WAIST_Y * h_scale, 0.0F);
 
@@ -710,13 +711,6 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
                           right_axis, out);
 
   draw_helmet(ctx, v, pose, out);
-
-  QVector3D const belt_top = pose.pelvisPos + QVector3D(0.0F, 0.05F, 0.0F);
-  QVector3D const belt_bottom = pose.pelvisPos + QVector3D(0.0F, 0.00F, 0.0F);
-  out.mesh(getUnitCylinder(),
-           cylinderBetween(ctx.model, belt_top, belt_bottom,
-                           torso_r * 0.85F * width_scale),
-           v.palette.leather, nullptr, 1.0F);
 }
 
 void HumanoidRendererBase::draw_armorOverlay(const DrawContext &,
@@ -726,10 +720,10 @@ void HumanoidRendererBase::draw_armorOverlay(const DrawContext &,
                                              ISubmitter &) const {}
 
 void HumanoidRendererBase::draw_armor(const DrawContext &,
-                                     const HumanoidVariant &,
-                                     const HumanoidPose &,
-                                     const HumanoidAnimationContext &,
-                                     ISubmitter &) const {}
+                                      const HumanoidVariant &,
+                                      const HumanoidPose &,
+                                      const HumanoidAnimationContext &,
+                                      ISubmitter &) const {}
 
 void HumanoidRendererBase::drawShoulderDecorations(const DrawContext &,
                                                    const HumanoidVariant &,
@@ -738,9 +732,9 @@ void HumanoidRendererBase::drawShoulderDecorations(const DrawContext &,
                                                    ISubmitter &) const {}
 
 void HumanoidRendererBase::draw_helmet(const DrawContext &,
-                                      const HumanoidVariant &,
-                                      const HumanoidPose &,
-                                      ISubmitter &) const {}
+                                       const HumanoidVariant &,
+                                       const HumanoidPose &,
+                                       ISubmitter &) const {}
 
 void HumanoidRendererBase::drawFacialHair(const DrawContext &ctx,
                                           const HumanoidVariant &v,
@@ -1114,7 +1108,8 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
     inst_ctx.rendererId = ctx.rendererId;
     inst_ctx.backend = ctx.backend;
 
-    VariationParams const variation = VariationParams::fromSeed(inst_seed);
+    VariationParams variation = VariationParams::fromSeed(inst_seed);
+    adjust_variation(inst_ctx, inst_seed, variation);
 
     float const combined_height_scale = height_scale * variation.height_scale;
     if (needs_height_scaling ||

+ 10 - 7
render/humanoid/rig.h

@@ -208,12 +208,15 @@ public:
     return {1.0F, 1.0F, 1.0F};
   }
 
+  virtual void adjust_variation(const DrawContext &, uint32_t,
+                                VariationParams &) const {}
+
   virtual void get_variant(const DrawContext &ctx, uint32_t seed,
-                          HumanoidVariant &v) const;
+                           HumanoidVariant &v) const;
 
   virtual void customize_pose(const DrawContext &ctx,
-                             const HumanoidAnimationContext &anim_ctx,
-                             uint32_t seed, HumanoidPose &ioPose) const;
+                              const HumanoidAnimationContext &anim_ctx,
+                              uint32_t seed, HumanoidPose &ioPose) const;
 
   virtual void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
                               const HumanoidPose &pose,
@@ -221,12 +224,12 @@ public:
                               ISubmitter &out) const;
 
   virtual void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                          const HumanoidPose &pose, ISubmitter &out) const;
+                           const HumanoidPose &pose, ISubmitter &out) const;
 
   virtual void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
-                         const HumanoidPose &pose,
-                         const HumanoidAnimationContext &anim,
-                         ISubmitter &out) const;
+                          const HumanoidPose &pose,
+                          const HumanoidAnimationContext &anim,
+                          ISubmitter &out) const;
 
   virtual void draw_armorOverlay(const DrawContext &ctx,
                                  const HumanoidVariant &v,

+ 21 - 5
tests/render/armor_renderer_test.cpp

@@ -1,4 +1,5 @@
-#include "render/equipment/armor/carthage_armor.h"
+#include "render/equipment/armor/armor_heavy_carthage.h"
+#include "render/equipment/armor/armor_light_carthage.h"
 #include "render/equipment/armor/kingdom_armor.h"
 #include "render/equipment/armor/roman_armor.h"
 #include "render/equipment/armor/tunic_renderer.h"
@@ -40,16 +41,31 @@ TEST_F(ArmorRendererTest, RomanLightArmorRegistered) {
   ASSERT_NE(armor, nullptr);
 }
 
-TEST_F(ArmorRendererTest, CarthageHeavyArmorRegistered) {
-  auto armor = registry->get(EquipmentCategory::Armor, "carthage_heavy_armor");
+// Test new separate Carthaginian archer armor files
+TEST_F(ArmorRendererTest, ArmorLightCarthageRegistered) {
+  auto armor = registry->get(EquipmentCategory::Armor, "armor_light_carthage");
   ASSERT_NE(armor, nullptr);
 }
 
-TEST_F(ArmorRendererTest, CarthageLightArmorRegistered) {
-  auto armor = registry->get(EquipmentCategory::Armor, "carthage_light_armor");
+TEST_F(ArmorRendererTest, ArmorHeavyCarthageRegistered) {
+  auto armor = registry->get(EquipmentCategory::Armor, "armor_heavy_carthage");
   ASSERT_NE(armor, nullptr);
 }
 
+// Verify both new armor variants share the same helmet
+TEST_F(ArmorRendererTest, CarthageArcherArmorSharesHelmet) {
+  auto light_armor =
+      registry->get(EquipmentCategory::Armor, "armor_light_carthage");
+  auto heavy_armor =
+      registry->get(EquipmentCategory::Armor, "armor_heavy_carthage");
+  auto shared_helmet =
+      registry->get(EquipmentCategory::Helmet, "carthage_light");
+
+  ASSERT_NE(light_armor, nullptr);
+  ASSERT_NE(heavy_armor, nullptr);
+  ASSERT_NE(shared_helmet, nullptr);
+}
+
 TEST_F(ArmorRendererTest, TunicRendererCreation) {
   TunicConfig config;
   config.torso_scale = 1.1F;

+ 22 - 15
tests/render/body_frames_test.cpp

@@ -10,13 +10,16 @@ using namespace Render::GL;
 class BodyFramesTest : public ::testing::Test {
 protected:
   void SetUp() override {
+    using HP = HumanProportions;
     // Initialize a basic pose
-    pose.headPos = QVector3D(0.0F, 1.70F, 0.0F);
-    pose.headR = 0.10F;
-    pose.neck_base = QVector3D(0.0F, 1.49F, 0.0F);
-    pose.shoulderL = QVector3D(-0.21F, 1.45F, 0.0F);
-    pose.shoulderR = QVector3D(0.21F, 1.45F, 0.0F);
-    pose.pelvisPos = QVector3D(0.0F, 0.95F, 0.0F);
+    float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+    float const half_shoulder = 0.5F * HP::SHOULDER_WIDTH;
+    pose.headPos = QVector3D(0.0F, head_center_y, 0.0F);
+    pose.headR = HP::HEAD_RADIUS;
+    pose.neck_base = QVector3D(0.0F, HP::NECK_BASE_Y, 0.0F);
+    pose.shoulderL = QVector3D(-half_shoulder, HP::SHOULDER_Y, 0.0F);
+    pose.shoulderR = QVector3D(half_shoulder, HP::SHOULDER_Y, 0.0F);
+    pose.pelvisPos = QVector3D(0.0F, HP::WAIST_Y, 0.0F);
     pose.handL = QVector3D(-0.25F, 1.20F, 0.30F);
     pose.hand_r = QVector3D(0.25F, 1.20F, 0.30F);
     pose.elbowL = QVector3D(-0.23F, 1.30F, 0.15F);
@@ -132,17 +135,19 @@ TEST_F(BodyFramesTest, MakeFrameLocalTransformCreatesValidMatrix) {
 }
 
 TEST_F(BodyFramesTest, LegacyHeadFunctionsStillWork) {
+  using HP = HumanProportions;
   HeadFrame headFrame;
-  headFrame.origin = QVector3D(0.0F, 1.7F, 0.0F);
+  float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+  headFrame.origin = QVector3D(0.0F, head_center_y, 0.0F);
   headFrame.right = QVector3D(1.0F, 0.0F, 0.0F);
   headFrame.up = QVector3D(0.0F, 1.0F, 0.0F);
   headFrame.forward = QVector3D(0.0F, 0.0F, 1.0F);
-  headFrame.radius = 0.1F;
+  headFrame.radius = HP::HEAD_RADIUS;
 
   // Test legacy headLocalPosition function
   QVector3D local(1.0F, 0.0F, 0.0F);
   QVector3D world = HumanoidRendererBase::headLocalPosition(headFrame, local);
-  QVector3D expected = QVector3D(0.1F, 1.7F, 0.0F);
+  QVector3D expected = QVector3D(HP::HEAD_RADIUS, head_center_y, 0.0F);
   EXPECT_TRUE(approxEqual(world, expected));
 
   // Test legacy makeHeadLocalTransform function
@@ -158,18 +163,20 @@ TEST_F(BodyFramesTest, LegacyHeadFunctionsStillWork) {
 }
 
 TEST_F(BodyFramesTest, PoseHasBothHeadFrameAndBodyFrames) {
+  using HP = HumanProportions;
   // Verify that the pose has both the legacy headFrame and new bodyFrames
   EXPECT_TRUE(true); // Just verify it compiles
 
   // Set headFrame
-  pose.headFrame.origin = QVector3D(0.0F, 1.7F, 0.0F);
-  pose.headFrame.radius = 0.1F;
+  float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+  pose.headFrame.origin = QVector3D(0.0F, head_center_y, 0.0F);
+  pose.headFrame.radius = HP::HEAD_RADIUS;
 
   // Set bodyFrames.head
-  pose.bodyFrames.head.origin = QVector3D(0.0F, 1.7F, 0.0F);
-  pose.bodyFrames.head.radius = 0.1F;
+  pose.bodyFrames.head.origin = QVector3D(0.0F, head_center_y, 0.0F);
+  pose.bodyFrames.head.radius = HP::HEAD_RADIUS;
 
   // Verify both can be accessed
-  EXPECT_EQ(pose.headFrame.origin, QVector3D(0.0F, 1.7F, 0.0F));
-  EXPECT_EQ(pose.bodyFrames.head.origin, QVector3D(0.0F, 1.7F, 0.0F));
+  EXPECT_EQ(pose.headFrame.origin, QVector3D(0.0F, head_center_y, 0.0F));
+  EXPECT_EQ(pose.bodyFrames.head.origin, QVector3D(0.0F, head_center_y, 0.0F));
 }

+ 4 - 2
tests/render/helmet_renderers_test.cpp

@@ -57,12 +57,14 @@ DrawContext createTestContext() {
 
 // Helper to create basic body frames
 BodyFrames createTestFrames() {
+  using HP = HumanProportions;
   BodyFrames frames;
-  frames.head.origin = QVector3D(0.0F, 1.7F, 0.0F);
+  float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+  frames.head.origin = QVector3D(0.0F, head_center_y, 0.0F);
   frames.head.right = QVector3D(1.0F, 0.0F, 0.0F);
   frames.head.up = QVector3D(0.0F, 1.0F, 0.0F);
   frames.head.forward = QVector3D(0.0F, 0.0F, 1.0F);
-  frames.head.radius = 0.12F;
+  frames.head.radius = HP::HEAD_RADIUS * 1.05F;
   return frames;
 }
 

+ 12 - 8
tests/render/pose_controller_compatibility_test.cpp

@@ -15,16 +15,20 @@ using namespace Render::GL;
 class PoseControllerCompatibilityTest : public ::testing::Test {
 protected:
   void SetUp() override {
+    using HP = HumanProportions;
+
     // Initialize a default pose
     pose = HumanoidPose{};
-    pose.headPos = QVector3D(0.0F, 1.70F, 0.0F);
-    pose.headR = 0.10F;
-    pose.neck_base = QVector3D(0.0F, 1.49F, 0.0F);
-    pose.shoulderL = QVector3D(-0.21F, 1.45F, 0.0F);
-    pose.shoulderR = QVector3D(0.21F, 1.45F, 0.0F);
-    pose.pelvisPos = QVector3D(0.0F, 0.95F, 0.0F);
-    pose.handL = QVector3D(-0.05F, 1.50F, 0.55F);
-    pose.hand_r = QVector3D(0.15F, 1.60F, 0.20F);
+    float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+    float const half_shoulder = 0.5F * HP::SHOULDER_WIDTH;
+    pose.headPos = QVector3D(0.0F, head_center_y, 0.0F);
+    pose.headR = HP::HEAD_RADIUS;
+    pose.neck_base = QVector3D(0.0F, HP::NECK_BASE_Y, 0.0F);
+    pose.shoulderL = QVector3D(-half_shoulder, HP::SHOULDER_Y, 0.0F);
+    pose.shoulderR = QVector3D(half_shoulder, HP::SHOULDER_Y, 0.0F);
+    pose.pelvisPos = QVector3D(0.0F, HP::WAIST_Y, 0.0F);
+    pose.handL = QVector3D(-0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+    pose.hand_r = QVector3D(0.15F, HP::SHOULDER_Y + 0.15F, 0.20F);
     pose.footL = QVector3D(-0.14F, 0.022F, 0.06F);
     pose.foot_r = QVector3D(0.14F, 0.022F, -0.06F);
     pose.footYOffset = 0.022F;

+ 19 - 14
tests/render/pose_controller_test.cpp

@@ -10,20 +10,24 @@ using namespace Render::GL;
 class HumanoidPoseControllerTest : public ::testing::Test {
 protected:
   void SetUp() override {
+    using HP = HumanProportions;
+
     // Initialize a default pose with basic standing configuration
     pose = HumanoidPose{};
-    pose.headPos = QVector3D(0.0F, 1.70F, 0.0F);
-    pose.headR = 0.10F;
-    pose.neck_base = QVector3D(0.0F, 1.49F, 0.0F);
-    pose.shoulderL = QVector3D(-0.21F, 1.45F, 0.0F);
-    pose.shoulderR = QVector3D(0.21F, 1.45F, 0.0F);
-    pose.pelvisPos = QVector3D(0.0F, 0.95F, 0.0F);
-    pose.handL = QVector3D(-0.05F, 1.50F, 0.55F);
-    pose.hand_r = QVector3D(0.15F, 1.60F, 0.20F);
-    pose.elbowL = QVector3D(-0.15F, 1.30F, 0.25F);
-    pose.elbowR = QVector3D(0.25F, 1.35F, 0.10F);
-    pose.knee_l = QVector3D(-0.10F, 0.44F, 0.05F);
-    pose.knee_r = QVector3D(0.10F, 0.44F, -0.05F);
+    float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+    float const half_shoulder = 0.5F * HP::SHOULDER_WIDTH;
+    pose.headPos = QVector3D(0.0F, head_center_y, 0.0F);
+    pose.headR = HP::HEAD_RADIUS;
+    pose.neck_base = QVector3D(0.0F, HP::NECK_BASE_Y, 0.0F);
+    pose.shoulderL = QVector3D(-half_shoulder, HP::SHOULDER_Y, 0.0F);
+    pose.shoulderR = QVector3D(half_shoulder, HP::SHOULDER_Y, 0.0F);
+    pose.pelvisPos = QVector3D(0.0F, HP::WAIST_Y, 0.0F);
+    pose.handL = QVector3D(-0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+    pose.hand_r = QVector3D(0.15F, HP::SHOULDER_Y + 0.15F, 0.20F);
+    pose.elbowL = QVector3D(-0.15F, HP::SHOULDER_Y - 0.15F, 0.25F);
+    pose.elbowR = QVector3D(0.25F, HP::SHOULDER_Y - 0.10F, 0.10F);
+    pose.knee_l = QVector3D(-0.10F, HP::KNEE_Y, 0.05F);
+    pose.knee_r = QVector3D(0.10F, HP::KNEE_Y, -0.05F);
     pose.footL = QVector3D(-0.14F, 0.022F, 0.06F);
     pose.foot_r = QVector3D(0.14F, 0.022F, -0.06F);
     pose.footYOffset = 0.022F;
@@ -52,8 +56,9 @@ protected:
 TEST_F(HumanoidPoseControllerTest, ConstructorInitializesCorrectly) {
   HumanoidPoseController controller(pose, anim_ctx);
   // Constructor should not modify the pose
-  EXPECT_FLOAT_EQ(pose.headPos.y(), 1.70F);
-  EXPECT_FLOAT_EQ(pose.pelvisPos.y(), 0.95F);
+  EXPECT_FLOAT_EQ(pose.headPos.y(), 0.5F * (HumanProportions::HEAD_TOP_Y +
+                                            HumanProportions::CHIN_Y));
+  EXPECT_FLOAT_EQ(pose.pelvisPos.y(), HumanProportions::WAIST_Y);
 }
 
 TEST_F(HumanoidPoseControllerTest, StandIdleDoesNotModifyPose) {