2
0
Эх сурвалжийг харах

Fix Carthage shaders not loaded - add healer, horse_archer, horse_spearman to ShaderCache

djeada 1 долоо хоног өмнө
parent
commit
1e8514aa75
32 өөрчлөгдсөн 1177 нэмэгдсэн , 648 устгасан
  1. 8 0
      CMakeLists.txt
  2. 8 0
      assets.qrc
  3. 252 81
      assets/shaders/spearman_carthage.frag
  4. 66 31
      assets/shaders/spearman_carthage.vert
  5. 259 117
      assets/shaders/swordsman_carthage.frag
  6. 17 12
      assets/shaders/swordsman_carthage.vert
  7. 18 9
      render/entity/nations/carthage/spearman_renderer.cpp
  8. 2 1
      render/entity/nations/carthage/swordsman_renderer.cpp
  9. 8 7
      render/entity/nations/kingdom/spearman_renderer.cpp
  10. 8 7
      render/entity/nations/roman/spearman_renderer.cpp
  11. 13 11
      render/equipment/armor/armor_heavy_carthage.cpp
  12. 7 4
      render/equipment/armor/armor_light_carthage.cpp
  13. 3 1
      render/equipment/armor/chainmail_armor.cpp
  14. 6 2
      render/equipment/armor/roman_armor.cpp
  15. 28 223
      render/equipment/helmets/carthage_heavy_helmet.cpp
  16. 0 13
      render/equipment/helmets/carthage_heavy_helmet.h
  17. 2 41
      render/equipment/weapons/spear_renderer.cpp
  18. 29 0
      render/gl/mesh.h
  19. 2 1
      render/gl/primitives.cpp
  20. 15 2
      render/gl/shader_cache.h
  21. 3 0
      render/humanoid/humanoid_specs.h
  22. 9 0
      render/humanoid/pose_controller.cpp
  23. 98 44
      render/humanoid/rig.cpp
  24. 3 0
      render/humanoid/rig.h
  25. 74 0
      render/humanoid/spear_pose_utils.h
  26. 3 3
      tests/render/body_frames_test.cpp
  27. 195 0
      tests/render/carthage_armor_bounds_test.cpp
  28. 1 1
      tests/render/helmet_renderers_test.cpp
  29. 1 1
      tests/render/mounted_pose_controller_test.cpp
  30. 2 3
      tests/render/pose_controller_compatibility_test.cpp
  31. 2 3
      tests/render/pose_controller_test.cpp
  32. 35 30
      utils/resource_utils.h

+ 8 - 0
CMakeLists.txt

@@ -230,6 +230,14 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/shaders/horse_spearman_roman_republic.vert
             assets/shaders/horse_spearman_carthage.frag
             assets/shaders/horse_spearman_carthage.vert
+            assets/shaders/healer.frag
+            assets/shaders/healer.vert
+            assets/shaders/healer_kingdom_of_iron.frag
+            assets/shaders/healer_kingdom_of_iron.vert
+            assets/shaders/healer_roman_republic.frag
+            assets/shaders/healer_roman_republic.vert
+            assets/shaders/healer_carthage.frag
+            assets/shaders/healer_carthage.vert
             assets/shaders/pine_instanced.frag
             assets/shaders/pine_instanced.vert
             assets/shaders/plant_instanced.frag

+ 8 - 0
assets.qrc

@@ -52,6 +52,14 @@
         <file>assets/shaders/horse_spearman_roman_republic.vert</file>
         <file>assets/shaders/horse_spearman_carthage.frag</file>
         <file>assets/shaders/horse_spearman_carthage.vert</file>
+        <file>assets/shaders/healer.frag</file>
+        <file>assets/shaders/healer.vert</file>
+        <file>assets/shaders/healer_kingdom_of_iron.frag</file>
+        <file>assets/shaders/healer_kingdom_of_iron.vert</file>
+        <file>assets/shaders/healer_roman_republic.frag</file>
+        <file>assets/shaders/healer_roman_republic.vert</file>
+        <file>assets/shaders/healer_carthage.frag</file>
+        <file>assets/shaders/healer_carthage.vert</file>
         <file>assets/shaders/pine_instanced.frag</file>
         <file>assets/shaders/pine_instanced.vert</file>
         <file>assets/shaders/plant_instanced.frag</file>

+ 252 - 81
assets/shaders/spearman_carthage.frag

@@ -7,24 +7,28 @@ in vec3 v_bitangent;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
 in float v_armorLayer;
-in float v_leatherTension;
 in float v_bodyHeight;
-in float v_layerNoise;
-in float v_bendAmount;
+in float v_helmetDetail;
+in float v_steelWear;
+in float v_chainmailPhase;
+in float v_rivetPattern;
+in float v_leatherWear;
 
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform bool u_useTexture;
 uniform float u_alpha;
-uniform float u_time;
-uniform float u_rainIntensity;
 uniform int u_materialId;
 
 out vec4 FragColor;
 
-const vec3 k_leather_base = vec3(0.42, 0.30, 0.20);
-const vec3 k_linen_base = vec3(0.88, 0.83, 0.74);
-const vec3 k_bronze_base = vec3(0.58, 0.44, 0.20);
+// ---------------------------------------------------------------------------
+// Utilities
+// ---------------------------------------------------------------------------
+const float k_pi = 3.14159265;
+
+float saturate(float v) { return clamp(v, 0.0, 1.0); }
+vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
 
 float hash21(vec2 p) {
   p = fract(p * vec2(234.34, 435.345));
@@ -56,23 +60,23 @@ float fbm(vec3 p) {
   float freq = 1.0;
   for (int i = 0; i < 5; ++i) {
     value += amp * noise3(p * freq);
-    freq *= 1.9;
-    amp *= 0.5;
+    freq *= 1.85;
+    amp *= 0.55;
   }
   return value;
 }
 
 vec3 hemi_ambient(vec3 n) {
-  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);
+  float up = saturate(n.y * 0.5 + 0.5);
+  vec3 sky = vec3(0.54, 0.68, 0.82);
+  vec3 ground = vec3(0.32, 0.26, 0.20);
   return mix(ground, sky, up);
 }
 
 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);
+  return a2 / max(k_pi * d * d, 1e-6);
 }
 
 float geometry_schlick_ggx(float NdotX, float k) {
@@ -93,6 +97,24 @@ vec3 perturb(vec3 N, vec3 T, vec3 B, vec3 amount) {
   return normalize(N + T * amount.x + B * amount.y);
 }
 
+float chainmail_rings(vec2 p) {
+  vec2 uv = p * 32.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
 struct MaterialSample {
   vec3 color;
   vec3 normal;
@@ -100,70 +122,187 @@ struct MaterialSample {
   vec3 F0;
 };
 
+// ---------------------------------------------------------------------------
+// Material sampling
+// ---------------------------------------------------------------------------
+MaterialSample sample_skin(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
+  MaterialSample m;
+  float pores = fbm(pos * 12.0);
+  float freckles = smoothstep(0.55, 0.78, fbm(pos * 6.5 + vec3(1.7, 0.0, 0.3)));
+  vec3 Np = perturb(N, T, B,
+                    vec3((pores - 0.5) * 0.05, (freckles - 0.5) * 0.04, 0.0));
+
+  vec3 tint = mix(base_color, vec3(0.93, 0.80, 0.68), 0.35);
+  tint += vec3(0.03) * freckles;
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.48 + pores * 0.10, 0.32, 0.72);
+  m.F0 = vec3(0.028);
+  return m;
+}
+
+MaterialSample sample_bronze_helmet(vec3 base_color, vec3 pos, vec3 N, vec3 T,
+                                    vec3 B) {
+  MaterialSample m;
+  float hammer = fbm(pos * 14.0);
+  float patina = fbm(pos * 5.5 + vec3(2.1, 0.0, 4.2));
+  float band = v_helmetDetail;
+  float rivet = v_rivetPattern;
+  vec3 Np =
+      perturb(N, T, B, vec3((hammer - 0.5) * 0.10, (patina - 0.5) * 0.06, 0.0));
+
+  vec3 tint = mix(vec3(0.62, 0.44, 0.18), base_color, 0.35);
+  tint = mix(tint, vec3(0.26, 0.48, 0.38),
+             clamp(patina * 0.65 + v_steelWear * 0.3, 0.0, 0.8));
+  tint += vec3(0.12) * band;
+  tint += vec3(0.05) * rivet;
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness =
+      clamp(0.26 + hammer * 0.22 + patina * 0.16 - band * 0.08, 0.14, 0.70);
+  m.F0 = mix(vec3(0.08), vec3(0.94, 0.82, 0.58),
+             clamp(0.45 + patina * 0.25 + band * 0.20, 0.0, 1.0));
+  return m;
+}
+
+MaterialSample sample_linen(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
+  MaterialSample m;
+  float warp = sin(pos.x * 120.0) * 0.05;
+  float weft = sin(pos.z * 116.0) * 0.05;
+  float slub = fbm(pos * 7.5) * 0.05;
+  vec3 Np = normalize(N + T * (warp + slub) + B * (weft + slub * 0.5));
+
+  vec3 tint = mix(vec3(0.88, 0.82, 0.72), base_color, 0.45);
+  tint *= 1.0 - slub * 0.15;
+  tint = mix(tint, tint * vec3(0.82, 0.76, 0.70),
+             smoothstep(0.55, 1.0, v_bodyHeight));
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.62 + slub * 0.12, 0.35, 0.90);
+  m.F0 = vec3(0.028);
+  return m;
+}
+
+MaterialSample sample_bronze_scales(vec3 base_color, vec3 pos, vec3 N, vec3 T,
+                                    vec3 B) {
+  MaterialSample m;
+  float scallop = smoothstep(0.35, 0.05, fract(pos.y * 6.4 + v_chainmailPhase));
+  float row = smoothstep(0.75, 0.98, sin(pos.y * 9.0 + pos.x * 1.5));
+  float hammer = fbm(pos * 12.0 + vec3(0.0, 1.8, 2.4));
+  vec3 Np = perturb(N, T, B,
+                    vec3((hammer - 0.5) * 0.08, (scallop - 0.5) * 0.05, 0.0));
+
+  vec3 tint = mix(vec3(0.64, 0.46, 0.20), base_color, 0.40);
+  tint += vec3(0.10) * scallop;
+  tint = mix(tint, tint * vec3(0.26, 0.48, 0.38),
+             clamp(v_steelWear * 0.55, 0.0, 0.6));
+  tint += vec3(0.05) * row;
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.34 + hammer * 0.20 - scallop * 0.08, 0.16, 0.78);
+  m.F0 = vec3(0.12, 0.10, 0.07);
+  return m;
+}
+
+MaterialSample sample_chainmail(vec3 base_color, vec3 pos, vec3 N, vec3 T,
+                                vec3 B) {
+  MaterialSample m;
+  vec2 mail_uv = pos.xz * 1.2;
+  float rings = chainmail_rings(mail_uv);
+  float grain = fbm(pos * 15.0 + vec3(1.7));
+  vec3 Np =
+      perturb(N, T, B, vec3((rings - 0.5) * 0.20, (grain - 0.5) * 0.10, 0.0));
+
+  vec3 tint = mix(vec3(0.62, 0.64, 0.68), base_color, 0.35);
+  tint = mix(tint, tint * vec3(0.34, 0.30, 0.26),
+             clamp(v_steelWear * 0.6, 0.0, 0.7));
+  tint += vec3(0.10) * rings;
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.28 + rings * 0.14 + grain * 0.12, 0.16, 0.82);
+  m.F0 = vec3(0.16, 0.16, 0.18);
+  return m;
+}
+
 MaterialSample sample_leather(vec3 base_color, 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));
+  float grain = fbm(pos * 6.0);
+  float crack = fbm(pos * 11.0 + vec3(0.0, 1.7, 2.3));
+  float wear = v_leatherWear;
+  vec3 Np =
+      perturb(N, T, B, vec3((grain - 0.5) * 0.10, (crack - 0.5) * 0.08, 0.0));
 
-  vec3 tint = mix(k_leather_base, base_color, 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));
+  vec3 tint = mix(vec3(0.38, 0.25, 0.15), base_color, 0.45);
+  tint *= 1.0 - 0.10 * grain;
+  tint += vec3(0.05) * smoothstep(0.4, 0.9, crack + wear * 0.2);
 
   m.color = tint;
   m.normal = Np;
-  m.roughness = clamp(0.55 + grain * 0.3 - v_leatherTension * 0.2, 0.32, 0.95);
+  m.roughness = clamp(0.55 + grain * 0.22 - crack * 0.12, 0.25, 0.95);
   m.F0 = vec3(0.035);
   return m;
 }
 
-MaterialSample sample_linen(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
+MaterialSample sample_wood(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
   MaterialSample m;
-  float weave_u = sin(pos.x * 68.0);
-  float weave_v = sin(pos.z * 72.0);
-  float weft = weave_u * weave_v;
-  float fray = fbm(pos * 5.0 + vec3(v_layerNoise));
-  vec3 Np = perturb(N, T, B, vec3(weft * 0.05, fray * 0.04, 0.0));
+  float grain = fbm(pos * 10.0);
+  float rings = sin(pos.y * 24.0 + pos.x * 3.0);
+  vec3 Np = perturb(N, T, B, vec3(grain * 0.06, rings * 0.04, 0.0));
 
-  vec3 tint = mix(k_linen_base, base_color, 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));
+  vec3 tint = mix(vec3(0.42, 0.32, 0.20), base_color, 0.45);
+  tint *= 1.0 + grain * 0.10 + rings * 0.04;
 
   m.color = tint;
   m.normal = Np;
-  m.roughness = clamp(0.78 + fray * 0.25, 0.35, 0.95);
+  m.roughness = clamp(0.52 + grain * 0.18, 0.28, 0.90);
   m.F0 = vec3(0.028);
   return m;
 }
 
-MaterialSample sample_bronze(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                             vec3 B) {
+MaterialSample sample_steel(vec3 base_color, 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));
+  float brushed = abs(sin(pos.y * 90.0)) * 0.04;
+  float dent = fbm(pos * 12.0) * v_steelWear;
+  vec3 Np = perturb(N, T, B, vec3((brushed - 0.5) * 0.06, dent * 0.05, 0.0));
 
-  vec3 tint = mix(k_bronze_base, base_color, 0.35);
-  vec3 patina_color = vec3(0.22, 0.5, 0.42);
-  tint = mix(tint, patina_color, 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);
+  vec3 tint = mix(vec3(0.74, 0.76, 0.80), base_color, 0.45);
+  tint += vec3(0.06) * brushed;
+  tint -= vec3(0.08) * dent;
 
   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);
+  m.roughness = clamp(0.20 + brushed * 0.20 + dent * 0.18 - v_steelWear * 0.08,
+                      0.08, 0.80);
+  m.F0 = vec3(0.62, 0.64, 0.66);
   return m;
 }
 
-vec3 apply_wet_darkening(vec3 color, float wet_mask) {
-  return mix(color, color * 0.6, wet_mask);
+MaterialSample sample_shield(vec3 base_color, vec3 pos, vec3 N, vec3 T,
+                             vec3 B) {
+  MaterialSample wood = sample_wood(base_color, pos * 0.9, N, T, B);
+  MaterialSample boss =
+      sample_steel(vec3(0.78, 0.74, 0.62), pos * 1.4, N, T, B);
+
+  float boss_mask = smoothstep(0.08, 0.02, length(pos.xz));
+  boss_mask = max(boss_mask, v_rivetPattern * 0.25);
+
+  MaterialSample m;
+  m.color = mix(wood.color, boss.color, boss_mask);
+  m.normal = normalize(mix(wood.normal, boss.normal, boss_mask));
+  m.roughness = mix(wood.roughness, boss.roughness, boss_mask);
+  m.F0 = mix(wood.F0, boss.F0, boss_mask);
+  return m;
 }
 
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
 void main() {
   vec3 base_color = u_color;
   if (u_useTexture) {
@@ -171,37 +310,73 @@ void main() {
   }
 
   // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
+  bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
 
-  // Use material IDs exclusively (no fallbacks)
-  bool helmet_region = is_helmet;
-  bool upper_region = is_armor;
-
   vec3 Nw = normalize(v_worldNormal);
   vec3 Tw = normalize(v_tangent);
   vec3 Bw = normalize(v_bitangent);
 
   MaterialSample mat;
-  if (helmet_region) {
-    mat = sample_bronze(base_color, v_worldPos, Nw, Tw, Bw);
-  } else if (upper_region) {
-    // Torso mixes linen and leather patches
-    MaterialSample linen = sample_linen(base_color, v_worldPos, Nw, Tw, Bw);
+  if (is_helmet) {
+    mat = sample_bronze_helmet(base_color, v_worldPos, Nw, Tw, Bw);
+  } else if (is_armor) {
+    MaterialSample linen =
+        sample_linen(base_color, v_worldPos * 1.1, Nw, Tw, Bw);
+    MaterialSample scales =
+        sample_bronze_scales(base_color, v_worldPos * 1.0, Nw, Tw, Bw);
+    MaterialSample mail =
+        sample_chainmail(base_color, v_worldPos * 0.9, Nw, Tw, Bw);
     MaterialSample leather =
-        sample_leather(base_color, 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 = sample_leather(base_color, v_worldPos * 1.1, Nw, Tw, Bw);
+        sample_leather(base_color, v_worldPos * 0.8, Nw, Tw, Bw);
+
+    float torsoBand = 1.0 - step(1.5, v_armorLayer);
+    float skirtBand = step(1.0, v_armorLayer);
+    float mailBlend =
+        clamp(smoothstep(0.25, 0.85, v_chainmailPhase + v_steelWear * 0.35),
+              0.0, 1.0) *
+        torsoBand;
+    float scaleBlend = clamp(0.35 + v_steelWear * 0.6, 0.0, 1.0) * torsoBand;
+    float leatherBlend = skirtBand * 0.75;
+
+    // Blend linen base with bronze scales and chainmail overlays
+    mat.color = linen.color;
+    mat.color = mix(mat.color, scales.color, scaleBlend);
+    mat.color = mix(mat.color, mail.color, mailBlend);
+    mat.color = mix(mat.color, leather.color, leatherBlend);
+
+    mat.normal = linen.normal;
+    mat.normal = normalize(mix(mat.normal, scales.normal, scaleBlend));
+    mat.normal = normalize(mix(mat.normal, mail.normal, mailBlend));
+    mat.normal = normalize(mix(mat.normal, leather.normal, leatherBlend));
+
+    mat.roughness = linen.roughness;
+    mat.roughness = mix(mat.roughness, scales.roughness, scaleBlend);
+    mat.roughness = mix(mat.roughness, mail.roughness, mailBlend);
+    mat.roughness = mix(mat.roughness, leather.roughness, leatherBlend);
+
+    mat.F0 = linen.F0;
+    mat.F0 = mix(mat.F0, scales.F0, scaleBlend);
+    mat.F0 = mix(mat.F0, mail.F0, mailBlend);
+    mat.F0 = mix(mat.F0, leather.F0, leatherBlend);
+  } else if (is_weapon) {
+    if (v_bodyHeight > 0.55) {
+      mat = sample_steel(base_color, v_worldPos * 1.4, Nw, Tw, Bw);
+    } else if (v_bodyHeight > 0.25) {
+      mat = sample_wood(base_color, v_worldPos * 0.9, Nw, Tw, Bw);
+    } else {
+      mat = sample_leather(base_color, v_worldPos * 1.0, Nw, Tw, Bw);
+    }
+  } else if (is_shield) {
+    mat = sample_shield(base_color, v_worldPos, Nw, Tw, Bw);
+  } else { // skin / clothing
+    mat = sample_skin(base_color, v_worldPos, Nw, Tw, Bw);
   }
 
-  vec3 L = normalize(vec3(0.45, 1.15, 0.35));
+  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);
 
@@ -210,8 +385,8 @@ void main() {
   float NdotH = max(dot(mat.normal, H), 0.0);
   float VdotH = max(dot(V, H), 0.0);
 
-  float wrap = helmet_region ? 0.18 : 0.34;
-  float diff = max(NdotL * (1.0 - wrap) + wrap, 0.0);
+  float wrap = is_helmet ? 0.15 : (is_armor ? 0.26 : 0.32);
+  float diff = max(NdotL * (1.0 - wrap) + wrap, 0.18);
 
   float a = max(0.01, mat.roughness * mat.roughness);
   float D = D_GGX(NdotH, a);
@@ -220,23 +395,19 @@ void main() {
   vec3 spec = (D * G * F) / max(4.0 * NdotL * NdotV + 1e-5, 1e-5);
 
   float kd = 1.0 - max(max(F.r, F.g), F.b);
-  if (helmet_region) {
-    kd *= 0.2;
-  }
-
-  float rain = clamp(u_rainIntensity, 0.0, 1.0);
-  float wet_mask = 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)));
+  if (is_helmet)
+    kd *= 0.25;
 
   vec3 ambient = hemi_ambient(mat.normal);
-  vec3 color = apply_wet_darkening(mat.color, wet_mask);
+  vec3 color = mat.color;
+
+  // Dust and campaign wear
+  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));
 
   vec3 lighting =
       ambient * color * 0.55 + color * kd * diff + spec * max(NdotL, 0.0);
 
-  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));
-
-  FragColor = vec4(clamp(lighting, 0.0, 1.0), u_alpha);
+  FragColor = vec4(saturate(lighting), u_alpha);
 }

+ 66 - 31
assets/shaders/spearman_carthage.vert

@@ -6,6 +6,7 @@ layout(location = 2) in vec2 a_texCoord;
 
 uniform mat4 u_mvp;
 uniform mat4 u_model;
+uniform int u_materialId;
 
 out vec3 v_normal;
 out vec3 v_worldNormal;
@@ -14,40 +15,62 @@ out vec3 v_bitangent;
 out vec2 v_texCoord;
 out vec3 v_worldPos;
 out float v_armorLayer;
-out float v_leatherTension;
 out float v_bodyHeight;
-out float v_layerNoise;
-out float v_bendAmount;
+out float v_helmetDetail;
+out float v_steelWear;
+out float v_chainmailPhase;
+out float v_rivetPattern;
+out float v_leatherWear;
 
 float hash13(vec3 p) {
   return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
 }
 
-vec3 fallbackUp(vec3 normal) {
-  return (abs(normal.y) > 0.92) ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
+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() {
+  vec3 position = a_position;
+  vec3 normal = a_normal;
+
+  // Slight curvature for large shields (materialId = 4)
+  if (u_materialId == 4) {
+    float curveRadius = 0.52;
+    float curveAmount = 0.46;
+    float angle = position.x * curveAmount;
+
+    float curved_x = sin(angle) * curveRadius;
+    float curved_z = position.z + (1.0 - cos(angle)) * curveRadius;
+    position = vec3(curved_x, position.y, curved_z);
+
+    normal = vec3(sin(angle) * normal.z + cos(angle) * normal.x, normal.y,
+                  cos(angle) * normal.z - sin(angle) * normal.x);
+  }
+
   mat3 normalMatrix = mat3(transpose(inverse(u_model)));
-  vec3 worldNormal = normalize(normalMatrix * a_normal);
+  vec3 worldNormal = normalize(normalMatrix * normal);
 
+  // Build tangent space
   vec3 t = normalize(cross(fallbackUp(worldNormal), worldNormal));
   if (length(t) < 1e-4)
     t = vec3(1.0, 0.0, 0.0);
   t = normalize(t - worldNormal * dot(worldNormal, t));
   vec3 b = normalize(cross(worldNormal, t));
 
-  vec4 modelPos = u_model * vec4(a_position, 1.0);
+  vec4 modelPos = u_model * vec4(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;
+  // Subtle battered offset to avoid self-shadowing
+  float dentSeed = hash13(worldPos * 0.82 + worldNormal * 0.28);
+  float hammerImpact = sin(worldPos.y * 15.0 + dentSeed * 18.84);
+  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0095);
+  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.22, -worldNormal.x));
+  vec3 shearOffset = shearAxis * hammerImpact * 0.0038;
 
+  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;
@@ -60,24 +83,36 @@ void main() {
   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;
+
+  // Armor segmentation (helmet / torso / lower)
+  if (height > 1.50) {
+    v_armorLayer = 0.0;
+  } else if (height > 0.85 && height <= 1.50) {
+    v_armorLayer = 1.0;
+  } else {
+    v_armorLayer = 2.0;
+  }
+
+  float torsoMin = 0.55;
+  float torsoMax = 1.68;
   v_bodyHeight =
       clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
-  v_layerNoise = dentNoise;
-  v_bendAmount = torsion;
+
+  // Helmet detail bands and rivets
+  float reinforcementBands = fract(height * 14.0);
+  float browBandRegion =
+      smoothstep(1.48, 1.52, height) * smoothstep(1.56, 1.52, height);
+  float cheekGuardArea =
+      smoothstep(1.45, 1.55, height) * smoothstep(1.65, 1.55, height);
+  v_helmetDetail =
+      reinforcementBands * 0.4 + browBandRegion * 0.4 + cheekGuardArea * 0.2;
+
+  // Wear masks
+  v_steelWear = dentSeed * (1.0 - v_bodyHeight * 0.3); // More wear lower down
+  v_chainmailPhase =
+      fract(offsetPos.x * 28.0 + offsetPos.z * 28.0 + offsetPos.y * 0.45);
+  v_rivetPattern = step(0.96, fract(offsetPos.x * 22.0)) *
+                   step(0.94, fract(offsetPos.z * 18.0));
+  v_leatherWear =
+      hash13(offsetPos * 0.45 + worldNormal * 1.6) * (0.6 + v_bodyHeight * 0.4);
 }

+ 259 - 117
assets/shaders/swordsman_carthage.frag

@@ -24,9 +24,13 @@ uniform int u_materialId;
 
 out vec4 FragColor;
 
-const vec3 k_bronze_base = vec3(0.60, 0.42, 0.18);
-const vec3 k_linen_base = vec3(0.88, 0.82, 0.72);
-const vec3 k_leather_base = vec3(0.38, 0.25, 0.15);
+// ---------------------------------------------------------------------------
+// Utilities
+// ---------------------------------------------------------------------------
+const float k_pi = 3.14159265;
+
+float saturate(float v) { return clamp(v, 0.0, 1.0); }
+vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
 
 float hash21(vec2 p) {
   p = fract(p * vec2(234.34, 435.345));
@@ -65,8 +69,8 @@ float fbm(vec3 p) {
 }
 
 vec3 hemi_ambient(vec3 n) {
-  float up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
-  vec3 sky = vec3(0.57, 0.66, 0.78);
+  float up = saturate(n.y * 0.5 + 0.5);
+  vec3 sky = vec3(0.54, 0.68, 0.82);
   vec3 ground = vec3(0.32, 0.26, 0.20);
   return mix(ground, sky, up);
 }
@@ -74,7 +78,7 @@ vec3 hemi_ambient(vec3 n) {
 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);
+  return a2 / max(k_pi * d * d, 1e-6);
 }
 
 float geometry_schlick_ggx(float NdotX, float k) {
@@ -95,6 +99,27 @@ vec3 perturb(vec3 N, vec3 T, vec3 B, vec3 amount) {
   return normalize(N + T * amount.x + B * amount.y);
 }
 
+float chainmail_rings(vec2 p) {
+  vec2 uv = p * 32.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+// ---------------------------------------------------------------------------
+// Material sampling
+// ---------------------------------------------------------------------------
 struct MaterialSample {
   vec3 color;
   vec3 normal;
@@ -102,115 +127,210 @@ struct MaterialSample {
   vec3 F0;
 };
 
-MaterialSample sample_hammered_bronze(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                      vec3 B) {
+MaterialSample sample_skin(vec3 base_color, 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));
+  float pores = fbm(pos * 12.0);
+  float freckles = smoothstep(0.55, 0.78, fbm(pos * 6.5 + vec3(1.7, 0.0, 0.3)));
+  vec3 Np = perturb(N, T, B,
+                    vec3((pores - 0.5) * 0.05, (freckles - 0.5) * 0.04, 0.0));
+
+  vec3 tint = mix(base_color, vec3(0.93, 0.80, 0.68), 0.35);
+  tint += vec3(0.03) * freckles;
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.48 + pores * 0.10, 0.32, 0.72);
+  m.F0 = vec3(0.028);
+  return m;
+}
+
+MaterialSample sample_bronze_helmet(vec3 base_color, vec3 pos, vec3 N, vec3 T,
+                                    vec3 B) {
+  MaterialSample m;
+  float hammer = fbm(pos * 14.0 + vec3(v_layerNoise));
+  float patina = fbm(pos * 5.5 + vec3(2.1, 0.0, 4.2));
+  float ridge = smoothstep(0.82, 1.02, v_bodyHeight);
+  float rim = smoothstep(0.64, 0.86, v_bodyHeight) *
+              (1.0 - smoothstep(0.94, 1.12, v_bodyHeight));
   vec3 Np =
-      perturb(N, T, B, vec3((hammer - 0.5) * 0.12, (patina - 0.5) * 0.07, 0.0));
+      perturb(N, T, B, vec3((hammer - 0.5) * 0.10, (patina - 0.5) * 0.08, 0.0));
 
-  vec3 tint = mix(k_bronze_base, base_color, 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);
+  vec3 tint = mix(vec3(0.62, 0.44, 0.18), base_color, 0.35);
+  tint = mix(tint, vec3(0.26, 0.48, 0.38),
+             clamp(patina * 0.65 + v_layerNoise * 0.2, 0.0, 0.8));
+  tint += vec3(0.10) * ridge;
+  tint += vec3(0.06) * rim;
 
   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);
+  m.roughness =
+      clamp(0.24 + hammer * 0.22 + patina * 0.16 - rim * 0.08, 0.14, 0.70);
+  m.F0 = mix(vec3(0.08), vec3(0.94, 0.82, 0.58),
+             clamp(0.42 + patina * 0.25 + rim * 0.15, 0.0, 1.0));
   return m;
 }
 
-MaterialSample sample_muscle_bronze(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                    vec3 B, float sculpt, float front_mask) {
+MaterialSample sample_bronze_cuirass(vec3 base_color, vec3 pos, vec3 N, vec3 T,
+                                     vec3 B) {
   MaterialSample m;
-  float hammer = fbm(pos * 9.0);
-  float profile = sculpt * 2.0 - 1.0;
-  float chest_line = sin(pos.x * 6.0 + profile * 3.5);
-  float ab_divide = sin(pos.y * 11.0 - profile * 4.0);
-  vec3 Np = perturb(N, T, B,
-                    vec3((chest_line + profile * 0.6) * 0.06,
-                         (ab_divide + front_mask * 0.4) * 0.05, 0.0));
+  float hammer = fbm(pos * 11.0);
+  float profile = v_cuirassProfile * 2.0 - 1.0;
+  float front = v_frontMask;
+
+  // Add stronger anatomical emboss + seam bevels
+  float rib = sin(pos.x * 8.0 + profile * 3.2);
+  float ab = sin(pos.y * 13.0 - profile * 3.8);
+  float seam = smoothstep(0.42, 0.58, fract(pos.y * 4.5)) * 0.6;
+  float edge = smoothstep(0.82, 0.96, v_bodyHeight) +
+               smoothstep(0.18, 0.04, v_bodyHeight);
 
-  vec3 tint = mix(k_bronze_base, base_color, 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) * front_mask * smoothstep(0.45, 0.95, sculpt);
-  tint -= vec3(0.04) * hammer;
+  vec3 Np = perturb(N, T, B,
+                    vec3((rib + profile * 0.7) * 0.06 + seam * 0.05,
+                         (ab + front * 0.45) * 0.05 + edge * 0.04, 0.0));
+
+  // Force a stronger bronze anchor; palette only tints slightly.
+  vec3 tint = mix(vec3(0.62, 0.46, 0.20), base_color, 0.20);
+  vec3 patina_color = vec3(0.26, 0.48, 0.38);
+  float patina = fbm(pos * 4.5 + vec3(1.6, 0.0, 2.3));
+  tint = mix(tint, patina_color,
+             clamp(patina * 0.55 + v_layerNoise * 0.2, 0.0, 0.65));
+  tint += vec3(0.08) * front * smoothstep(0.45, 0.95, v_cuirassProfile);
+  tint += vec3(0.07) * edge;
+  tint -= vec3(0.05) * hammer;
+
+  // Grime and cavity darkening toward the waist; edge brightening on ridges.
+  float downward = smoothstep(0.35, 0.05, v_bodyHeight);
+  float curvature = length(dFdx(N)) + length(dFdy(N));
+  float edgeWear = smoothstep(0.12, 0.35, curvature);
+  tint = mix(tint, tint * vec3(0.78, 0.72, 0.66), downward * 0.4);
+  tint = mix(tint, tint * vec3(1.16, 1.10, 1.02), edgeWear * 0.6);
 
   m.color = tint;
   m.normal = Np;
-  m.roughness = clamp(0.28 + hammer * 0.25 - front_mask * 0.05, 0.16, 0.70);
-  m.F0 = mix(vec3(0.08), vec3(0.94, 0.68, 0.44), 0.85);
+  m.roughness =
+      clamp(0.20 + hammer * 0.16 + patina * 0.08 - edgeWear * 0.12, 0.08, 0.60);
+  m.F0 = mix(vec3(0.08), vec3(0.94, 0.72, 0.50),
+             clamp(0.82 + edgeWear * 0.16, 0.0, 1.0));
   return m;
 }
 
 MaterialSample sample_chainmail(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                vec3 B, float band_mix) {
+                                vec3 B) {
   MaterialSample m;
-  vec2 uv = pos.xz * 12.0 + pos.yx * 6.0;
-  float ring_a = sin(uv.x) * cos(uv.y);
-  float ring_b = sin((uv.x + uv.y) * 0.5);
-  float ring_pattern = mix(ring_a, ring_b, 0.5);
-  float weave = fbm(vec3(uv, 0.0) * 0.6 + v_layerNoise);
-  vec3 Np = perturb(
-      N, T, B, vec3((ring_pattern - 0.5) * 0.05, (band_mix - 0.5) * 0.04, 0.0));
-
-  vec3 tint = mix(vec3(0.46, 0.48, 0.53), base_color, 0.3);
-  tint *= 1.0 - weave * 0.12;
-  tint += vec3(0.05) * smoothstep(0.4, 0.9, band_mix);
+  vec2 uv = pos.xz * 14.0 + pos.yx * 5.5;
+  float rings = chainmail_rings(uv * 0.55 + v_chainmailMix * 0.25);
+  float weave = fbm(vec3(uv, 0.0) * 0.65 + v_layerNoise);
+  float gaps = smoothstep(0.25, 0.55, weave);
+  vec3 Np =
+      perturb(N, T, B, vec3((rings - 0.5) * 0.22, (weave - 0.5) * 0.14, 0.0));
+
+  vec3 tint = mix(vec3(0.52, 0.54, 0.60), base_color, 0.20);
+  tint = mix(tint, tint * vec3(0.32, 0.28, 0.24),
+             clamp(v_layerNoise * 0.45, 0.0, 0.8));
+  tint += vec3(0.10) * rings;
+  tint -= vec3(0.06) * gaps;
 
   m.color = tint;
   m.normal = Np;
-  m.roughness = clamp(0.42 - ring_pattern * 0.08 + weave * 0.18, 0.2, 0.85);
-  m.F0 = vec3(0.14);
+  m.roughness = clamp(0.34 + rings * 0.14 + weave * 0.18, 0.18, 0.85);
+  m.F0 = vec3(0.16, 0.16, 0.18);
   return m;
 }
 
-MaterialSample sample_lamellar_linen(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                     vec3 B) {
+MaterialSample sample_linen(vec3 base_color, 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(k_linen_base, base_color, 0.4);
-  tint *= 1.0 - 0.12 * weave;
+  float warp = sin(pos.x * 120.0) * 0.04;
+  float weft = sin(pos.z * 116.0) * 0.04;
+  float slub = fbm(pos * 7.0) * 0.05;
+  vec3 Np = normalize(N + T * (warp + slub) + B * (weft + slub * 0.5));
+
+  vec3 tint = mix(vec3(0.88, 0.82, 0.72), base_color, 0.45);
+  tint *= 1.0 - slub * 0.12;
   tint = mix(tint, tint * vec3(0.82, 0.76, 0.70),
              smoothstep(0.55, 1.0, v_bodyHeight));
 
-  float plate_highlight = edge * 0.10;
-  float rivet_noise = smoothstep(0.85, 1.0, seam) *
-                      hash21(vec2(pos.x * 9.0, pos.y * 7.0)) * 0.04;
-
-  m.color = tint + vec3(plate_highlight + rivet_noise);
+  m.color = tint;
   m.normal = Np;
-  m.roughness = clamp(0.62 + weave * 0.18 - edge * 0.1, 0.35, 0.9);
+  m.roughness = clamp(0.62 + slub * 0.12, 0.35, 0.90);
   m.F0 = vec3(0.028);
   return m;
 }
 
-MaterialSample sample_dyed_leather(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                   vec3 B) {
+MaterialSample sample_leather(vec3 base_color, 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));
+  float grain = fbm(pos * 6.0);
+  float crack = fbm(pos * 11.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));
+      perturb(N, T, B, vec3((grain - 0.5) * 0.10, (crack - 0.5) * 0.08, 0.0));
 
-  vec3 tint = mix(k_leather_base, base_color, 0.5);
-  tint *= 1.0 - 0.12 * grain;
-  tint += vec3(0.05) * smoothstep(0.4, 0.9, crack);
+  vec3 tint = mix(vec3(0.38, 0.25, 0.15), base_color, 0.45);
+  tint *= 1.0 - 0.10 * grain;
+  tint += vec3(0.05) * smoothstep(0.4, 0.9, crack + v_layerNoise * 0.2);
+  tint = mix(tint, tint * vec3(0.85, 0.80, 0.72),
+             smoothstep(0.35, 0.15, v_bodyHeight)); // dustier toward ground
 
   m.color = tint;
   m.normal = Np;
-  m.roughness = clamp(0.55 + grain * 0.25 - crack * 0.18, 0.25, 0.95);
+  m.roughness = clamp(0.55 + grain * 0.22 - crack * 0.12, 0.25, 0.95);
   m.F0 = vec3(0.035);
   return m;
 }
 
+MaterialSample sample_wood(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
+  MaterialSample m;
+  float grain = fbm(pos * 10.0);
+  float rings = sin(pos.y * 24.0 + pos.x * 3.0);
+  vec3 Np = perturb(N, T, B, vec3(grain * 0.06, rings * 0.04, 0.0));
+
+  vec3 tint = mix(vec3(0.42, 0.32, 0.20), base_color, 0.45);
+  tint *= 1.0 + grain * 0.10 + rings * 0.04;
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.52 + grain * 0.18, 0.28, 0.90);
+  m.F0 = vec3(0.028);
+  return m;
+}
+
+MaterialSample sample_steel(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
+  MaterialSample m;
+  float brushed = abs(sin(pos.y * 90.0)) * 0.04;
+  float dent = fbm(pos * 12.0) * (0.4 + v_layerNoise * 0.3);
+  vec3 Np = perturb(N, T, B, vec3((brushed - 0.5) * 0.06, dent * 0.05, 0.0));
+
+  vec3 tint = mix(vec3(0.74, 0.76, 0.80), base_color, 0.45);
+  tint += vec3(0.06) * brushed;
+  tint -= vec3(0.08) * dent;
+
+  m.color = tint;
+  m.normal = Np;
+  m.roughness = clamp(0.20 + brushed * 0.20 + dent * 0.18 - v_layerNoise * 0.08,
+                      0.08, 0.80);
+  m.F0 = vec3(0.62, 0.64, 0.66);
+  return m;
+}
+
+MaterialSample sample_shield(vec3 base_color, vec3 pos, vec3 N, vec3 T,
+                             vec3 B) {
+  MaterialSample wood = sample_wood(base_color, pos * 0.9, N, T, B);
+  MaterialSample boss =
+      sample_steel(vec3(0.78, 0.74, 0.62), pos * 1.4, N, T, B);
+
+  float boss_mask = smoothstep(0.12, 0.04, length(pos.xz));
+  boss_mask = max(boss_mask, v_frontMask * 0.3);
+
+  MaterialSample m;
+  m.color = mix(wood.color, boss.color, boss_mask);
+  m.normal = normalize(mix(wood.normal, boss.normal, boss_mask));
+  m.roughness = mix(wood.roughness, boss.roughness, boss_mask);
+  m.F0 = mix(wood.F0, boss.F0, boss_mask);
+  return m;
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
 void main() {
   vec3 base_color = u_color;
   if (u_useTexture) {
@@ -218,58 +338,80 @@ void main() {
   }
 
   // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
+  bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
 
-  // Use material IDs exclusively (no fallbacks)
-  bool helmet_region = is_helmet;
-  bool torso_region = is_armor;
-
   vec3 Nw = normalize(v_worldNormal);
   vec3 Tw = normalize(v_tangent);
   vec3 Bw = normalize(v_bitangent);
 
   MaterialSample mat;
-  if (helmet_region) {
-    mat = sample_hammered_bronze(base_color, v_worldPos, Nw, Tw, Bw);
-  } else if (torso_region) {
-    MaterialSample bronze = sample_muscle_bronze(
-        base_color, v_worldPos, Nw, Tw, Bw, v_cuirassProfile, v_frontMask);
-    MaterialSample mail = sample_chainmail(
-        base_color, v_worldPos * 1.1, Nw, Tw, Bw,
-        clamp(v_chainmailMix + (1.0 - v_frontMask) * 0.25, 0.0, 1.0));
-    float mail_blend = smoothstep(0.3, 0.85, v_chainmailMix) *
-                       smoothstep(0.1, 0.85, 1.0 - v_frontMask);
-    float bronze_blend = 1.0 - mail_blend;
-    mat.color = bronze.color * bronze_blend + mail.color * mail_blend;
-    mat.normal =
-        normalize(bronze.normal * bronze_blend + mail.normal * mail_blend);
-    mat.roughness = mix(bronze.roughness, mail.roughness, mail_blend);
-    mat.F0 = mix(bronze.F0, mail.F0, mail_blend);
-  } else {
-    MaterialSample greave =
-        sample_hammered_bronze(base_color, v_worldPos * 1.2, Nw, Tw, Bw);
-    MaterialSample skirt_mail =
-        sample_chainmail(base_color, v_worldPos * 0.9, Nw, Tw, Bw,
-                         clamp(v_chainmailMix + 0.35, 0.0, 1.0));
-    MaterialSample skirt_leather =
-        sample_dyed_leather(base_color, v_worldPos * 0.8, Nw, Tw, Bw);
-    float mail_bias = smoothstep(0.25, 0.75, v_chainmailMix);
-    float bronze_mix = smoothstep(0.2, 0.6, v_layerNoise);
-    float skirt_blend = mix(mail_bias, bronze_mix, 0.4);
-    float mail_weight = clamp(skirt_blend, 0.0, 1.0);
-    mat.color = mix(skirt_leather.color, greave.color, bronze_mix);
-    mat.color = mix(mat.color, skirt_mail.color, mail_weight * 0.7);
-    mat.normal =
-        normalize(mix(skirt_leather.normal, greave.normal, bronze_mix));
-    mat.normal =
-        normalize(mix(mat.normal, skirt_mail.normal, mail_weight * 0.7));
-    mat.roughness = mix(skirt_leather.roughness, greave.roughness, bronze_mix);
-    mat.roughness = mix(mat.roughness, skirt_mail.roughness, mail_weight * 0.7);
-    mat.F0 = mix(skirt_leather.F0, greave.F0, bronze_mix);
-    mat.F0 = mix(mat.F0, skirt_mail.F0, mail_weight * 0.7);
+  if (is_helmet) {
+    mat = sample_bronze_helmet(base_color, v_worldPos, Nw, Tw, Bw);
+  } else if (is_armor) {
+    // Override palette to canonical materials so armor is visibly metal.
+    vec3 bronze_base = vec3(0.62, 0.46, 0.20);
+    vec3 steel_base = vec3(0.68, 0.70, 0.74);
+    vec3 linen_base = vec3(0.86, 0.80, 0.72);
+    vec3 leather_base = vec3(0.38, 0.25, 0.15);
+
+    MaterialSample cuirass =
+        sample_bronze_cuirass(bronze_base, v_worldPos, Nw, Tw, Bw);
+    MaterialSample mail =
+        sample_chainmail(steel_base, v_worldPos * 1.05, Nw, Tw, Bw);
+    MaterialSample linen =
+        sample_linen(linen_base, v_worldPos * 1.0, Nw, Tw, Bw);
+    MaterialSample leather =
+        sample_leather(leather_base, v_worldPos * 0.9, Nw, Tw, Bw);
+
+    float torsoBand = 1.0 - step(1.5, v_armorLayer);
+    float skirtBand = step(1.0, v_armorLayer);
+    float mailBlend =
+        clamp(smoothstep(0.15, 0.78, v_chainmailMix + v_layerNoise * 0.25),
+              0.15, 1.0) *
+        torsoBand;
+    float cuirassBlend = torsoBand;
+    float leatherBlend = skirtBand * 0.65;
+    float linenBlend = skirtBand * 0.45;
+
+    mat.color = cuirass.color;
+    mat.color = mix(mat.color, mail.color, mailBlend);
+    mat.color = mix(mat.color, linen.color, linenBlend);
+    mat.color = mix(mat.color, leather.color, leatherBlend);
+    // Make sure metal stays bright: bias toward bronze/steel luma.
+    float armor_luma = dot(mat.color, vec3(0.299, 0.587, 0.114));
+    mat.color =
+        mix(mat.color, mat.color * 1.25, smoothstep(0.35, 0.65, armor_luma));
+
+    mat.normal = cuirass.normal;
+    mat.normal = normalize(mix(mat.normal, mail.normal, mailBlend));
+    mat.normal = normalize(mix(mat.normal, linen.normal, linenBlend));
+    mat.normal = normalize(mix(mat.normal, leather.normal, leatherBlend));
+
+    mat.roughness = cuirass.roughness;
+    mat.roughness = mix(mat.roughness, mail.roughness, mailBlend);
+    mat.roughness = mix(mat.roughness, linen.roughness, linenBlend);
+    mat.roughness = mix(mat.roughness, leather.roughness, leatherBlend);
+
+    mat.F0 = cuirass.F0;
+    mat.F0 = mix(mat.F0, mail.F0, mailBlend);
+    mat.F0 = mix(mat.F0, linen.F0, linenBlend);
+    mat.F0 = mix(mat.F0, leather.F0, leatherBlend);
+  } else if (is_weapon) {
+    if (v_bodyHeight > 0.55) {
+      mat = sample_steel(base_color, v_worldPos * 1.4, Nw, Tw, Bw);
+    } else if (v_bodyHeight > 0.25) {
+      mat = sample_wood(base_color, v_worldPos * 0.9, Nw, Tw, Bw);
+    } else {
+      mat = sample_leather(base_color, v_worldPos * 1.0, Nw, Tw, Bw);
+    }
+  } else if (is_shield) {
+    mat = sample_shield(base_color, v_worldPos, Nw, Tw, Bw);
+  } else { // skin / cloth under layer
+    mat = sample_skin(base_color, v_worldPos, Nw, Tw, Bw);
   }
 
   vec3 L = normalize(vec3(0.45, 1.12, 0.35));
@@ -281,8 +423,8 @@ void main() {
   float NdotH = max(dot(mat.normal, H), 0.0);
   float VdotH = max(dot(V, H), 0.0);
 
-  float wrap = helmet_region ? 0.15 : (torso_region ? 0.28 : 0.32);
-  float diff = max(NdotL * (1.0 - wrap) + wrap, 0.18);
+  float wrap = is_helmet ? 0.15 : (is_armor ? 0.12 : 0.32);
+  float diff = max(NdotL * (1.0 - wrap) + wrap, 0.10);
 
   float a = max(0.01, mat.roughness * mat.roughness);
   float D = D_GGX(NdotH, a);
@@ -291,7 +433,7 @@ void main() {
   vec3 spec = (D * G * F) / max(4.0 * NdotL * NdotV + 1e-5, 1e-5);
 
   float kd = 1.0 - max(max(F.r, F.g), F.b);
-  if (helmet_region) {
+  if (is_helmet) {
     kd *= 0.25;
   }
 
@@ -305,5 +447,5 @@ void main() {
   vec3 lighting =
       ambient * color * 0.55 + color * kd * diff + spec * max(NdotL, 0.0);
 
-  FragColor = vec4(clamp(lighting, 0.0, 1.0), u_alpha);
+  FragColor = vec4(saturate(lighting), u_alpha);
 }

+ 17 - 12
assets/shaders/swordsman_carthage.vert

@@ -6,6 +6,7 @@ layout(location = 2) in vec2 a_texCoord;
 
 uniform mat4 u_mvp;
 uniform mat4 u_model;
+uniform int u_materialId;
 
 out vec3 v_normal;
 out vec3 v_worldNormal;
@@ -32,8 +33,15 @@ vec3 fallbackUp(vec3 n) {
 }
 
 void main() {
+  vec3 position = a_position;
+  vec3 normal = a_normal;
+
+  // Only annotate helmet flag; shield bending removed to keep mesh intact.
+  bool is_shield = (u_materialId == 4);
+  bool is_helmet = (u_materialId == 2);
+
   mat3 normalMatrix = mat3(transpose(inverse(u_model)));
-  vec3 worldNormal = normalize(normalMatrix * a_normal);
+  vec3 worldNormal = normalize(normalMatrix * normal);
 
   vec3 t = normalize(cross(fallbackUp(worldNormal), worldNormal));
   if (length(t) < 1e-4)
@@ -41,20 +49,17 @@ void main() {
   t = normalize(t - worldNormal * dot(worldNormal, t));
   vec3 b = normalize(cross(worldNormal, t));
 
-  vec4 modelPos = u_model * vec4(a_position, 1.0);
+  vec4 modelPos = u_model * vec4(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;
+  float torsion = 0.0; // removed bulk shear to avoid squashing
 
-  vec3 batteredPos = worldPos + dentOffset + shearOffset;
-  vec3 offsetPos = batteredPos + worldNormal * 0.005;
+  // Minimal acne offset; no sculpting except shield bend above.
+  vec3 offsetPos = worldPos + worldNormal * 0.003;
 
   mat4 invModel = inverse(u_model);
-  vec4 localPosition = invModel * vec4(batteredPos, 1.0);
+  vec4 localPosition = invModel * vec4(offsetPos, 1.0);
   gl_Position = u_mvp * localPosition;
 
   v_worldPos = offsetPos;
@@ -78,10 +83,10 @@ void main() {
 
   v_frontMask = clamp(smoothstep(-0.18, 0.18, -localPos.z), 0.0, 1.0);
 
-  float height = offsetPos.y;
-  if (height > 1.5) {
+  float armorHeight = offsetPos.y;
+  if (armorHeight > 1.5) {
     v_armorLayer = 0.0;
-  } else if (height > 0.8) {
+  } else if (armorHeight > 0.8) {
     v_armorLayer = 1.0;
   } else {
     v_armorLayer = 2.0;

+ 18 - 9
render/entity/nations/carthage/spearman_renderer.cpp

@@ -12,6 +12,7 @@
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/pose_controller.h"
 #include "../../../humanoid/rig.h"
+#include "../../../humanoid/spear_pose_utils.h"
 #include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
@@ -20,6 +21,7 @@
 #include "../../renderer_constants.h"
 #include "spearman_style.h"
 
+#include <QDebug>
 #include <QMatrix4x4>
 #include <QString>
 #include <QVector3D>
@@ -160,10 +162,11 @@ public:
                                      (pelvis_y + 0.05F) * t,
                                  0.15F * (1.0F - t) + 0.20F * t);
 
-      QVector3D const hand_l_pos(0.0F,
-                                 lowered_shoulder_y * (1.0F - t) +
-                                     (lowered_shoulder_y - 0.10F) * t,
-                                 0.30F * (1.0F - t) + 0.55F * t);
+      float const offhand_along = lerp(-0.06F, -0.02F, t);
+      float const offhand_drop = 0.10F + 0.02F * t;
+      QVector3D const hand_l_pos =
+          computeOffhandSpearGrip(pose, anim_ctx, hand_r_pos, false,
+                                  offhand_along, offhand_drop, -0.08F);
 
       controller.placeHandAt(false, hand_r_pos);
       controller.placeHandAt(true, hand_l_pos);
@@ -176,9 +179,8 @@ public:
       QVector3D const idle_hand_r(0.28F + arm_asymmetry,
                                   HP::SHOULDER_Y - 0.02F + arm_height_jitter,
                                   0.30F);
-      QVector3D const idle_hand_l(
-          -0.08F - 0.5F * arm_asymmetry,
-          HP::SHOULDER_Y - 0.08F + 0.5F * arm_height_jitter, 0.45F);
+      QVector3D const idle_hand_l = computeOffhandSpearGrip(
+          pose, anim_ctx, idle_hand_r, false, -0.04F, 0.10F, -0.08F);
 
       controller.placeHandAt(false, idle_hand_r);
       controller.placeHandAt(true, idle_hand_l);
@@ -367,9 +369,16 @@ void registerSpearmanRenderer(Render::GL::EntityRendererRegistry &registry) {
           return shader;
         };
         if (ctx.backend != nullptr) {
-          QString shader_key = static_renderer.resolve_shader_key(ctx);
-          spearman_shader = acquireShader(shader_key);
+
+          spearman_shader = acquireShader(QStringLiteral("spearman_carthage"));
           if (spearman_shader == nullptr) {
+            static bool warned = false;
+            if (!warned) {
+              qWarning()
+                  << "Carthage spearman: missing spearman_carthage shader;"
+                  << "falling back to generic spearman shader.";
+              warned = true;
+            }
             spearman_shader = acquireShader(QStringLiteral("spearman"));
           }
         }

+ 2 - 1
render/entity/nations/carthage/swordsman_renderer.cpp

@@ -98,7 +98,8 @@ struct KnightExtras {
 class KnightRenderer : public HumanoidRendererBase {
 public:
   auto get_proportion_scaling() const -> QVector3D override {
-    return {0.95F, 1.05F, 0.95F};
+
+    return {0.750F, 1.05F, 0.50F};
   }
 
 private:

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

@@ -10,6 +10,7 @@
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/pose_controller.h"
 #include "../../../humanoid/rig.h"
+#include "../../../humanoid/spear_pose_utils.h"
 #include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
@@ -153,10 +154,11 @@ public:
                                      (pelvis_y + 0.05F) * t,
                                  0.15F * (1.0F - t) + 0.20F * t);
 
-      QVector3D const hand_l_pos(0.0F,
-                                 lowered_shoulder_y * (1.0F - t) +
-                                     (lowered_shoulder_y - 0.10F) * t,
-                                 0.30F * (1.0F - t) + 0.55F * t);
+      float const offhand_along = lerp(-0.06F, -0.02F, t);
+      float const offhand_drop = 0.10F + 0.02F * t;
+      QVector3D const hand_l_pos =
+          computeOffhandSpearGrip(pose, anim_ctx, hand_r_pos, false,
+                                  offhand_along, offhand_drop, -0.08F);
 
       controller.placeHandAt(false, hand_r_pos);
       controller.placeHandAt(true, hand_l_pos);
@@ -169,9 +171,8 @@ public:
       QVector3D const idle_hand_r(0.28F + arm_asymmetry,
                                   HP::SHOULDER_Y - 0.02F + arm_height_jitter,
                                   0.30F);
-      QVector3D const idle_hand_l(
-          -0.08F - 0.5F * arm_asymmetry,
-          HP::SHOULDER_Y - 0.08F + 0.5F * arm_height_jitter, 0.45F);
+      QVector3D const idle_hand_l = computeOffhandSpearGrip(
+          pose, anim_ctx, idle_hand_r, false, -0.04F, 0.10F, -0.08F);
 
       controller.placeHandAt(false, idle_hand_r);
       controller.placeHandAt(true, idle_hand_l);

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

@@ -12,6 +12,7 @@
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/pose_controller.h"
 #include "../../../humanoid/rig.h"
+#include "../../../humanoid/spear_pose_utils.h"
 #include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
@@ -153,10 +154,11 @@ public:
                                      (pelvis_y + 0.05F) * t,
                                  0.15F * (1.0F - t) + 0.20F * t);
 
-      QVector3D const hand_l_pos(0.0F,
-                                 lowered_shoulder_y * (1.0F - t) +
-                                     (lowered_shoulder_y - 0.10F) * t,
-                                 0.30F * (1.0F - t) + 0.55F * t);
+      float const offhand_along = lerp(-0.06F, -0.02F, t);
+      float const offhand_drop = 0.10F + 0.02F * t;
+      QVector3D const hand_l_pos =
+          computeOffhandSpearGrip(pose, anim_ctx, hand_r_pos, false,
+                                  offhand_along, offhand_drop, -0.08F);
 
       controller.placeHandAt(false, hand_r_pos);
       controller.placeHandAt(true, hand_l_pos);
@@ -169,9 +171,8 @@ public:
       QVector3D const idle_hand_r(0.28F + arm_asymmetry,
                                   HP::SHOULDER_Y - 0.02F + arm_height_jitter,
                                   0.30F);
-      QVector3D const idle_hand_l(
-          -0.08F - 0.5F * arm_asymmetry,
-          HP::SHOULDER_Y - 0.08F + 0.5F * arm_height_jitter, 0.45F);
+      QVector3D const idle_hand_l = computeOffhandSpearGrip(
+          pose, anim_ctx, idle_hand_r, false, -0.04F, 0.10F, -0.08F);
 
       controller.placeHandAt(false, idle_hand_r);
       controller.placeHandAt(true, idle_hand_l);

+ 13 - 11
render/equipment/armor/armor_heavy_carthage.cpp

@@ -50,16 +50,16 @@ void ArmorHeavyCarthageRenderer::render(const DrawContext &ctx,
       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 top = torso.origin + up * (torso_r * 0.60F);
   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 bottom = waist.origin - waist_up * (waist_r * 0.20F) -
+                     forward * (torso_r * 0.015F);
+  QVector3D chainmail_bottom = waist.origin - waist_up * (waist_r * 0.18F) -
+                               forward * (torso_r * 0.020F);
 
   QVector3D bronze_color = QVector3D(0.72F, 0.53F, 0.28F);
   QVector3D bronze_core = bronze_color * 0.92F;
@@ -70,17 +70,19 @@ void ArmorHeavyCarthageRenderer::render(const DrawContext &ctx,
                         int material_id = 1) {
     QMatrix4x4 m = cylinderBetween(ctx.model, a, b, radius);
     m.scale(scale_x, 1.0F, depth_scale_for(base_z));
-    submitter.mesh(getUnitTorso(), m, color, nullptr, 1.0F, material_id);
+    Mesh *torso_mesh = torso_mesh_without_bottom_cap();
+    submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(), m,
+                   color, nullptr, 1.0F, material_id);
   };
 
-  draw_torso(top, chainmail_bottom, torso_r * 0.90F, chainmail_color, 1.00F,
-             0.88F, 1);
+  draw_torso(top, chainmail_bottom, torso_r * 1.06F, chainmail_color, 1.05F,
+             1.02F, 1);
 
   draw_torso(top + forward * (torso_r * 0.02F),
-             bottom + forward * (torso_r * 0.02F), torso_r * 0.98F,
-             bronze_color, 1.05F, 0.84F, 1);
+             bottom + forward * (torso_r * 0.02F), torso_r * 1.10F,
+             bronze_color, 1.08F, 1.00F, 1);
 
-  draw_torso(top, bottom, torso_r * 0.90F, bronze_core, 0.98F, 0.80F, 1);
+  draw_torso(top, bottom, torso_r * 1.04F, bronze_core, 1.03F, 0.95F, 1);
 }
 
 } // namespace Render::GL

+ 7 - 4
render/equipment/armor/armor_light_carthage.cpp

@@ -67,7 +67,9 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
 
   QMatrix4x4 cuirass = cylinderBetween(ctx.model, top, bottom, main_radius);
   cuirass.scale(1.0F, 1.0F, std::max(0.15F, main_depth / main_radius));
-  submitter.mesh(getUnitTorso(), cuirass, leather_highlight, nullptr, 1.0F, 1);
+  Mesh *torso_mesh = torso_mesh_without_bottom_cap();
+  submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(), cuirass,
+                 leather_highlight, nullptr, 1.0F, 1);
 
   auto strap = [&](float side) {
     QVector3D shoulder_anchor =
@@ -90,8 +92,8 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
                                            front_panel_bottom, torso_r * 0.48F);
   front_panel.scale(0.95F, 1.0F,
                     std::max(0.12F, (torso_depth * 0.5F) / (torso_r * 0.48F)));
-  submitter.mesh(getUnitTorso(), front_panel, leather_highlight, nullptr, 1.0F,
-                 1);
+  submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(),
+                 front_panel, leather_highlight, nullptr, 1.0F, 1);
 
   QVector3D back_panel_top =
       top - forward * (torso_depth * 0.32F) - up * (torso_r * 0.05F);
@@ -101,7 +103,8 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
                                           back_panel_bottom, torso_r * 0.50F);
   back_panel.scale(0.96F, 1.0F,
                    std::max(0.12F, (torso_depth * 0.45F) / (torso_r * 0.50F)));
-  submitter.mesh(getUnitTorso(), back_panel, leather_shadow, nullptr, 1.0F, 1);
+  submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(),
+                 back_panel, leather_shadow, nullptr, 1.0F, 1);
 }
 
 } // namespace Render::GL

+ 3 - 1
render/equipment/armor/chainmail_armor.cpp

@@ -92,7 +92,9 @@ void ChainmailArmorRenderer::renderTorsoMail(const DrawContext &ctx,
 
   QVector3D steel_color = QVector3D(0.65F, 0.67F, 0.70F);
 
-  submitter.mesh(getUnitTorso(), mail_transform, steel_color, nullptr, 1.0F);
+  Mesh *torso_mesh = torso_mesh_without_bottom_cap();
+  submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(),
+                 mail_transform, steel_color, nullptr, 1.0F);
 }
 
 void ChainmailArmorRenderer::renderShoulderGuards(const DrawContext &ctx,

+ 6 - 2
render/equipment/armor/roman_armor.cpp

@@ -74,7 +74,9 @@ void RomanHeavyArmorRenderer::render(const DrawContext &ctx,
 
   QMatrix4x4 plates = cylinderBetween(ctx.model, top, bottom, torso_r * 1.02F);
   plates.scale(1.05F, 1.0F, depth_scale_for(0.86F));
-  submitter.mesh(getUnitTorso(), plates, steel_color, nullptr, 1.0F, 1);
+  Mesh *torso_mesh = torso_mesh_without_bottom_cap();
+  submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(), plates,
+                 steel_color, nullptr, 1.0F, 1);
 
   auto renderShoulderGuard = [&](const QVector3D &shoulder_pos,
                                  const QVector3D &outward) {
@@ -160,7 +162,9 @@ void RomanLightArmorRenderer::render(const DrawContext &ctx,
   QMatrix4x4 chainmail =
       cylinderBetween(ctx.model, top, bottom, torso_r * 0.98F);
   chainmail.scale(1.02F, 1.0F, depth_scale_for(0.82F));
-  submitter.mesh(getUnitTorso(), chainmail, chainmail_color, nullptr, 1.0F, 1);
+  Mesh *torso_mesh = torso_mesh_without_bottom_cap();
+  submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(), chainmail,
+                 chainmail_color, nullptr, 1.0F, 1);
 
   QVector3D chest_center =
       torso.origin + up * (torso_r * 0.12F) + forward * (torso_depth * 0.48F);

+ 28 - 223
render/equipment/helmets/carthage_heavy_helmet.cpp

@@ -15,6 +15,7 @@ auto mixColor(const QVector3D &a, const QVector3D &b, float t) -> QVector3D {
 
 namespace Render::GL {
 
+using Render::Geom::coneFromTo;
 using Render::Geom::cylinderBetween;
 using Render::Geom::sphereAt;
 
@@ -31,234 +32,38 @@ void CarthageHeavyHelmetRenderer::render(const DrawContext &ctx,
     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;
-  const float helm_scale = 1.2f;
-  const float helmet_y_offset = R * 0.1f;
+  const float lift = R * 0.03f;
+  const float helmet_scale = 1.08f;
   auto head_point = [&](const QVector3D &n) {
-    QVector3D scaled_n = n;
-
-    if (scaled_n.z() > 0.2f) {
-      scaled_n.setZ(std::max(scaled_n.z(), 1.05f / helm_scale));
-    }
-
-    scaled_n = scaled_n * helm_scale;
-
-    QVector3D p = HumanoidRendererBase::frameLocalPosition(head, scaled_n);
-    return p + head.up * helmet_y_offset;
+    QVector3D scaled = n * helmet_scale;
+    return HumanoidRendererBase::frameLocalPosition(head, scaled) +
+           head.up * lift;
   };
 
-  QVector3D bowl_center = head_point(QVector3D(0.0f, 1.47f, 0.0f));
-  QMatrix4x4 bowl = ctx.model;
-  bowl.translate(bowl_center);
-  bowl.scale(R * 1.12f, R * 0.68f, R * 1.08f);
-
-  QVector3D luminous_bronze =
-      mixColor(m_config.bronze_color, m_config.glow_color, 0.35F);
-
-  submitter.mesh(getUnitSphere(), bowl, luminous_bronze, nullptr, 0.3f, 2);
-
-  QVector3D rim_center = head_point(QVector3D(0.0f, 1.17f, 0.0f));
-  QMatrix4x4 rim = ctx.model;
-  rim.translate(rim_center);
-  rim.scale(R * 1.28f, R * 0.16f, R * 1.25f);
-  submitter.mesh(getUnitSphere(), rim, m_config.glow_color, nullptr, 0.16f, 2);
-}
-
-void CarthageHeavyHelmetRenderer::render_cheek_guards(
-    const DrawContext &ctx, const AttachmentFrame &head,
-    ISubmitter &submitter) {
-  const float R = head.radius;
-  const float helm_scale = 1.2f;
-  const float helmet_y_offset = R * 0.1f;
-  auto head_point = [&](const QVector3D &n) {
-    QVector3D scaled_n = n;
-
-    if (scaled_n.z() > 0.2f) {
-      scaled_n.setZ(std::max(scaled_n.z(), 1.05f / helm_scale));
-    }
-
-    scaled_n = scaled_n * helm_scale;
-
-    QVector3D p = HumanoidRendererBase::frameLocalPosition(head, scaled_n);
-    return p + head.up * helmet_y_offset;
-  };
-
-  QVector3D left_cheek = head_point(QVector3D(-0.58f, 0.73f, 0.42f));
-  QVector3D right_cheek = head_point(QVector3D(0.58f, 0.73f, 0.42f));
-
-  QMatrix4x4 left_guard = ctx.model;
-  left_guard.translate(left_cheek);
-  left_guard.scale(R * 0.32f, R * 0.48f, R * 0.18f);
-  left_guard.rotate(-6.0f, QVector3D(0.0f, 0.0f, 1.0f));
-
-  QMatrix4x4 right_guard = ctx.model;
-  right_guard.translate(right_cheek);
-  right_guard.scale(R * 0.32f, R * 0.48f, R * 0.18f);
-  right_guard.rotate(6.0f, QVector3D(0.0f, 0.0f, 1.0f));
-
-  submitter.mesh(getUnitSphere(), left_guard, m_config.bronze_color, nullptr,
-                 0.6f, 2);
-  submitter.mesh(getUnitSphere(), right_guard, m_config.bronze_color, nullptr,
-                 0.6f, 2);
-}
-
-void CarthageHeavyHelmetRenderer::render_face_plate(const DrawContext &ctx,
-                                                    const AttachmentFrame &head,
-                                                    ISubmitter &submitter) {
-  const float R = head.radius;
-  const float helm_scale = 1.2f;
-  const float helmet_y_offset = R * 0.1f;
-  auto head_point = [&](const QVector3D &n) {
-    QVector3D scaled_n = n;
-
-    if (scaled_n.z() > 0.2f) {
-      scaled_n.setZ(std::max(scaled_n.z(), 1.05f / helm_scale));
-    }
-
-    scaled_n = scaled_n * helm_scale;
-
-    QVector3D p = HumanoidRendererBase::frameLocalPosition(head, scaled_n);
-    return p + head.up * helmet_y_offset;
-  };
-
-  QVector3D brow = head_point(QVector3D(0.0f, 1.25f, 0.60f));
-  QVector3D chin = head_point(QVector3D(0.0f, 0.47f, 0.34f));
-  QMatrix4x4 mask =
-      cylinderBetween(ctx.model, chin, brow, std::max(0.10f, R * 0.26f));
-  QVector3D plate_color =
-      mixColor(m_config.bronze_color, m_config.glow_color, 0.25F);
-  submitter.mesh(getUnitCylinder(), mask, plate_color, nullptr, 0.45f, 2);
-
-  QVector3D nose_top = head_point(QVector3D(0.0f, 1.13f, 0.70f));
-  QVector3D nose_bottom = head_point(QVector3D(0.0f, 0.53f, 0.46f));
-  QMatrix4x4 nose = cylinderBetween(ctx.model, nose_bottom, nose_top,
-                                    std::max(0.05f, R * 0.12f));
-  submitter.mesh(getUnitCylinder(), nose, m_config.glow_color, nullptr, 0.65f,
-                 2);
-
-  render_brow_arch(ctx, head, submitter);
-}
-
-void CarthageHeavyHelmetRenderer::render_neck_guard(const DrawContext &ctx,
-                                                    const AttachmentFrame &head,
-                                                    ISubmitter &submitter) {
-  const float R = head.radius;
-  const float helm_scale = 1.2f;
-  const float helmet_y_offset = R * 0.1f;
-  auto head_point = [&](const QVector3D &n) {
-    QVector3D scaled_n = n;
-
-    if (scaled_n.z() > 0.2f) {
-      scaled_n.setZ(std::max(scaled_n.z(), 1.05f / helm_scale));
-    }
-
-    scaled_n = scaled_n * helm_scale;
-
-    QVector3D p = HumanoidRendererBase::frameLocalPosition(head, scaled_n);
-    return p + head.up * helmet_y_offset;
-  };
-
-  QVector3D guard_center = head_point(QVector3D(0.0f, 0.70f, -0.65f));
-  QMatrix4x4 guard = ctx.model;
-  guard.translate(guard_center);
-  guard.scale(R * 1.25f, R * 0.52f, R * 0.58f);
-  QVector3D guard_color =
-      mixColor(m_config.bronze_color, m_config.glow_color, 0.15F);
-  submitter.mesh(getUnitSphere(), guard, guard_color, nullptr, 0.28f, 2);
-}
-
-void CarthageHeavyHelmetRenderer::render_brow_arch(const DrawContext &ctx,
-                                                   const AttachmentFrame &head,
-                                                   ISubmitter &submitter) {
-  const float R = head.radius;
-  const float helm_scale = 1.2f;
-  const float helmet_y_offset = R * 0.1f;
-  auto head_point = [&](const QVector3D &n) {
-    QVector3D scaled_n = n;
-
-    if (scaled_n.z() > 0.2f) {
-      scaled_n.setZ(std::max(scaled_n.z(), 1.05f / helm_scale));
-    }
-
-    scaled_n = scaled_n * helm_scale;
-
-    QVector3D p = HumanoidRendererBase::frameLocalPosition(head, scaled_n);
-    return p + head.up * helmet_y_offset;
-  };
-
-  QVector3D left = head_point(QVector3D(-0.62f, 1.21f, 0.60f));
-  QVector3D right = head_point(QVector3D(0.62f, 1.21f, 0.60f));
-  float arch_radius = std::max(0.04f, R * 0.10f);
-  QMatrix4x4 arch = cylinderBetween(ctx.model, left, right, arch_radius);
-  QVector3D arch_color =
-      mixColor(m_config.glow_color, m_config.bronze_color, 0.5F);
-  submitter.mesh(getUnitCylinder(), arch, arch_color, nullptr, 0.52f, 2);
-
-  QVector3D ridge_top = head_point(QVector3D(0.0f, 1.37f, 0.58f));
-  QMatrix4x4 ridge = ctx.model;
-  ridge.translate(ridge_top);
-  ridge.scale(R * 0.22f, R * 0.10f, R * 0.26f);
-  submitter.mesh(getUnitSphere(), ridge, m_config.glow_color, nullptr, 0.58f,
+  QVector3D const base_color = m_config.bronze_color;
+  QVector3D const accent =
+      mixColor(m_config.bronze_color, m_config.glow_color, 0.32F);
+
+  float base_r = R * 1.04f;
+  QVector3D cone_base = head_point(QVector3D(0.0f, 0.58f, 0.0f));
+  QVector3D cone_tip = head_point(QVector3D(0.0f, 1.46f, 0.0f));
+  submitter.mesh(getUnitCone(),
+                 coneFromTo(ctx.model, cone_base, cone_tip, base_r), base_color,
+                 nullptr, 1.0f, 2);
+
+  QVector3D tip_base = head_point(QVector3D(0.0f, 1.12f, 0.0f));
+  QVector3D tip_apex = head_point(QVector3D(0.0f, 1.70f, 0.0f));
+  submitter.mesh(getUnitCone(),
+                 coneFromTo(ctx.model, tip_base, tip_apex,
+                            std::max(0.05f, base_r * 0.28f)),
+                 accent, nullptr, 1.0f, 2);
+
+  QMatrix4x4 tip_cap =
+      sphereAt(ctx.model, tip_apex + head.up * (R * 0.015f), R * 0.06f);
+  submitter.mesh(getUnitSphere(), tip_cap,
+                 mixColor(accent, m_config.glow_color, 0.48F), nullptr, 1.0f,
                  2);
 }
 
-void CarthageHeavyHelmetRenderer::render_crest(const DrawContext &ctx,
-                                               const AttachmentFrame &head,
-                                               ISubmitter &submitter) {
-  const float R = head.radius;
-  const float helm_scale = 1.2f;
-  const float helmet_y_offset = R * 0.1f;
-  auto head_point = [&](const QVector3D &n) {
-    QVector3D scaled_n = n;
-
-    if (scaled_n.z() > 0.2f) {
-      scaled_n.setZ(std::max(scaled_n.z(), 1.05f / helm_scale));
-    }
-
-    scaled_n = scaled_n * helm_scale;
-
-    QVector3D p = HumanoidRendererBase::frameLocalPosition(head, scaled_n);
-    return p + head.up * helmet_y_offset;
-  };
-
-  QVector3D crest_back = head_point(QVector3D(0.0f, 1.73f, -0.28f));
-  QVector3D crest_front = head_point(QVector3D(0.0f, 1.73f, 0.28f));
-  float crest_radius = std::max(0.06f, R * 0.26f);
-  QMatrix4x4 crest_bridge =
-      cylinderBetween(ctx.model, crest_back, crest_front, crest_radius);
-  submitter.mesh(getUnitCylinder(), crest_bridge, m_config.crest_color, nullptr,
-                 0.52f, 2);
-
-  QVector3D plume_top = head_point(QVector3D(0.0f, 2.25f, 0.0f));
-  QVector3D plume_base = head_point(QVector3D(0.0f, 1.63f, 0.0f));
-  float plume_radius = std::max(0.05f, R * 0.18f);
-  QMatrix4x4 plume =
-      cylinderBetween(ctx.model, plume_base, plume_top, plume_radius);
-  QVector3D plume_color =
-      mixColor(m_config.crest_color, m_config.glow_color, 0.40F);
-  submitter.mesh(getUnitCylinder(), plume, plume_color, nullptr, 0.70f, 2);
-}
-
 } // namespace Render::GL

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

@@ -29,19 +29,6 @@ public:
 
 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

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

@@ -3,6 +3,7 @@
 #include "../../geom/transforms.h"
 #include "../../gl/primitives.h"
 #include "../../humanoid/rig.h"
+#include "../../humanoid/spear_pose_utils.h"
 #include "../../submitter.h"
 
 #include <QMatrix4x4>
@@ -23,47 +24,7 @@ void SpearRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
                            ISubmitter &submitter) {
   QVector3D const grip_pos = frames.hand_r.origin;
 
-  bool const is_attacking = anim.inputs.is_attacking && anim.inputs.is_melee;
-  float attack_phase = 0.0F;
-  if (is_attacking) {
-    attack_phase =
-        std::fmod(anim.inputs.time * SPEARMAN_INV_ATTACK_CYCLE_TIME, 1.0F);
-  }
-
-  QVector3D spear_dir = QVector3D(0.05F, 0.55F, 0.85F);
-  if (spear_dir.lengthSquared() > 1e-6F) {
-    spear_dir.normalize();
-  }
-
-  if (anim.inputs.is_in_hold_mode || anim.inputs.is_exiting_hold) {
-    float const t = anim.inputs.is_in_hold_mode
-                        ? 1.0F
-                        : (1.0F - anim.inputs.hold_exit_progress);
-
-    QVector3D braced_dir = QVector3D(0.05F, 0.40F, 0.91F);
-    if (braced_dir.lengthSquared() > 1e-6F) {
-      braced_dir.normalize();
-    }
-
-    spear_dir = spear_dir * (1.0F - t) + braced_dir * t;
-    if (spear_dir.lengthSquared() > 1e-6F) {
-      spear_dir.normalize();
-    }
-  } else if (is_attacking) {
-    if (attack_phase >= 0.30F && attack_phase < 0.50F) {
-      float const t = (attack_phase - 0.30F) / 0.20F;
-
-      QVector3D attack_dir = QVector3D(0.03F, -0.15F, 1.0F);
-      if (attack_dir.lengthSquared() > 1e-6F) {
-        attack_dir.normalize();
-      }
-
-      spear_dir = spear_dir * (1.0F - t) + attack_dir * t;
-      if (spear_dir.lengthSquared() > 1e-6F) {
-        spear_dir.normalize();
-      }
-    }
-  }
+  QVector3D const spear_dir = computeSpearDirection(anim.inputs);
 
   QVector3D const shaft_base = grip_pos - spear_dir * 0.28F;
   QVector3D shaft_mid = grip_pos + spear_dir * (m_config.spear_length * 0.5F);

+ 29 - 0
render/gl/mesh.h

@@ -3,6 +3,7 @@
 #include "buffer.h"
 #include <QOpenGLFunctions_3_3_Core>
 #include <array>
+#include <functional>
 #include <memory>
 #include <vector>
 
@@ -29,6 +30,34 @@ public:
     return m_indices;
   }
 
+  [[nodiscard]] auto cloneWithFilteredIndices(
+      const std::function<bool(unsigned int, unsigned int, unsigned int,
+                               const std::vector<Vertex> &)> &predicate) const
+      -> std::unique_ptr<Mesh> {
+    if (!predicate) {
+      return nullptr;
+    }
+
+    std::vector<unsigned int> filtered;
+    filtered.reserve(m_indices.size());
+    for (std::size_t i = 0; i + 2 < m_indices.size(); i += 3) {
+      unsigned int a = m_indices[i];
+      unsigned int b = m_indices[i + 1];
+      unsigned int c = m_indices[i + 2];
+      if (!predicate(a, b, c, m_vertices)) {
+        filtered.push_back(a);
+        filtered.push_back(b);
+        filtered.push_back(c);
+      }
+    }
+
+    if (filtered.empty()) {
+      return nullptr;
+    }
+
+    return std::make_unique<Mesh>(m_vertices, filtered);
+  }
+
 private:
   std::vector<Vertex> m_vertices;
   std::vector<unsigned int> m_indices;

+ 2 - 1
render/gl/primitives.cpp

@@ -275,7 +275,8 @@ auto simpleHash(float seed) -> float {
 
 auto createUnitTorsoMesh(int radialSegments, int heightSegments) -> Mesh * {
   const float half_h = k_half_scalar;
-  constexpr float k_lower_extension = 0.14F;
+
+  constexpr float k_lower_extension = 0.05F;
   const float torso_bottom_y = -half_h;
   const float torso_top_y = half_h + k_lower_extension;
   const float torso_height = torso_top_y - torso_bottom_y;

+ 15 - 2
render/gl/shader_cache.h

@@ -4,6 +4,7 @@
 #include "utils/resource_utils.h"
 #include <QDebug>
 #include <QFile>
+#include <QFileInfo>
 #include <QString>
 #include <QStringList>
 #include <memory>
@@ -170,11 +171,18 @@ public:
         loadBaseShader(QStringLiteral("horse_swordsman"));
     const auto [spearmanVert, spearmanFrag] =
         loadBaseShader(QStringLiteral("spearman"));
+    const auto [healerVert, healerFrag] =
+        loadBaseShader(QStringLiteral("healer"));
 
     const QStringList nationVariants = {QStringLiteral("kingdom_of_iron"),
                                         QStringLiteral("roman_republic"),
                                         QStringLiteral("carthage")};
 
+    auto resourceExists = [](const QString &path) -> bool {
+      QFileInfo const info(path);
+      return info.exists();
+    };
+
     auto loadVariant = [&](const QString &baseKey, const QString &baseVertPath,
                            const QString &baseFragPath) {
       for (const QString &nation : nationVariants) {
@@ -187,12 +195,12 @@ public:
                                        QStringLiteral(".frag");
 
         QString resolvedVert = resolve(variantVertRes);
-        if (!QFile::exists(resolvedVert)) {
+        if (!resourceExists(resolvedVert)) {
           resolvedVert = baseVertPath;
         }
 
         QString resolvedFrag = resolve(variantFragRes);
-        if (!QFile::exists(resolvedFrag)) {
+        if (!resourceExists(resolvedFrag)) {
           resolvedFrag = baseFragPath;
         }
 
@@ -205,6 +213,11 @@ public:
     loadVariant(QStringLiteral("swordsman"), swordsmanVert, swordsmanFrag);
     loadVariant(QStringLiteral("horse_swordsman"), horseKnightVert,
                 horseKnightFrag);
+    loadVariant(QStringLiteral("healer"), healerVert, healerFrag);
+    loadVariant(QStringLiteral("horse_archer"), horseKnightVert,
+                horseKnightFrag);
+    loadVariant(QStringLiteral("horse_spearman"), horseKnightVert,
+                horseKnightFrag);
   }
 
   void clear() {

+ 3 - 0
render/humanoid/humanoid_specs.h

@@ -13,6 +13,9 @@ struct HumanProportions {
   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 HEAD_NECK_OVERLAP = 0.025F;
+  static constexpr float HEAD_CENTER_Y =
+      (HEAD_TOP_Y + CHIN_Y) * 0.5F - HEAD_NECK_OVERLAP;
   static constexpr float NECK_BASE_Y = CHIN_Y - 0.045F;
   static constexpr float SHOULDER_Y = NECK_BASE_Y - 0.09F;
   static constexpr float CHEST_Y = SHOULDER_Y - 0.27F;

+ 9 - 0
render/humanoid/pose_controller.cpp

@@ -1,5 +1,6 @@
 #include "pose_controller.h"
 #include "humanoid_math.h"
+#include "spear_pose_utils.h"
 #include <QVector3D>
 #include <algorithm>
 #include <cmath>
@@ -360,6 +361,14 @@ void HumanoidPoseController::spearThrust(float attack_phase) {
                   HP::SHOULDER_Y - 0.06F + 0.01F * t, lerp(0.35F, 0.25F, t));
   }
 
+  float const thrust_extent =
+      std::clamp((attack_phase - 0.20F) / 0.60F, 0.0F, 1.0F);
+  float const along_offset = -0.06F + 0.02F * thrust_extent;
+  float const y_drop = 0.10F + 0.02F * thrust_extent;
+
+  hand_l_target = computeOffhandSpearGrip(m_pose, m_anim_ctx, hand_r_target,
+                                          false, along_offset, y_drop, -0.08F);
+
   placeHandAt(false, hand_r_target);
   placeHandAt(true, hand_l_target);
 }

+ 98 - 44
render/humanoid/rig.cpp

@@ -28,7 +28,10 @@
 #include <algorithm>
 #include <cmath>
 #include <cstdint>
+#include <functional>
+#include <limits>
 #include <numbers>
+#include <vector>
 
 namespace Render::GL {
 
@@ -47,6 +50,52 @@ constexpr float k_shadow_base_alpha = 0.24F;
 constexpr QVector3D k_shadow_light_dir(0.4F, 1.0F, 0.25F);
 } // namespace
 
+auto torso_mesh_without_bottom_cap() -> Mesh * {
+  static std::unique_ptr<Mesh> s_mesh;
+  if (s_mesh != nullptr) {
+    return s_mesh.get();
+  }
+
+  Mesh *base = getUnitTorso();
+  if (base == nullptr) {
+    return nullptr;
+  }
+
+  auto filtered = base->cloneWithFilteredIndices(
+      [](unsigned int a, unsigned int b, unsigned int c,
+         const std::vector<Vertex> &verts) -> bool {
+        float min_y = std::numeric_limits<float>::max();
+        float max_y = -std::numeric_limits<float>::max();
+        auto sample = [&](unsigned int idx) -> QVector3D {
+          return {verts[idx].position[0], verts[idx].position[1],
+                  verts[idx].position[2]};
+        };
+        QVector3D pa = sample(a);
+        QVector3D pb = sample(b);
+        QVector3D pc = sample(c);
+        min_y = std::min({pa.y(), pb.y(), pc.y()});
+        max_y = std::max({pa.y(), pb.y(), pc.y()});
+
+        QVector3D n(
+            verts[a].normal[0] + verts[b].normal[0] + verts[c].normal[0],
+            verts[a].normal[1] + verts[b].normal[1] + verts[c].normal[1],
+            verts[a].normal[2] + verts[b].normal[2] + verts[c].normal[2]);
+        if (n.lengthSquared() > 0.0F) {
+          n.normalize();
+        }
+
+        constexpr float k_band_height = 0.02F;
+        bool is_bottom_band = (max_y - min_y) < k_band_height &&
+                              (min_y < (pa.y() + pb.y() + pc.y()) / 3.0F);
+        bool facing_down = (n.y() < -0.35F);
+        return is_bottom_band && facing_down;
+      });
+
+  s_mesh =
+      (filtered != nullptr) ? std::move(filtered) : std::unique_ptr<Mesh>(base);
+  return s_mesh.get();
+}
+
 auto HumanoidRendererBase::frameLocalPosition(
     const AttachmentFrame &frame, const QVector3D &local) -> QVector3D {
   float const lx = local.x() * frame.radius;
@@ -161,8 +210,7 @@ void HumanoidRendererBase::computeLocomotionPose(
 
   float const h_scale = variation.height_scale;
 
-  pose.head_pos =
-      QVector3D(0.0F, (HP::HEAD_TOP_Y + HP::CHIN_Y) * 0.5F * h_scale, 0.0F);
+  pose.head_pos = QVector3D(0.0F, HP::HEAD_CENTER_Y * h_scale, 0.0F);
   pose.head_r = HP::HEAD_RADIUS * h_scale;
   pose.neck_base = QVector3D(0.0F, HP::NECK_BASE_Y * h_scale, 0.0F);
 
@@ -367,7 +415,7 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
   QVector3D const tunic_top{shoulder_mid.x(), y_top_cover - 0.006F,
                             shoulder_mid.z()};
 
-  QVector3D const tunic_bot{pose.pelvis_pos.x(), pose.pelvis_pos.y() + 0.03F,
+  QVector3D const tunic_bot{pose.pelvis_pos.x(), pose.pelvis_pos.y() - 0.05F,
                             pose.pelvis_pos.z()};
 
   QMatrix4x4 torso_transform =
@@ -375,7 +423,10 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
 
   torso_transform.scale(torso_r, 1.0F, torso_depth);
 
-  out.mesh(getUnitTorso(), torso_transform, v.palette.cloth, nullptr, 1.0F);
+  Mesh *torso_mesh = torso_mesh_without_bottom_cap();
+  if (torso_mesh != nullptr) {
+    out.mesh(torso_mesh, torso_transform, v.palette.cloth, nullptr, 1.0F);
+  }
 
   float const head_r = pose.head_r;
 
@@ -1286,49 +1337,52 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
         float const shadowDepth =
             shadowSize * (is_mounted_spawn ? 1.30F : 1.10F) * depth_boost;
 
-        QVector3D const instPos =
-            inst_ctx.model.map(QVector3D(0.0F, 0.0F, 0.0F));
-
-        float shadowY = instPos.y();
         auto &terrain_service = Game::Map::TerrainService::instance();
+
         if (terrain_service.isInitialized()) {
-          shadowY = terrain_service.getTerrainHeight(instPos.x(), instPos.z());
-        }
 
-        QVector3D light_dir = k_shadow_light_dir.normalized();
-        QVector2D light_dir_xz(light_dir.x(), light_dir.z());
-        if (light_dir_xz.lengthSquared() < 1e-6F) {
-          light_dir_xz = QVector2D(0.0F, 1.0F);
-        } else {
-          light_dir_xz.normalize();
-        }
-        QVector2D const shadow_dir = -light_dir_xz;
-        QVector2D dir_for_use = shadow_dir;
-        if (dir_for_use.lengthSquared() < 1e-6F) {
-          dir_for_use = QVector2D(0.0F, 1.0F);
-        } else {
-          dir_for_use.normalize();
-        }
-        float const shadowOffset = shadowDepth * 1.25F;
-        QVector2D const offset2d = dir_for_use * shadowOffset;
-        float const lightYawDeg = qRadiansToDegrees(
-            std::atan2(double(dir_for_use.x()), double(dir_for_use.y())));
-
-        QMatrix4x4 shadowModel;
-        shadowModel.translate(instPos.x() + offset2d.x(),
-                              shadowY + k_shadow_ground_offset,
-                              instPos.z() + offset2d.y());
-        shadowModel.rotate(lightYawDeg, 0.0F, 1.0F, 0.0F);
-        shadowModel.rotate(-90.0F, 1.0F, 0.0F, 0.0F);
-        shadowModel.scale(shadowWidth, shadowDepth, 1.0F);
-
-        if (auto *renderer = dynamic_cast<Renderer *>(&out)) {
-          renderer->setCurrentShader(shadowShader);
-          shadowShader->setUniform(QStringLiteral("u_lightDir"), dir_for_use);
-
-          out.mesh(quadMesh, shadowModel, QVector3D(0.0F, 0.0F, 0.0F), nullptr,
-                   k_shadow_base_alpha, 0);
-          renderer->setCurrentShader(nullptr);
+          QVector3D const instPos =
+              inst_ctx.model.map(QVector3D(0.0F, 0.0F, 0.0F));
+          float const shadowY =
+              terrain_service.getTerrainHeight(instPos.x(), instPos.z());
+
+          QVector3D light_dir = k_shadow_light_dir.normalized();
+          QVector2D light_dir_xz(light_dir.x(), light_dir.z());
+          if (light_dir_xz.lengthSquared() < 1e-6F) {
+            light_dir_xz = QVector2D(0.0F, 1.0F);
+          } else {
+            light_dir_xz.normalize();
+          }
+          QVector2D const shadow_dir = -light_dir_xz;
+          QVector2D dir_for_use = shadow_dir;
+          if (dir_for_use.lengthSquared() < 1e-6F) {
+            dir_for_use = QVector2D(0.0F, 1.0F);
+          } else {
+            dir_for_use.normalize();
+          }
+          float const shadowOffset = shadowDepth * 1.25F;
+          QVector2D const offset2d = dir_for_use * shadowOffset;
+          float const lightYawDeg = qRadiansToDegrees(
+              std::atan2(double(dir_for_use.x()), double(dir_for_use.y())));
+
+          QMatrix4x4 shadowModel;
+          shadowModel.translate(instPos.x() + offset2d.x(),
+                                shadowY + k_shadow_ground_offset,
+                                instPos.z() + offset2d.y());
+          shadowModel.rotate(lightYawDeg, 0.0F, 1.0F, 0.0F);
+          shadowModel.rotate(-90.0F, 1.0F, 0.0F, 0.0F);
+          shadowModel.scale(shadowWidth, shadowDepth, 1.0F);
+
+          if (auto *renderer = dynamic_cast<Renderer *>(&out)) {
+            Shader *previous_shader = renderer->getCurrentShader();
+            renderer->setCurrentShader(shadowShader);
+            shadowShader->setUniform(QStringLiteral("u_lightDir"), dir_for_use);
+
+            out.mesh(quadMesh, shadowModel, QVector3D(0.0F, 0.0F, 0.0F),
+                     nullptr, k_shadow_base_alpha, 0);
+
+            renderer->setCurrentShader(previous_shader);
+          }
         }
       }
     }

+ 3 - 0
render/humanoid/rig.h

@@ -2,6 +2,7 @@
 
 #include "../entity/registry.h"
 #include "../gl/humanoid/humanoid_types.h"
+#include "../gl/mesh.h"
 #include "humanoid_specs.h"
 #include <QMatrix4x4>
 #include <QVector3D>
@@ -17,6 +18,8 @@ class UnitComponent;
 
 namespace Render::GL {
 
+auto torso_mesh_without_bottom_cap() -> Mesh *;
+
 class HumanoidRendererBase {
 public:
   virtual ~HumanoidRendererBase() = default;

+ 74 - 0
render/humanoid/spear_pose_utils.h

@@ -0,0 +1,74 @@
+#pragma once
+
+#include "../entity/renderer_constants.h"
+#include "../gl/humanoid/animation/animation_inputs.h"
+#include "../gl/humanoid/humanoid_types.h"
+
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+
+namespace Render::GL {
+
+inline auto
+computeSpearDirection(const AnimationInputs &anim_inputs) -> QVector3D {
+
+  auto normalize = [](QVector3D dir) {
+    if (dir.lengthSquared() > 1e-6F) {
+      dir.normalize();
+    }
+    return dir;
+  };
+
+  QVector3D spear_dir = normalize(QVector3D(0.05F, 0.55F, 0.85F));
+
+  if (anim_inputs.is_in_hold_mode || anim_inputs.is_exiting_hold) {
+    float const t = anim_inputs.is_in_hold_mode
+                        ? 1.0F
+                        : (1.0F - anim_inputs.hold_exit_progress);
+
+    QVector3D const braced_dir = normalize(QVector3D(0.05F, 0.40F, 0.91F));
+    spear_dir = normalize(spear_dir * (1.0F - t) + braced_dir * t);
+  } else if (anim_inputs.is_attacking && anim_inputs.is_melee) {
+    float const attack_phase =
+        std::fmod(anim_inputs.time * SPEARMAN_INV_ATTACK_CYCLE_TIME, 1.0F);
+    if (attack_phase >= 0.30F && attack_phase < 0.50F) {
+      float const t = (attack_phase - 0.30F) / 0.20F;
+
+      QVector3D const attack_dir = normalize(QVector3D(0.03F, -0.15F, 1.0F));
+      spear_dir = normalize(spear_dir * (1.0F - t) + attack_dir * t);
+    }
+  }
+
+  return spear_dir;
+}
+
+inline auto computeOffhandSpearGrip(const HumanoidPose &pose,
+                                    const HumanoidAnimationContext &anim_ctx,
+                                    const QVector3D &main_hand_pos,
+                                    bool main_is_left, float along_offset,
+                                    float y_drop = 0.05F,
+                                    float lateral_offset = 0.05F) -> QVector3D {
+  QVector3D const spear_dir = computeSpearDirection(anim_ctx.inputs);
+
+  QVector3D offhand = main_hand_pos + spear_dir * along_offset;
+
+  QVector3D right_axis = pose.shoulder_r - pose.shoulder_l;
+  right_axis.setY(0.0F);
+  if (right_axis.lengthSquared() < 1e-6F) {
+    right_axis = QVector3D(1.0F, 0.0F, 0.0F);
+  } else {
+    right_axis.normalize();
+  }
+
+  offhand += (main_is_left ? right_axis : -right_axis) * lateral_offset;
+  offhand.setY(offhand.y() - y_drop);
+
+  QVector3D torso_center = (pose.shoulder_l + pose.shoulder_r) * 0.5F;
+  torso_center.setY(offhand.y());
+  offhand = offhand * 0.65F + torso_center * 0.35F;
+
+  return offhand;
+}
+
+} // namespace Render::GL

+ 3 - 3
tests/render/body_frames_test.cpp

@@ -12,7 +12,7 @@ protected:
   void SetUp() override {
     using HP = HumanProportions;
     // Initialize a basic pose
-    float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+    float const head_center_y = HP::HEAD_CENTER_Y;
     float const half_shoulder = 0.5F * HP::SHOULDER_WIDTH;
     pose.head_pos = QVector3D(0.0F, head_center_y, 0.0F);
     pose.head_r = HP::HEAD_RADIUS;
@@ -137,7 +137,7 @@ TEST_F(BodyFramesTest, MakeFrameLocalTransformCreatesValidMatrix) {
 TEST_F(BodyFramesTest, LegacyHeadFunctionsStillWork) {
   using HP = HumanProportions;
   HeadFrame headFrame;
-  float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+  float const head_center_y = HP::HEAD_CENTER_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);
@@ -168,7 +168,7 @@ TEST_F(BodyFramesTest, PoseHasBothHeadFrameAndBodyFrames) {
   EXPECT_TRUE(true); // Just verify it compiles
 
   // Set headFrame
-  float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+  float const head_center_y = HP::HEAD_CENTER_Y;
   pose.head_frame.origin = QVector3D(0.0F, head_center_y, 0.0F);
   pose.head_frame.radius = HP::HEAD_RADIUS;
 

+ 195 - 0
tests/render/carthage_armor_bounds_test.cpp

@@ -0,0 +1,195 @@
+#include "render/equipment/armor/armor_heavy_carthage.h"
+#include "render/equipment/armor/armor_light_carthage.h"
+#include "render/humanoid/rig.h"
+#include "render/humanoid/style_palette.h"
+
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <gtest/gtest.h>
+#include <limits>
+#include <sstream>
+#include <vector>
+
+using namespace Render::GL;
+
+namespace {
+
+struct MeshBounds {
+  QVector3D min;
+  QVector3D max;
+  int materialId = 0;
+};
+
+class BoundsSubmitter : public ISubmitter {
+public:
+  std::vector<MeshBounds> meshes;
+
+  void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D & /*color*/,
+            Texture * /*tex*/ = nullptr, float /*alpha*/ = 1.0F,
+            int materialId = 0) override {
+    if (mesh == nullptr) {
+      return;
+    }
+
+    MeshBounds b;
+    b.min = QVector3D(std::numeric_limits<float>::max(),
+                      std::numeric_limits<float>::max(),
+                      std::numeric_limits<float>::max());
+    b.max = QVector3D(std::numeric_limits<float>::lowest(),
+                      std::numeric_limits<float>::lowest(),
+                      std::numeric_limits<float>::lowest());
+    b.materialId = materialId;
+
+    for (const auto &v : mesh->getVertices()) {
+      QVector3D p(v.position[0], v.position[1], v.position[2]);
+      QVector3D world = model.map(p);
+      b.min.setX(std::min(b.min.x(), world.x()));
+      b.min.setY(std::min(b.min.y(), world.y()));
+      b.min.setZ(std::min(b.min.z(), world.z()));
+      b.max.setX(std::max(b.max.x(), world.x()));
+      b.max.setY(std::max(b.max.y(), world.y()));
+      b.max.setZ(std::max(b.max.z(), world.z()));
+    }
+
+    meshes.push_back(b);
+  }
+
+  void cylinder(const QVector3D &, const QVector3D &, float, const QVector3D &,
+                float) override {}
+  void selectionRing(const QMatrix4x4 &, float, float,
+                     const QVector3D &) override {}
+  void grid(const QMatrix4x4 &, const QVector3D &, float, float,
+            float) override {}
+  void selectionSmoke(const QMatrix4x4 &, const QVector3D &, float) override {}
+};
+
+// Minimal renderer that reproduces the Carthage spearman proportions and
+// variation tweaks to build BodyFrames.
+class TestCarthageSpearmanBase : public HumanoidRendererBase {
+public:
+  auto get_proportion_scaling() const -> QVector3D override {
+    return {0.94F, 1.04F, 0.92F};
+  }
+
+  void adjust_variation(const DrawContext &, uint32_t,
+                        VariationParams &variation) const override {
+    variation.bulk_scale *= 0.90F;
+    variation.stance_width *= 0.92F;
+  }
+};
+
+class TestCarthageSwordsmanBase : public HumanoidRendererBase {
+public:
+  auto get_proportion_scaling() const -> QVector3D override {
+    return {0.95F, 1.05F, 0.95F};
+  }
+};
+
+struct PoseResult {
+  HumanoidPose pose;
+  HumanoidVariant variant;
+  DrawContext ctx;
+};
+
+template <typename Renderer> class PoseBuilder : public Renderer {
+public:
+  auto build(uint32_t seed) -> PoseResult {
+    VariationParams variation = VariationParams::fromSeed(seed);
+    this->adjust_variation(DrawContext{}, seed, variation);
+
+    const QVector3D prop_scale = this->get_proportion_scaling();
+    const float combined_height_scale = prop_scale.y() * variation.height_scale;
+
+    PoseResult result;
+    result.ctx.model.scale(variation.bulk_scale, combined_height_scale, 1.0F);
+
+    AnimationInputs inputs{};
+    inputs.time = 0.0F;
+    inputs.is_moving = false;
+    inputs.is_attacking = false;
+    inputs.is_melee = false;
+    inputs.is_in_hold_mode = false;
+    inputs.is_exiting_hold = false;
+    inputs.hold_exit_progress = 0.0F;
+
+    HumanoidPose pose;
+    this->computeLocomotionPose(seed, inputs.time, inputs.is_moving, variation,
+                                pose);
+
+    HumanoidVariant variant;
+    QVector3D team_tint(0.8F, 0.9F, 1.0F);
+    variant.palette = makeHumanoidPalette(team_tint, seed);
+
+    BoundsSubmitter sink;
+    this->drawCommonBody(result.ctx, variant, pose, sink);
+
+    result.pose = pose;
+    result.variant = variant;
+    return result;
+  }
+};
+
+auto extractMinY(const std::vector<MeshBounds> &meshes) -> float {
+  float min_y = std::numeric_limits<float>::max();
+  for (const auto &m : meshes) {
+    min_y = std::min(min_y, m.min.y());
+  }
+  return min_y;
+}
+
+} // namespace
+
+TEST(CarthageArmorBoundsTest, LightArmorStaysNearWaist) {
+  PoseBuilder<TestCarthageSpearmanBase> renderer;
+  auto pose_result = renderer.build(/*seed=*/1337U);
+
+  ArmorLightCarthageRenderer armor;
+  HumanoidAnimationContext anim_ctx{};
+  BoundsSubmitter submitter;
+  armor.render(pose_result.ctx, pose_result.pose.body_frames,
+               pose_result.variant.palette, anim_ctx, submitter);
+
+  std::ostringstream debug;
+  for (size_t i = 0; i < submitter.meshes.size(); ++i) {
+    const auto &m = submitter.meshes[i];
+    debug << "#" << i << ": [" << m.min.y() << ", " << m.max.y() << "] (mat "
+          << m.materialId << ") ";
+  }
+  debug << "waist_r=" << pose_result.pose.body_frames.waist.radius;
+  SCOPED_TRACE(debug.str());
+
+  float const armor_min_y = extractMinY(submitter.meshes);
+  float const waist_y =
+      pose_result.ctx.model.map(pose_result.pose.body_frames.waist.origin).y();
+
+  // Armor should not extend noticeably below the waist/hip line.
+  EXPECT_GT(armor_min_y, waist_y - 0.05F)
+      << "min_y=" << armor_min_y << " waist_y=" << waist_y;
+}
+
+TEST(CarthageArmorBoundsTest, HeavyArmorStaysNearWaist) {
+  PoseBuilder<TestCarthageSwordsmanBase> renderer;
+  auto pose_result = renderer.build(/*seed=*/4242U);
+
+  ArmorHeavyCarthageRenderer armor;
+  HumanoidAnimationContext anim_ctx{};
+  BoundsSubmitter submitter;
+  armor.render(pose_result.ctx, pose_result.pose.body_frames,
+               pose_result.variant.palette, anim_ctx, submitter);
+
+  std::ostringstream debug;
+  for (size_t i = 0; i < submitter.meshes.size(); ++i) {
+    const auto &m = submitter.meshes[i];
+    debug << "#" << i << ": [" << m.min.y() << ", " << m.max.y() << "] (mat "
+          << m.materialId << ") ";
+  }
+  debug << "waist_r=" << pose_result.pose.body_frames.waist.radius;
+  SCOPED_TRACE(debug.str());
+
+  float const armor_min_y = extractMinY(submitter.meshes);
+  float const waist_y =
+      pose_result.ctx.model.map(pose_result.pose.body_frames.waist.origin).y();
+
+  EXPECT_GT(armor_min_y, waist_y - 0.05F)
+      << "min_y=" << armor_min_y << " waist_y=" << waist_y;
+}

+ 1 - 1
tests/render/helmet_renderers_test.cpp

@@ -59,7 +59,7 @@ DrawContext createTestContext() {
 BodyFrames createTestFrames() {
   using HP = HumanProportions;
   BodyFrames frames;
-  float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+  float const head_center_y = HP::HEAD_CENTER_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);

+ 1 - 1
tests/render/mounted_pose_controller_test.cpp

@@ -15,7 +15,7 @@ protected:
 
     // Initialize a default pose with basic standing configuration
     pose = HumanoidPose{};
-    float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+    float const head_center_y = HP::HEAD_CENTER_Y;
     float const half_shoulder = 0.5F * HP::SHOULDER_WIDTH;
     pose.head_pos = QVector3D(0.0F, head_center_y, 0.0F);
     pose.head_r = HP::HEAD_RADIUS;

+ 2 - 3
tests/render/pose_controller_compatibility_test.cpp

@@ -19,7 +19,7 @@ protected:
 
     // Initialize a default pose
     pose = HumanoidPose{};
-    float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+    float const head_center_y = HP::HEAD_CENTER_Y;
     float const half_shoulder = 0.5F * HP::SHOULDER_WIDTH;
     pose.head_pos = QVector3D(0.0F, head_center_y, 0.0F);
     pose.head_r = HP::HEAD_RADIUS;
@@ -145,8 +145,7 @@ TEST_F(PoseControllerCompatibilityTest,
   reference_pose.shoulder_l.setY(HP::SHOULDER_Y - kneel_depth);
   reference_pose.shoulder_r.setY(HP::SHOULDER_Y - kneel_depth);
   reference_pose.neck_base.setY(HP::NECK_BASE_Y - kneel_depth);
-  reference_pose.head_pos.setY((HP::HEAD_TOP_Y + HP::CHIN_Y) * 0.5F -
-                               kneel_depth);
+  reference_pose.head_pos.setY(HP::HEAD_CENTER_Y - kneel_depth);
 
   // Use controller to kneel
   HumanoidPoseController controller(pose, anim_ctx);

+ 2 - 3
tests/render/pose_controller_test.cpp

@@ -14,7 +14,7 @@ protected:
 
     // Initialize a default pose with basic standing configuration
     pose = HumanoidPose{};
-    float const head_center_y = 0.5F * (HP::HEAD_TOP_Y + HP::CHIN_Y);
+    float const head_center_y = HP::HEAD_CENTER_Y;
     float const half_shoulder = 0.5F * HP::SHOULDER_WIDTH;
     pose.head_pos = QVector3D(0.0F, head_center_y, 0.0F);
     pose.head_r = HP::HEAD_RADIUS;
@@ -56,8 +56,7 @@ protected:
 TEST_F(HumanoidPoseControllerTest, ConstructorInitializesCorrectly) {
   HumanoidPoseController controller(pose, anim_ctx);
   // Constructor should not modify the pose
-  EXPECT_FLOAT_EQ(pose.head_pos.y(), 0.5F * (HumanProportions::HEAD_TOP_Y +
-                                             HumanProportions::CHIN_Y));
+  EXPECT_FLOAT_EQ(pose.head_pos.y(), HumanProportions::HEAD_CENTER_Y);
   EXPECT_FLOAT_EQ(pose.pelvis_pos.y(), HumanProportions::WAIST_Y);
 }
 

+ 35 - 30
utils/resource_utils.h

@@ -14,6 +14,9 @@ inline auto resolveResourcePath(const QString &path) -> QString {
     return path;
   }
 
+  const bool is_resource = path.startsWith(QStringLiteral(":/"));
+  const QString relative = is_resource ? path.mid(2) : QString{};
+
   auto exists = [](const QString &candidate) {
     QFileInfo const info(candidate);
     if (info.exists()) {
@@ -23,11 +26,42 @@ inline auto resolveResourcePath(const QString &path) -> QString {
     return dir.exists();
   };
 
+  // For Qt resource paths, prefer a filesystem override when available so live
+  // shader edits are picked up without rebuilding the resource bundle.
+  if (is_resource) {
+    auto search_upwards = [&](const QString &startDir) -> QString {
+      if (startDir.isEmpty()) {
+        return {};
+      }
+      QDir dir(startDir);
+      for (int i = 0; i < 5; ++i) {
+        QString candidate = dir.filePath(relative);
+        if (exists(candidate)) {
+          return candidate;
+        }
+        if (!dir.cdUp()) {
+          break;
+        }
+      }
+      return {};
+    };
+
+    if (QString candidate =
+            search_upwards(QCoreApplication::applicationDirPath());
+        !candidate.isEmpty()) {
+      return candidate;
+    }
+    if (QString candidate = search_upwards(QDir::currentPath());
+        !candidate.isEmpty()) {
+      return candidate;
+    }
+  }
+
   if (exists(path)) {
     return path;
   }
 
-  if (!path.startsWith(QStringLiteral(":/"))) {
+  if (!is_resource) {
     return path;
   }
 
@@ -36,7 +70,6 @@ inline auto resolveResourcePath(const QString &path) -> QString {
       QStringLiteral(":/qt/qml/StandardOfIron"),
       QStringLiteral(":/qt/qml/default")};
 
-  const QString relative = path.mid(2); // strip ":/"
   for (const auto &root : kAlternateRoots) {
     QString candidate = root;
     if (!candidate.endsWith('/')) {
@@ -48,34 +81,6 @@ inline auto resolveResourcePath(const QString &path) -> QString {
     }
   }
 
-  // Fallbacks for development and packaging where resources live on disk
-  auto search_upwards = [&](const QString &startDir) -> QString {
-    if (startDir.isEmpty()) {
-      return {};
-    }
-    QDir dir(startDir);
-    for (int i = 0; i < 5; ++i) {
-      QString candidate = dir.filePath(relative);
-      if (exists(candidate)) {
-        return candidate;
-      }
-      if (!dir.cdUp()) {
-        break;
-      }
-    }
-    return {};
-  };
-
-  if (QString candidate =
-          search_upwards(QCoreApplication::applicationDirPath());
-      !candidate.isEmpty()) {
-    return candidate;
-  }
-  if (QString candidate = search_upwards(QDir::currentPath());
-      !candidate.isEmpty()) {
-    return candidate;
-  }
-
   return path;
 }