浏览代码

improved archer renderer

djeada 1 月之前
父节点
当前提交
d63f5701a6
共有 38 个文件被更改,包括 1823 次插入962 次删除
  1. 4 3
      app/core/game_engine.cpp
  2. 50 6
      assets/shaders/archer.frag
  3. 50 12
      assets/shaders/archer_carthage.frag
  4. 32 0
      assets/shaders/archer_carthage.vert
  5. 171 0
      assets/shaders/archer_kingdom_of_iron.frag
  6. 31 0
      assets/shaders/archer_kingdom_of_iron.vert
  7. 32 0
      assets/shaders/archer_roman_republic.vert
  8. 34 0
      docs/head_attachment_refactor_plan.md
  9. 399 377
      render/entity/nations/carthage/archer_renderer.cpp
  10. 1 0
      render/entity/nations/carthage/archer_style.cpp
  11. 2 0
      render/entity/nations/carthage/archer_style.h
  12. 55 40
      render/entity/nations/carthage/horse_swordsman_renderer.cpp
  13. 10 10
      render/entity/nations/carthage/horse_swordsman_style.cpp
  14. 12 12
      render/entity/nations/carthage/horse_swordsman_style.h
  15. 31 31
      render/entity/nations/carthage/spearman_renderer.cpp
  16. 56 44
      render/entity/nations/carthage/swordsman_renderer.cpp
  17. 1 1
      render/entity/nations/carthage/swordsman_renderer.h
  18. 151 185
      render/entity/nations/kingdom/archer_renderer.cpp
  19. 3 2
      render/entity/nations/kingdom/horse_swordsman_renderer.cpp
  20. 9 9
      render/entity/nations/kingdom/horse_swordsman_style.cpp
  21. 12 12
      render/entity/nations/kingdom/horse_swordsman_style.h
  22. 34 31
      render/entity/nations/kingdom/spearman_renderer.cpp
  23. 59 44
      render/entity/nations/kingdom/swordsman_renderer.cpp
  24. 1 1
      render/entity/nations/kingdom/swordsman_renderer.h
  25. 61 21
      render/entity/nations/roman/archer_renderer.cpp
  26. 3 2
      render/entity/nations/roman/horse_swordsman_renderer.cpp
  27. 9 9
      render/entity/nations/roman/horse_swordsman_style.cpp
  28. 12 12
      render/entity/nations/roman/horse_swordsman_style.h
  29. 2 2
      render/entity/nations/roman/spearman_renderer.cpp
  30. 3 3
      render/entity/nations/roman/swordsman_renderer.cpp
  31. 1 1
      render/entity/nations/roman/swordsman_renderer.h
  32. 3 3
      render/entity/registry.cpp
  33. 2 1
      render/gl/backend/character_pipeline.cpp
  34. 16 16
      render/humanoid/humanoid_math.cpp
  35. 12 12
      render/humanoid/humanoid_math.h
  36. 33 33
      render/humanoid/humanoid_specs.h
  37. 379 26
      render/humanoid/rig.cpp
  38. 47 1
      render/humanoid/rig.h

+ 4 - 3
app/core/game_engine.cpp

@@ -1892,9 +1892,10 @@ void GameEngine::loadAudioResources() {
                << (base_path + "voices/archer_voice.wav");
                << (base_path + "voices/archer_voice.wav");
   }
   }
 
 
-  if (audio_sys.loadSound("swordsman_voice",
-                          (base_path + "voices/swordsman_voice.wav").toStdString(),
-                          AudioCategory::VOICE)) {
+  if (audio_sys.loadSound(
+          "swordsman_voice",
+          (base_path + "voices/swordsman_voice.wav").toStdString(),
+          AudioCategory::VOICE)) {
     qInfo() << "Loaded swordsman voice";
     qInfo() << "Loaded swordsman voice";
   } else {
   } else {
     qWarning() << "Failed to load swordsman voice from:"
     qWarning() << "Failed to load swordsman voice from:"

+ 50 - 6
assets/shaders/archer.frag

@@ -3,6 +3,7 @@
 in vec3 v_normal;
 in vec3 v_normal;
 in vec2 v_texCoord;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
 in vec3 v_worldPos;
+in float v_armorLayer; // NEW: Armor layer from vertex shader
 
 
 uniform sampler2D u_texture;
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform vec3 u_color;
@@ -59,6 +60,28 @@ float pterugesStrips(vec2 p, float y) {
   return strip * leatherTex * hang;
   return strip * leatherTex * hang;
 }
 }
 
 
+// Scale armor (lorica squamata) - overlapping metal scales
+float scaleArmor(vec2 p, float y) {
+  // Offset scale rows for overlap effect
+  vec2 scaleGrid = p * 18.0;
+  scaleGrid.y += sin(scaleGrid.x * 2.0) * 0.15; // Wave pattern
+
+  vec2 scaleFract = fract(scaleGrid) - 0.5;
+
+  // Individual scale shape (teardrop/fish scale)
+  float scaleDist = length(scaleFract * vec2(1.0, 1.4));
+  float scaleShape = smoothstep(0.48, 0.38, scaleDist);
+
+  // Overlap shadow
+  float overlap = sin(scaleGrid.y * 3.14159) * 0.08;
+
+  // Edge highlight on scales
+  float edge =
+      smoothstep(0.42, 0.38, scaleDist) - smoothstep(0.38, 0.34, scaleDist);
+
+  return scaleShape * 0.15 + overlap + edge * 0.12;
+}
+
 void main() {
 void main() {
   vec3 color = u_color;
   vec3 color = u_color;
   if (u_useTexture) {
   if (u_useTexture) {
@@ -69,15 +92,20 @@ void main() {
   vec2 uv = v_worldPos.xz * 4.5;
   vec2 uv = v_worldPos.xz * 4.5;
   float avgColor = (color.r + color.g + color.b) / 3.0;
   float avgColor = (color.r + color.g + color.b) / 3.0;
 
 
-  // Detect bronze vs steel by color warmth
+  // Detect materials by color and armor layer
   bool isBronze =
   bool isBronze =
       (color.r > color.g * 1.08 && color.r > color.b * 1.15 && avgColor > 0.50);
       (color.r > color.g * 1.08 && color.r > color.b * 1.15 && avgColor > 0.50);
   bool isRedCape = (color.r > color.g * 1.3 && color.r > color.b * 1.4);
   bool isRedCape = (color.r > color.g * 1.3 && color.r > color.b * 1.4);
+  bool isHelmet = (v_armorLayer == 0.0);
+  bool isChainmail =
+      (v_armorLayer == 1.0 && avgColor > 0.40 && avgColor <= 0.60);
+  bool isScaleArmor =
+      (v_armorLayer == 1.0 && avgColor > 0.50 && avgColor <= 0.70);
 
 
-  // === ROMAN ARCHER (SAGITTARIUS) MATERIALS ===
+  // === ROMAN AUXILIARY ARCHER (SAGITTARII) MATERIALS ===
 
 
-  // BRONZE GALEA HELMET & PHALERAE (warm golden metal)
-  if (isBronze) {
+  // BRONZE AUXILIARY HELMET (simpler than legionary)
+  if (isBronze && isHelmet) {
     // Ancient bronze patina and wear
     // Ancient bronze patina and wear
     float bronzePatina = noise(uv * 8.0) * 0.12;
     float bronzePatina = noise(uv * 8.0) * 0.12;
     float verdigris = noise(uv * 15.0) * 0.08; // Green oxidation
     float verdigris = noise(uv * 15.0) * 0.08; // Green oxidation
@@ -94,8 +122,24 @@ void main() {
     color -= vec3(bronzePatina * 0.4 + verdigris * 0.3);
     color -= vec3(bronzePatina * 0.4 + verdigris * 0.3);
     color += vec3(hammerMarks * 0.5);
     color += vec3(hammerMarks * 0.5);
   }
   }
-  // STEEL CHAINMAIL (lorica hamata - grey-blue tint)
-  else if (avgColor > 0.40 && avgColor <= 0.60 && !isRedCape) {
+  // LORICA SQUAMATA (SCALE ARMOR) - Auxiliary preference over chainmail
+  else if (isScaleArmor && !isRedCape) {
+    // Overlapping bronze/iron scales
+    float scales = scaleArmor(v_worldPos.xz, v_worldPos.y);
+
+    // Scale armor has moderate shine
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float scaleSheen = pow(viewAngle, 6.0) * 0.20;
+
+    // Rust and wear between scales
+    float wearBetweenScales = noise(uv * 12.0) * 0.09;
+
+    color += vec3(scales + scaleSheen);
+    color -= vec3(wearBetweenScales * 0.35);
+    color *= 1.0 - noise(uv * 20.0) * 0.05; // Shadow between scales
+  }
+  // LORICA HAMATA (CHAINMAIL) - Some auxiliaries wore this
+  else if (isChainmail && !isRedCape) {
     // Interlocked iron rings
     // Interlocked iron rings
     float rings = chainmailRings(v_worldPos.xz);
     float rings = chainmailRings(v_worldPos.xz);
 
 

+ 50 - 12
assets/shaders/archer_carthage.frag

@@ -3,6 +3,7 @@
 in vec3 v_normal;
 in vec3 v_normal;
 in vec2 v_texCoord;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
 in vec3 v_worldPos;
+in float v_armorLayer; // NEW: Armor layer from vertex shader
 
 
 uniform sampler2D u_texture;
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform vec3 u_color;
@@ -73,11 +74,48 @@ void main() {
   bool isBronze =
   bool isBronze =
       (color.r > color.g * 1.02 && color.r > color.b * 1.10 && avgColor > 0.48);
       (color.r > color.g * 1.02 && color.r > color.b * 1.10 && avgColor > 0.48);
   bool isSeaCloak = (color.g > color.r * 1.2 && color.b > color.r * 1.3);
   bool isSeaCloak = (color.g > color.r * 1.2 && color.b > color.r * 1.3);
+  bool isLinothorax =
+      (v_armorLayer == 1.0 && avgColor > 0.55 && avgColor < 0.78);
+  bool isLeatherCap = (v_armorLayer == 0.0 && !isBronze);
 
 
-  // === CARTHAGINIAN MARINE ARCHER MATERIALS ===
+  // === CARTHAGINIAN LIGHT ARCHER MATERIALS (North African/Mercenary style) ===
 
 
-  // SUN-DULLED BRONZE WITH SEA SALT PATINA
-  if (isBronze) {
+  // LEATHER CAP/HEADBAND (instead of heavy bronze helmet)
+  if (isLeatherCap) {
+    // Thick tanned leather with Numidian/Libyan styling
+    float leatherGrain = noise(uv * 14.0) * 0.20;
+    float leatherPores = noise(uv * 28.0) * 0.10;
+    float tooledPattern =
+        sin(v_worldPos.x * 40.0) * sin(v_worldPos.y * 35.0) * 0.06;
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
+    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
+
+    color *= 1.0 + leatherGrain + leatherPores - 0.08;
+    color += vec3(tooledPattern + leatherSheen);
+  }
+  // LINOTHORAX (LAYERED LINEN ARMOR) - Carthaginian specialty
+  else if (isLinothorax) {
+    // Multiple layers of glued linen - very distinctive texture
+    float linenWeaveX = sin(v_worldPos.x * 65.0);
+    float linenWeaveZ = sin(v_worldPos.z * 68.0);
+    float weave = linenWeaveX * linenWeaveZ * 0.08;
+
+    // Visible layering from edge-on angles
+    float layers = abs(sin(v_worldPos.y * 22.0)) * 0.12;
+
+    // Glue/resin stiffening (darker patches)
+    float resinStains = noise(uv * 8.0) * 0.10;
+
+    // Soft matte finish (not shiny like metal)
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.2))));
+    float linenSheen = pow(1.0 - viewAngle, 12.0) * 0.05;
+
+    color *= 1.0 + weave + layers - resinStains * 0.5;
+    color += vec3(linenSheen);
+  }
+  // SUN-DULLED BRONZE (minimal use - only decorative pieces)
+  else if (isBronze) {
     float saltPatina = noise(uv * 9.0) * 0.16;
     float saltPatina = noise(uv * 9.0) * 0.16;
     float verdigris = noise(uv * 12.0) * 0.10;
     float verdigris = noise(uv * 12.0) * 0.10;
     float viewAngle = abs(dot(normal, normalize(vec3(0.1, 1.0, 0.2))));
     float viewAngle = abs(dot(normal, normalize(vec3(0.1, 1.0, 0.2))));
@@ -86,16 +124,16 @@ void main() {
     color += vec3(bronzeSheen + bronzeFresnel);
     color += vec3(bronzeSheen + bronzeFresnel);
     color -= vec3(saltPatina * 0.4 + verdigris * 0.35);
     color -= vec3(saltPatina * 0.4 + verdigris * 0.35);
   }
   }
-  // DARKENED IRON MAIL
+  // LIGHT LEATHER ARMOR (not chainmail - mercenary style)
   else if (avgColor > 0.35 && avgColor <= 0.58 && !isSeaCloak) {
   else if (avgColor > 0.35 && avgColor <= 0.58 && !isSeaCloak) {
-    float rings = chainmailRings(v_worldPos.xz);
-
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
-    float chainSheen = pow(viewAngle, 5.5) * 0.18;
-    float brineWear = noise(uv * 11.0) * 0.07;
-
-    color += vec3(rings * 0.9 + chainSheen);
-    color -= vec3(brineWear * 0.3);
+    // Hardened leather cuirass instead of heavy mail
+    float hardenedGrain = noise(uv * 16.0) * 0.18;
+    float cracks = noise(uv * 32.0) * 0.08;
+    float oilSheen =
+        pow(abs(dot(normal, normalize(vec3(0.2, 1.0, 0.3)))), 8.0) * 0.14;
+
+    color += vec3(hardenedGrain + oilSheen);
+    color -= vec3(cracks * 0.4);
   }
   }
   // TEAL SEA CLOAK
   // TEAL SEA CLOAK
   else if (isSeaCloak) {
   else if (isSeaCloak) {

+ 32 - 0
assets/shaders/archer_carthage.vert

@@ -0,0 +1,32 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float
+    v_armorLayer; // NEW: Distinguish armor pieces for Carthaginian light armor
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for lighter Carthaginian equipment
+  // Upper body (helmet/head) = 0, Torso = 1, Lower = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Leather cap/light helmet region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Linothorax (linen) torso region
+  } else {
+    v_armorLayer = 2.0; // Leather skirt/pteruges region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 171 - 0
assets/shaders/archer_kingdom_of_iron.frag

@@ -3,6 +3,7 @@
 in vec3 v_normal;
 in vec3 v_normal;
 in vec2 v_texCoord;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
 in vec3 v_worldPos;
+in float v_armorLayer; // Armor layer from vertex shader
 
 
 uniform sampler2D u_texture;
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform vec3 u_color;
@@ -28,6 +29,176 @@ float noise(vec2 p) {
   return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
   return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
 }
 }
 
 
+// Quilted gambeson texture (padded cloth armor)
+float gambesonQuilt(vec2 p) {
+  // Diamond quilting pattern
+  vec2 grid = p * 12.0;
+  float diagA = sin((grid.x + grid.y) * 3.14159);
+  float diagB = sin((grid.x - grid.y) * 3.14159);
+  float quilt = diagA * diagB * 0.12;
+
+  // Stuffing bumps
+  float padding = noise(p * 18.0) * 0.10;
+
+  return quilt + padding;
+}
+
+// Riveted mail (lighter European chainmail)
+float rivetedMail(vec2 p) {
+  vec2 grid = fract(p * 28.0) - 0.5;
+  float ring = length(grid);
+  float ringPattern =
+      smoothstep(0.40, 0.35, ring) - smoothstep(0.30, 0.25, ring);
+
+  // Offset rows
+  vec2 offsetGrid = fract(p * 28.0 + vec2(0.5, 0.0)) - 0.5;
+  float offsetRing = length(offsetGrid);
+  float offsetPattern =
+      smoothstep(0.40, 0.35, offsetRing) - smoothstep(0.30, 0.25, offsetRing);
+
+  // Rivets on rings (more visible than Roman mail)
+  float rivet = noise(p * 30.0) * 0.06;
+
+  return (ringPattern + offsetPattern) * 0.16 + rivet;
+}
+
+// Wool/linen cloth texture
+float clothWeave(vec2 p) {
+  float warpThread = sin(p.x * 70.0);
+  float weftThread = sin(p.y * 68.0);
+  return warpThread * weftThread * 0.06;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 4.5;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  // Detect materials by color and layer
+  bool isSteel = (avgColor > 0.55 && avgColor <= 0.75);
+  bool isGreen = (color.g > color.r * 1.15 && color.g > color.b * 1.05);
+  bool isBrown =
+      (color.r > color.g * 1.05 && color.r > color.b * 1.12 && avgColor < 0.60);
+  bool isHelmet = (v_armorLayer == 0.0);
+  bool isTorsoArmor = (v_armorLayer == 1.0);
+
+  // === KINGDOM/MEDIEVAL ARCHER (ENGLISH LONGBOWMAN STYLE) ===
+
+  // STEEL KETTLE HELMET or CHAPEL DE FER
+  if (isSteel && isHelmet) {
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
+    float steelSheen = pow(viewAngle, 8.0) * 0.28;
+    float steelFresnel = pow(1.0 - viewAngle, 2.5) * 0.22;
+
+    // Hammer marks and imperfections
+    float hammerMarks = noise(uv * 22.0) * 0.04;
+    float scratches = noise(uv * 35.0) * 0.03;
+
+    color += vec3(steelSheen + steelFresnel);
+    color += vec3(hammerMarks + scratches);
+  }
+  // PADDED GAMBESON (quilted cloth armor) - PRIMARY DEFENSE
+  else if (isTorsoArmor && (isBrown || avgColor > 0.45 && avgColor < 0.65)) {
+    // Thick quilted padding
+    float quilt = gambesonQuilt(v_worldPos.xz);
+
+    // Wool/linen texture
+    float weave = clothWeave(v_worldPos.xz);
+
+    // Soft matte finish (not shiny)
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.2))));
+    float clothSheen = pow(1.0 - viewAngle, 12.0) * 0.06;
+
+    // Natural wear and dirt
+    float wear = noise(uv * 6.0) * 0.12 - 0.06;
+
+    color *= 1.0 + quilt + weave - 0.04 + wear;
+    color += vec3(clothSheen);
+  }
+  // LIGHT MAIL SHIRT (optional, over gambeson)
+  else if (isTorsoArmor && isSteel) {
+    float mail = rivetedMail(v_worldPos.xz);
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
+    float mailSheen = pow(viewAngle, 6.0) * 0.18;
+
+    // Light rust
+    float rust = noise(uv * 11.0) * 0.06;
+
+    color += vec3(mail + mailSheen);
+    color -= vec3(rust * 0.3);
+  }
+  // GREEN TUNIC/HOOD (Lincoln green - iconic archer color)
+  else if (isGreen) {
+    float weave = clothWeave(v_worldPos.xz);
+    float woolFuzz = noise(uv * 22.0) * 0.09;
+
+    // Natural dye variations
+    float dyeVariation = noise(uv * 5.0) * 0.10;
+
+    // Soft cloth sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
+    float clothSheen = pow(1.0 - viewAngle, 10.0) * 0.07;
+
+    color *= 1.0 + weave + woolFuzz - 0.03 + dyeVariation - 0.05;
+    color += vec3(clothSheen);
+  }
+  // LEATHER ELEMENTS (belt, bracers, boots)
+  else if (avgColor > 0.30 && avgColor <= 0.55) {
+    float leatherGrain = noise(uv * 14.0) * 0.16;
+    float tooling = noise(uv * 20.0) * 0.06;
+    float wear = noise(uv * 4.0) * 0.08 - 0.04;
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
+    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
+
+    color *= 1.0 + leatherGrain + tooling - 0.06 + wear;
+    color += vec3(leatherSheen);
+  }
+  // DARK ELEMENTS (boots, belts, straps)
+  else {
+    float darkDetail = noise(uv * 10.0) * 0.12;
+    float wear = noise(uv * 3.0) * 0.06;
+
+    color *= 1.0 + darkDetail - 0.08 - wear;
+  }
+
+  color = clamp(color, 0.0, 1.0);
+
+  // Lighting - softer for cloth/padding, harder for metal
+  vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
+  float nDotL = dot(normal, lightDir);
+
+  // Cloth has more wrap-around lighting
+  float wrapAmount = (isSteel && isHelmet) ? 0.15 : 0.42;
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.24);
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
 // Roman chainmail (lorica hamata) ring pattern
 // Roman chainmail (lorica hamata) ring pattern
 float chainmailRings(vec2 p) {
 float chainmailRings(vec2 p) {
   vec2 grid = fract(p * 32.0) - 0.5;
   vec2 grid = fract(p * 32.0) - 0.5;

+ 31 - 0
assets/shaders/archer_kingdom_of_iron.vert

@@ -0,0 +1,31 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_armorLayer; // Distinguish armor pieces for Kingdom archer
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for Kingdom equipment
+  // Upper body (helmet) = 0, Torso (gambeson/mail) = 1, Lower (belt/skirt) = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Kingdom helmet/coif region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Padded gambeson/light mail region
+  } else {
+    v_armorLayer = 2.0; // Belt/cloth skirt region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 32 - 0
assets/shaders/archer_roman_republic.vert

@@ -0,0 +1,32 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_armorLayer; // Distinguish armor pieces for Roman auxiliary archer
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for Roman equipment
+  // Upper body (helmet) = 0, Torso (chainmail/scale) = 1, Lower (pteruges/belt)
+  // = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Roman auxiliary helmet region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Lorica hamata/squamata region
+  } else {
+    v_armorLayer = 2.0; // Pteruges/cingulum belt region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 34 - 0
docs/head_attachment_refactor_plan.md

@@ -0,0 +1,34 @@
+# Head Attachment Refactor Plan
+
+## Overview
+The goal is to redesign the humanoid head attachment system so that facial hair and helmets can be added, removed, or customised without duplicating per-unit math. The new architecture pivots around a "head frame" transform that provides a stable local coordinate space for anything mounted on the head.
+
+---
+
+## Step 1 – Build the HeadFrame abstraction
+- [x] Extend `HumanoidPose` (or introduce a companion struct) with:
+  - origin (QVector3D)
+  - orthonormal basis vectors (right, up, forward)
+  - uniform head radius
+- [x] Populate the frame inside `HumanoidRendererBase::drawCommonBody` right after the head sphere is placed.
+- [x] Add helper functions (e.g. `makeHeadLocalTransform(ctxModel, headFrame, localPos)`) that convert head-local offsets (expressed as multiples of the head radius) into world transforms with the correct orientation and translation.
+- [x] Ensure immediate head attachments in the base renderer (eyes today, facial hair in Step 2) migrate to the helper and no longer multiply by world-space matrices directly.
+
+## Step 2 – Refactor base beard rendering
+- [x] Rewrite `drawFacialHair` so all tufts are created via the HeadFrame helpers.
+- [x] Keep beard density logic but express every position as a head-local vector before conversion.
+- [x] Guarantee that disabling facial hair (`style == None` or `coverage == 0`) simply early-returns without touching anything else.
+- [x] Remove nation-specific beard overrides where possible so nations supply only style parameters, not geometry. *(Verified none exist; base renderer now covers all facial hair geometry.)*
+
+## Step 3 – Standardise helmet attachment helpers
+- [x] Introduce head-frame aware primitives in the base renderer (e.g. `makeHeadLocalTransform`, `headLocalPosition`).
+- [x] Update existing helmet implementations (starting with Carthage) to call into the helpers using head-local offsets.
+- [ ] Port remaining nation helmet renderers to the head-frame helpers.
+  - Progress: Carthage (all done); Kingdom archer, spearman, swordsman migrated; Kingdom horse_swordsman and Roman units still pending.
+- [ ] Ensure per-style toggles (`show_helmet`, etc.) become simple guard clauses that skip invoking the helpers.
+- [ ] Validate that animations (kneeling, leaning, attacking) carry the helmet correctly via the head frame.
+
+## Step 4 – Regression tests and documentation
+- [ ] Compile and run the game to inspect standing, kneeling, and attack cycles across at least two nations.
+- [ ] Verify that enabling/disabling facial hair or helmets is a data change only (no code edits required).
+- [ ] Document the new API in `docs/rendering.md` (or equivalent) so future units follow the same pattern.

+ 399 - 377
render/entity/nations/carthage/archer_renderer.cpp

@@ -8,10 +8,10 @@
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/render_constants.h"
 #include "../../../gl/render_constants.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -57,7 +57,7 @@ void ensure_archer_styles_registered() {
 constexpr float k_team_mix_weight = 0.65F;
 constexpr float k_team_mix_weight = 0.65F;
 constexpr float k_style_mix_weight = 0.35F;
 constexpr float k_style_mix_weight = 0.35F;
 
 
-} // namespace
+}
 
 
 void register_archer_style(const std::string &nation_id,
 void register_archer_style(const std::string &nation_id,
                            const ArcherStyleConfig &style) {
                            const ArcherStyleConfig &style) {
@@ -87,8 +87,9 @@ struct ArcherExtras {
 class ArcherRenderer : public HumanoidRendererBase {
 class ArcherRenderer : public HumanoidRendererBase {
 public:
 public:
   auto getProportionScaling() const -> QVector3D override {
   auto getProportionScaling() const -> QVector3D override {
-
-    return {0.94F, 1.01F, 0.96F};
+    // X = width (shoulders/chest WIDE), Y = height, Z = depth (front-to-back NARROW)
+    // Human torso is much wider than it is deep!
+    return {1.15F, 1.02F, 0.75F};  // Wide shoulders, narrow chest depth
   }
   }
 
 
   void getVariant(const DrawContext &ctx, uint32_t seed,
   void getVariant(const DrawContext &ctx, uint32_t seed,
@@ -97,6 +98,73 @@ public:
     v.palette = makeHumanoidPalette(team_tint, seed);
     v.palette = makeHumanoidPalette(team_tint, seed);
     auto const &style = resolve_style(ctx);
     auto const &style = resolve_style(ctx);
     apply_palette_overrides(style, team_tint, v);
     apply_palette_overrides(style, team_tint, v);
+
+    auto nextRand = [](uint32_t &s) -> float {
+      s = s * 1664525U + 1013904223U;
+      return float(s & 0x7FFFFFU) / float(0x7FFFFFU);
+    };
+
+    uint32_t beard_seed = seed ^ 0xBEAD01U;
+
+    float const beard_chance = nextRand(beard_seed);
+    bool const wants_beard = style.force_beard || (beard_chance < 0.85F);
+
+    if (wants_beard) {
+
+      float const style_roll = nextRand(beard_seed);
+
+      if (style_roll < 0.50F) {
+
+        v.facialHair.style = FacialHairStyle::FullBeard;
+        v.facialHair.length = 0.9F + nextRand(beard_seed) * 0.6F;
+      } else if (style_roll < 0.75F) {
+
+        v.facialHair.style = FacialHairStyle::LongBeard;
+        v.facialHair.length = 1.2F + nextRand(beard_seed) * 0.8F;
+      } else if (style_roll < 0.90F) {
+
+        v.facialHair.style = FacialHairStyle::ShortBeard;
+        v.facialHair.length = 0.8F + nextRand(beard_seed) * 0.4F;
+      } else {
+
+        v.facialHair.style = FacialHairStyle::Goatee;
+        v.facialHair.length = 0.9F + nextRand(beard_seed) * 0.5F;
+      }
+
+      float const color_roll = nextRand(beard_seed);
+      if (color_roll < 0.60F) {
+        // Dark brown/black (most common)
+        v.facialHair.color = QVector3D(0.18F + nextRand(beard_seed) * 0.10F,
+                                       0.14F + nextRand(beard_seed) * 0.08F,
+                                       0.10F + nextRand(beard_seed) * 0.06F);
+      } else if (color_roll < 0.85F) {
+        // Medium brown
+        v.facialHair.color = QVector3D(0.30F + nextRand(beard_seed) * 0.12F,
+                                       0.24F + nextRand(beard_seed) * 0.10F,
+                                       0.16F + nextRand(beard_seed) * 0.08F);
+      } else {
+        // Reddish-brown (Libyan/Berber influence)
+        v.facialHair.color = QVector3D(0.35F + nextRand(beard_seed) * 0.10F,
+                                       0.20F + nextRand(beard_seed) * 0.08F,
+                                       0.12F + nextRand(beard_seed) * 0.06F);
+      }
+
+      v.facialHair.thickness = 0.85F + nextRand(beard_seed) * 0.35F;
+      v.facialHair.coverage = 0.75F + nextRand(beard_seed) * 0.25F;
+
+      if (nextRand(beard_seed) < 0.10F) {
+        v.facialHair.greyness = 0.15F + nextRand(beard_seed) * 0.35F;
+      } else {
+        v.facialHair.greyness = 0.0F;
+      }
+    } else {
+
+      v.facialHair.style = FacialHairStyle::None;
+    }
+
+    v.muscularity = 0.95F + nextRand(beard_seed) * 0.25F;
+    v.scarring = nextRand(beard_seed) * 0.30F;
+    v.weathering = 0.40F + nextRand(beard_seed) * 0.40F;
   }
   }
 
 
   void customizePose(const DrawContext &,
   void customizePose(const DrawContext &,
@@ -330,86 +398,100 @@ public:
       return;
       return;
     }
     }
 
 
-    auto draw_montefortino = [&](const QVector3D &base_color) {
-      QVector3D bronze =
-          saturate_color(base_color * QVector3D(1.14F, 1.00F, 0.76F));
-      QVector3D tinned_highlight =
-          saturate_color(base_color * QVector3D(1.38F, 1.36F, 1.44F));
-      QVector3D patina =
-          saturate_color(base_color * QVector3D(0.92F, 1.05F, 1.04F));
-      QVector3D leather_band =
-          saturate_color(v.palette.leather * QVector3D(1.12F, 0.98F, 0.84F));
-
-      float const head_r = pose.headR;
-      QVector3D const bowl_center(0.0F, pose.headPos.y() + head_r * 0.72F,
-                                  0.0F);
-
-      QMatrix4x4 bowl = ctx.model;
-      bowl.translate(bowl_center);
-      bowl.scale(head_r * 1.08F, head_r * 1.24F, head_r * 1.02F);
-      bowl.rotate(-5.0F, 1.0F, 0.0F, 0.0F);
-      out.mesh(getUnitSphere(), bowl, bronze, nullptr, 1.0F);
-
-      QMatrix4x4 ridge = ctx.model;
-      ridge.translate(QVector3D(0.0F, pose.headPos.y() + head_r * 1.00F, 0.0F));
-      ridge.scale(head_r * 0.20F, head_r * 0.48F, head_r * 0.20F);
-      out.mesh(getUnitCone(), ridge, patina, nullptr, 1.0F);
-
-      QMatrix4x4 knob = ctx.model;
-      knob.translate(QVector3D(0.0F, pose.headPos.y() + head_r * 1.38F, 0.0F));
-      knob.scale(head_r * 0.22F, head_r * 0.32F, head_r * 0.22F);
-      out.mesh(getUnitSphere(), knob, tinned_highlight, nullptr, 1.0F);
-
-      QVector3D const brow_top(0.0F, pose.headPos.y() + head_r * 0.58F, 0.0F);
-      QVector3D const brow_bottom(0.0F, pose.headPos.y() + head_r * 0.46F,
-                                  0.0F);
-      QMatrix4x4 brow =
-          cylinderBetween(ctx.model, brow_bottom, brow_top, head_r * 1.20F);
-      brow.scale(1.04F, 1.0F, 0.86F);
-      out.mesh(getUnitCylinder(), brow, leather_band, nullptr, 1.0F);
-
-      QVector3D const rim_upper(0.0F, pose.headPos.y() + head_r * 0.44F, 0.0F);
-      QVector3D const rim_lower(0.0F, pose.headPos.y() + head_r * 0.34F, 0.0F);
-      QMatrix4x4 rim =
-          cylinderBetween(ctx.model, rim_lower, rim_upper, head_r * 1.30F);
-      rim.scale(1.06F, 1.0F, 0.90F);
-      out.mesh(getUnitCylinder(), rim, bronze * QVector3D(0.94F, 0.92F, 0.88F),
-               nullptr, 1.0F);
-
-      QVector3D const neck_base(0.0F, pose.headPos.y() + head_r * 0.32F,
-                                -head_r * 0.68F);
-      QVector3D const neck_drop(0.0F, pose.headPos.y() - head_r * 0.48F,
-                                -head_r * 0.96F);
-      QMatrix4x4 neck =
-          coneFromTo(ctx.model, neck_drop, neck_base, head_r * 1.34F);
-      neck.scale(1.0F, 1.0F, 0.94F);
-      out.mesh(getUnitCone(), neck, bronze * 0.96F, nullptr, 1.0F);
-
-      auto cheek_plate = [&](float sign) {
-        QVector3D const hinge(sign * head_r * 0.80F,
-                              pose.headPos.y() + head_r * 0.38F,
-                              head_r * 0.38F);
-        QVector3D const lobe =
-            hinge + QVector3D(sign * head_r * 0.20F, -head_r * 0.82F, 0.02F);
-        QMatrix4x4 cheek = coneFromTo(ctx.model, lobe, hinge, head_r * 0.46F);
-        cheek.scale(0.60F, 1.0F, 0.42F);
-        out.mesh(getUnitCone(), cheek, patina, nullptr, 1.0F);
-      };
-      cheek_plate(+1.0F);
-      cheek_plate(-1.0F);
-
-      QVector3D const crest_front(0.0F, pose.headPos.y() + head_r * 0.96F,
-                                  head_r * 0.82F);
-      QVector3D const crest_back(0.0F, pose.headPos.y() + head_r * 0.96F,
-                                 -head_r * 0.90F);
-      QMatrix4x4 crest =
-          cylinderBetween(ctx.model, crest_back, crest_front, head_r * 0.14F);
-      crest.scale(0.54F, 1.0F, 1.0F);
-      out.mesh(getUnitCylinder(), crest,
-               tinned_highlight * QVector3D(0.94F, 0.96F, 1.02F), nullptr,
-               1.0F);
+  auto draw_montefortino = [&](const QVector3D &base_metal) {
+    const HeadFrame &head = pose.headFrame;
+    float const head_r = head.radius;
+    if (head_r <= 0.0F) {
+    return;
+    }
+
+    auto headPoint = [&](const QVector3D &norm) -> QVector3D {
+    return headLocalPosition(head, norm);
+    };
+
+    auto headTransform = [&](const QVector3D &norm, float scale) -> QMatrix4x4 {
+    return makeHeadLocalTransform(ctx.model, head, norm, scale);
     };
     };
 
 
+    QVector3D bronze =
+      saturate_color(base_metal * QVector3D(1.22F, 1.04F, 0.70F));
+    QVector3D patina =
+      saturate_color(bronze * QVector3D(0.88F, 0.96F, 0.92F));
+    QVector3D tinned_highlight =
+      saturate_color(bronze * QVector3D(1.12F, 1.08F, 1.04F));
+    QVector3D leather_band =
+      saturate_color(v.palette.leatherDark * QVector3D(1.10F, 0.96F, 0.80F));
+
+    auto draw_leather_cap = [&]() {
+    QVector3D leather_brown = saturate_color(v.palette.leatherDark *
+                         QVector3D(1.15F, 0.95F, 0.78F));
+    QVector3D leather_dark =
+      saturate_color(leather_brown * QVector3D(0.85F, 0.88F, 0.92F));
+    QVector3D bronze_stud =
+      saturate_color(v.palette.metal * QVector3D(1.20F, 1.02F, 0.70F));
+
+    // Cap sits on crown in head-local coordinates (0,0,0) = head center
+    QMatrix4x4 cap_transform = headTransform(QVector3D(0.0F, 0.70F, 0.0F), 1.0F);
+    cap_transform.scale(0.92F, 0.55F, 0.88F); // Non-uniform scaling for cap shape
+    out.mesh(getUnitSphere(), cap_transform, leather_brown, nullptr, 1.0F);
+
+    // Band at forehead level in head-local coordinates
+    QVector3D const band_top = headPoint(QVector3D(0.0F, 0.20F, 0.0F));
+    QVector3D const band_bot = headPoint(QVector3D(0.0F, 0.15F, 0.0F));
+
+    out.mesh(getUnitCylinder(),
+         cylinderBetween(ctx.model, band_bot, band_top, head_r * 1.02F),
+         leather_dark, nullptr, 1.0F);
+
+    auto draw_stud = [&](float angle) {
+      QVector3D const stud_pos =
+        headPoint(QVector3D(std::sin(angle) * 1.03F, 0.175F,
+                  std::cos(angle) * 1.03F));
+      out.mesh(getUnitSphere(),
+           sphereAt(ctx.model, stud_pos, head_r * 0.012F),
+           bronze_stud, nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 4; ++i) {
+      float const angle = (i / 4.0F) * 2.0F * std::numbers::pi_v<float>;
+      draw_stud(angle);
+    }
+
+    };
+
+    draw_leather_cap();
+
+    // Top knob
+    QMatrix4x4 top_knob = headTransform(QVector3D(0.0F, 0.88F, 0.0F), 0.18F);
+    out.mesh(getUnitSphere(), top_knob, tinned_highlight, nullptr, 1.0F);
+
+    // Brow reinforcement
+    QVector3D const brow_top = headPoint(QVector3D(0.0F, 0.55F, 0.0F));
+    QVector3D const brow_bot = headPoint(QVector3D(0.0F, 0.42F, 0.0F));
+    QMatrix4x4 brow =
+      cylinderBetween(ctx.model, brow_bot, brow_top, head_r * 1.20F);
+    brow.scale(1.04F, 1.0F, 0.86F);
+    out.mesh(getUnitCylinder(), brow, leather_band, nullptr, 1.0F);
+
+    // Rim
+    QVector3D const rim_upper = headPoint(QVector3D(0.0F, 0.40F, 0.0F));
+    QVector3D const rim_lower = headPoint(QVector3D(0.0F, 0.30F, 0.0F));
+    QMatrix4x4 rim =
+      cylinderBetween(ctx.model, rim_lower, rim_upper, head_r * 1.30F);
+    rim.scale(1.06F, 1.0F, 0.90F);
+    out.mesh(getUnitCylinder(), rim, bronze * QVector3D(0.94F, 0.92F, 0.88F),
+         nullptr, 1.0F);
+
+    // Crest
+    QVector3D const crest_front = headPoint(QVector3D(0.0F, 0.92F, 0.82F));
+    QVector3D const crest_back = headPoint(QVector3D(0.0F, 0.92F, -0.90F));
+    QMatrix4x4 crest =
+      cylinderBetween(ctx.model, crest_back, crest_front, head_r * 0.14F);
+    crest.scale(0.54F, 1.0F, 1.0F);
+    out.mesh(getUnitCylinder(), crest,
+         tinned_highlight * QVector3D(0.94F, 0.96F, 1.02F), nullptr, 1.0F);
+  };
+
     draw_montefortino(v.palette.metal);
     draw_montefortino(v.palette.metal);
   }
   }
 
 
@@ -424,126 +506,60 @@ public:
       return;
       return;
     }
     }
 
 
+    // Carthaginian archers wore simple tunics, not heavy armor
+    // Light linen or wool tunic in natural/earthy colors
+    
     float const waist_y = pose.pelvisPos.y();
     float const waist_y = pose.pelvisPos.y();
-    float const cuirass_top = y_top_cover + 0.02F;
-    float const cuirass_bottom = waist_y - 0.10F;
-
-    QVector3D linen =
-        saturate_color(v.palette.cloth * QVector3D(1.12F, 1.04F, 0.88F));
-    QVector3D bronze =
-        saturate_color(v.palette.metal * QVector3D(1.18F, 1.02F, 0.74F));
-    QVector3D bronze_shadow =
-        saturate_color(bronze * QVector3D(0.90F, 0.94F, 0.98F));
-    QVector3D tinned =
-        saturate_color(v.palette.metal * QVector3D(1.36F, 1.36F, 1.42F));
-    QVector3D leather =
-        saturate_color(v.palette.leatherDark * QVector3D(1.06F, 0.98F, 0.84F));
-
-    QVector3D const tunic_top(0.0F, cuirass_top + 0.04F, 0.0F);
-    QVector3D const tunic_bot(0.0F, waist_y + 0.05F, 0.0F);
-    QMatrix4x4 tunic =
-        cylinderBetween(ctx.model, tunic_bot, tunic_top, torso_r * 0.94F);
-    tunic.scale(1.02F, 1.0F, 0.90F);
-    out.mesh(getUnitCylinder(), tunic, linen, nullptr, 1.0F);
-
-    constexpr int k_scale_rows = 6;
-    constexpr float k_tile_height = 0.085F;
-    constexpr float k_tile_width = 0.11F;
-    constexpr float k_tile_thickness = 0.020F;
-    constexpr float k_row_overlap = 0.032F;
-    constexpr float k_radial_push = 0.010F;
-
-    auto draw_scale_tile = [&](const QVector3D &center, float yaw,
-                               const QVector3D &color, float height,
-                               float width_scale) {
-      QVector3D const top = center + QVector3D(0.0F, height * 0.5F, 0.0F);
-      QVector3D const bot = center - QVector3D(0.0F, height * 0.5F, 0.0F);
-      float const radius = (k_tile_width * width_scale) * 0.5F;
-      QMatrix4x4 plate = cylinderBetween(ctx.model, bot, top, radius);
-      float const yaw_deg = yaw * (180.0F / std::numbers::pi_v<float>);
-      plate.rotate(yaw_deg, 0.0F, 1.0F, 0.0F);
-      plate.scale(0.92F, 1.0F,
-                  (k_tile_thickness / (k_tile_width * width_scale)));
-      out.mesh(getUnitCylinder(18), plate, color, nullptr, 1.0F);
-
-      QVector3D const lip_top = bot + QVector3D(0.0F, height * 0.22F, 0.0F);
-      QMatrix4x4 lip = cylinderBetween(ctx.model, bot, lip_top, radius * 0.92F);
-      lip.rotate(yaw_deg, 0.0F, 1.0F, 0.0F);
-      lip.scale(0.88F, 1.0F,
-                (k_tile_thickness / (k_tile_width * width_scale)) * 0.72F);
-      out.mesh(getUnitCylinder(16), lip, color * QVector3D(0.90F, 0.92F, 0.96F),
-               nullptr, 1.0F);
-    };
-
-    auto emit_scale_band = [&](float center_angle, float span, int columns,
-                               float radius_scale) {
-      float const start = center_angle - span * 0.5F;
-      float const step = columns > 1 ? span / float(columns - 1) : 0.0F;
-
-      for (int row = 0; row < k_scale_rows; ++row) {
-        float const row_y = cuirass_top - row * (k_tile_height - k_row_overlap);
-        float const layer_radius =
-            torso_r * radius_scale + row * (k_radial_push * radius_scale);
-        for (int col = 0; col < columns; ++col) {
-          float const angle = start + step * col;
-          QVector3D const radial(std::sin(angle), 0.0F, std::cos(angle));
-          QVector3D center(radial.x() * layer_radius, row_y,
-                           radial.z() * layer_radius);
-          center += radial * (row * 0.006F);
-          float const yaw = std::atan2(radial.x(), radial.z());
-          bool const tinned_tile = ((row + col) % 4) == 0;
-          QVector3D color =
-              tinned_tile ? tinned : (row % 2 == 0 ? bronze : bronze_shadow);
-          float const width_scale =
-              1.0F - 0.04F * std::abs((columns - 1) * 0.5F - col);
-          draw_scale_tile(center, yaw, color, k_tile_height,
-                          std::clamp(width_scale, 0.78F, 1.05F));
-        }
-      }
-    };
-
-    emit_scale_band(0.0F, 1.30F, 7, 1.08F);
-    emit_scale_band(std::numbers::pi_v<float>, 1.20F, 7, 1.04F);
-    emit_scale_band(std::numbers::pi_v<float> * 0.5F, 0.82F, 5, 1.02F);
-    emit_scale_band(-std::numbers::pi_v<float> * 0.5F, 0.82F, 5, 1.02F);
-
-    QVector3D const collar_top(0.0F, cuirass_top + 0.020F, 0.0F);
-    QVector3D const collar_bot(0.0F, cuirass_top - 0.010F, 0.0F);
-    QMatrix4x4 collar = cylinderBetween(ctx.model, collar_bot, collar_top,
-                                        HP::NECK_RADIUS * 1.90F);
-    collar.scale(1.04F, 1.0F, 0.90F);
-    out.mesh(getUnitCylinder(), collar, leather, nullptr, 1.0F);
-
-    QVector3D const waist_top(0.0F, waist_y + 0.05F, 0.0F);
-    QVector3D const waist_bot(0.0F, waist_y - 0.02F, 0.0F);
-    QMatrix4x4 waist =
-        cylinderBetween(ctx.model, waist_bot, waist_top, torso_r * 1.16F);
-    waist.scale(1.06F, 1.0F, 0.90F);
-    out.mesh(getUnitCylinder(), waist, leather, nullptr, 1.0F);
-
-    auto draw_pteruge = [&](float angle, float length) {
-      QVector3D const radial(std::sin(angle), 0.0F, std::cos(angle));
-      QVector3D const base =
-          QVector3D(radial.x() * torso_r * 1.18F, waist_y - 0.03F,
-                    radial.z() * torso_r * 1.18F);
-      QVector3D const tip =
-          base + QVector3D(radial.x() * 0.02F, -length, radial.z() * 0.02F);
-      QMatrix4x4 strap =
-          cylinderBetween(ctx.model, tip, base, k_tile_width * 0.18F);
-      float const yaw = std::atan2(radial.x(), radial.z());
-      strap.rotate(yaw * (180.0F / std::numbers::pi_v<float>), 0.0F, 1.0F,
-                   0.0F);
-      strap.scale(0.65F, 1.0F, 0.40F);
-      out.mesh(getUnitCylinder(12), strap,
-               leather * QVector3D(0.90F, 0.92F, 0.96F), nullptr, 1.0F);
-    };
-
-    constexpr int k_pteruge_count = 12;
-    for (int i = 0; i < k_pteruge_count; ++i) {
-      float const angle = (static_cast<float>(i) / k_pteruge_count) * 2.0F *
-                          std::numbers::pi_v<float>;
-      draw_pteruge(angle, 0.18F);
-    }
+    float const tunic_top = y_top_cover;
+    float const tunic_bottom = waist_y - 0.08F; // Short tunic ending just below waist
+
+    QVector3D tunic_color =
+        saturate_color(v.palette.cloth * QVector3D(1.05F, 1.02F, 0.92F));
+    QVector3D tunic_trim =
+        saturate_color(tunic_color * QVector3D(0.75F, 0.70F, 0.65F));
+    QVector3D leather_belt =
+        saturate_color(v.palette.leatherDark * QVector3D(1.08F, 0.94F, 0.78F));
+
+    // Main tunic body - simple cylinder
+    QVector3D const tunic_top_pos(0.0F, tunic_top, 0.0F);
+    QVector3D const tunic_bot_pos(0.0F, tunic_bottom, 0.0F);
+    
+    QMatrix4x4 tunic_main =
+        cylinderBetween(ctx.model, tunic_bot_pos, tunic_top_pos, torso_r * 1.06F);
+    tunic_main.scale(1.0F, 1.0F, 0.98F);
+    out.mesh(getUnitCylinder(), tunic_main, tunic_color, nullptr, 1.0F);
+
+    // Simple trim at neck
+    QVector3D const neck_trim_top(0.0F, tunic_top + 0.005F, 0.0F);
+    QVector3D const neck_trim_bot(0.0F, tunic_top - 0.015F, 0.0F);
+    QMatrix4x4 neck_trim =
+        cylinderBetween(ctx.model, neck_trim_bot, neck_trim_top, HP::NECK_RADIUS * 1.85F);
+    neck_trim.scale(1.03F, 1.0F, 0.92F);
+    out.mesh(getUnitCylinder(), neck_trim, tunic_trim, nullptr, 1.0F);
+
+    // Simple trim at bottom hem
+    QVector3D const hem_top(0.0F, tunic_bottom + 0.020F, 0.0F);
+    QVector3D const hem_bot(0.0F, tunic_bottom - 0.010F, 0.0F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, hem_bot, hem_top, torso_r * 1.05F),
+             tunic_trim, nullptr, 1.0F);
+
+    // Leather belt at waist
+    QVector3D const belt_top(0.0F, waist_y + 0.03F, 0.0F);
+    QVector3D const belt_bot(0.0F, waist_y - 0.03F, 0.0F);
+    QMatrix4x4 belt =
+        cylinderBetween(ctx.model, belt_bot, belt_top, torso_r * 1.08F);
+    belt.scale(1.04F, 1.0F, 0.94F);
+    out.mesh(getUnitCylinder(), belt, leather_belt, nullptr, 1.0F);
+
+    // Simple belt buckle
+    QVector3D const buckle_pos(0.0F, waist_y, torso_r * 1.10F);
+    QMatrix4x4 buckle = ctx.model;
+    buckle.translate(buckle_pos);
+    buckle.scale(0.025F, 0.035F, 0.012F);
+    QVector3D bronze_buckle =
+        saturate_color(v.palette.metal * QVector3D(1.15F, 1.00F, 0.68F));
+    out.mesh(getUnitSphere(), buckle, bronze_buckle, nullptr, 1.0F);
   }
   }
 
 
   void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
   void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
@@ -594,207 +610,214 @@ public:
   }
   }
 
 
 private:
 private:
-  mutable std::unordered_map<uint32_t, ArcherExtras> m_extrasCache;
+mutable std::unordered_map<uint32_t, ArcherExtras> m_extrasCache;
 
 
-  auto
-  resolve_style(const DrawContext &ctx) const -> const ArcherStyleConfig & {
-    ensure_archer_styles_registered();
-    auto &styles = style_registry();
-    std::string nation_id;
-    if (ctx.entity != nullptr) {
-      if (auto *unit =
-              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
-        nation_id = Game::Systems::nationIDToString(unit->nation_id);
-      }
-    }
-    if (!nation_id.empty()) {
-      auto it = styles.find(nation_id);
-      if (it != styles.end()) {
-        return it->second;
-      }
+auto resolve_style(const DrawContext &ctx) const -> const ArcherStyleConfig & {
+  ensure_archer_styles_registered();
+  auto &styles = style_registry();
+  std::string nation_id;
+  if (ctx.entity != nullptr) {
+    if (auto *unit = ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+      nation_id = Game::Systems::nationIDToString(unit->nation_id);
     }
     }
-    auto fallback = styles.find(std::string(k_default_style_key));
-    if (fallback != styles.end()) {
-      return fallback->second;
+  }
+  if (!nation_id.empty()) {
+    auto it = styles.find(nation_id);
+    if (it != styles.end()) {
+      return it->second;
     }
     }
-    static const ArcherStyleConfig default_style{};
-    return default_style;
   }
   }
+  auto fallback = styles.find(std::string(k_default_style_key));
+  if (fallback != styles.end()) {
+    return fallback->second;
+  }
+  static const ArcherStyleConfig default_style{};
+  return default_style;
+}
 
 
 public:
 public:
-  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
-    const ArcherStyleConfig &style = resolve_style(ctx);
-    if (!style.shader_id.empty()) {
-      return QString::fromStdString(style.shader_id);
-    }
-    return QStringLiteral("archer");
+auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+  const ArcherStyleConfig &style = resolve_style(ctx);
+  if (!style.shader_id.empty()) {
+    return QString::fromStdString(style.shader_id);
   }
   }
+  return QStringLiteral("archer");
+}
 
 
 private:
 private:
-  void apply_palette_overrides(const ArcherStyleConfig &style,
-                               const QVector3D &team_tint,
-                               HumanoidVariant &variant) const {
-    auto apply_color = [&](const std::optional<QVector3D> &override_color,
-                           QVector3D &target) {
-      target = mix_palette_color(target, override_color, team_tint,
-                                 k_team_mix_weight, k_style_mix_weight);
-    };
+void apply_palette_overrides(const ArcherStyleConfig &style,
+                             const QVector3D &team_tint,
+                             HumanoidVariant &variant) const {
+  auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                         QVector3D &target) {
+    target = mix_palette_color(target, override_color, team_tint,
+                               k_team_mix_weight, k_style_mix_weight);
+  };
+
+  apply_color(style.cloth_color, variant.palette.cloth);
+  apply_color(style.leather_color, variant.palette.leather);
+  apply_color(style.leather_dark_color, variant.palette.leatherDark);
+  apply_color(style.metal_color, variant.palette.metal);
+  apply_color(style.wood_color, variant.palette.wood);
+}
 
 
-    apply_color(style.cloth_color, variant.palette.cloth);
-    apply_color(style.leather_color, variant.palette.leather);
-    apply_color(style.leather_dark_color, variant.palette.leatherDark);
-    apply_color(style.metal_color, variant.palette.metal);
-    apply_color(style.wood_color, variant.palette.wood);
+void apply_extras_overrides(const ArcherStyleConfig &style,
+                            ArcherExtras &extras) const {
+  if (style.fletching_color) {
+    extras.fletch = saturate_color(*style.fletching_color);
   }
   }
-
-  void apply_extras_overrides(const ArcherStyleConfig &style,
-                              ArcherExtras &extras) const {
-    if (style.fletching_color) {
-      extras.fletch = saturate_color(*style.fletching_color);
-    }
-    if (style.bow_string_color) {
-      extras.stringCol = saturate_color(*style.bow_string_color);
-    }
+  if (style.bow_string_color) {
+    extras.stringCol = saturate_color(*style.bow_string_color);
   }
   }
+}
 
 
-  void draw_headwrap(const DrawContext &ctx, const HumanoidVariant &v,
-                     const HumanoidPose &pose, ISubmitter &out) const {
-    QVector3D const cloth_color =
-        saturate_color(v.palette.cloth * QVector3D(0.9F, 1.05F, 1.05F));
-    float const head_r = pose.headR;
-
-    QVector3D const band_top(0, pose.headPos.y() + head_r * 0.70F, 0);
-    QVector3D const band_bot(0, pose.headPos.y() + head_r * 0.30F, 0);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, band_bot, band_top, head_r * 1.08F),
-             cloth_color, nullptr, 1.0F);
-
-    QVector3D const knot_center(0.10F, pose.headPos.y() + head_r * 0.60F,
-                                head_r * 0.72F);
-    QMatrix4x4 knot_m = ctx.model;
-    knot_m.translate(knot_center);
-    knot_m.scale(head_r * 0.32F);
-    out.mesh(getUnitSphere(), knot_m, cloth_color * 1.05F, nullptr, 1.0F);
-
-    QVector3D const tail_top = knot_center + QVector3D(-0.08F, -0.05F, -0.06F);
-    QVector3D const tail_bot = tail_top + QVector3D(0.02F, -0.28F, -0.08F);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, tail_top, tail_bot, head_r * 0.28F),
-             cloth_color * QVector3D(0.92F, 0.98F, 1.05F), nullptr, 1.0F);
+void draw_headwrap(const DrawContext &ctx, const HumanoidVariant &v,
+                   const HumanoidPose &pose, ISubmitter &out) const {
+  QVector3D const cloth_color =
+      saturate_color(v.palette.cloth * QVector3D(0.9F, 1.05F, 1.05F));
+  const HeadFrame &head = pose.headFrame;
+  float const head_r = head.radius;
+  if (head_r <= 0.0F) {
+    return;
   }
   }
 
 
-  static void drawQuiver(const DrawContext &ctx, const HumanoidVariant &v,
-                         const HumanoidPose &pose, const ArcherExtras &extras,
-                         uint32_t seed, ISubmitter &out) {
-    using HP = HumanProportions;
+  auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
+    return headLocalPosition(head, normalized);
+  };
+
+  QVector3D const band_top = headPoint(QVector3D(0.0F, 0.70F, 0.0F));
+  QVector3D const band_bot = headPoint(QVector3D(0.0F, 0.30F, 0.0F));
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, band_bot, band_top, head_r * 1.08F),
+           cloth_color, nullptr, 1.0F);
+
+  QVector3D const knot_center =
+      headPoint(QVector3D(0.10F, 0.60F, 0.72F));
+  QMatrix4x4 knot_m = ctx.model;
+  knot_m.translate(knot_center);
+  knot_m.scale(head_r * 0.32F);
+  out.mesh(getUnitSphere(), knot_m, cloth_color * 1.05F, nullptr, 1.0F);
+
+  QVector3D const tail_top = knot_center + head.right * (-0.08F) +
+                             head.up * (-0.05F) + head.forward * (-0.06F);
+  QVector3D const tail_bot =
+      tail_top + head.right * 0.02F + head.up * (-0.28F) + head.forward * (-0.08F);
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, tail_top, tail_bot, head_r * 0.28F),
+           cloth_color * QVector3D(0.92F, 0.98F, 1.05F), nullptr, 1.0F);
+}
 
 
-    QVector3D const spine_mid = (pose.shoulderL + pose.shoulderR) * 0.5F;
-    QVector3D const quiver_offset(-0.08F, 0.10F, -0.25F);
-    QVector3D const q_top = spine_mid + quiver_offset;
-    QVector3D const q_base = q_top + QVector3D(-0.02F, -0.30F, 0.03F);
+static void drawQuiver(const DrawContext &ctx, const HumanoidVariant &v,
+                       const HumanoidPose &pose, const ArcherExtras &extras,
+                       uint32_t seed, ISubmitter &out) {
+  using HP = HumanProportions;
+
+  QVector3D const spine_mid = (pose.shoulderL + pose.shoulderR) * 0.5F;
+  QVector3D const quiver_offset(-0.08F, 0.10F, -0.25F);
+  QVector3D const q_top = spine_mid + quiver_offset;
+  QVector3D const q_base = q_top + QVector3D(-0.02F, -0.30F, 0.03F);
+
+  float const quiver_r = HP::HEAD_RADIUS * 0.45F;
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, q_base, q_top, quiver_r),
+           v.palette.leather, nullptr, 1.0F);
+
+  float const j = (hash_01(seed) - 0.5F) * 0.04F;
+  float const k = (hash_01(seed ^ HashXorShift::k_golden_ratio) - 0.5F) * 0.04F;
+
+  QVector3D const a1 = q_top + QVector3D(0.00F + j, 0.08F, 0.00F + k);
+  out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, q_top, a1, 0.010F),
+           v.palette.wood, nullptr, 1.0F);
+  out.mesh(getUnitCone(),
+           coneFromTo(ctx.model, a1, a1 + QVector3D(0, 0.05F, 0), 0.025F),
+           extras.fletch, nullptr, 1.0F);
+
+  QVector3D const a2 = q_top + QVector3D(0.02F - j, 0.07F, 0.02F - k);
+  out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, q_top, a2, 0.010F),
+           v.palette.wood, nullptr, 1.0F);
+  out.mesh(getUnitCone(),
+           coneFromTo(ctx.model, a2, a2 + QVector3D(0, 0.05F, 0), 0.025F),
+           extras.fletch, nullptr, 1.0F);
+}
 
 
-    float const quiver_r = HP::HEAD_RADIUS * 0.45F;
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, q_base, q_top, quiver_r),
-             v.palette.leather, nullptr, 1.0F);
+static void drawBowAndArrow(const DrawContext &ctx, const HumanoidPose &pose,
+                            const HumanoidVariant &v,
+                            const ArcherExtras &extras, bool is_attacking,
+                            float attack_phase, ISubmitter &out) {
+  const QVector3D up(0.0F, 1.0F, 0.0F);
+  const QVector3D forward(0.0F, 0.0F, 1.0F);
 
 
-    float const j = (hash_01(seed) - 0.5F) * 0.04F;
-    float const k =
-        (hash_01(seed ^ HashXorShift::k_golden_ratio) - 0.5F) * 0.04F;
+  QVector3D const grip = pose.handL;
 
 
-    QVector3D const a1 = q_top + QVector3D(0.00F + j, 0.08F, 0.00F + k);
-    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, q_top, a1, 0.010F),
-             v.palette.wood, nullptr, 1.0F);
-    out.mesh(getUnitCone(),
-             coneFromTo(ctx.model, a1, a1 + QVector3D(0, 0.05F, 0), 0.025F),
-             extras.fletch, nullptr, 1.0F);
+  float const bow_plane_z = 0.45F;
+  QVector3D const top_end(extras.bowX, extras.bowTopY, bow_plane_z);
+  QVector3D const bot_end(extras.bowX, extras.bowBotY, bow_plane_z);
 
 
-    QVector3D const a2 = q_top + QVector3D(0.02F - j, 0.07F, 0.02F - k);
-    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, q_top, a2, 0.010F),
-             v.palette.wood, nullptr, 1.0F);
-    out.mesh(getUnitCone(),
-             coneFromTo(ctx.model, a2, a2 + QVector3D(0, 0.05F, 0), 0.025F),
-             extras.fletch, nullptr, 1.0F);
-  }
+  QVector3D const nock(
+      extras.bowX,
+      clampf(pose.hand_r.y(), extras.bowBotY + 0.05F, extras.bowTopY - 0.05F),
+      clampf(pose.hand_r.z(), bow_plane_z - 0.30F, bow_plane_z + 0.30F));
 
 
-  static void drawBowAndArrow(const DrawContext &ctx, const HumanoidPose &pose,
-                              const HumanoidVariant &v,
-                              const ArcherExtras &extras, bool is_attacking,
-                              float attack_phase, ISubmitter &out) {
-    const QVector3D up(0.0F, 1.0F, 0.0F);
-    const QVector3D forward(0.0F, 0.0F, 1.0F);
-
-    QVector3D const grip = pose.handL;
-
-    float const bow_plane_z = 0.45F;
-    QVector3D const top_end(extras.bowX, extras.bowTopY, bow_plane_z);
-    QVector3D const bot_end(extras.bowX, extras.bowBotY, bow_plane_z);
-
-    QVector3D const nock(
-        extras.bowX,
-        clampf(pose.hand_r.y(), extras.bowBotY + 0.05F, extras.bowTopY - 0.05F),
-        clampf(pose.hand_r.z(), bow_plane_z - 0.30F, bow_plane_z + 0.30F));
-
-    constexpr int k_bowstring_segments = 22;
-    auto q_bezier = [](const QVector3D &a, const QVector3D &c,
-                       const QVector3D &b, float t) {
-      float const u = 1.0F - t;
-      return u * u * a + 2.0F * u * t * c + t * t * b;
-    };
+  constexpr int k_bowstring_segments = 22;
+  auto q_bezier = [](const QVector3D &a, const QVector3D &c, const QVector3D &b,
+                     float t) {
+    float const u = 1.0F - t;
+    return u * u * a + 2.0F * u * t * c + t * t * b;
+  };
 
 
-    float const bow_mid_y = (top_end.y() + bot_end.y()) * 0.5F;
+  float const bow_mid_y = (top_end.y() + bot_end.y()) * 0.5F;
 
 
-    float const ctrl_y = bow_mid_y + 0.45F;
+  float const ctrl_y = bow_mid_y + 0.45F;
 
 
-    QVector3D const ctrl(extras.bowX, ctrl_y,
-                         bow_plane_z + extras.bowDepth * 0.6F);
+  QVector3D const ctrl(extras.bowX, ctrl_y,
+                       bow_plane_z + extras.bowDepth * 0.6F);
 
 
-    QVector3D prev = bot_end;
-    for (int i = 1; i <= k_bowstring_segments; ++i) {
-      float const t = float(i) / float(k_bowstring_segments);
-      QVector3D const cur = q_bezier(bot_end, ctrl, top_end, t);
-      out.mesh(getUnitCylinder(),
-               cylinderBetween(ctx.model, prev, cur, extras.bowRodR),
-               v.palette.wood, nullptr, 1.0F);
-      prev = cur;
-    }
+  QVector3D prev = bot_end;
+  for (int i = 1; i <= k_bowstring_segments; ++i) {
+    float const t = float(i) / float(k_bowstring_segments);
+    QVector3D const cur = q_bezier(bot_end, ctrl, top_end, t);
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, grip - up * 0.05F, grip + up * 0.05F,
-                             extras.bowRodR * 1.45F),
+             cylinderBetween(ctx.model, prev, cur, extras.bowRodR),
              v.palette.wood, nullptr, 1.0F);
              v.palette.wood, nullptr, 1.0F);
-
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, top_end, nock, extras.stringR),
-             extras.stringCol, nullptr, 1.0F);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, nock, bot_end, extras.stringR),
-             extras.stringCol, nullptr, 1.0F);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, pose.hand_r, nock, 0.0045F),
-             extras.stringCol * 0.9F, nullptr, 1.0F);
-
-    bool const show_arrow =
-        !is_attacking ||
-        (is_attacking && attack_phase >= 0.0F && attack_phase < 0.52F);
-
-    if (show_arrow) {
-      QVector3D const tail = nock - forward * 0.06F;
-      QVector3D const tip = tail + forward * 0.90F;
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, tail, tip, 0.018F),
-               v.palette.wood, nullptr, 1.0F);
-      QVector3D const head_base = tip - forward * 0.10F;
-      out.mesh(getUnitCone(), coneFromTo(ctx.model, head_base, tip, 0.05F),
-               extras.metalHead, nullptr, 1.0F);
-      QVector3D const f1b = tail - forward * 0.02F;
-      QVector3D const f1a = f1b - forward * 0.06F;
-      QVector3D const f2b = tail + forward * 0.02F;
-      QVector3D const f2a = f2b + forward * 0.06F;
-      out.mesh(getUnitCone(), coneFromTo(ctx.model, f1b, f1a, 0.04F),
-               extras.fletch, nullptr, 1.0F);
-      out.mesh(getUnitCone(), coneFromTo(ctx.model, f2a, f2b, 0.04F),
-               extras.fletch, nullptr, 1.0F);
-    }
+    prev = cur;
+  }
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, grip - up * 0.05F, grip + up * 0.05F,
+                           extras.bowRodR * 1.45F),
+           v.palette.wood, nullptr, 1.0F);
+
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, top_end, nock, extras.stringR),
+           extras.stringCol, nullptr, 1.0F);
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, nock, bot_end, extras.stringR),
+           extras.stringCol, nullptr, 1.0F);
+  out.mesh(getUnitCylinder(),
+           cylinderBetween(ctx.model, pose.hand_r, nock, 0.0045F),
+           extras.stringCol * 0.9F, nullptr, 1.0F);
+
+  bool const show_arrow =
+      !is_attacking ||
+      (is_attacking && attack_phase >= 0.0F && attack_phase < 0.52F);
+
+  if (show_arrow) {
+    QVector3D const tail = nock - forward * 0.06F;
+    QVector3D const tip = tail + forward * 0.90F;
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, tail, tip, 0.018F),
+             v.palette.wood, nullptr, 1.0F);
+    QVector3D const head_base = tip - forward * 0.10F;
+    out.mesh(getUnitCone(), coneFromTo(ctx.model, head_base, tip, 0.05F),
+             extras.metalHead, nullptr, 1.0F);
+    QVector3D const f1b = tail - forward * 0.02F;
+    QVector3D const f1a = f1b - forward * 0.06F;
+    QVector3D const f2b = tail + forward * 0.02F;
+    QVector3D const f2a = f2b + forward * 0.06F;
+    out.mesh(getUnitCone(), coneFromTo(ctx.model, f1b, f1a, 0.04F),
+             extras.fletch, nullptr, 1.0F);
+    out.mesh(getUnitCone(), coneFromTo(ctx.model, f2a, f2b, 0.04F),
+             extras.fletch, nullptr, 1.0F);
   }
   }
+}
 };
 };
 
 
 void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
 void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
@@ -821,5 +844,4 @@ void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
         }
         }
       });
       });
 }
 }
-
-} // namespace Render::GL::Carthage
+}

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

@@ -30,6 +30,7 @@ void register_carthage_archer_style() {
   style.show_armor = true;
   style.show_armor = true;
   style.show_shoulder_decor = false;
   style.show_shoulder_decor = false;
   style.show_cape = false;
   style.show_cape = false;
+  style.force_beard = true;
   style.attachment_profile.clear();
   style.attachment_profile.clear();
   style.shader_id = "archer_carthage";
   style.shader_id = "archer_carthage";
 
 

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

@@ -21,6 +21,8 @@ struct ArcherStyleConfig {
   bool show_shoulder_decor = true;
   bool show_shoulder_decor = true;
   bool show_cape = true;
   bool show_cape = true;
 
 
+  bool force_beard = false;
+
   std::string attachment_profile;
   std::string attachment_profile;
   std::string shader_id;
   std::string shader_id;
 };
 };

+ 55 - 40
render/entity/nations/carthage/horse_swordsman_renderer.cpp

@@ -7,9 +7,9 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -274,57 +274,69 @@ public:
 
 
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
                   const HumanoidPose &pose, ISubmitter &out) const override {
                   const HumanoidPose &pose, ISubmitter &out) const override {
-    using HP = HumanProportions;
+    const HeadFrame &head = pose.headFrame;
+    float const head_r = head.radius;
+    if (head_r <= 0.0F) {
+      return;
+    }
 
 
-    auto ring = [&](const QVector3D &center, float r, float h,
-                    const QVector3D &col) {
-      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
-      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
-               nullptr, 1.0F);
+    const QVector3D steel_color = v.palette.metal * STEEL_TINT;
+    float const helm_r = head_r * 1.15F;
+
+    auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
+      return headLocalPosition(head, normalized);
     };
     };
 
 
-    const QVector3D steel_color = v.palette.metal * STEEL_TINT;
+    auto ring = [&](float y_offset, const QVector3D &col) {
+      QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
+      float const height = head_r * 0.015F;
+      QVector3D const a = center + head.up * (height * 0.5F);
+      QVector3D const b = center - head.up * (height * 0.5F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, a, b, helm_r * 1.02F), col,
+               nullptr, 1.0F);
+    };
 
 
-    float helm_r = pose.headR * 1.15F;
-    QVector3D const helm_bot(0, pose.headPos.y() - pose.headR * 0.20F, 0);
-    QVector3D const helm_top(0, pose.headPos.y() + pose.headR * 1.40F, 0);
+    QVector3D const helm_bot = headPoint(QVector3D(0.0F, -0.20F, 0.0F));
+    QVector3D const helm_top = headPoint(QVector3D(0.0F, 1.40F, 0.0F));
 
 
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
              steel_color, nullptr, 1.0F);
              steel_color, nullptr, 1.0F);
 
 
-    QVector3D const cap_top(0, pose.headPos.y() + pose.headR * 1.48F, 0);
+    QVector3D const cap_top = headPoint(QVector3D(0.0F, 1.48F, 0.0F));
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
              steel_color * 1.05F, nullptr, 1.0F);
              steel_color * 1.05F, nullptr, 1.0F);
 
 
     const QVector3D ring_color = steel_color * 1.08F;
     const QVector3D ring_color = steel_color * 1.08F;
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 1.25F, 0), helm_r * 1.02F,
-         0.015F, ring_color);
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.50F, 0), helm_r * 1.02F,
-         0.015F, ring_color);
-    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.05F, 0), helm_r * 1.02F,
-         0.015F, ring_color);
-
-    const float visor_y = pose.headPos.y() + pose.headR * 0.15F;
-    const float visor_z = helm_r * 0.72F;
+    ring(1.25F, ring_color);
+    ring(0.50F, ring_color);
+    ring(-0.05F, ring_color);
+
+    float const visor_forward = helm_r * 0.72F;
+    QVector3D const visor_center =
+        headPoint(QVector3D(0.0F, 0.15F, visor_forward / head_r));
+    QVector3D const lateral = head.right * (helm_r * 0.35F);
+    QVector3D const vertical = head.up * (helm_r * 0.25F);
     static const QVector3D visor_color(0.1F, 0.1F, 0.1F);
     static const QVector3D visor_color(0.1F, 0.1F, 0.1F);
 
 
-    QVector3D const visor_hl(-helm_r * 0.35F, visor_y, visor_z);
-    QVector3D const visor_hr(helm_r * 0.35F, visor_y, visor_z);
+    QVector3D const visor_hl = visor_center - lateral;
+    QVector3D const visor_hr = visor_center + lateral;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, visor_hl, visor_hr, 0.012F),
+             cylinderBetween(ctx.model, visor_hl, visor_hr, head_r * 0.012F),
              visor_color, nullptr, 1.0F);
              visor_color, nullptr, 1.0F);
 
 
-    QVector3D const visor_vt(0, visor_y + helm_r * 0.25F, visor_z);
-    QVector3D const visor_vb(0, visor_y - helm_r * 0.25F, visor_z);
+    QVector3D const visor_vt = visor_center + vertical;
+    QVector3D const visor_vb = visor_center - vertical;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, visor_vb, visor_vt, 0.012F),
+             cylinderBetween(ctx.model, visor_vb, visor_vt, head_r * 0.012F),
              visor_color, nullptr, 1.0F);
              visor_color, nullptr, 1.0F);
 
 
-    auto draw_breathing_hole = [&](float x, float y) {
-      QVector3D const pos(x, pose.headPos.y() + y, helm_r * 0.70F);
+    auto draw_breathing_hole = [&](float x_scale, float y_offset) {
+      QVector3D const pos = headPoint(
+          QVector3D(x_scale * (helm_r / head_r), y_offset / head_r,
+                    visor_forward / head_r * 0.97F));
       QMatrix4x4 m = ctx.model;
       QMatrix4x4 m = ctx.model;
       m.translate(pos);
       m.translate(pos);
       m.scale(0.010F);
       m.scale(0.010F);
@@ -332,14 +344,13 @@ public:
     };
     };
 
 
     for (int i = 0; i < 4; ++i) {
     for (int i = 0; i < 4; ++i) {
-      draw_breathing_hole(helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+      draw_breathing_hole(+0.50F, pose.headR * (0.05F - i * 0.10F));
     }
     }
-
     for (int i = 0; i < 4; ++i) {
     for (int i = 0; i < 4; ++i) {
-      draw_breathing_hole(-helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+      draw_breathing_hole(-0.50F, pose.headR * (0.05F - i * 0.10F));
     }
     }
 
 
-    const QVector3D plume_base(0, pose.headPos.y() + pose.headR * 1.50F, 0);
+    QVector3D const plume_base = headPoint(QVector3D(0.0F, 1.50F, 0.0F));
     const QVector3D brass_color = v.palette.metal * BRASS_TINT;
     const QVector3D brass_color = v.palette.metal * BRASS_TINT;
 
 
     QMatrix4x4 plume = ctx.model;
     QMatrix4x4 plume = ctx.model;
@@ -347,16 +358,19 @@ public:
     plume.scale(0.030F, 0.015F, 0.030F);
     plume.scale(0.030F, 0.015F, 0.030F);
     out.mesh(getUnitSphere(), plume, brass_color * 1.2F, nullptr, 1.0F);
     out.mesh(getUnitSphere(), plume, brass_color * 1.2F, nullptr, 1.0F);
 
 
+  QVector3D const plume_forward = head.forward * -0.020F;
+  QVector3D const plume_up = head.up;
+
     for (int i = 0; i < 5; ++i) {
     for (int i = 0; i < 5; ++i) {
       float const offset = i * 0.025F;
       float const offset = i * 0.025F;
-      QVector3D const feather_start =
-          plume_base + QVector3D(0, 0.005F, -0.020F + offset * 0.5F);
+      QVector3D const base = plume_base + plume_forward + head.right * (offset * 0.5F);
+      QVector3D const feather_start = base + plume_up * 0.005F;
       QVector3D const feather_end =
       QVector3D const feather_end =
-          feather_start +
-          QVector3D(0, 0.15F - i * 0.015F, -0.08F + offset * 0.3F);
+          feather_start + plume_up * (0.15F - i * 0.015F) +
+          head.forward * (-0.08F + offset * 0.3F);
 
 
       out.mesh(getUnitCylinder(),
       out.mesh(getUnitCylinder(),
-               cylinderBetween(ctx.model, feather_start, feather_end, 0.008F),
+               cylinderBetween(ctx.model, feather_start, feather_end, head_r * 0.008F),
                v.palette.cloth * (1.1F - i * 0.05F), nullptr, 1.0F);
                v.palette.cloth * (1.1F - i * 0.05F), nullptr, 1.0F);
     }
     }
   }
   }
@@ -795,7 +809,8 @@ void registerMountedKnightRenderer(
           }
           }
         }
         }
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) && (horse_swordsman_shader != nullptr)) {
+        if ((scene_renderer != nullptr) &&
+            (horse_swordsman_shader != nullptr)) {
           scene_renderer->setCurrentShader(horse_swordsman_shader);
           scene_renderer->setCurrentShader(horse_swordsman_shader);
         }
         }
         static_renderer.render(ctx, out);
         static_renderer.render(ctx, out);

+ 10 - 10
render/entity/nations/carthage/horse_swordsman_style.cpp

@@ -4,14 +4,14 @@
 static std::unordered_map<std::string, HorseSwordsmanStyleConfig> styles;
 static std::unordered_map<std::string, HorseSwordsmanStyleConfig> styles;
 
 
 void register_carthage_horse_swordsman_style() {
 void register_carthage_horse_swordsman_style() {
-    // Example: register a default style
-    HorseSwordsmanStyleConfig default_style;
-    default_style.cloth_color = QVector3D(0.8F, 0.7F, 0.6F);
-    default_style.leather_color = QVector3D(0.5F, 0.3F, 0.2F);
-    default_style.metal_color = QVector3D(0.7F, 0.7F, 0.8F);
-    default_style.shader_id = "horse_swordsman_carthage";
-    default_style.show_helmet = true;
-    default_style.show_armor = true;
-    default_style.has_cavalry_shield = true;
-    styles["default"] = default_style;
+
+  HorseSwordsmanStyleConfig default_style;
+  default_style.cloth_color = QVector3D(0.8F, 0.7F, 0.6F);
+  default_style.leather_color = QVector3D(0.5F, 0.3F, 0.2F);
+  default_style.metal_color = QVector3D(0.7F, 0.7F, 0.8F);
+  default_style.shader_id = "horse_swordsman_carthage";
+  default_style.show_helmet = true;
+  default_style.show_armor = true;
+  default_style.has_cavalry_shield = true;
+  styles["default"] = default_style;
 }
 }

+ 12 - 12
render/entity/nations/carthage/horse_swordsman_style.h

@@ -5,18 +5,18 @@
 #include <string>
 #include <string>
 
 
 struct HorseSwordsmanStyleConfig {
 struct HorseSwordsmanStyleConfig {
-    std::optional<QVector3D> cloth_color;
-    std::optional<QVector3D> leather_color;
-    std::optional<QVector3D> leather_dark_color;
-    std::optional<QVector3D> metal_color;
-    std::optional<QVector3D> wood_color;
-    std::optional<QVector3D> cape_color;
-    std::string shader_id;
-    bool show_helmet = true;
-    bool show_armor = true;
-    bool show_shoulder_decor = false;
-    bool show_cape = false;
-    bool has_cavalry_shield = false;
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> wood_color;
+  std::optional<QVector3D> cape_color;
+  std::string shader_id;
+  bool show_helmet = true;
+  bool show_armor = true;
+  bool show_shoulder_decor = false;
+  bool show_cape = false;
+  bool has_cavalry_shield = false;
 };
 };
 
 
 void register_carthage_horse_swordsman_style();
 void register_carthage_horse_swordsman_style();

+ 31 - 31
render/entity/nations/carthage/spearman_renderer.cpp

@@ -6,10 +6,10 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -263,55 +263,55 @@ public:
 
 
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
                   const HumanoidPose &pose, ISubmitter &out) const override {
                   const HumanoidPose &pose, ISubmitter &out) const override {
-    using HP = HumanProportions;
+    const HeadFrame &head = pose.headFrame;
+    float const head_r = head.radius;
+    if (head_r <= 0.0F) {
+      return;
+    }
 
 
     const QVector3D iron_color = v.palette.metal * IRON_TINT;
     const QVector3D iron_color = v.palette.metal * IRON_TINT;
+    float const helm_r = head_r * 1.12F;
 
 
-    const float helm_r = pose.headR * 1.12F;
+    auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
+      return headLocalPosition(head, normalized);
+    };
 
 
-    QVector3D const helm_bot(pose.headPos.x(),
-                             pose.headPos.y() - pose.headR * 0.15F,
-                             pose.headPos.z());
-    QVector3D const helm_top(pose.headPos.x(),
-                             pose.headPos.y() + pose.headR * 1.25F,
-                             pose.headPos.z());
+    QVector3D const helm_bot = headPoint(QVector3D(0.0F, -0.15F, 0.0F));
+    QVector3D const helm_top = headPoint(QVector3D(0.0F, 1.25F, 0.0F));
 
 
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r), iron_color,
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r), iron_color,
              nullptr, 1.0F);
              nullptr, 1.0F);
 
 
-    QVector3D const cap_top(pose.headPos.x(),
-                            pose.headPos.y() + pose.headR * 1.32F,
-                            pose.headPos.z());
+    QVector3D const cap_top = headPoint(QVector3D(0.0F, 1.32F, 0.0F));
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.96F),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.96F),
              iron_color * 1.04F, nullptr, 1.0F);
              iron_color * 1.04F, nullptr, 1.0F);
 
 
-    auto ring = [&](const QVector3D &center, float r, float h,
-                    const QVector3D &col) {
-      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
-      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+    auto ring = [&](float y_offset, float radius_scale, const QVector3D &col) {
+      QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
+      float const height = head_r * 0.012F;
+      QVector3D const a = center + head.up * (height * 0.5F);
+      QVector3D const b = center - head.up * (height * 0.5F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, a, b, helm_r * radius_scale), col,
                nullptr, 1.0F);
                nullptr, 1.0F);
     };
     };
 
 
-    ring(QVector3D(pose.headPos.x(), pose.headPos.y() + pose.headR * 0.95F,
-                   pose.headPos.z()),
-         helm_r * 1.01F, 0.012F, iron_color * 1.06F);
-    ring(QVector3D(pose.headPos.x(), pose.headPos.y() - pose.headR * 0.02F,
-                   pose.headPos.z()),
-         helm_r * 1.01F, 0.012F, iron_color * 1.06F);
+    ring(0.95F, 1.01F, iron_color * 1.06F);
+    ring(-0.02F, 1.01F, iron_color * 1.06F);
 
 
-    float const visor_y = pose.headPos.y() + pose.headR * 0.10F;
-    float const visor_z = pose.headPos.z() + helm_r * 0.68F;
+    float const visor_forward = helm_r * 0.68F;
 
 
     for (int i = 0; i < 3; ++i) {
     for (int i = 0; i < 3; ++i) {
-      float const y = visor_y + pose.headR * (0.18F - i * 0.12F);
-      QVector3D const visor_l(pose.headPos.x() - helm_r * 0.30F, y, visor_z);
-      QVector3D const visor_r(pose.headPos.x() + helm_r * 0.30F, y, visor_z);
+      float const y_offset = 0.10F + (0.18F - i * 0.12F);
+      QVector3D const center = headPoint(QVector3D(0.0F, y_offset, visor_forward / head_r));
+      QVector3D const lateral = head.right * (helm_r * 0.30F);
+      QVector3D const visor_l = center - lateral;
+      QVector3D const visor_r = center + lateral;
       out.mesh(getUnitCylinder(),
       out.mesh(getUnitCylinder(),
-               cylinderBetween(ctx.model, visor_l, visor_r, 0.010F), DARK_METAL,
-               nullptr, 1.0F);
+               cylinderBetween(ctx.model, visor_l, visor_r, head_r * 0.010F),
+               DARK_METAL, nullptr, 1.0F);
     }
     }
   }
   }
 
 

+ 56 - 44
render/entity/nations/carthage/swordsman_renderer.cpp

@@ -6,10 +6,10 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -57,7 +57,7 @@ void ensure_swordsman_styles_registered() {
 } // namespace
 } // namespace
 
 
 void register_swordsman_style(const std::string &nation_id,
 void register_swordsman_style(const std::string &nation_id,
-                           const KnightStyleConfig &style) {
+                              const KnightStyleConfig &style) {
   swordsman_style_registry()[nation_id] = style;
   swordsman_style_registry()[nation_id] = style;
 }
 }
 
 
@@ -212,56 +212,70 @@ public:
 
 
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
                   const HumanoidPose &pose, ISubmitter &out) const override {
                   const HumanoidPose &pose, ISubmitter &out) const override {
-    using HP = HumanProportions;
-
-    auto ring = [&](const QVector3D &center, float r, float h,
-                    const QVector3D &col) {
-      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
-      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
-               nullptr, 1.0F);
-    };
+    const HeadFrame &head = pose.headFrame;
+    float const head_r = head.radius;
+    if (head_r <= 0.0F) {
+      return;
+    }
 
 
     QVector3D const steel_color =
     QVector3D const steel_color =
         v.palette.metal * QVector3D(0.95F, 0.96F, 1.0F);
         v.palette.metal * QVector3D(0.95F, 0.96F, 1.0F);
+    float const helm_r = head_r * 1.15F;
 
 
-    float helm_r = pose.headR * 1.15F;
-    QVector3D const helm_bot(0, pose.headPos.y() - pose.headR * 0.20F, 0);
-    QVector3D const helm_top(0, pose.headPos.y() + pose.headR * 1.40F, 0);
+    auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
+      return headLocalPosition(head, normalized);
+    };
+
+    auto ring = [&](float y_offset, float radius_scale, const QVector3D &col) {
+      QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
+      float const height = head_r * 0.015F;
+      QVector3D const a = center + head.up * (height * 0.5F);
+      QVector3D const b = center - head.up * (height * 0.5F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, a, b, helm_r * radius_scale), col,
+               nullptr, 1.0F);
+    };
+
+    QVector3D const helm_bot = headPoint(QVector3D(0.0F, -0.20F, 0.0F));
+    QVector3D const helm_top = headPoint(QVector3D(0.0F, 1.40F, 0.0F));
 
 
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
              steel_color, nullptr, 1.0F);
              steel_color, nullptr, 1.0F);
 
 
-    QVector3D const cap_top(0, pose.headPos.y() + pose.headR * 1.48F, 0);
+    QVector3D const cap_top = headPoint(QVector3D(0.0F, 1.48F, 0.0F));
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
              steel_color * 1.05F, nullptr, 1.0F);
              steel_color * 1.05F, nullptr, 1.0F);
 
 
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 1.25F, 0), helm_r * 1.02F,
-         0.015F, steel_color * 1.08F);
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.50F, 0), helm_r * 1.02F,
-         0.015F, steel_color * 1.08F);
-    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.05F, 0), helm_r * 1.02F,
-         0.015F, steel_color * 1.08F);
+    ring(1.25F, 1.02F, steel_color * 1.08F);
+    ring(0.50F, 1.02F, steel_color * 1.08F);
+    ring(-0.05F, 1.02F, steel_color * 1.08F);
+
+    float const visor_forward = helm_r * 0.72F;
+    float const visor_y = 0.15F;
 
 
-    float const visor_y = pose.headPos.y() + pose.headR * 0.15F;
-    float const visor_z = helm_r * 0.72F;
+    QVector3D const visor_center =
+        headPoint(QVector3D(0.0F, visor_y, visor_forward / head_r));
+    QVector3D const lateral = head.right * (helm_r * 0.35F);
+    QVector3D const vertical = head.up * (helm_r * 0.25F);
 
 
-    QVector3D const visor_hl(-helm_r * 0.35F, visor_y, visor_z);
-    QVector3D const visor_hr(helm_r * 0.35F, visor_y, visor_z);
+    QVector3D const visor_hl = visor_center - lateral;
+    QVector3D const visor_hr = visor_center + lateral;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, visor_hl, visor_hr, 0.012F),
+             cylinderBetween(ctx.model, visor_hl, visor_hr, head_r * 0.012F),
              QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
              QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
 
 
-    QVector3D const visor_vt(0, visor_y + helm_r * 0.25F, visor_z);
-    QVector3D const visor_vb(0, visor_y - helm_r * 0.25F, visor_z);
+    QVector3D const visor_vt = visor_center + vertical;
+    QVector3D const visor_vb = visor_center - vertical;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, visor_vb, visor_vt, 0.012F),
+             cylinderBetween(ctx.model, visor_vb, visor_vt, head_r * 0.012F),
              QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
              QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
 
 
-    auto draw_breathing_hole = [&](float x, float y) {
-      QVector3D const pos(x, pose.headPos.y() + y, helm_r * 0.70F);
+    auto draw_breathing_hole = [&](float x_scale, float y_offset) {
+      QVector3D const pos = headPoint(
+          QVector3D(x_scale * (helm_r / head_r), y_offset / head_r,
+                    visor_forward / head_r * 0.97F));
       QMatrix4x4 m = ctx.model;
       QMatrix4x4 m = ctx.model;
       m.translate(pos);
       m.translate(pos);
       m.scale(0.010F);
       m.scale(0.010F);
@@ -269,27 +283,25 @@ public:
     };
     };
 
 
     for (int i = 0; i < 4; ++i) {
     for (int i = 0; i < 4; ++i) {
-      draw_breathing_hole(helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+      draw_breathing_hole(+0.50F, pose.headR * (0.05F - i * 0.10F));
     }
     }
-
     for (int i = 0; i < 4; ++i) {
     for (int i = 0; i < 4; ++i) {
-      draw_breathing_hole(-helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+      draw_breathing_hole(-0.50F, pose.headR * (0.05F - i * 0.10F));
     }
     }
 
 
-    QVector3D const cross_center(0, pose.headPos.y() + pose.headR * 0.60F,
-                                 helm_r * 0.75F);
     QVector3D const brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
     QVector3D const brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
-
-    QVector3D const cross_h1 = cross_center + QVector3D(-0.04F, 0, 0);
-    QVector3D const cross_h2 = cross_center + QVector3D(0.04F, 0, 0);
+    QVector3D const cross_center =
+        headPoint(QVector3D(0.0F, 0.60F, (helm_r * 0.75F) / head_r));
+    QVector3D const cross_h1 = cross_center - head.right * 0.04F;
+    QVector3D const cross_h2 = cross_center + head.right * 0.04F;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cross_h1, cross_h2, 0.008F),
+             cylinderBetween(ctx.model, cross_h1, cross_h2, head_r * 0.008F),
              brass_color, nullptr, 1.0F);
              brass_color, nullptr, 1.0F);
 
 
-    QVector3D const cross_v1 = cross_center + QVector3D(0, -0.04F, 0);
-    QVector3D const cross_v2 = cross_center + QVector3D(0, 0.04F, 0);
+    QVector3D const cross_v1 = cross_center - head.up * 0.04F;
+    QVector3D const cross_v2 = cross_center + head.up * 0.04F;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cross_v1, cross_v2, 0.008F),
+             cylinderBetween(ctx.model, cross_v1, cross_v2, head_r * 0.008F),
              brass_color, nullptr, 1.0F);
              brass_color, nullptr, 1.0F);
   }
   }
 
 

+ 1 - 1
render/entity/nations/carthage/swordsman_renderer.h

@@ -8,7 +8,7 @@ namespace Render::GL::Carthage {
 struct KnightStyleConfig;
 struct KnightStyleConfig;
 
 
 void register_swordsman_style(const std::string &nation_id,
 void register_swordsman_style(const std::string &nation_id,
-                           const KnightStyleConfig &style);
+                              const KnightStyleConfig &style);
 
 
 void registerKnightRenderer(EntityRendererRegistry &registry);
 void registerKnightRenderer(EntityRendererRegistry &registry);
 
 

+ 151 - 185
render/entity/nations/kingdom/archer_renderer.cpp

@@ -8,10 +8,10 @@
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/render_constants.h"
 #include "../../../gl/render_constants.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -329,81 +329,63 @@ public:
       return;
       return;
     }
     }
 
 
-    QVector3D const helmet_color =
-        v.palette.metal * QVector3D(1.08F, 0.98F, 0.78F);
-    QVector3D const helmet_accent = helmet_color * 1.12F;
-
-    QVector3D const helmet_top(0, pose.headPos.y() + pose.headR * 1.28F, 0);
-    QVector3D const helmet_bot(0, pose.headPos.y() + pose.headR * 0.08F, 0);
-    float const helmet_r = pose.headR * 1.10F;
-
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, helmet_bot, helmet_top, helmet_r),
-             helmet_color, nullptr, 1.0F);
+    const HeadFrame &head = pose.headFrame;
+    float const head_r = head.radius;
+    if (head_r <= 0.0F) {
+      return;
+    }
 
 
-    QVector3D const apex_pos(0, pose.headPos.y() + pose.headR * 1.48F, 0);
-    out.mesh(getUnitCone(),
-             coneFromTo(ctx.model, helmet_top, apex_pos, helmet_r * 0.97F),
-             helmet_accent, nullptr, 1.0F);
+    QVector3D const steel_color =
+        v.palette.metal * QVector3D(0.88F, 0.90F, 0.95F);
+    QVector3D const steel_dark = steel_color * 0.82F;
 
 
-    auto ring = [&](const QVector3D &center, float r, float h,
-                    const QVector3D &col) {
-      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
-      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
-               nullptr, 1.0F);
+    auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
+      return headLocalPosition(head, normalized);
     };
     };
 
 
-    QVector3D const brow_pos(0, pose.headPos.y() + pose.headR * 0.35F, 0);
-    ring(brow_pos, helmet_r * 1.07F, 0.020F, helmet_accent);
-
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.65F, 0),
-         helmet_r * 1.03F, 0.015F, helmet_color * 1.05F);
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.95F, 0),
-         helmet_r * 1.01F, 0.012F, helmet_color * 1.03F);
+    float const bowl_scale = 1.06F;
+    QVector3D const bowl_top =
+        headPoint(QVector3D(0.0F, 1.10F, 0.0F));
+    QVector3D const bowl_bot =
+        headPoint(QVector3D(0.0F, 0.15F, 0.0F));
+    float const bowl_r = head_r * bowl_scale;
 
 
-    float const cheek_w = pose.headR * 0.48F;
-    QVector3D const cheek_top(0, pose.headPos.y() + pose.headR * 0.22F, 0);
-    QVector3D const cheek_bot(0, pose.headPos.y() - pose.headR * 0.42F, 0);
-
-    QVector3D const cheek_ltop =
-        cheek_top + QVector3D(-cheek_w, 0, pose.headR * 0.38F);
-    QVector3D const cheek_lbot =
-        cheek_bot + QVector3D(-cheek_w * 0.82F, 0, pose.headR * 0.28F);
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cheek_lbot, cheek_ltop, 0.028F),
-             helmet_color * 0.96F, nullptr, 1.0F);
+             cylinderBetween(ctx.model, bowl_bot, bowl_top, bowl_r),
+             steel_color, nullptr, 1.0F);
 
 
-    QVector3D const cheek_rtop =
-        cheek_top + QVector3D(cheek_w, 0, pose.headR * 0.38F);
-    QVector3D const cheek_rbot =
-        cheek_bot + QVector3D(cheek_w * 0.82F, 0, pose.headR * 0.28F);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cheek_rbot, cheek_rtop, 0.028F),
-             helmet_color * 0.96F, nullptr, 1.0F);
+    QMatrix4x4 cap_m = ctx.model;
+    cap_m.translate(bowl_top);
+    cap_m.scale(bowl_r * 0.92F, head_r * 0.28F, bowl_r * 0.92F);
+    out.mesh(getUnitSphere(), cap_m, steel_color * 1.05F, nullptr, 1.0F);
 
 
-    QVector3D const neck_guard_top(0, pose.headPos.y() + pose.headR * 0.03F,
-                                   -pose.headR * 0.82F);
-    QVector3D const neck_guard_bot(0, pose.headPos.y() - pose.headR * 0.32F,
-                                   -pose.headR * 0.88F);
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, neck_guard_bot, neck_guard_top,
-                             helmet_r * 0.88F),
-             helmet_color * 0.93F, nullptr, 1.0F);
-
-    QVector3D const crest_base = apex_pos;
-    QVector3D const crest_mid = crest_base + QVector3D(0, 0.09F, 0);
-    QVector3D const crest_top = crest_mid + QVector3D(0, 0.12F, 0);
+    QVector3D const brim_top =
+        headPoint(QVector3D(0.0F, 0.18F, 0.0F));
+    QVector3D const brim_bot =
+        headPoint(QVector3D(0.0F, 0.08F, 0.0F));
+    float const brim_r = head_r * 1.42F;
 
 
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, crest_base, crest_mid, 0.018F),
-             helmet_accent, nullptr, 1.0F);
+             cylinderBetween(ctx.model, brim_bot, brim_top, brim_r),
+             steel_dark, nullptr, 1.0F);
+
+    auto ring = [&](float y_offset, float radius_scale,
+                    const QVector3D &col) {
+      QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
+      float const height = head_r * 0.010F;
+      QVector3D const a = center + head.up * (height * 0.5F);
+      QVector3D const b = center - head.up * (height * 0.5F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, a, b, head_r * radius_scale), col,
+               nullptr, 1.0F);
+    };
 
 
-    out.mesh(getUnitCone(), coneFromTo(ctx.model, crest_mid, crest_top, 0.042F),
-             QVector3D(0.88F, 0.18F, 0.18F), nullptr, 1.0F);
+    ring(0.13F, 1.42F * 1.01F / head_r, steel_color);
 
 
-    out.mesh(getUnitSphere(), sphereAt(ctx.model, crest_top, 0.020F),
-             helmet_accent, nullptr, 1.0F);
+    QMatrix4x4 rivet_m = ctx.model;
+    rivet_m.translate(headPoint(QVector3D(0.0F, 1.15F, 0.0F)));
+    rivet_m.scale(0.015F);
+    out.mesh(getUnitSphere(), rivet_m, steel_color * 1.15F, nullptr, 1.0F);
   }
   }
 
 
   void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
   void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
@@ -417,59 +399,59 @@ public:
       return;
       return;
     }
     }
 
 
-    auto ring = [&](const QVector3D &center, float r, float h,
-                    const QVector3D &col) {
-      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
-      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
-               nullptr, 1.0F);
-    };
-
-    QVector3D mail_color = v.palette.metal * QVector3D(0.85F, 0.87F, 0.92F);
-    QVector3D leather_trim = v.palette.leatherDark * 0.90F;
+    QVector3D const gambeson_base =
+        v.palette.cloth * QVector3D(0.92F, 0.88F, 0.75F);
+    QVector3D const gambeson_dark = gambeson_base * 0.85F;
+    QVector3D const leather_trim = v.palette.leatherDark * 0.88F;
+    QVector3D const green_tunic =
+        v.palette.cloth * QVector3D(0.45F, 0.75F, 0.52F);
 
 
     float const waist_y = pose.pelvisPos.y();
     float const waist_y = pose.pelvisPos.y();
 
 
-    QVector3D const mail_top(0, y_top_cover + 0.01F, 0);
-    QVector3D const mail_mid(0, (y_top_cover + waist_y) * 0.5F, 0);
-    QVector3D const mail_bot(0, waist_y + 0.08F, 0);
-    float const r_top = torso_r * 1.10F;
-    float const r_mid = torso_r * 1.08F;
+    QVector3D const gambeson_top(0, y_top_cover, 0);
+    QVector3D const gambeson_mid(0, (y_top_cover + waist_y) * 0.55F, 0);
+    QVector3D const gambeson_bot(0, waist_y + 0.05F, 0);
+    float const r_top = torso_r * 1.14F;
+    float const r_mid = torso_r * 1.16F;
 
 
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, mail_top, mail_mid, r_top), mail_color,
-             nullptr, 1.0F);
+             cylinderBetween(ctx.model, gambeson_top, gambeson_mid, r_top),
+             gambeson_base, nullptr, 1.0F);
 
 
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, mail_mid, mail_bot, r_mid),
-             mail_color * 0.95F, nullptr, 1.0F);
+             cylinderBetween(ctx.model, gambeson_mid, gambeson_bot, r_mid),
+             gambeson_dark, nullptr, 1.0F);
 
 
-    for (int i = 0; i < 3; ++i) {
-      float const y = mail_top.y() - (i * 0.12F);
-      ring(QVector3D(0, y, 0), r_top * (1.01F + i * 0.005F), 0.012F,
-           leather_trim);
+    auto stitch_ring = [&](float y, float r, const QVector3D &col) {
+      QVector3D const a = QVector3D(0, y + 0.005F, 0);
+      QVector3D const b = QVector3D(0, y - 0.005F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 6; ++i) {
+      float const y = gambeson_top.y() - (i * 0.08F);
+      if (y > waist_y) {
+        stitch_ring(y, r_top * (1.005F + i * 0.002F), gambeson_base * 0.78F);
+      }
     }
     }
 
 
-    auto draw_pauldron = [&](const QVector3D &shoulder,
-                             const QVector3D &outward) {
-      for (int i = 0; i < 3; ++i) {
-        float const seg_y = shoulder.y() + 0.02F - i * 0.035F;
-        float const seg_r = upper_arm_r * (2.2F - i * 0.15F);
-        QVector3D seg_top(shoulder.x(), seg_y + 0.025F, shoulder.z());
-        QVector3D seg_bot(shoulder.x(), seg_y - 0.010F, shoulder.z());
+    auto draw_padded_sleeve = [&](const QVector3D &shoulder,
+                                  const QVector3D &elbow,
+                                  const QVector3D &outward) {
+      for (int i = 0; i < 2; ++i) {
+        float const seg_y = shoulder.y() - i * 0.04F;
+        float const seg_r = upper_arm_r * (1.55F - i * 0.08F);
+        QVector3D seg_top(shoulder.x(), seg_y + 0.022F, shoulder.z());
+        QVector3D seg_bot(shoulder.x(), seg_y - 0.018F, shoulder.z());
 
 
-        seg_top += outward * 0.02F;
-        seg_bot += outward * 0.02F;
+        seg_top += outward * 0.015F;
+        seg_bot += outward * 0.015F;
 
 
         out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_top, seg_r),
         out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_top, seg_r),
-                 mail_color * (1.0F - i * 0.05F), nullptr, 1.0F);
+                 gambeson_base * (1.0F - i * 0.06F), nullptr, 1.0F);
       }
       }
-    };
 
 
-    draw_pauldron(pose.shoulderL, -right_axis);
-    draw_pauldron(pose.shoulderR, right_axis);
-
-    auto draw_manica = [&](const QVector3D &shoulder, const QVector3D &elbow) {
       QVector3D dir = (elbow - shoulder);
       QVector3D dir = (elbow - shoulder);
       float const len = dir.length();
       float const len = dir.length();
       if (len < 1e-5F) {
       if (len < 1e-5F) {
@@ -477,65 +459,40 @@ public:
       }
       }
       dir /= len;
       dir /= len;
 
 
-      for (int i = 0; i < 4; ++i) {
-        float const t0 = 0.08F + i * 0.18F;
-        float const t1 = t0 + 0.16F;
+      for (int i = 0; i < 3; ++i) {
+        float const t0 = 0.10F + i * 0.20F;
+        float const t1 = t0 + 0.18F;
         QVector3D const a = shoulder + dir * (t0 * len);
         QVector3D const a = shoulder + dir * (t0 * len);
         QVector3D const b = shoulder + dir * (t1 * len);
         QVector3D const b = shoulder + dir * (t1 * len);
-        float const r = upper_arm_r * (1.25F - i * 0.03F);
+        float const r = upper_arm_r * (1.28F - i * 0.04F);
         out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
         out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
-                 mail_color * (0.95F - i * 0.03F), nullptr, 1.0F);
+                 gambeson_base * (0.96F - i * 0.04F), nullptr, 1.0F);
       }
       }
     };
     };
 
 
-    draw_manica(pose.shoulderL, pose.elbowL);
-    draw_manica(pose.shoulderR, pose.elbowR);
+    draw_padded_sleeve(pose.shoulderL, pose.elbowL, -right_axis);
+    draw_padded_sleeve(pose.shoulderR, pose.elbowR, right_axis);
+
+    QVector3D const belt_top(0, waist_y + 0.04F, 0);
+    QVector3D const belt_bot(0, waist_y - 0.03F, 0);
+    float const belt_r = torso_r * 1.18F;
 
 
-    QVector3D const belt_top(0, waist_y + 0.06F, 0);
-    QVector3D const belt_bot(0, waist_y - 0.02F, 0);
-    float const belt_r = torso_r * 1.12F;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, belt_top, belt_bot, belt_r),
              cylinderBetween(ctx.model, belt_top, belt_bot, belt_r),
              leather_trim, nullptr, 1.0F);
              leather_trim, nullptr, 1.0F);
 
 
-    QVector3D const brass_color =
-        v.palette.metal * QVector3D(1.2F, 1.0F, 0.65F);
-    ring(QVector3D(0, waist_y + 0.02F, 0), belt_r * 1.02F, 0.010F, brass_color);
-
-    auto draw_pteruge = [&](float angle, float yStart, float length) {
-      float const rad = torso_r * 1.15F;
-      float const x = rad * std::sin(angle);
-      float const z = rad * std::cos(angle);
-      QVector3D const top(x, yStart, z);
-      QVector3D const bot(x * 0.95F, yStart - length, z * 0.95F);
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, top, bot, 0.018F),
-               leather_trim * 0.85F, nullptr, 1.0F);
-    };
+    QVector3D const iron_color =
+        v.palette.metal * QVector3D(0.60F, 0.62F, 0.68F);
+    QMatrix4x4 buckle_m = ctx.model;
+    buckle_m.translate(QVector3D(0, waist_y, torso_r * 1.22F));
+    buckle_m.scale(0.032F, 0.020F, 0.010F);
+    out.mesh(getUnitCylinder(), buckle_m, iron_color, nullptr, 1.0F);
 
 
-    float const shoulder_pteruge_y = y_top_cover - 0.02F;
-    constexpr int k_shoulder_pteruge_count = 8;
-    constexpr float k_shoulder_pteruge_divisor = 8.0F;
-    for (int i = 0; i < k_shoulder_pteruge_count; ++i) {
-      float const angle =
-          (i / k_shoulder_pteruge_divisor) * 2.0F * std::numbers::pi_v<float>;
-      draw_pteruge(angle, shoulder_pteruge_y, 0.14F);
-    }
-
-    float const waist_pteruge_y = waist_y - 0.04F;
-    constexpr int k_waist_pteruge_count = 10;
-    constexpr float k_waist_pteruge_divisor = 10.0F;
-    for (int i = 0; i < k_waist_pteruge_count; ++i) {
-      float const angle =
-          (i / k_waist_pteruge_divisor) * 2.0F * std::numbers::pi_v<float>;
-      draw_pteruge(angle, waist_pteruge_y, 0.18F);
-    }
-
-    QVector3D const collar_top(0, y_top_cover + 0.018F, 0);
-    QVector3D const collar_bot(0, y_top_cover - 0.008F, 0);
+    QVector3D const tunic_top = gambeson_bot;
+    QVector3D const tunic_bot(0, waist_y - 0.02F, 0);
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, collar_top, collar_bot,
-                             HP::NECK_RADIUS * 1.8F),
-             mail_color * 1.05F, nullptr, 1.0F);
+             cylinderBetween(ctx.model, tunic_bot, tunic_top, r_mid * 1.02F),
+             green_tunic, nullptr, 1.0F);
   }
   }
 
 
   void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
   void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
@@ -545,44 +502,43 @@ public:
     using HP = HumanProportions;
     using HP = HumanProportions;
 
 
     auto const &style = resolve_style(ctx);
     auto const &style = resolve_style(ctx);
+
     if (!style.show_shoulder_decor && !style.show_cape) {
     if (!style.show_shoulder_decor && !style.show_cape) {
       return;
       return;
     }
     }
 
 
-    QVector3D brass_color = v.palette.metal * QVector3D(1.2F, 1.0F, 0.65F);
-
-    auto draw_phalera = [&](const QVector3D &pos) {
-      QMatrix4x4 m = ctx.model;
-      m.translate(pos);
-      m.scale(0.025F);
-      out.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
-    };
-
     if (style.show_shoulder_decor) {
     if (style.show_shoulder_decor) {
-      draw_phalera(pose.shoulderL + QVector3D(0, 0.05F, 0.02F));
-      draw_phalera(pose.shoulderR + QVector3D(0, 0.05F, 0.02F));
-    }
+      QVector3D const hood_color =
+          v.palette.cloth * QVector3D(0.75F, 0.68F, 0.58F);
 
 
-    if (!style.show_cape) {
-      return;
+      QVector3D const collar_top(0, y_neck + 0.01F, 0);
+      QVector3D const collar_bot(0, y_neck - 0.03F, 0);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, collar_bot, collar_top,
+                               HP::NECK_RADIUS * 1.35F),
+               hood_color, nullptr, 1.0F);
     }
     }
 
 
-    QVector3D const clasp_pos(0, y_neck + 0.02F, 0.08F);
-    QMatrix4x4 clasp_m = ctx.model;
-    clasp_m.translate(clasp_pos);
-    clasp_m.scale(0.020F);
-    out.mesh(getUnitSphere(), clasp_m, brass_color * 1.1F, nullptr, 1.0F);
-
-    QVector3D const cape_top = clasp_pos + QVector3D(0, -0.02F, -0.05F);
-    QVector3D const cape_bot = clasp_pos + QVector3D(0, -0.25F, -0.15F);
-    QVector3D cape_fabric = v.palette.cloth * QVector3D(1.2F, 0.3F, 0.3F);
-    if (style.cape_color) {
-      cape_fabric = saturate_color(*style.cape_color);
-    }
+    if (style.show_cape) {
+      QVector3D cloak_color = v.palette.cloth * QVector3D(0.48F, 0.62F, 0.52F);
+      if (style.cape_color) {
+        cloak_color = saturate_color(*style.cape_color);
+      }
 
 
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cape_top, cape_bot, 0.025F),
-             cape_fabric * 0.85F, nullptr, 1.0F);
+      QVector3D const toggle_pos(0, y_neck, 0.06F);
+      QMatrix4x4 toggle_m = ctx.model;
+      toggle_m.translate(toggle_pos);
+      toggle_m.scale(0.012F, 0.025F, 0.012F);
+      out.mesh(getUnitCylinder(), toggle_m, v.palette.wood * 0.75F, nullptr,
+               1.0F);
+
+      QVector3D const cloak_top = toggle_pos + QVector3D(0, -0.01F, -0.04F);
+      QVector3D const cloak_bot = cloak_top + QVector3D(0, -0.22F, -0.12F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, cloak_top, cloak_bot, 0.022F),
+               cloak_color * 0.88F, nullptr, 1.0F);
+    }
   }
   }
 
 
 private:
 private:
@@ -653,23 +609,33 @@ private:
                      const HumanoidPose &pose, ISubmitter &out) const {
                      const HumanoidPose &pose, ISubmitter &out) const {
     QVector3D const cloth_color =
     QVector3D const cloth_color =
         saturate_color(v.palette.cloth * QVector3D(0.9F, 1.05F, 1.05F));
         saturate_color(v.palette.cloth * QVector3D(0.9F, 1.05F, 1.05F));
-    float const head_r = pose.headR;
+    const HeadFrame &head = pose.headFrame;
+    float const head_r = head.radius;
+    if (head_r <= 0.0F) {
+      return;
+    }
+
+    auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
+      return headLocalPosition(head, normalized);
+    };
 
 
-    QVector3D const band_top(0, pose.headPos.y() + head_r * 0.70F, 0);
-    QVector3D const band_bot(0, pose.headPos.y() + head_r * 0.30F, 0);
+    QVector3D const band_top = headPoint(QVector3D(0.0F, 0.70F, 0.0F));
+    QVector3D const band_bot = headPoint(QVector3D(0.0F, 0.30F, 0.0F));
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, band_bot, band_top, head_r * 1.08F),
              cylinderBetween(ctx.model, band_bot, band_top, head_r * 1.08F),
              cloth_color, nullptr, 1.0F);
              cloth_color, nullptr, 1.0F);
 
 
-    QVector3D const knot_center(0.10F, pose.headPos.y() + head_r * 0.60F,
-                                head_r * 0.72F);
+    QVector3D const knot_center =
+        headPoint(QVector3D(0.10F, 0.60F, 0.72F));
     QMatrix4x4 knot_m = ctx.model;
     QMatrix4x4 knot_m = ctx.model;
     knot_m.translate(knot_center);
     knot_m.translate(knot_center);
     knot_m.scale(head_r * 0.32F);
     knot_m.scale(head_r * 0.32F);
     out.mesh(getUnitSphere(), knot_m, cloth_color * 1.05F, nullptr, 1.0F);
     out.mesh(getUnitSphere(), knot_m, cloth_color * 1.05F, nullptr, 1.0F);
 
 
-    QVector3D const tail_top = knot_center + QVector3D(-0.08F, -0.05F, -0.06F);
-    QVector3D const tail_bot = tail_top + QVector3D(0.02F, -0.28F, -0.08F);
+    QVector3D const tail_top = knot_center + head.right * (-0.08F) +
+                               head.up * (-0.05F) + head.forward * (-0.06F);
+    QVector3D const tail_bot =
+        tail_top + head.right * 0.02F + head.up * (-0.28F) + head.forward * (-0.08F);
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, tail_top, tail_bot, head_r * 0.28F),
              cylinderBetween(ctx.model, tail_top, tail_bot, head_r * 0.28F),
              cloth_color * QVector3D(0.92F, 0.98F, 1.05F), nullptr, 1.0F);
              cloth_color * QVector3D(0.92F, 0.98F, 1.05F), nullptr, 1.0F);

+ 3 - 2
render/entity/nations/kingdom/horse_swordsman_renderer.cpp

@@ -7,9 +7,9 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -795,7 +795,8 @@ void registerMountedKnightRenderer(
           }
           }
         }
         }
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) && (horse_swordsman_shader != nullptr)) {
+        if ((scene_renderer != nullptr) &&
+            (horse_swordsman_shader != nullptr)) {
           scene_renderer->setCurrentShader(horse_swordsman_shader);
           scene_renderer->setCurrentShader(horse_swordsman_shader);
         }
         }
         static_renderer.render(ctx, out);
         static_renderer.render(ctx, out);

+ 9 - 9
render/entity/nations/kingdom/horse_swordsman_style.cpp

@@ -4,13 +4,13 @@
 static std::unordered_map<std::string, HorseSwordsmanStyleConfig> styles;
 static std::unordered_map<std::string, HorseSwordsmanStyleConfig> styles;
 
 
 void register_kingdom_horse_swordsman_style() {
 void register_kingdom_horse_swordsman_style() {
-    HorseSwordsmanStyleConfig default_style;
-    default_style.cloth_color = QVector3D(0.7F, 0.7F, 0.9F);
-    default_style.leather_color = QVector3D(0.4F, 0.3F, 0.2F);
-    default_style.metal_color = QVector3D(0.8F, 0.8F, 0.7F);
-    default_style.shader_id = "horse_swordsman_kingdom";
-    default_style.show_helmet = true;
-    default_style.show_armor = true;
-    default_style.has_cavalry_shield = true;
-    styles["default"] = default_style;
+  HorseSwordsmanStyleConfig default_style;
+  default_style.cloth_color = QVector3D(0.7F, 0.7F, 0.9F);
+  default_style.leather_color = QVector3D(0.4F, 0.3F, 0.2F);
+  default_style.metal_color = QVector3D(0.8F, 0.8F, 0.7F);
+  default_style.shader_id = "horse_swordsman_kingdom";
+  default_style.show_helmet = true;
+  default_style.show_armor = true;
+  default_style.has_cavalry_shield = true;
+  styles["default"] = default_style;
 }
 }

+ 12 - 12
render/entity/nations/kingdom/horse_swordsman_style.h

@@ -5,18 +5,18 @@
 #include <string>
 #include <string>
 
 
 struct HorseSwordsmanStyleConfig {
 struct HorseSwordsmanStyleConfig {
-    std::optional<QVector3D> cloth_color;
-    std::optional<QVector3D> leather_color;
-    std::optional<QVector3D> leather_dark_color;
-    std::optional<QVector3D> metal_color;
-    std::optional<QVector3D> wood_color;
-    std::optional<QVector3D> cape_color;
-    std::string shader_id;
-    bool show_helmet = true;
-    bool show_armor = true;
-    bool show_shoulder_decor = false;
-    bool show_cape = false;
-    bool has_cavalry_shield = false;
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> wood_color;
+  std::optional<QVector3D> cape_color;
+  std::string shader_id;
+  bool show_helmet = true;
+  bool show_armor = true;
+  bool show_shoulder_decor = false;
+  bool show_cape = false;
+  bool has_cavalry_shield = false;
 };
 };
 
 
 void register_kingdom_horse_swordsman_style();
 void register_kingdom_horse_swordsman_style();

+ 34 - 31
render/entity/nations/kingdom/spearman_renderer.cpp

@@ -6,10 +6,10 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -265,53 +265,56 @@ public:
                   const HumanoidPose &pose, ISubmitter &out) const override {
                   const HumanoidPose &pose, ISubmitter &out) const override {
     using HP = HumanProportions;
     using HP = HumanProportions;
 
 
-    const QVector3D iron_color = v.palette.metal * IRON_TINT;
+    const HeadFrame &head = pose.headFrame;
+    float const head_r = head.radius;
+    if (head_r <= 0.0F) {
+      return;
+    }
+
+    QVector3D const iron_color = v.palette.metal * IRON_TINT;
+    float const helm_r = head_r * 1.12F;
 
 
-    const float helm_r = pose.headR * 1.12F;
+    auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
+      return headLocalPosition(head, normalized);
+    };
 
 
-    QVector3D const helm_bot(pose.headPos.x(),
-                             pose.headPos.y() - pose.headR * 0.15F,
-                             pose.headPos.z());
-    QVector3D const helm_top(pose.headPos.x(),
-                             pose.headPos.y() + pose.headR * 1.25F,
-                             pose.headPos.z());
+    QVector3D const helm_bot = headPoint(QVector3D(0.0F, -0.15F, 0.0F));
+    QVector3D const helm_top = headPoint(QVector3D(0.0F, 1.25F, 0.0F));
 
 
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r), iron_color,
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r), iron_color,
              nullptr, 1.0F);
              nullptr, 1.0F);
 
 
-    QVector3D const cap_top(pose.headPos.x(),
-                            pose.headPos.y() + pose.headR * 1.32F,
-                            pose.headPos.z());
+    QVector3D const cap_top = headPoint(QVector3D(0.0F, 1.32F, 0.0F));
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.96F),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.96F),
              iron_color * 1.04F, nullptr, 1.0F);
              iron_color * 1.04F, nullptr, 1.0F);
 
 
-    auto ring = [&](const QVector3D &center, float r, float h,
-                    const QVector3D &col) {
-      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
-      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+    auto ring = [&](float y_offset, const QVector3D &col) {
+      QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
+      float const height = head_r * 0.012F;
+      QVector3D const a = center + head.up * (height * 0.5F);
+      QVector3D const b = center - head.up * (height * 0.5F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, a, b, helm_r * 1.01F), col,
                nullptr, 1.0F);
                nullptr, 1.0F);
     };
     };
 
 
-    ring(QVector3D(pose.headPos.x(), pose.headPos.y() + pose.headR * 0.95F,
-                   pose.headPos.z()),
-         helm_r * 1.01F, 0.012F, iron_color * 1.06F);
-    ring(QVector3D(pose.headPos.x(), pose.headPos.y() - pose.headR * 0.02F,
-                   pose.headPos.z()),
-         helm_r * 1.01F, 0.012F, iron_color * 1.06F);
+    ring(0.95F, iron_color * 1.06F);
+    ring(-0.02F, iron_color * 1.06F);
 
 
-    float const visor_y = pose.headPos.y() + pose.headR * 0.10F;
-    float const visor_z = pose.headPos.z() + helm_r * 0.68F;
+    float const visor_forward = helm_r * 0.68F;
 
 
     for (int i = 0; i < 3; ++i) {
     for (int i = 0; i < 3; ++i) {
-      float const y = visor_y + pose.headR * (0.18F - i * 0.12F);
-      QVector3D const visor_l(pose.headPos.x() - helm_r * 0.30F, y, visor_z);
-      QVector3D const visor_r(pose.headPos.x() + helm_r * 0.30F, y, visor_z);
+      float const y_offset = 0.10F + (0.18F - i * 0.12F);
+      QVector3D const center = headPoint(
+          QVector3D(0.0F, y_offset, visor_forward / head_r));
+      QVector3D const lateral = head.right * (helm_r * 0.30F);
+      QVector3D const visor_l = center - lateral;
+      QVector3D const visor_r = center + lateral;
       out.mesh(getUnitCylinder(),
       out.mesh(getUnitCylinder(),
-               cylinderBetween(ctx.model, visor_l, visor_r, 0.010F), DARK_METAL,
-               nullptr, 1.0F);
+               cylinderBetween(ctx.model, visor_l, visor_r, head_r * 0.010F),
+               DARK_METAL, nullptr, 1.0F);
     }
     }
   }
   }
 
 

+ 59 - 44
render/entity/nations/kingdom/swordsman_renderer.cpp

@@ -6,10 +6,10 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -57,7 +57,7 @@ void ensure_swordsman_styles_registered() {
 } // namespace
 } // namespace
 
 
 void register_swordsman_style(const std::string &nation_id,
 void register_swordsman_style(const std::string &nation_id,
-                           const KnightStyleConfig &style) {
+                              const KnightStyleConfig &style) {
   swordsman_style_registry()[nation_id] = style;
   swordsman_style_registry()[nation_id] = style;
 }
 }
 
 
@@ -212,56 +212,74 @@ public:
 
 
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
                   const HumanoidPose &pose, ISubmitter &out) const override {
                   const HumanoidPose &pose, ISubmitter &out) const override {
-    using HP = HumanProportions;
+    const HeadFrame &head = pose.headFrame;
+    float const head_r = head.radius;
+    if (head_r <= 0.0F) {
+      return;
+    }
 
 
-    auto ring = [&](const QVector3D &center, float r, float h,
+    QVector3D const steel_color =
+        v.palette.metal * QVector3D(0.95F, 0.96F, 1.0F);
+    QVector3D const brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
+
+    float const helm_r = head_r * 1.15F;
+    float const helm_ratio = helm_r / head_r;
+
+    auto headPoint = [&](const QVector3D &normalized) -> QVector3D {
+      return headLocalPosition(head, normalized);
+    };
+
+    auto ring = [&](float y_offset, float radius_scale,
                     const QVector3D &col) {
                     const QVector3D &col) {
-      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
-      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
-      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+      QVector3D const center = headPoint(QVector3D(0.0F, y_offset, 0.0F));
+      float const height = head_r * 0.015F;
+      QVector3D const a = center + head.up * (height * 0.5F);
+      QVector3D const b = center - head.up * (height * 0.5F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, a, b, helm_r * radius_scale), col,
                nullptr, 1.0F);
                nullptr, 1.0F);
     };
     };
 
 
-    QVector3D const steel_color =
-        v.palette.metal * QVector3D(0.95F, 0.96F, 1.0F);
-
-    float helm_r = pose.headR * 1.15F;
-    QVector3D const helm_bot(0, pose.headPos.y() - pose.headR * 0.20F, 0);
-    QVector3D const helm_top(0, pose.headPos.y() + pose.headR * 1.40F, 0);
+    QVector3D const helm_bot = headPoint(QVector3D(0.0F, -0.20F, 0.0F));
+    QVector3D const helm_top = headPoint(QVector3D(0.0F, 1.40F, 0.0F));
 
 
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
              cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
              steel_color, nullptr, 1.0F);
              steel_color, nullptr, 1.0F);
 
 
-    QVector3D const cap_top(0, pose.headPos.y() + pose.headR * 1.48F, 0);
+    QVector3D const cap_top = headPoint(QVector3D(0.0F, 1.48F, 0.0F));
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
              cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
              steel_color * 1.05F, nullptr, 1.0F);
              steel_color * 1.05F, nullptr, 1.0F);
 
 
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 1.25F, 0), helm_r * 1.02F,
-         0.015F, steel_color * 1.08F);
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.50F, 0), helm_r * 1.02F,
-         0.015F, steel_color * 1.08F);
-    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.05F, 0), helm_r * 1.02F,
-         0.015F, steel_color * 1.08F);
+    ring(1.25F, 1.02F, steel_color * 1.08F);
+    ring(0.50F, 1.02F, steel_color * 1.08F);
+    ring(-0.05F, 1.02F, steel_color * 1.08F);
+
+    float const visor_y = 0.15F;
+    float const visor_forward = helm_r * 0.72F;
+    float const visor_forward_norm = visor_forward / head_r;
 
 
-    float const visor_y = pose.headPos.y() + pose.headR * 0.15F;
-    float const visor_z = helm_r * 0.72F;
+    QVector3D const visor_center =
+        headPoint(QVector3D(0.0F, visor_y, visor_forward_norm));
+    QVector3D const lateral = head.right * (helm_r * 0.35F);
+    QVector3D const vertical = head.up * (helm_r * 0.25F);
 
 
-    QVector3D const visor_hl(-helm_r * 0.35F, visor_y, visor_z);
-    QVector3D const visor_hr(helm_r * 0.35F, visor_y, visor_z);
+    QVector3D const visor_hl = visor_center - lateral;
+    QVector3D const visor_hr = visor_center + lateral;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, visor_hl, visor_hr, 0.012F),
+             cylinderBetween(ctx.model, visor_hl, visor_hr, head_r * 0.012F),
              QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
              QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
 
 
-    QVector3D const visor_vt(0, visor_y + helm_r * 0.25F, visor_z);
-    QVector3D const visor_vb(0, visor_y - helm_r * 0.25F, visor_z);
+    QVector3D const visor_vt = visor_center + vertical;
+    QVector3D const visor_vb = visor_center - vertical;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, visor_vb, visor_vt, 0.012F),
+             cylinderBetween(ctx.model, visor_vb, visor_vt, head_r * 0.012F),
              QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
              QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
 
 
-    auto draw_breathing_hole = [&](float x, float y) {
-      QVector3D const pos(x, pose.headPos.y() + y, helm_r * 0.70F);
+    auto draw_breathing_hole = [&](float x_norm, float y_norm) {
+      QVector3D const pos = headPoint(QVector3D(x_norm * helm_ratio, y_norm,
+                                                visor_forward_norm * 0.97F));
       QMatrix4x4 m = ctx.model;
       QMatrix4x4 m = ctx.model;
       m.translate(pos);
       m.translate(pos);
       m.scale(0.010F);
       m.scale(0.010F);
@@ -269,27 +287,24 @@ public:
     };
     };
 
 
     for (int i = 0; i < 4; ++i) {
     for (int i = 0; i < 4; ++i) {
-      draw_breathing_hole(helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+      draw_breathing_hole(+0.50F, 0.05F - i * 0.10F);
     }
     }
-
     for (int i = 0; i < 4; ++i) {
     for (int i = 0; i < 4; ++i) {
-      draw_breathing_hole(-helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+      draw_breathing_hole(-0.50F, 0.05F - i * 0.10F);
     }
     }
 
 
-    QVector3D const cross_center(0, pose.headPos.y() + pose.headR * 0.60F,
-                                 helm_r * 0.75F);
-    QVector3D const brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
-
-    QVector3D const cross_h1 = cross_center + QVector3D(-0.04F, 0, 0);
-    QVector3D const cross_h2 = cross_center + QVector3D(0.04F, 0, 0);
+    QVector3D const cross_center =
+        headPoint(QVector3D(0.0F, 0.60F, helm_ratio * 0.75F));
+    QVector3D const cross_h1 = cross_center - head.right * 0.04F;
+    QVector3D const cross_h2 = cross_center + head.right * 0.04F;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cross_h1, cross_h2, 0.008F),
+             cylinderBetween(ctx.model, cross_h1, cross_h2, head_r * 0.008F),
              brass_color, nullptr, 1.0F);
              brass_color, nullptr, 1.0F);
 
 
-    QVector3D const cross_v1 = cross_center + QVector3D(0, -0.04F, 0);
-    QVector3D const cross_v2 = cross_center + QVector3D(0, 0.04F, 0);
+    QVector3D const cross_v1 = cross_center - head.up * 0.04F;
+    QVector3D const cross_v2 = cross_center + head.up * 0.04F;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cross_v1, cross_v2, 0.008F),
+             cylinderBetween(ctx.model, cross_v1, cross_v2, head_r * 0.008F),
              brass_color, nullptr, 1.0F);
              brass_color, nullptr, 1.0F);
   }
   }
 
 

+ 1 - 1
render/entity/nations/kingdom/swordsman_renderer.h

@@ -8,7 +8,7 @@ namespace Render::GL::Kingdom {
 struct KnightStyleConfig;
 struct KnightStyleConfig;
 
 
 void register_swordsman_style(const std::string &nation_id,
 void register_swordsman_style(const std::string &nation_id,
-                           const KnightStyleConfig &style);
+                              const KnightStyleConfig &style);
 
 
 void registerKnightRenderer(EntityRendererRegistry &registry);
 void registerKnightRenderer(EntityRendererRegistry &registry);
 
 

+ 61 - 21
render/entity/nations/roman/archer_renderer.cpp

@@ -8,10 +8,10 @@
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/render_constants.h"
 #include "../../../gl/render_constants.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -425,29 +425,64 @@ public:
                nullptr, 1.0F);
                nullptr, 1.0F);
     };
     };
 
 
+    uint32_t seed = 0U;
+    if (ctx.entity != nullptr) {
+      seed = reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU;
+    }
+
+    bool const use_scale_armor = (hash_01(seed ^ 0x9876U) > 0.50F);
+
     QVector3D mail_color = v.palette.metal * QVector3D(0.85F, 0.87F, 0.92F);
     QVector3D mail_color = v.palette.metal * QVector3D(0.85F, 0.87F, 0.92F);
+    QVector3D scale_color = v.palette.metal * QVector3D(0.95F, 0.80F, 0.55F);
     QVector3D leather_trim = v.palette.leatherDark * 0.90F;
     QVector3D leather_trim = v.palette.leatherDark * 0.90F;
+    QVector3D red_tunic = QVector3D(0.72F, 0.18F, 0.15F);
 
 
     float const waist_y = pose.pelvisPos.y();
     float const waist_y = pose.pelvisPos.y();
 
 
-    QVector3D const mail_top(0, y_top_cover + 0.01F, 0);
-    QVector3D const mail_mid(0, (y_top_cover + waist_y) * 0.5F, 0);
-    QVector3D const mail_bot(0, waist_y + 0.08F, 0);
-    float const r_top = torso_r * 1.10F;
-    float const r_mid = torso_r * 1.08F;
+    QVector3D const armor_top(0, y_top_cover + 0.01F, 0);
+    QVector3D const armor_mid(0, (y_top_cover + waist_y) * 0.5F, 0);
+    QVector3D const armor_bot(0, waist_y + 0.08F, 0);
+    float const r_top = torso_r * 1.12F;
+    float const r_mid = torso_r * 1.10F;
 
 
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, mail_top, mail_mid, r_top), mail_color,
-             nullptr, 1.0F);
+    QVector3D armor_color = use_scale_armor ? scale_color : mail_color;
 
 
-    out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, mail_mid, mail_bot, r_mid),
-             mail_color * 0.95F, nullptr, 1.0F);
+    if (use_scale_armor) {
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, armor_top, armor_mid, r_top),
+               scale_color, nullptr, 1.0F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, armor_mid, armor_bot, r_mid),
+               scale_color * 0.92F, nullptr, 1.0F);
+
+      for (int i = 0; i < 8; ++i) {
+        float const y = armor_top.y() - (i * 0.06F);
+        if (y > armor_bot.y()) {
+          ring(QVector3D(0, y, 0), r_top * (1.00F + i * 0.002F), 0.008F,
+               scale_color * (1.05F - i * 0.03F));
+        }
+      }
 
 
-    for (int i = 0; i < 3; ++i) {
-      float const y = mail_top.y() - (i * 0.12F);
-      ring(QVector3D(0, y, 0), r_top * (1.01F + i * 0.005F), 0.012F,
+      ring(QVector3D(0, armor_top.y() - 0.01F, 0), r_top * 1.02F, 0.012F,
            leather_trim);
            leather_trim);
+
+    } else {
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, armor_top, armor_mid, r_top),
+               mail_color, nullptr, 1.0F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, armor_mid, armor_bot, r_mid),
+               mail_color * 0.95F, nullptr, 1.0F);
+
+      for (int i = 0; i < 3; ++i) {
+        float const y = armor_top.y() - (i * 0.12F);
+        ring(QVector3D(0, y, 0), r_top * (1.01F + i * 0.005F), 0.012F,
+             leather_trim);
+      }
     }
     }
 
 
     auto draw_pauldron = [&](const QVector3D &shoulder,
     auto draw_pauldron = [&](const QVector3D &shoulder,
@@ -462,7 +497,7 @@ public:
         seg_bot += outward * 0.02F;
         seg_bot += outward * 0.02F;
 
 
         out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_top, seg_r),
         out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_top, seg_r),
-                 mail_color * (1.0F - i * 0.05F), nullptr, 1.0F);
+                 armor_color * (1.0F - i * 0.05F), nullptr, 1.0F);
       }
       }
     };
     };
 
 
@@ -484,7 +519,7 @@ public:
         QVector3D const b = shoulder + dir * (t1 * len);
         QVector3D const b = shoulder + dir * (t1 * len);
         float const r = upper_arm_r * (1.25F - i * 0.03F);
         float const r = upper_arm_r * (1.25F - i * 0.03F);
         out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
         out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
-                 mail_color * (0.95F - i * 0.03F), nullptr, 1.0F);
+                 armor_color * (0.95F - i * 0.03F), nullptr, 1.0F);
       }
       }
     };
     };
 
 
@@ -493,7 +528,7 @@ public:
 
 
     QVector3D const belt_top(0, waist_y + 0.06F, 0);
     QVector3D const belt_top(0, waist_y + 0.06F, 0);
     QVector3D const belt_bot(0, waist_y - 0.02F, 0);
     QVector3D const belt_bot(0, waist_y - 0.02F, 0);
-    float const belt_r = torso_r * 1.12F;
+    float const belt_r = torso_r * 1.14F;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, belt_top, belt_bot, belt_r),
              cylinderBetween(ctx.model, belt_top, belt_bot, belt_r),
              leather_trim, nullptr, 1.0F);
              leather_trim, nullptr, 1.0F);
@@ -503,7 +538,7 @@ public:
     ring(QVector3D(0, waist_y + 0.02F, 0), belt_r * 1.02F, 0.010F, brass_color);
     ring(QVector3D(0, waist_y + 0.02F, 0), belt_r * 1.02F, 0.010F, brass_color);
 
 
     auto draw_pteruge = [&](float angle, float yStart, float length) {
     auto draw_pteruge = [&](float angle, float yStart, float length) {
-      float const rad = torso_r * 1.15F;
+      float const rad = torso_r * 1.17F;
       float const x = rad * std::sin(angle);
       float const x = rad * std::sin(angle);
       float const z = rad * std::cos(angle);
       float const z = rad * std::cos(angle);
       QVector3D const top(x, yStart, z);
       QVector3D const top(x, yStart, z);
@@ -535,7 +570,12 @@ public:
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, collar_top, collar_bot,
              cylinderBetween(ctx.model, collar_top, collar_bot,
                              HP::NECK_RADIUS * 1.8F),
                              HP::NECK_RADIUS * 1.8F),
-             mail_color * 1.05F, nullptr, 1.0F);
+             armor_color * 1.05F, nullptr, 1.0F);
+
+    QVector3D const tunic_peek(0, armor_bot.y() - 0.01F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, tunic_peek, armor_bot, r_mid * 1.01F),
+             red_tunic, nullptr, 1.0F);
   }
   }
 
 
   void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
   void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,

+ 3 - 2
render/entity/nations/roman/horse_swordsman_renderer.cpp

@@ -7,9 +7,9 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -795,7 +795,8 @@ void registerMountedKnightRenderer(
           }
           }
         }
         }
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);
         auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) && (horse_swordsman_shader != nullptr)) {
+        if ((scene_renderer != nullptr) &&
+            (horse_swordsman_shader != nullptr)) {
           scene_renderer->setCurrentShader(horse_swordsman_shader);
           scene_renderer->setCurrentShader(horse_swordsman_shader);
         }
         }
         static_renderer.render(ctx, out);
         static_renderer.render(ctx, out);

+ 9 - 9
render/entity/nations/roman/horse_swordsman_style.cpp

@@ -4,13 +4,13 @@
 static std::unordered_map<std::string, HorseSwordsmanStyleConfig> styles;
 static std::unordered_map<std::string, HorseSwordsmanStyleConfig> styles;
 
 
 void register_roman_horse_swordsman_style() {
 void register_roman_horse_swordsman_style() {
-    HorseSwordsmanStyleConfig default_style;
-    default_style.cloth_color = QVector3D(0.6F, 0.6F, 0.7F);
-    default_style.leather_color = QVector3D(0.3F, 0.2F, 0.2F);
-    default_style.metal_color = QVector3D(0.9F, 0.8F, 0.7F);
-    default_style.shader_id = "horse_swordsman_roman";
-    default_style.show_helmet = true;
-    default_style.show_armor = true;
-    default_style.has_cavalry_shield = true;
-    styles["default"] = default_style;
+  HorseSwordsmanStyleConfig default_style;
+  default_style.cloth_color = QVector3D(0.6F, 0.6F, 0.7F);
+  default_style.leather_color = QVector3D(0.3F, 0.2F, 0.2F);
+  default_style.metal_color = QVector3D(0.9F, 0.8F, 0.7F);
+  default_style.shader_id = "horse_swordsman_roman";
+  default_style.show_helmet = true;
+  default_style.show_armor = true;
+  default_style.has_cavalry_shield = true;
+  styles["default"] = default_style;
 }
 }

+ 12 - 12
render/entity/nations/roman/horse_swordsman_style.h

@@ -5,18 +5,18 @@
 #include <string>
 #include <string>
 
 
 struct HorseSwordsmanStyleConfig {
 struct HorseSwordsmanStyleConfig {
-    std::optional<QVector3D> cloth_color;
-    std::optional<QVector3D> leather_color;
-    std::optional<QVector3D> leather_dark_color;
-    std::optional<QVector3D> metal_color;
-    std::optional<QVector3D> wood_color;
-    std::optional<QVector3D> cape_color;
-    std::string shader_id;
-    bool show_helmet = true;
-    bool show_armor = true;
-    bool show_shoulder_decor = false;
-    bool show_cape = false;
-    bool has_cavalry_shield = false;
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> wood_color;
+  std::optional<QVector3D> cape_color;
+  std::string shader_id;
+  bool show_helmet = true;
+  bool show_armor = true;
+  bool show_shoulder_decor = false;
+  bool show_cape = false;
+  bool has_cavalry_shield = false;
 };
 };
 
 
 void register_roman_horse_swordsman_style();
 void register_roman_horse_swordsman_style();

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

@@ -6,10 +6,10 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"

+ 3 - 3
render/entity/nations/roman/swordsman_renderer.cpp

@@ -6,10 +6,10 @@
 #include "../../../gl/backend.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/primitives.h"
 #include "../../../gl/shader.h"
 #include "../../../gl/shader.h"
-#include "../../../humanoid/rig.h"
-#include "../../../humanoid/style_palette.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_math.h"
 #include "../../../humanoid/humanoid_specs.h"
 #include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../palette.h"
 #include "../../../palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../../submitter.h"
@@ -57,7 +57,7 @@ void ensure_swordsman_styles_registered() {
 } // namespace
 } // namespace
 
 
 void register_swordsman_style(const std::string &nation_id,
 void register_swordsman_style(const std::string &nation_id,
-                           const KnightStyleConfig &style) {
+                              const KnightStyleConfig &style) {
   swordsman_style_registry()[nation_id] = style;
   swordsman_style_registry()[nation_id] = style;
 }
 }
 
 

+ 1 - 1
render/entity/nations/roman/swordsman_renderer.h

@@ -8,7 +8,7 @@ namespace Render::GL::Roman {
 struct KnightStyleConfig;
 struct KnightStyleConfig;
 
 
 void register_swordsman_style(const std::string &nation_id,
 void register_swordsman_style(const std::string &nation_id,
-                           const KnightStyleConfig &style);
+                              const KnightStyleConfig &style);
 
 
 void registerKnightRenderer(EntityRendererRegistry &registry);
 void registerKnightRenderer(EntityRendererRegistry &registry);
 
 

+ 3 - 3
render/entity/registry.cpp

@@ -2,17 +2,17 @@
 #include "../scene_renderer.h"
 #include "../scene_renderer.h"
 #include "barracks_renderer.h"
 #include "barracks_renderer.h"
 #include "nations/carthage/archer_renderer.h"
 #include "nations/carthage/archer_renderer.h"
-#include "nations/carthage/swordsman_renderer.h"
 #include "nations/carthage/horse_swordsman_renderer.h"
 #include "nations/carthage/horse_swordsman_renderer.h"
 #include "nations/carthage/spearman_renderer.h"
 #include "nations/carthage/spearman_renderer.h"
+#include "nations/carthage/swordsman_renderer.h"
 #include "nations/kingdom/archer_renderer.h"
 #include "nations/kingdom/archer_renderer.h"
-#include "nations/kingdom/swordsman_renderer.h"
 #include "nations/kingdom/horse_swordsman_renderer.h"
 #include "nations/kingdom/horse_swordsman_renderer.h"
 #include "nations/kingdom/spearman_renderer.h"
 #include "nations/kingdom/spearman_renderer.h"
+#include "nations/kingdom/swordsman_renderer.h"
 #include "nations/roman/archer_renderer.h"
 #include "nations/roman/archer_renderer.h"
-#include "nations/roman/swordsman_renderer.h"
 #include "nations/roman/horse_swordsman_renderer.h"
 #include "nations/roman/horse_swordsman_renderer.h"
 #include "nations/roman/spearman_renderer.h"
 #include "nations/roman/spearman_renderer.h"
+#include "nations/roman/swordsman_renderer.h"
 #include <string>
 #include <string>
 #include <utility>
 #include <utility>
 
 

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

@@ -88,7 +88,8 @@ void CharacterPipeline::cacheKnightUniforms() {
   m_swordsmanUniforms.mvp = m_swordsmanShader->uniformHandle("u_mvp");
   m_swordsmanUniforms.mvp = m_swordsmanShader->uniformHandle("u_mvp");
   m_swordsmanUniforms.model = m_swordsmanShader->uniformHandle("u_model");
   m_swordsmanUniforms.model = m_swordsmanShader->uniformHandle("u_model");
   m_swordsmanUniforms.texture = m_swordsmanShader->uniformHandle("u_texture");
   m_swordsmanUniforms.texture = m_swordsmanShader->uniformHandle("u_texture");
-  m_swordsmanUniforms.useTexture = m_swordsmanShader->uniformHandle("u_useTexture");
+  m_swordsmanUniforms.useTexture =
+      m_swordsmanShader->uniformHandle("u_useTexture");
   m_swordsmanUniforms.color = m_swordsmanShader->uniformHandle("u_color");
   m_swordsmanUniforms.color = m_swordsmanShader->uniformHandle("u_color");
   m_swordsmanUniforms.alpha = m_swordsmanShader->uniformHandle("u_alpha");
   m_swordsmanUniforms.alpha = m_swordsmanShader->uniformHandle("u_alpha");
 }
 }

+ 16 - 16
render/humanoid/humanoid_math.cpp

@@ -5,24 +5,24 @@
 namespace Render::GL {
 namespace Render::GL {
 
 
 auto elbowBendTorso(const QVector3D &shoulder, const QVector3D &hand,
 auto elbowBendTorso(const QVector3D &shoulder, const QVector3D &hand,
-										const QVector3D &outwardDir, float alongFrac,
-										float lateral_offset, float yBias,
-										float outwardSign) -> QVector3D {
-	QVector3D dir = hand - shoulder;
-	float const dist = std::max(dir.length(), 1e-5F);
-	dir /= dist;
+                    const QVector3D &outwardDir, float alongFrac,
+                    float lateral_offset, float yBias,
+                    float outwardSign) -> QVector3D {
+  QVector3D dir = hand - shoulder;
+  float const dist = std::max(dir.length(), 1e-5F);
+  dir /= dist;
 
 
-	QVector3D lateral = outwardDir - dir * QVector3D::dotProduct(outwardDir, dir);
-	if (lateral.lengthSquared() < 1e-8F) {
-		lateral = QVector3D::crossProduct(dir, QVector3D(0, 1, 0));
-	}
-	if (QVector3D::dotProduct(lateral, outwardDir) < 0.0F) {
-		lateral = -lateral;
-	}
-	lateral.normalize();
+  QVector3D lateral = outwardDir - dir * QVector3D::dotProduct(outwardDir, dir);
+  if (lateral.lengthSquared() < 1e-8F) {
+    lateral = QVector3D::crossProduct(dir, QVector3D(0, 1, 0));
+  }
+  if (QVector3D::dotProduct(lateral, outwardDir) < 0.0F) {
+    lateral = -lateral;
+  }
+  lateral.normalize();
 
 
-	return shoulder + dir * (dist * alongFrac) +
-				 lateral * (lateral_offset * outwardSign) + QVector3D(0, yBias, 0);
+  return shoulder + dir * (dist * alongFrac) +
+         lateral * (lateral_offset * outwardSign) + QVector3D(0, yBias, 0);
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 12 - 12
render/humanoid/humanoid_math.h

@@ -8,26 +8,26 @@
 namespace Render::GL {
 namespace Render::GL {
 
 
 inline auto hash_01(uint32_t x) -> float {
 inline auto hash_01(uint32_t x) -> float {
-	x ^= x << HashXorShift::k_xor_shift_amount_13;
-	x ^= x >> HashXorShift::k_xor_shift_amount_17;
-	x ^= x << HashXorShift::k_xor_shift_amount_5;
-	return (x & BitShift::Mask24Bit) / float(BitShift::k_mask_24bit_hex);
+  x ^= x << HashXorShift::k_xor_shift_amount_13;
+  x ^= x >> HashXorShift::k_xor_shift_amount_17;
+  x ^= x << HashXorShift::k_xor_shift_amount_5;
+  return (x & BitShift::Mask24Bit) / float(BitShift::k_mask_24bit_hex);
 }
 }
 
 
 inline auto rotY(const QVector3D &v, float angle_rad) -> QVector3D {
 inline auto rotY(const QVector3D &v, float angle_rad) -> QVector3D {
-	const float c = std::cos(angle_rad);
-	const float s = std::sin(angle_rad);
-	return {c * v.x() + s * v.z(), v.y(), -s * v.x() + c * v.z()};
+  const float c = std::cos(angle_rad);
+  const float s = std::sin(angle_rad);
+  return {c * v.x() + s * v.z(), v.y(), -s * v.x() + c * v.z()};
 }
 }
 
 
 inline auto rightOf(const QVector3D &fwd) -> QVector3D {
 inline auto rightOf(const QVector3D &fwd) -> QVector3D {
-	const QVector3D UP(0.0F, 1.0F, 0.0F);
-	return QVector3D::crossProduct(UP, fwd).normalized();
+  const QVector3D UP(0.0F, 1.0F, 0.0F);
+  return QVector3D::crossProduct(UP, fwd).normalized();
 }
 }
 
 
 auto elbowBendTorso(const QVector3D &shoulder, const QVector3D &hand,
 auto elbowBendTorso(const QVector3D &shoulder, const QVector3D &hand,
-										const QVector3D &outwardDir, float alongFrac,
-										float lateral_offset, float yBias,
-										float outwardSign) -> QVector3D;
+                    const QVector3D &outwardDir, float alongFrac,
+                    float lateral_offset, float yBias,
+                    float outwardSign) -> QVector3D;
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 33 - 33
render/humanoid/humanoid_specs.h

@@ -6,42 +6,42 @@ namespace Render::GL {
 
 
 struct HumanProportions {
 struct HumanProportions {
 
 
-	static constexpr float TOTAL_HEIGHT = 1.80F;
-	static constexpr float HEAD_HEIGHT = 0.23F;
-
-	static constexpr float GROUND_Y = 0.0F;
-	static constexpr float HEAD_TOP_Y = GROUND_Y + TOTAL_HEIGHT;
-	static constexpr float CHIN_Y = HEAD_TOP_Y - HEAD_HEIGHT;
-	static constexpr float NECK_BASE_Y = CHIN_Y - 0.08F;
-	static constexpr float SHOULDER_Y = NECK_BASE_Y - 0.04F;
-	static constexpr float CHEST_Y = SHOULDER_Y - 0.31F;
-	static constexpr float WAIST_Y = CHEST_Y - 0.25F;
-
-	static constexpr float UPPER_LEG_LEN = 0.46F;
-	static constexpr float LOWER_LEG_LEN = 0.44F;
-	static constexpr float KNEE_Y = WAIST_Y - UPPER_LEG_LEN;
-
-	static constexpr float SHOULDER_WIDTH = HEAD_HEIGHT * 1.85F;
-	static constexpr float HEAD_RADIUS = HEAD_HEIGHT * 0.42F;
-	static constexpr float NECK_RADIUS = HEAD_RADIUS * 0.38F;
-	static constexpr float TORSO_TOP_R = HEAD_RADIUS * 1.15F;
-	static constexpr float TORSO_BOT_R = HEAD_RADIUS * 1.05F;
-	static constexpr float UPPER_ARM_R = HEAD_RADIUS * 0.38F;
-	static constexpr float FORE_ARM_R = HEAD_RADIUS * 0.30F;
-	static constexpr float HAND_RADIUS = HEAD_RADIUS * 0.28F;
-	static constexpr float UPPER_LEG_R = HEAD_RADIUS * 0.50F;
-	static constexpr float LOWER_LEG_R = HEAD_RADIUS * 0.42F;
-
-	static constexpr float UPPER_ARM_LEN = 0.28F;
-	static constexpr float FORE_ARM_LEN = 0.30F;
+  static constexpr float TOTAL_HEIGHT = 1.80F;
+  static constexpr float HEAD_HEIGHT = 0.23F;
+
+  static constexpr float GROUND_Y = 0.0F;
+  static constexpr float HEAD_TOP_Y = GROUND_Y + TOTAL_HEIGHT;
+  static constexpr float CHIN_Y = HEAD_TOP_Y - HEAD_HEIGHT;
+  static constexpr float NECK_BASE_Y = CHIN_Y - 0.08F;
+  static constexpr float SHOULDER_Y = NECK_BASE_Y - 0.04F;
+  static constexpr float CHEST_Y = SHOULDER_Y - 0.31F;
+  static constexpr float WAIST_Y = CHEST_Y - 0.25F;
+
+  static constexpr float UPPER_LEG_LEN = 0.46F;
+  static constexpr float LOWER_LEG_LEN = 0.44F;
+  static constexpr float KNEE_Y = WAIST_Y - UPPER_LEG_LEN;
+
+  static constexpr float SHOULDER_WIDTH = HEAD_HEIGHT * 1.85F;
+  static constexpr float HEAD_RADIUS = HEAD_HEIGHT * 0.42F;
+  static constexpr float NECK_RADIUS = HEAD_RADIUS * 0.38F;
+  static constexpr float TORSO_TOP_R = HEAD_RADIUS * 1.15F;
+  static constexpr float TORSO_BOT_R = HEAD_RADIUS * 1.05F;
+  static constexpr float UPPER_ARM_R = HEAD_RADIUS * 0.38F;
+  static constexpr float FORE_ARM_R = HEAD_RADIUS * 0.30F;
+  static constexpr float HAND_RADIUS = HEAD_RADIUS * 0.28F;
+  static constexpr float UPPER_LEG_R = HEAD_RADIUS * 0.50F;
+  static constexpr float LOWER_LEG_R = HEAD_RADIUS * 0.42F;
+
+  static constexpr float UPPER_ARM_LEN = 0.28F;
+  static constexpr float FORE_ARM_LEN = 0.30F;
 };
 };
 
 
 enum class MaterialType : uint8_t {
 enum class MaterialType : uint8_t {
-	Cloth = 0,
-	Leather = 1,
-	Metal = 2,
-	Wood = 3,
-	Skin = 4
+  Cloth = 0,
+  Leather = 1,
+  Metal = 2,
+  Wood = 3,
+  Skin = 4
 };
 };
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 379 - 26
render/humanoid/rig.cpp

@@ -11,9 +11,9 @@
 #include "../geom/transforms.h"
 #include "../geom/transforms.h"
 #include "../gl/primitives.h"
 #include "../gl/primitives.h"
 #include "../gl/render_constants.h"
 #include "../gl/render_constants.h"
-#include "humanoid_math.h"
 #include "../palette.h"
 #include "../palette.h"
 #include "../submitter.h"
 #include "../submitter.h"
+#include "humanoid_math.h"
 #include <QMatrix4x4>
 #include <QMatrix4x4>
 #include <QtMath>
 #include <QtMath>
 #include <algorithm>
 #include <algorithm>
@@ -22,12 +22,14 @@
 #include <numbers>
 #include <numbers>
 #include <qmatrix4x4.h>
 #include <qmatrix4x4.h>
 #include <qvectornd.h>
 #include <qvectornd.h>
+#include <QVector4D>
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
 using namespace Render::GL::Geometry;
 using namespace Render::GL::Geometry;
 using Render::Geom::capsuleBetween;
 using Render::Geom::capsuleBetween;
 using Render::Geom::cylinderBetween;
 using Render::Geom::cylinderBetween;
+using Render::Geom::coneFromTo;
 using Render::Geom::sphereAt;
 using Render::Geom::sphereAt;
 
 
 namespace {
 namespace {
@@ -38,6 +40,35 @@ constexpr float k_reference_run_speed = 5.10F;
 
 
 } // namespace
 } // namespace
 
 
+auto HumanoidRendererBase::headLocalPosition(const HeadFrame &frame,
+                                             const QVector3D &local)
+    -> QVector3D {
+  float const lx = local.x() * frame.radius;
+  float const ly = local.y() * frame.radius;
+  float const lz = local.z() * frame.radius;
+  return frame.origin + frame.right * lx + frame.up * ly + frame.forward * lz;
+}
+
+auto HumanoidRendererBase::makeHeadLocalTransform(const QMatrix4x4 &parent,
+                                                  const HeadFrame &frame,
+                                                  const QVector3D &local_offset,
+                                                  float uniform_scale)
+    -> QMatrix4x4 {
+  float scale = frame.radius * uniform_scale;
+  if (scale == 0.0F) {
+    scale = uniform_scale;
+  }
+
+  QVector3D const origin = headLocalPosition(frame, local_offset);
+
+  QMatrix4x4 local;
+  local.setColumn(0, QVector4D(frame.right * scale, 0.0F));
+  local.setColumn(1, QVector4D(frame.up * scale, 0.0F));
+  local.setColumn(2, QVector4D(frame.forward * scale, 0.0F));
+  local.setColumn(3, QVector4D(origin, 1.0F));
+  return parent * local;
+}
+
 void HumanoidRendererBase::getVariant(const DrawContext &ctx, uint32_t seed,
 void HumanoidRendererBase::getVariant(const DrawContext &ctx, uint32_t seed,
                                       HumanoidVariant &v) const {
                                       HumanoidVariant &v) const {
   QVector3D const team_tint = resolveTeamTint(ctx);
   QVector3D const team_tint = resolveTeamTint(ctx);
@@ -360,14 +391,15 @@ void HumanoidRendererBase::computeLocomotionPose(
 
 
 void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
 void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
                                           const HumanoidVariant &v,
                                           const HumanoidVariant &v,
-                                          const HumanoidPose &pose,
+                                          HumanoidPose &pose,
                                           ISubmitter &out) const {
                                           ISubmitter &out) const {
   using HP = HumanProportions;
   using HP = HumanProportions;
 
 
   QVector3D const scaling = getProportionScaling();
   QVector3D const scaling = getProportionScaling();
   float const width_scale = scaling.x();
   float const width_scale = scaling.x();
   float const height_scale = scaling.y();
   float const height_scale = scaling.y();
-  float const head_scale = scaling.z();
+  // Head should use uniform scaling, not depth scaling
+  float const head_scale = 1.0F;  // Don't scale head by proportions
 
 
   QVector3D right_axis = pose.shoulderR - pose.shoulderL;
   QVector3D right_axis = pose.shoulderR - pose.shoulderL;
   if (right_axis.lengthSquared() < 1e-8F) {
   if (right_axis.lengthSquared() < 1e-8F) {
@@ -375,6 +407,13 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
   }
   }
   right_axis.normalize();
   right_axis.normalize();
 
 
+  QVector3D const up_axis(0.0F, 1.0F, 0.0F);
+  QVector3D forward_axis = QVector3D::crossProduct(right_axis, up_axis);
+  if (forward_axis.lengthSquared() < 1e-8F) {
+    forward_axis = QVector3D(0.0F, 0.0F, 1.0F);
+  }
+  forward_axis.normalize();
+
   const float y_shoulder = 0.5F * (pose.shoulderL.y() + pose.shoulderR.y());
   const float y_shoulder = 0.5F * (pose.shoulderL.y() + pose.shoulderR.y());
   const float y_neck = pose.neck_base.y();
   const float y_neck = pose.neck_base.y();
   const float shoulder_half_span =
   const float shoulder_half_span =
@@ -387,9 +426,13 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
   QVector3D const tunic_top{0.0F, y_top_cover - 0.006F, 0.0F};
   QVector3D const tunic_top{0.0F, y_top_cover - 0.006F, 0.0F};
 
 
   QVector3D const tunic_bot{0.0F, pose.pelvisPos.y() + 0.03F, 0.0F};
   QVector3D const tunic_bot{0.0F, pose.pelvisPos.y() + 0.03F, 0.0F};
-  out.mesh(getUnitTorso(),
-           cylinderBetween(ctx.model, tunic_top, tunic_bot, torso_r),
-           v.palette.cloth, nullptr, 1.0F);
+  
+  // Human torso is WIDE (shoulders) but NARROW (front-to-back)
+  // Scale the cylinder to be elliptical, not circular
+  QMatrix4x4 torso_transform = cylinderBetween(ctx.model, tunic_top, tunic_bot, torso_r);
+  torso_transform.scale(1.0F, 1.0F, 0.65F);  // Compress Z (depth) to 65% - makes it flat front-to-back
+  
+  out.mesh(getUnitTorso(), torso_transform, v.palette.cloth, nullptr, 1.0F);
 
 
   QVector3D const chin_pos{0.0F, pose.headPos.y() - pose.headR, 0.0F};
   QVector3D const chin_pos{0.0F, pose.headPos.y() - pose.headR, 0.0F};
   out.mesh(getUnitCylinder(),
   out.mesh(getUnitCylinder(),
@@ -397,21 +440,63 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
                            HP::NECK_RADIUS * width_scale),
                            HP::NECK_RADIUS * width_scale),
            v.palette.skin * 0.9F, nullptr, 1.0F);
            v.palette.skin * 0.9F, nullptr, 1.0F);
 
 
-  out.mesh(getUnitSphere(),
-           sphereAt(ctx.model, pose.headPos, pose.headR * head_scale),
+  float const head_r = pose.headR; // Head uses uniform scaling
+  out.mesh(getUnitSphere(), sphereAt(ctx.model, pose.headPos, head_r),
            v.palette.skin, nullptr, 1.0F);
            v.palette.skin, nullptr, 1.0F);
 
 
-  QVector3D const iris(0.06F, 0.06F, 0.07F);
-  float const eye_z = pose.headR * head_scale * 0.7F;
-  float const eye_y = pose.headPos.y() + pose.headR * head_scale * 0.1F;
-  float const eye_spacing = pose.headR * head_scale * 0.35F;
-  out.mesh(getUnitSphere(),
-           ctx.model * sphereAt(QVector3D(-eye_spacing, eye_y, eye_z),
-                                pose.headR * head_scale * 0.15F),
+  QVector3D head_up = pose.headPos - pose.neck_base;
+  if (head_up.lengthSquared() < 1e-8F) {
+    head_up = up_axis;
+  } else {
+    head_up.normalize();
+  }
+
+  QVector3D head_right =
+      right_axis - head_up * QVector3D::dotProduct(right_axis, head_up);
+  if (head_right.lengthSquared() < 1e-8F) {
+    head_right = QVector3D::crossProduct(head_up, forward_axis);
+    if (head_right.lengthSquared() < 1e-8F) {
+      head_right = QVector3D(1.0F, 0.0F, 0.0F);
+    }
+  }
+  head_right.normalize();
+
+  if (QVector3D::dotProduct(head_right, right_axis) < 0.0F) {
+    head_right = -head_right;
+  }
+
+  QVector3D head_forward = QVector3D::crossProduct(head_right, head_up);
+  if (head_forward.lengthSquared() < 1e-8F) {
+    head_forward = forward_axis;
+  } else {
+    head_forward.normalize();
+  }
+
+  if (QVector3D::dotProduct(head_forward, forward_axis) < 0.0F) {
+    head_right = -head_right;
+    head_forward = -head_forward;
+  }
+
+  pose.headFrame.origin = pose.headPos;
+  pose.headFrame.right = head_right;
+  pose.headFrame.up = head_up;
+  pose.headFrame.forward = head_forward;
+  pose.headFrame.radius = head_r;
+
+  QVector3D const iris = QVector3D(0.10F, 0.10F, 0.12F);
+  auto eyePosition = [&](float lateral) {
+    QVector3D const local(lateral, 0.12F, 0.92F);
+    QVector3D world = headLocalPosition(pose.headFrame, local);
+    world += pose.headFrame.forward * (pose.headFrame.radius * 0.02F);
+    return world;
+  };
+  QVector3D const left_eye_world = eyePosition(-0.32F);
+  QVector3D const right_eye_world = eyePosition(0.32F);
+  float const eye_radius = pose.headFrame.radius * 0.17F;
+
+  out.mesh(getUnitSphere(), sphereAt(ctx.model, left_eye_world, eye_radius),
            iris, nullptr, 1.0F);
            iris, nullptr, 1.0F);
-  out.mesh(getUnitSphere(),
-           ctx.model * sphereAt(QVector3D(eye_spacing, eye_y, eye_z),
-                                pose.headR * head_scale * 0.15F),
+  out.mesh(getUnitSphere(), sphereAt(ctx.model, right_eye_world, eye_radius),
            iris, nullptr, 1.0F);
            iris, nullptr, 1.0F);
 
 
   const float upper_arm_r = HP::UPPER_ARM_R * width_scale;
   const float upper_arm_r = HP::UPPER_ARM_R * width_scale;
@@ -467,13 +552,6 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
            cylinderBetween(ctx.model, pose.knee_r, pose.foot_r, shin_r),
            cylinderBetween(ctx.model, pose.knee_r, pose.foot_r, shin_r),
            v.palette.leather * 0.95F, nullptr, 1.0F);
            v.palette.leather * 0.95F, nullptr, 1.0F);
 
 
-  QVector3D const up_axis(0.0F, 1.0F, 0.0F);
-  QVector3D forward_axis = QVector3D::crossProduct(right_axis, up_axis);
-  if (forward_axis.lengthSquared() < 1e-8F) {
-    forward_axis = QVector3D(0.0F, 0.0F, 1.0F);
-  }
-  forward_axis.normalize();
-
   auto draw_foot = [&](const QVector3D &ankle, bool is_left) {
   auto draw_foot = [&](const QVector3D &ankle, bool is_left) {
     QVector3D lateral = is_left ? -right_axis : right_axis;
     QVector3D lateral = is_left ? -right_axis : right_axis;
     QVector3D foot_forward =
     QVector3D foot_forward =
@@ -528,6 +606,280 @@ void HumanoidRendererBase::drawShoulderDecorations(const DrawContext &,
                                                    float, const QVector3D &,
                                                    float, const QVector3D &,
                                                    ISubmitter &) const {}
                                                    ISubmitter &) const {}
 
 
+void HumanoidRendererBase::drawFacialHair(const DrawContext &ctx,
+                                          const HumanoidVariant &v,
+                                          const HumanoidPose &pose,
+                                          ISubmitter &out) const {
+  const FacialHairParams &fh = v.facialHair;
+
+  if (fh.style == FacialHairStyle::None || fh.coverage < 0.01F) {
+    return;
+  }
+
+  const HeadFrame &frame = pose.headFrame;
+  float const head_r = frame.radius;
+  if (head_r <= 0.0F) {
+    return;
+  }
+
+  auto saturate = [](const QVector3D &c) -> QVector3D {
+    return {std::clamp(c.x(), 0.0F, 1.0F), std::clamp(c.y(), 0.0F, 1.0F),
+            std::clamp(c.z(), 0.0F, 1.0F)};
+  };
+
+  QVector3D hair_color = fh.color * (1.0F - fh.greyness) +
+                         QVector3D(0.75F, 0.75F, 0.75F) * fh.greyness;
+  QVector3D hair_dark = hair_color * 0.80F;
+  QVector3D const hair_root = hair_dark * 0.95F;
+  QVector3D const hair_tip = hair_color * 1.08F;
+
+  float const chin_y = -head_r * 0.95F;
+  float const mouth_y = -head_r * 0.18F;
+  float const jaw_z = head_r * 0.68F;
+
+  float const chin_norm = chin_y / head_r;
+  float const mouth_norm = mouth_y / head_r;
+  float const jaw_forward_norm = jaw_z / head_r;
+
+  uint32_t rand_state = 0x9E3779B9U;
+  if (ctx.entity != nullptr) {
+    uintptr_t ptr = reinterpret_cast<uintptr_t>(ctx.entity);
+    rand_state ^= static_cast<uint32_t>(ptr & 0xFFFFFFFFU);
+    rand_state ^= static_cast<uint32_t>((ptr >> 32) & 0xFFFFFFFFU);
+  }
+  rand_state ^= static_cast<uint32_t>(fh.length * 9973.0F);
+  rand_state ^= static_cast<uint32_t>(fh.thickness * 6151.0F);
+  rand_state ^= static_cast<uint32_t>(fh.coverage * 4099.0F);
+
+  auto random01 = [&]() -> float {
+    rand_state = rand_state * 1664525U + 1013904223U;
+    return hash_01(rand_state);
+  };
+
+  auto jitter = [&](float amplitude) -> float {
+    return (random01() - 0.5F) * 2.0F * amplitude;
+  };
+
+  auto place_strands = [&](int rows, int segments, float jaw_span,
+                           float row_spacing_norm, float base_length_norm,
+                           float length_variation, float forward_bias_norm,
+                           float base_radius_norm) {
+    if (rows <= 0 || segments <= 0) {
+      return;
+    }
+
+    const float phi_half_range = std::max(0.35F, jaw_span * 0.5F);
+    const float base_y_norm = chin_norm + 0.10F;
+    for (int row = 0; row < rows; ++row) {
+      float const row_t = (rows > 1) ? float(row) / float(rows - 1) : 0.0F;
+      float const target_y_norm =
+          std::clamp(base_y_norm + row_t * row_spacing_norm, -0.92F, 0.10F);
+      float const plane_radius =
+          std::sqrt(std::max(0.02F, 1.0F - target_y_norm * target_y_norm));
+      for (int seg = 0; seg < segments; ++seg) {
+        float const seg_t = (segments > 1) ? float(seg) / float(segments - 1)
+                                           : 0.5F;
+        float const base_phi = (seg_t - 0.5F) * jaw_span;
+        float const phi = std::clamp(base_phi + jitter(0.25F), -phi_half_range,
+                                     phi_half_range);
+        float const coverage_falloff = 1.0F -
+                                       std::abs(phi) /
+                                           std::max(0.001F, phi_half_range);
+        float const keep_prob = std::clamp(
+            fh.coverage * (0.75F + coverage_falloff * 0.35F), 0.05F, 1.0F);
+        if (random01() > keep_prob) {
+          continue;
+        }
+
+        float const wrap_scale = 0.80F + (1.0F - row_t) * 0.20F;
+        float lateral_norm = plane_radius * std::sin(phi) * wrap_scale;
+        float forward_norm = plane_radius * std::cos(phi);
+        lateral_norm += jitter(0.06F);
+        forward_norm += jitter(0.08F);
+        float const y_norm = target_y_norm + jitter(0.05F);
+
+        QVector3D surface_dir(lateral_norm, y_norm,
+                              forward_norm * (0.75F + forward_bias_norm * 0.45F) +
+                                  (forward_bias_norm - 0.05F));
+        float const dir_len = surface_dir.length();
+        if (dir_len < 1e-4F) {
+          continue;
+        }
+        surface_dir /= dir_len;
+
+        float const shell = 1.02F + jitter(0.03F);
+        QVector3D const root = headLocalPosition(frame, surface_dir * shell);
+
+        QVector3D local_dir(jitter(0.15F),
+                             - (0.55F + row_t * 0.30F) + jitter(0.10F),
+                             forward_bias_norm + row_t * 0.20F + jitter(0.12F));
+        QVector3D strand_dir = frame.right * local_dir.x() +
+                               frame.up * local_dir.y() +
+                               frame.forward * local_dir.z() - surface_dir * 0.25F;
+        if (strand_dir.lengthSquared() < 1e-6F) {
+          continue;
+        }
+        strand_dir.normalize();
+
+        float const strand_length = base_length_norm * fh.length *
+                                    (1.0F + length_variation * jitter(0.5F)) *
+                                    (1.0F + row_t * 0.25F);
+        if (strand_length < 0.05F) {
+          continue;
+        }
+
+        QVector3D const tip = root + strand_dir * (head_r * strand_length);
+
+        float const base_radius =
+            std::max(head_r * base_radius_norm * fh.thickness *
+                         (0.7F + coverage_falloff * 0.35F),
+                     head_r * 0.010F);
+        float const mid_radius = base_radius * 0.55F;
+
+    float const color_jitter = 0.85F + random01() * 0.30F;
+    QVector3D const root_color = saturate(hair_root * color_jitter);
+    QVector3D const tip_color = saturate(hair_tip * color_jitter);
+
+        QMatrix4x4 base_blob = sphereAt(ctx.model, root, base_radius * 0.95F);
+        out.mesh(getUnitSphere(), base_blob, root_color, nullptr, 1.0F);
+
+  QVector3D const mid = root + (tip - root) * 0.40F;
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, root, mid, base_radius),
+                 root_color, nullptr, 1.0F);
+
+        out.mesh(getUnitCone(), coneFromTo(ctx.model, mid, tip, mid_radius),
+                 tip_color, nullptr, 1.0F);
+      }
+    }
+  };
+
+  auto place_mustache = [&](int segments, float base_length_norm,
+                            float upward_bias_norm) {
+    if (segments <= 0) {
+      return;
+    }
+
+    float const mustache_y_norm = mouth_norm + upward_bias_norm - 0.04F;
+    float const phi_half_range = 0.55F;
+    for (int side = -1; side <= 1; side += 2) {
+      for (int seg = 0; seg < segments; ++seg) {
+        float const t = (segments > 1) ? float(seg) / float(segments - 1)
+                                      : 0.5F;
+        float const base_phi = (t - 0.5F) * (phi_half_range * 2.0F);
+        float const phi = std::clamp(base_phi + jitter(0.18F), -phi_half_range,
+                                     phi_half_range);
+        float const plane_radius =
+            std::sqrt(std::max(0.02F, 1.0F - mustache_y_norm * mustache_y_norm));
+        float lateral_norm = plane_radius * std::sin(phi);
+        float forward_norm = plane_radius * std::cos(phi);
+        lateral_norm += jitter(0.04F);
+        forward_norm += jitter(0.05F);
+        if (random01() > fh.coverage) {
+          continue;
+        }
+        QVector3D surface_dir(lateral_norm,
+                              mustache_y_norm + jitter(0.03F),
+                              forward_norm * 0.85F + 0.20F);
+        float const dir_len = surface_dir.length();
+        if (dir_len < 1e-4F) {
+          continue;
+        }
+        surface_dir /= dir_len;
+        QVector3D const root =
+            headLocalPosition(frame, surface_dir * (1.01F + jitter(0.02F)));
+
+        QVector3D const dir_local(side * (0.55F + jitter(0.12F)),
+                                  jitter(0.06F),
+                                  0.34F + jitter(0.08F));
+        QVector3D strand_dir = frame.right * dir_local.x() +
+                               frame.up * dir_local.y() +
+                               frame.forward * dir_local.z() -
+                               surface_dir * 0.20F;
+        if (strand_dir.lengthSquared() < 1e-6F) {
+          continue;
+        }
+        strand_dir.normalize();
+
+        float const strand_length =
+            base_length_norm * fh.length * (1.0F + jitter(0.35F));
+        QVector3D const tip = root + strand_dir * (head_r * strand_length);
+
+    float const base_radius =
+      std::max(head_r * 0.028F * fh.thickness, head_r * 0.0065F);
+    float const mid_radius = base_radius * 0.45F;
+    float const color_jitter = 0.92F + random01() * 0.18F;
+    QVector3D const root_color = saturate(hair_root * (color_jitter * 0.95F));
+    QVector3D const tip_color =
+      saturate(hair_tip * (color_jitter * 1.02F));
+
+    out.mesh(getUnitSphere(), sphereAt(ctx.model, root, base_radius * 0.7F),
+                 root_color, nullptr, 1.0F);
+
+        QVector3D const mid = root + (tip - root) * 0.5F;
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, root, mid, base_radius * 0.85F),
+                 root_color, nullptr, 1.0F);
+        out.mesh(getUnitCone(), coneFromTo(ctx.model, mid, tip, mid_radius),
+                 tip_color, nullptr, 1.0F);
+      }
+    }
+  };
+
+  switch (fh.style) {
+  case FacialHairStyle::Stubble: {
+    place_strands(1, 11, 2.0F, 0.12F, 0.28F, 0.30F, 0.08F, 0.035F);
+    break;
+  }
+
+  case FacialHairStyle::ShortBeard: {
+    place_strands(3, 14, 2.6F, 0.18F, 0.58F, 0.38F, 0.12F, 0.055F);
+    break;
+  }
+
+  case FacialHairStyle::FullBeard:
+  case FacialHairStyle::LongBeard: {
+    bool const is_long = (fh.style == FacialHairStyle::LongBeard);
+    if (is_long) {
+      place_strands(5, 20, 3.0F, 0.24F, 1.00F, 0.48F, 0.18F, 0.060F);
+    } else {
+      place_strands(4, 18, 2.8F, 0.22F, 0.85F, 0.42F, 0.16F, 0.058F);
+    }
+    break;
+  }
+
+  case FacialHairStyle::Goatee: {
+    place_strands(2, 8, 1.8F, 0.16F, 0.70F, 0.34F, 0.14F, 0.055F);
+    break;
+  }
+
+  case FacialHairStyle::Mustache: {
+    place_mustache(5, 0.32F, 0.05F);
+    break;
+  }
+
+  case FacialHairStyle::MustacheAndBeard: {
+    FacialHairParams mustache_only = fh;
+    mustache_only.style = FacialHairStyle::Mustache;
+    FacialHairParams beard_only = fh;
+    beard_only.style = FacialHairStyle::ShortBeard;
+
+    HumanoidVariant v_mustache = v;
+    v_mustache.facialHair = mustache_only;
+    drawFacialHair(ctx, v_mustache, pose, out);
+
+    HumanoidVariant v_beard = v;
+    v_beard.facialHair = beard_only;
+    drawFacialHair(ctx, v_beard, pose, out);
+    break;
+  }
+
+  case FacialHairStyle::None:
+  default:
+    break;
+  }
+}
+
 void HumanoidRendererBase::render(const DrawContext &ctx,
 void HumanoidRendererBase::render(const DrawContext &ctx,
                                   ISubmitter &out) const {
                                   ISubmitter &out) const {
   FormationParams const formation = resolveFormation(ctx);
   FormationParams const formation = resolveFormation(ctx);
@@ -748,7 +1100,8 @@ void HumanoidRendererBase::render(const DrawContext &ctx,
     }
     }
 
 
     drawCommonBody(inst_ctx, variant, pose, out);
     drawCommonBody(inst_ctx, variant, pose, out);
-    drawHelmet(inst_ctx, variant, pose, out);
+    drawFacialHair(inst_ctx, variant, pose, out);
+    //drawHelmet(inst_ctx, variant, pose, out);
     addAttachments(inst_ctx, variant, pose, anim_ctx, out);
     addAttachments(inst_ctx, variant, pose, anim_ctx, out);
   }
   }
 }
 }

+ 47 - 1
render/humanoid/rig.h

@@ -3,6 +3,7 @@
 #include "../entity/registry.h"
 #include "../entity/registry.h"
 #include "../palette.h"
 #include "../palette.h"
 #include "humanoid_specs.h"
 #include "humanoid_specs.h"
+#include <QMatrix4x4>
 #include <QVector3D>
 #include <QVector3D>
 #include <cstdint>
 #include <cstdint>
 
 
@@ -31,11 +32,21 @@ struct FormationParams {
   float spacing;
   float spacing;
 };
 };
 
 
+struct HeadFrame {
+  QVector3D origin{0.0F, 0.0F, 0.0F};
+  QVector3D right{1.0F, 0.0F, 0.0F};
+  QVector3D up{0.0F, 1.0F, 0.0F};
+  QVector3D forward{0.0F, 0.0F, 1.0F};
+  float radius{0.0F};
+};
+
 struct HumanoidPose {
 struct HumanoidPose {
   QVector3D headPos;
   QVector3D headPos;
   float headR{};
   float headR{};
   QVector3D neck_base;
   QVector3D neck_base;
 
 
+  HeadFrame headFrame{};
+
   QVector3D shoulderL, shoulderR;
   QVector3D shoulderL, shoulderR;
   QVector3D elbowL, elbowR;
   QVector3D elbowL, elbowR;
   QVector3D handL, hand_r;
   QVector3D handL, hand_r;
@@ -77,8 +88,32 @@ struct VariationParams {
   }
   }
 };
 };
 
 
+enum class FacialHairStyle {
+  None,
+  Stubble,
+  ShortBeard,
+  FullBeard,
+  LongBeard,
+  Goatee,
+  Mustache,
+  MustacheAndBeard
+};
+
+struct FacialHairParams {
+  FacialHairStyle style = FacialHairStyle::None;
+  QVector3D color{0.15F, 0.12F, 0.10F};
+  float length = 1.0F;
+  float thickness = 1.0F;
+  float coverage = 1.0F;
+  float greyness = 0.0F;
+};
+
 struct HumanoidVariant {
 struct HumanoidVariant {
   HumanoidPalette palette;
   HumanoidPalette palette;
+  FacialHairParams facialHair;
+  float muscularity = 1.0F;
+  float scarring = 0.0F;
+  float weathering = 0.0F;
 };
 };
 
 
 enum class HumanoidMotionState {
 enum class HumanoidMotionState {
@@ -185,6 +220,9 @@ public:
                                        const QVector3D &right_axis,
                                        const QVector3D &right_axis,
                                        ISubmitter &out) const;
                                        ISubmitter &out) const;
 
 
+  virtual void drawFacialHair(const DrawContext &ctx, const HumanoidVariant &v,
+                              const HumanoidPose &pose, ISubmitter &out) const;
+
   void render(const DrawContext &ctx, ISubmitter &out) const;
   void render(const DrawContext &ctx, ISubmitter &out) const;
 
 
 protected:
 protected:
@@ -205,7 +243,15 @@ protected:
   static auto resolveTeamTint(const DrawContext &ctx) -> QVector3D;
   static auto resolveTeamTint(const DrawContext &ctx) -> QVector3D;
 
 
   void drawCommonBody(const DrawContext &ctx, const HumanoidVariant &v,
   void drawCommonBody(const DrawContext &ctx, const HumanoidVariant &v,
-                      const HumanoidPose &pose, ISubmitter &out) const;
+                      HumanoidPose &pose, ISubmitter &out) const;
+
+  static auto headLocalPosition(const HeadFrame &frame,
+                                const QVector3D &local) -> QVector3D;
+
+  static auto makeHeadLocalTransform(const QMatrix4x4 &parent,
+                                     const HeadFrame &frame,
+                                     const QVector3D &local_offset,
+                                     float uniform_scale) -> QMatrix4x4;
 };
 };
 
 
 } // namespace Render::GL
 } // namespace Render::GL