Browse Source

Merge pull request #495 from djeada/fix/torso

Fix/torso
Adam Djellouli 6 days ago
parent
commit
ca6933ec67
31 changed files with 717 additions and 1767 deletions
  1. 90 675
      assets/shaders/archer_carthage.frag
  2. 15 23
      assets/shaders/archer_roman_republic.frag
  3. 89 118
      assets/shaders/healer_carthage.frag
  4. 53 12
      assets/shaders/healer_roman_republic.frag
  5. 43 22
      assets/shaders/horse_archer_carthage.frag
  6. 5 0
      assets/shaders/horse_archer_roman_republic.frag
  7. 38 22
      assets/shaders/horse_spearman_carthage.frag
  8. 1 0
      assets/shaders/horse_spearman_carthage.vert
  9. 42 23
      assets/shaders/horse_swordsman_carthage.frag
  10. 1 0
      assets/shaders/horse_swordsman_carthage.vert
  11. 78 371
      assets/shaders/spearman_carthage.frag
  12. 11 18
      assets/shaders/spearman_roman_republic.frag
  13. 87 389
      assets/shaders/swordsman_carthage.frag
  14. 11 17
      assets/shaders/swordsman_roman_republic.frag
  15. 2 2
      render/entity/horse_archer_renderer_base.cpp
  16. 3 3
      render/entity/horse_spearman_renderer_base.cpp
  17. 2 2
      render/entity/mounted_knight_renderer_base.cpp
  18. 1 0
      render/entity/nations/carthage/archer_renderer.cpp
  19. 54 35
      render/entity/nations/carthage/healer_renderer.cpp
  20. 5 2
      render/entity/nations/carthage/healer_style.cpp
  21. 1 0
      render/entity/nations/carthage/healer_style.h
  22. 1 0
      render/entity/nations/roman/archer_renderer.cpp
  23. 2 2
      render/equipment/armor/armor_heavy_carthage.cpp
  24. 16 6
      render/equipment/armor/armor_light_carthage.cpp
  25. 3 2
      render/equipment/armor/cloak_renderer.cpp
  26. 47 11
      render/equipment/weapons/bow_renderer.cpp
  27. 3 0
      render/equipment/weapons/bow_renderer.h
  28. 2 2
      render/graphics_settings.h
  29. 7 4
      render/humanoid/mounted_pose_controller.cpp
  30. 3 5
      render/humanoid/rig.cpp
  31. 1 1
      tests/render/carthage_armor_bounds_test.cpp

+ 90 - 675
assets/shaders/archer_carthage.frag

@@ -1,724 +1,139 @@
 #version 330 core
 
-in vec3 v_normal;
 in vec3 v_worldNormal;
-in vec3 v_tangent;
-in vec3 v_bitangent;
-in vec2 v_texCoord;
 in vec3 v_worldPos;
-in float v_armorLayer;
-in float v_leatherTension;
-in float v_bodyHeight;
+in vec2 v_texCoord;
 
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform bool u_useTexture;
 uniform float u_alpha;
-uniform float u_time;
-uniform float u_rainIntensity;
 uniform int u_materialId;
 
 out vec4 FragColor;
 
-// ----------------------------- Utils & Noise -----------------------------
-const float PI = 3.14159265359;
+float saturate(float v) { return clamp(v, 0.0, 1.0); }
+vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
 
-float hash(vec2 p) {
+float hash12(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);
-}
-
-float triplanar_noise(vec3 pos, vec3 normal, float scale) {
-  vec3 b = abs(normal);
-  b = max(b, vec3(0.0001));
-  b /= (b.x + b.y + b.z);
-  float xy = noise(pos.xy * scale);
-  float yz = noise(pos.yz * scale);
-  float zx = noise(pos.zx * scale);
-  return xy * b.z + yz * b.x + zx * b.y;
-}
-
-float fbm(vec3 pos, vec3 normal, float scale) {
-  float total = 0.0;
-  float amp = 0.5;
-  float freq = 1.0;
-  for (int i = 0; i < 5; ++i) {
-    total += amp * triplanar_noise(pos * freq, normal, scale * freq);
-    freq *= 2.02;
-    amp *= 0.45;
+float fbm(vec2 p) {
+  float v = 0.0;
+  float a = 0.5;
+  for (int i = 0; i < 4; ++i) {
+    v += a * hash12(p);
+    p *= 2.0;
+    a *= 0.55;
   }
-  return total;
-}
-
-struct MaterialSample {
-  vec3 albedo;
-  vec3 normal;
-  float roughness;
-  float ao;
-  float metallic;
-  vec3 F0;
-};
-
-struct Light {
-  vec3 dir;
-  vec3 color;
-  float intensity;
-};
-
-const vec3 REF_LEATHER = vec3(0.38, 0.26, 0.14);
-const vec3 REF_LEATHER_DARK = vec3(0.24, 0.16, 0.10);
-const vec3 REF_WOOD = vec3(0.38, 0.28, 0.18);
-const vec3 REF_CLOTH = vec3(0.14, 0.34, 0.52);
-const vec3 REF_SKIN = vec3(0.93, 0.78, 0.65);
-const vec3 REF_BEARD = vec3(0.28, 0.20, 0.13);
-const vec3 REF_METAL = vec3(0.75, 0.75, 0.78);
-const vec3 CANON_WOOD = vec3(0.24, 0.16, 0.09);
-const vec3 CANON_SKIN = vec3(0.92, 0.78, 0.64);
-const vec3 CANON_BEARD = vec3(0.05, 0.05, 0.05);
-const vec3 CANON_HELMET = vec3(0.78, 0.80, 0.88);
-
-// Luma / chroma helpers
-float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }
-float sat(vec3 c) {
-  float mx = max(max(c.r, c.g), c.b);
-  float mn = min(min(c.r, c.g), c.b);
-  return (mx - mn) / max(mx, 1e-5);
-}
-
-float color_distance(vec3 a, vec3 b) { return length(a - b); }
-
-// ----------------------------- Geometry helpers -----------------------------
-float fresnel_schlick(float cos_theta, float F0) {
-  return F0 + (1.0 - F0) * pow(1.0 - cos_theta, 5.0);
+  return v;
 }
 
-vec3 fresnel_schlick(vec3 F0, float cos_theta) {
-  return F0 + (vec3(1.0) - F0) * pow(1.0 - cos_theta, 5.0);
-}
-
-float compute_curvature(vec3 normal) {
-  vec3 dx = dFdx(normal);
-  vec3 dy = dFdy(normal);
-  return length(dx) + length(dy);
-}
-
-// Microfacet (GGX) – isotropic (we’ll tint/spec weight per material)
 float D_GGX(float NdotH, float a) {
   float a2 = a * a;
-  float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
-  return a2 / max(PI * d * d, 1e-6);
-}
-
-float G_Smith(float NdotV, float NdotL, float a) {
-  // Schlick-GGX
-  float k = (a + 1.0);
-  k = (k * k) / 8.0;
-  float g_v = NdotV / (NdotV * (1.0 - k) + k);
-  float g_l = NdotL / (NdotL * (1.0 - k) + k);
-  return g_v * g_l;
-}
-
-// Perturb normal procedurally to add micro detail per material
-vec3 perturb_normal_leather(vec3 N, vec3 T, vec3 B, vec3 P) {
-  float g1 = fbm(P * 6.0, N, 8.0);
-  float g2 = fbm(P * 18.0 + vec3(2.1), N, 24.0);
-  vec3 n_t = T * (g1 * 0.06 + g2 * 0.02);
-  vec3 n_b = B * (g1 * 0.03 + g2 * 0.03);
-  vec3 p_n = normalize(N + n_t + n_b);
-  return p_n;
-}
-
-vec3 perturb_normal_linen(vec3 N, vec3 T, vec3 B, vec3 P) {
-  // warp/weft weave
-  float warp = sin(P.x * 140.0) * 0.06;
-  float weft = sin(P.z * 146.0) * 0.06;
-  float slub = fbm(P * 7.0, N, 10.0) * 0.04;
-  vec3 p_n = normalize(N + T * (warp + slub) + B * (weft + slub * 0.5));
-  return p_n;
-}
-
-vec3 perturb_normal_bronze(vec3 N, vec3 T, vec3 B, vec3 P) {
-  float hammer = fbm(P * 16.0, N, 22.0) * 0.10;
-  float ripple = fbm(P * 40.0 + vec3(3.7), N, 55.0) * 0.03;
-  vec3 p_n = normalize(N + T * hammer + B * ripple);
-  return p_n;
-}
-
-// Clearcoat (water film) lobe for rain
-vec3 clearcoat_spec(vec3 N, vec3 L, vec3 V, float coat_strength,
-                    float coat_rough) {
-  vec3 H = normalize(L + V);
-  float NdotV = max(dot(N, V), 0.0);
-  float NdotL = max(dot(N, L), 0.0);
-  float NdotH = max(dot(N, H), 0.0);
-  float a = max(coat_rough, 0.02);
-  float D = D_GGX(NdotH, a);
-  float G = G_Smith(NdotV, NdotL, a);
-  // IOR ~1.33 → F0 ≈ 0.02
-  float F = fresnel_schlick(max(dot(H, V), 0.0), 0.02);
-  float spec = (D * G * F) / max(4.0 * NdotV * NdotL + 1e-5, 1e-5);
-  return vec3(spec * coat_strength);
-}
-
-mat3 make_tangent_basis(vec3 N, vec3 T, vec3 B) {
-  vec3 t = normalize(T - N * dot(N, T));
-  vec3 b = normalize(B - N * dot(N, B));
-  if (length(t) < 1e-4)
-    t = vec3(1.0, 0.0, 0.0);
-  if (length(b) < 1e-4)
-    b = normalize(cross(N, t));
-  return mat3(t, b, N);
-}
-
-vec3 apply_micro_normal(vec3 base_n, vec3 T, vec3 B, vec3 pos,
-                        float intensity) {
-  mat3 basis = make_tangent_basis(base_n, T, B);
-  float noise_x = fbm(pos * 18.0 + vec3(1.37, 2.07, 3.11), base_n, 24.0);
-  float noise_y = fbm(pos * 20.0 + vec3(-2.21, 1.91, 0.77), base_n, 26.0);
-  vec3 tangent_normal =
-      normalize(vec3(noise_x * 2.0 - 1.0, noise_y * 2.0 - 1.0, 1.0));
-  tangent_normal.xy *= intensity;
-  return normalize(basis * tangent_normal);
-}
-
-vec3 tone_map_and_gamma(vec3 color) {
-  color = color / (color + vec3(1.0));
-  return pow(color, vec3(1.0 / 2.2));
-}
-
-vec3 compute_ambient(vec3 normal) {
-  float up = clamp(normal.y, 0.0, 1.0);
-  float down = clamp(-normal.y, 0.0, 1.0);
-  vec3 sky = vec3(0.60, 0.70, 0.85);
-  vec3 ground = vec3(0.40, 0.34, 0.28);
-  return sky * (0.25 + 0.55 * up) + ground * (0.10 + 0.35 * down);
-}
-
-MaterialSample make_leather_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                   vec3 world_pos, float tension,
-                                   float body_height, float layer,
-                                   float wet_mask, float curvature) {
-  MaterialSample mat;
-  mat.metallic = 0.0;
-
-  vec3 target_leather = vec3(0.42, 0.30, 0.20);
-  vec3 color = mix(base_color, target_leather, 0.12);
-
-  float torso_bleach = mix(0.92, 1.08, clamp(body_height, 0.0, 1.0));
-  color *= vec3(torso_bleach, mix(0.90, 0.98, body_height),
-                mix(0.87, 0.95, body_height));
-
-  float macro = fbm(world_pos * 3.2, Nw, 4.2);
-  float medium = fbm(world_pos * 7.6, Nw, 8.5);
-  float fine = fbm(world_pos * 16.0, Nw, 18.0);
-  float pores = fbm(world_pos * 32.0 + vec3(3.7), Nw, 38.0);
-  float albedo_noise =
-      macro * 0.35 + medium * 0.30 + fine * 0.22 + pores * 0.13;
-  color *= mix(0.88, 1.12, albedo_noise);
-
-  float dirt = fbm(world_pos * vec3(2.5, 1.1, 2.5), Nw, 3.5) *
-               (1.0 - clamp(body_height, 0.0, 1.0));
-  color = mix(color, color * vec3(0.70, 0.58, 0.42),
-              smoothstep(0.45, 0.75, dirt) * 0.25);
-
-  float salt =
-      smoothstep(0.62, 0.95, fbm(world_pos * vec3(12.0, 6.0, 12.0), Nw, 14.0));
-  color = mix(color, color * vec3(0.82, 0.80, 0.76), salt * 0.18);
-
-  float strap_band = smoothstep(0.1, 0.9, sin(world_pos.y * 5.2 + layer * 1.7));
-  float seam = sin(world_pos.x * 3.7 + world_pos.z * 2.9);
-  color = mix(color, color * vec3(0.86, 0.83, 0.78), strap_band * 0.12);
-  color = mix(color, base_color, smoothstep(0.2, 0.8, seam) * 0.08);
-
-  color = mix(color, color * 0.55, wet_mask * 0.85);
-
-  vec3 macro_normal = perturb_normal_leather(Nw, Tw, Bw, world_pos);
-  vec3 normal = apply_micro_normal(macro_normal, Tw, Bw, world_pos, 0.55);
-
-  float grain = macro * 0.40 + medium * 0.32 + fine * 0.20 + pores * 0.08;
-  float rough_base = clamp(0.78 - tension * 0.18 + grain * 0.15, 0.52, 0.92);
-  float wet_influence = mix(0.0, -0.28, wet_mask);
-  mat.roughness = clamp(rough_base + wet_influence, 0.35, 0.95);
-
-  float crease =
-      smoothstep(0.45, 0.75, fbm(world_pos * vec3(1.4, 3.5, 1.4), Nw, 2.6));
-  float layer_ao = mix(0.85, 0.65, clamp(layer / 2.0, 0.0, 1.0));
-  float curvature_ao = mix(0.75, 1.0, clamp(1.0 - curvature * 1.2, 0.0, 1.0));
-  mat.ao = clamp((1.0 - crease * 0.35) * layer_ao * curvature_ao *
-                     (0.9 - wet_mask * 0.15),
-                 0.35, 1.0);
-
-  mat.albedo = color;
-  mat.normal = normal;
-  vec3 tint_spec = mix(vec3(0.035), color, 0.16);
-  mat.F0 = mix(tint_spec, vec3(0.08), wet_mask * 0.45);
-  return mat;
-}
-
-MaterialSample make_linen_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                 vec3 world_pos, float body_height,
-                                 float wet_mask, float curvature) {
-  MaterialSample mat;
-  mat.metallic = 0.0;
-
-  vec3 target = vec3(0.88, 0.85, 0.78);
-  vec3 color = mix(target, base_color, 0.45);
-  float weave = sin(world_pos.x * 62.0) * sin(world_pos.z * 66.0) * 0.08;
-  float sizing = fbm(world_pos * 3.0, Nw, 4.5) * 0.10;
-  float fray = fbm(world_pos * 9.0, Nw, 10.0) *
-               clamp(1.4 - clamp(Nw.y, 0.0, 1.0), 0.0, 1.0) * 0.12;
-  color += vec3(weave * 0.35);
-  color -= vec3(sizing * 0.5);
-  color -= vec3(fray * 0.12);
-
-  float dust = clamp(1.0 - Nw.y, 0.0, 1.0) * fbm(world_pos * 1.1, Nw, 2.0);
-  float sweat =
-      smoothstep(0.6, 1.0, body_height) * fbm(world_pos * 2.4, Nw, 3.1);
-  color = mix(color, color * (1.0 - dust * 0.35), 0.7);
-  color = mix(color, color * vec3(0.96, 0.93, 0.88),
-              1.0 - clamp(sweat * 0.5, 0.0, 1.0));
-
-  color *= (1.0 - wet_mask * 0.35);
-
-  mat.albedo = color;
-  mat.normal = perturb_normal_linen(Nw, Tw, Bw, world_pos);
-  float rough_noise = fbm(world_pos * 5.0, Nw, 7.5);
-  mat.roughness =
-      clamp(0.82 + rough_noise * 0.12 - wet_mask * 0.22, 0.55, 0.96);
-  mat.ao =
-      clamp(0.85 - dust * 0.20 - sweat * 0.15 + curvature * 0.05, 0.4, 1.0);
-  mat.F0 = vec3(0.028);
-  return mat;
-}
-
-MaterialSample make_bronze_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                  vec3 world_pos, float wet_mask,
-                                  float curvature) {
-  MaterialSample mat;
-  mat.metallic = 0.9;
-  vec3 bronze_warm = vec3(0.58, 0.44, 0.20);
-  vec3 cuprite = vec3(0.36, 0.18, 0.10);
-  vec3 malachite = vec3(0.18, 0.45, 0.36);
-
-  vec3 macro_normal = perturb_normal_bronze(Nw, Tw, Bw, world_pos);
-  float hammer = fbm(world_pos * 14.0, Nw, 20.0) * 0.18;
-  float patina = fbm(world_pos * 6.0 + vec3(5.0), Nw, 8.0) * 0.14;
-  float run_off = fbm(world_pos * vec3(1.2, 3.4, 1.2), Nw, 2.2) *
-                  (1.0 - clamp(Nw.y, 0.0, 1.0));
-
-  vec3 bronze_base = mix(bronze_warm, base_color, 0.35) + vec3(hammer);
-  vec3 with_cuprite =
-      mix(bronze_base, cuprite,
-          smoothstep(0.70, 0.95, fbm(world_pos * 9.0, Nw, 12.0)));
-  vec3 color = mix(with_cuprite, malachite,
-                   clamp(patina * 0.5 + run_off * 0.6, 0.0, 1.0));
-
-  color = mix(color, color * 0.65, wet_mask * 0.6);
-
-  mat.albedo = color;
-  mat.normal = apply_micro_normal(macro_normal, Tw, Bw, world_pos, 0.35);
-  mat.roughness = clamp(0.32 + hammer * 0.25 + patina * 0.15 + wet_mask * -0.18,
-                        0.18, 0.75);
-  mat.ao = clamp(0.85 - patina * 0.3 + curvature * 0.1, 0.45, 1.0);
-  vec3 F0 = mix(vec3(0.06), clamp(bronze_warm, 0.0, 1.0), mat.metallic);
-  mat.F0 = mix(F0, vec3(0.08), wet_mask * 0.3);
-  return mat;
-}
-
-MaterialSample make_fallback_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                    vec3 world_pos, float wet_mask,
-                                    float curvature) {
-  MaterialSample mat;
-  mat.albedo = base_color * (0.9 + 0.2 * fbm(world_pos * 4.0, Nw, 5.5));
-  vec3 macro_normal = perturb_normal_leather(Nw, Tw, Bw, world_pos);
-  mat.normal = apply_micro_normal(macro_normal, Tw, Bw, world_pos, 0.25);
-  mat.roughness = clamp(0.60 - wet_mask * 0.20, 0.35, 0.85);
-  mat.ao = clamp(0.8 + curvature * 0.1, 0.4, 1.0);
-  mat.metallic = 0.0;
-  mat.F0 = vec3(0.04);
-  mat.albedo = mix(mat.albedo, mat.albedo * 0.7, wet_mask * 0.5);
-  return mat;
-}
-
-MaterialSample make_wood_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                vec3 world_pos, float wet_mask,
-                                float curvature) {
-  MaterialSample mat;
-  vec3 color = base_color;
-  float grain = sin(world_pos.y * 18.0 + fbm(world_pos * 2.0, Nw, 2.5) * 3.5);
-  float rings = sin(world_pos.x * 6.5 + grain * 2.0);
-  float burn = fbm(world_pos * vec3(1.2, 0.6, 1.2), Nw, 1.6);
-  color *= 1.0 + grain * 0.05;
-  color -= burn * 0.08;
-  color = mix(color, color * 0.6, wet_mask * 0.4);
-  mat.albedo = color;
-  vec3 macro_normal = apply_micro_normal(Nw, Tw, Bw, world_pos, 0.18);
-  mat.normal = normalize(macro_normal + Tw * (grain * 0.05));
-  mat.roughness =
-      clamp(0.62 + fbm(world_pos * 6.0, Nw, 6.0) * 0.15 - wet_mask * 0.18, 0.35,
-            0.92);
-  mat.ao = clamp(0.9 - burn * 0.15 + curvature * 0.08, 0.4, 1.0);
-  mat.metallic = 0.0;
-  mat.F0 = vec3(0.035);
-  return mat;
-}
-
-MaterialSample make_skin_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                vec3 world_pos, float wet_mask,
-                                float curvature) {
-  MaterialSample mat;
-  vec3 color = base_color;
-  float freckle = fbm(world_pos * vec3(9.0, 4.0, 9.0), Nw, 12.0);
-  float blush = smoothstep(0.2, 0.9, Nw.y) * 0.08;
-  color += freckle * 0.03;
-  color += blush;
-  color = mix(color, color * 0.9, wet_mask * 0.15);
-  mat.albedo = color;
-  mat.normal = apply_micro_normal(Nw, Tw, Bw, world_pos, 0.12);
-  mat.roughness = clamp(0.58 + freckle * 0.1 - wet_mask * 0.15, 0.38, 0.85);
-  mat.ao = clamp(0.92 - curvature * 0.15, 0.5, 1.0);
-  mat.metallic = 0.0;
-  mat.F0 = vec3(0.028);
-  return mat;
-}
-
-MaterialSample make_hair_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                vec3 world_pos, float wet_mask) {
-  MaterialSample mat;
-  vec3 color = base_color * (0.9 + fbm(world_pos * 5.0, Nw, 7.0) * 0.12);
-  float strand = sin(world_pos.x * 64.0) * 0.08;
-  color += strand * 0.04;
-  color = mix(color, color * 0.7, wet_mask * 0.35);
-  mat.albedo = color;
-  mat.normal = apply_micro_normal(Nw, Tw, Bw, world_pos, 0.45);
-  mat.roughness = clamp(0.42 + strand * 0.05 - wet_mask * 0.18, 0.2, 0.7);
-  mat.ao = 0.8;
-  mat.metallic = 0.0;
-  mat.F0 = vec3(0.06);
-  return mat;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(3.14159 * d * d, 1e-5);
 }
 
-MaterialSample make_cloth_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                 vec3 world_pos, float wet_mask,
-                                 float curvature) {
-  MaterialSample mat;
-  vec3 color = base_color;
-  float weave = sin(world_pos.x * 52.0) * sin(world_pos.z * 55.0) * 0.08;
-  float fold = fbm(world_pos * 3.4, Nw, 4.0) * 0.15;
-  float stripe = sin(world_pos.y * 8.0 + world_pos.x * 2.0);
-  float team_accent = smoothstep(0.2, 0.9, stripe);
-  color += weave * 0.3;
-  color += fold * 0.08;
-  color = mix(color, color * 1.15, team_accent * 0.08);
-  color = mix(color, color * 0.7, wet_mask * 0.3);
-  mat.albedo = color;
-  mat.normal = apply_micro_normal(Nw, Tw, Bw, world_pos, 0.12);
-  mat.roughness =
-      clamp(0.78 + weave * 0.1 + fold * 0.05 - wet_mask * 0.2, 0.45, 0.95);
-  mat.ao = clamp(0.9 - fold * 0.2 + curvature * 0.05, 0.4, 1.0);
-  mat.metallic = 0.0;
-  mat.F0 = vec3(0.03);
-  return mat;
+float geometry_schlick(float NdotX, float k) {
+  return NdotX / max(NdotX * (1.0 - k) + k, 1e-5);
 }
 
-MaterialSample make_cloak_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                 vec3 world_pos, float wet_mask,
-                                 float curvature) {
-  MaterialSample mat;
-  vec3 color = base_color;
-
-  // Fine fabric detail (high frequency weave)
-  float weave = sin(world_pos.x * 200.0) * sin(world_pos.y * 200.0) * 0.03;
-  float silk_sheen = fbm(world_pos * 4.0, Nw, 5.0) * 0.15;
-
-  color += weave * 0.1;
-  color += silk_sheen * 0.05;
-
-  // Wetness darkening
-  color = mix(color, color * 0.6, wet_mask * 0.5);
-
-  mat.albedo = color;
-
-  // Normal perturbation for fabric
-  vec3 macro_normal = apply_micro_normal(Nw, Tw, Bw, world_pos, 0.15);
-  mat.normal = macro_normal;
-
-  // Roughness - silk/fine cloth is smoother than wool
-  // Add some anisotropic feel via sheen noise
-  mat.roughness = clamp(0.55 - silk_sheen * 0.2 - wet_mask * 0.3, 0.25, 0.9);
-
-  mat.ao = clamp(0.9 - curvature * 0.1, 0.5, 1.0);
-  mat.metallic = 0.0;
-  mat.F0 = vec3(0.05); // Slightly higher F0 for silk/satin
-
-  return mat;
+float geometry_smith(float NdotV, float NdotL, float roughness) {
+  float r = roughness + 1.0;
+  float k = (r * r) / 8.0;
+  return geometry_schlick(NdotV, k) * geometry_schlick(NdotL, k);
 }
 
-MaterialSample make_metal_sample(vec3 base_color, vec3 Nw, vec3 Tw, vec3 Bw,
-                                 vec3 world_pos, float wet_mask,
-                                 float curvature) {
-  MaterialSample mat;
-  mat.metallic = 1.0;
-  vec3 silver = vec3(0.78, 0.80, 0.88);
-  vec3 color = mix(silver, base_color, 0.25);
-  float hammer = fbm(world_pos * 22.0, Nw, 28.0) * 0.08;
-  float scrape = fbm(world_pos * 8.0, Nw, 10.0) * 0.12;
-  color += hammer * 0.2;
-  color -= scrape * 0.08;
-  color = mix(color, color * 0.7, wet_mask * 0.4);
-  mat.albedo = color;
-  mat.normal = apply_micro_normal(Nw, Tw, Bw, world_pos, 0.25);
-  mat.roughness =
-      clamp(0.22 + hammer * 0.12 + scrape * 0.08 - wet_mask * 0.15, 0.08, 0.5);
-  mat.ao = clamp(0.85 - scrape * 0.25 + curvature * 0.08, 0.4, 1.0);
-  mat.F0 = mix(vec3(0.08), color, 0.85);
-  return mat;
-}
-
-vec3 evaluate_light(const MaterialSample mat, const Light light, vec3 V) {
-  vec3 N = mat.normal;
-  vec3 L = normalize(light.dir);
-  float NdotL = max(dot(N, L), 0.0);
-  if (NdotL <= 0.0)
-    return vec3(0.0);
-
-  vec3 H = normalize(V + L);
-  float NdotV = max(dot(N, V), 0.0);
-  float NdotH = max(dot(N, H), 0.0);
-
-  float alpha = max(mat.roughness, 0.05);
-  float NDF = D_GGX(NdotH, alpha);
-  float G = G_Smith(NdotV, NdotL, alpha);
-  vec3 F = fresnel_schlick(mat.F0, max(dot(H, V), 0.0));
-
-  vec3 numerator = NDF * G * F;
-  float denom = max(4.0 * NdotV * NdotL, 0.001);
-  vec3 specular = numerator / denom;
-  specular *= mix(vec3(1.0), mat.albedo, 0.18 * (1.0 - mat.metallic));
-
-  vec3 k_s = F;
-  vec3 k_d = (vec3(1.0) - k_s) * (1.0 - mat.metallic);
-  vec3 diffuse = k_d * mat.albedo / PI;
-
-  vec3 radiance = light.color * light.intensity;
-  return (diffuse + specular) * radiance * NdotL;
+vec3 fresnel_schlick(float cos_theta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cos_theta, 5.0);
 }
 
-// ----------------------------- Main -----------------------------
 void main() {
-  vec3 base_color = u_color;
+  vec3 base = u_color;
   if (u_useTexture) {
-    base_color *= texture(u_texture, v_texCoord).rgb;
+    base *= texture(u_texture, v_texCoord).rgb;
   }
 
-  float Y = luma(base_color);
-  float S = sat(base_color);
-  float blue_ratio = base_color.b / max(base_color.r, 0.001);
-
-  bool likely_leather =
-      (Y > 0.18 && Y < 0.65 && base_color.r > base_color.g * 1.03);
-  bool likely_linen = (Y > 0.65 && S < 0.22);
-  bool likely_bronze = (base_color.r > base_color.g * 1.03 &&
-                        base_color.r > base_color.b * 1.10 && Y > 0.42);
-  float leather_dist = min(color_distance(base_color, REF_LEATHER),
-                           color_distance(base_color, REF_LEATHER_DARK));
-  bool palette_leather = leather_dist < 0.18;
-  bool looks_wood =
-      (blue_ratio > 0.42 && blue_ratio < 0.8 && Y < 0.55 && S < 0.55) ||
-      color_distance(base_color, REF_WOOD) < 0.12;
-  bool looks_cloth = color_distance(base_color, REF_CLOTH) < 0.22 ||
-                     (base_color.b > base_color.g * 1.25 &&
-                      base_color.b > base_color.r * 1.35);
-  bool looks_skin = color_distance(base_color, REF_SKIN) < 0.2 ||
-                    (S < 0.35 && base_color.r > 0.55 && base_color.g > 0.35 &&
-                     base_color.b > 0.28);
-  bool looks_beard =
-      (!looks_skin &&
-       (color_distance(base_color, REF_BEARD) < 0.16 || (Y < 0.32 && S < 0.4)));
-  bool looks_metal = color_distance(base_color, REF_METAL) < 0.18 ||
-                     (S < 0.15 && Y > 0.4 && base_color.b > base_color.r * 0.9);
-
-  bool prefer_leather = (palette_leather && blue_ratio < 0.42) ||
-                        (likely_leather && !looks_wood && blue_ratio < 0.4);
-
-  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield, 5=cloak
+  bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
-  bool is_shield = (u_materialId == 4);
-  bool is_cloak = (u_materialId == 5);
-
-  // Use material ID masks only (no fallback detection)
-  bool is_helmet_region = is_helmet;
-  bool is_face_region = (u_materialId == 0);
-
-  vec3 Nw = normalize(v_worldNormal);
-  vec3 Tw = normalize(v_tangent);
-  vec3 Bw = normalize(v_bitangent);
 
+  vec3 N = normalize(v_worldNormal);
   vec3 V = normalize(vec3(0.0, 0.0, 1.0));
+  vec3 L = normalize(vec3(0.5, 1.0, 0.4));
+  vec3 H = normalize(L + V);
 
-  float rain = clamp(u_rainIntensity, 0.0, 1.0);
-  float curvature = compute_curvature(Nw);
-
-  float streak = smoothstep(0.65, 1.0,
-                            sin(v_worldPos.y * 22.0 - u_time * 4.0 +
-                                fbm(v_worldPos * 0.8, Nw, 1.2) * 6.283));
-  float wet_gather = (1.0 - clamp(Nw.y, 0.0, 1.0)) *
-                     (0.4 + 0.6 * fbm(v_worldPos * 2.0, Nw, 3.0));
-  float wet_mask =
-      clamp(rain * mix(0.5 * wet_gather, 1.0 * wet_gather, streak), 0.0, 1.0);
-
-  MaterialSample material = make_fallback_sample(
-      base_color, Nw, Tw, Bw, v_worldPos, wet_mask, curvature);
-  if (is_cloak) {
-    material = make_cloak_sample(base_color, Nw, Tw, Bw, v_worldPos, wet_mask,
-                                 curvature);
-  } else if (looks_metal && is_helmet_region) {
-    material = make_metal_sample(CANON_HELMET, Nw, Tw, Bw, v_worldPos, wet_mask,
-                                 curvature);
-  } else if (looks_skin && is_face_region) {
-    material = make_skin_sample(CANON_SKIN, Nw, Tw, Bw, v_worldPos, wet_mask,
-                                curvature);
-  } else if (looks_beard && is_face_region) {
-    material = make_hair_sample(CANON_BEARD, Nw, Tw, Bw, v_worldPos, wet_mask);
-  } else if (is_armor) {
-    // Reuse the spearman armor stack (leather + scales + mail) for consistent
-    // Carthage torso armor.
-    vec3 leather_base = vec3(0.44, 0.30, 0.19);
-    vec3 linen_base = vec3(0.86, 0.80, 0.72);
-    vec3 bronze_base = vec3(0.62, 0.46, 0.20);
-    vec3 chain_base = vec3(0.78, 0.80, 0.82);
-
-    MaterialSample leather = make_leather_sample(
-        leather_base, Nw, Tw, Bw, v_worldPos, clamp(v_leatherTension, 0.0, 1.0),
-        clamp(v_bodyHeight, 0.0, 1.0), v_armorLayer, wet_mask, curvature);
-    MaterialSample linen =
-        make_linen_sample(linen_base, Nw, Tw, Bw, v_worldPos,
-                          clamp(v_bodyHeight, 0.0, 1.0), wet_mask, curvature);
-    MaterialSample scales = make_bronze_sample(bronze_base, Nw, Tw, Bw,
-                                               v_worldPos, wet_mask, curvature);
-    MaterialSample mail = make_metal_sample(chain_base, Nw, Tw, Bw, v_worldPos,
-                                            wet_mask, curvature);
-
-    float torsoBand = 1.0;
-    float skirtBand = 0.0;
-    float mailBlend = clamp(smoothstep(0.25, 0.85,
-                                       fbm(v_worldPos * 1.2, Nw, 2.5) +
-                                           v_leatherTension * 0.2),
-                            0.0, 1.0) *
-                      torsoBand * 0.30;
-    float scaleBlend = torsoBand * 0.55;
-    float linenBlend = skirtBand * 0.40;
-    float leatherOverlay = skirtBand * 0.90 + torsoBand * 0.30;
-
-    float edge = 1.0 - clamp(dot(Nw, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
-    vec3 highlight = vec3(0.10, 0.08, 0.05) * smoothstep(0.3, 0.9, edge);
-
-    vec3 albedo = leather.albedo;
-    albedo = mix(albedo, linen.albedo, linenBlend);
-    albedo = mix(albedo, scales.albedo, scaleBlend);
-    albedo = mix(albedo, mail.albedo, mailBlend);
-    albedo = mix(albedo, leather.albedo + highlight, leatherOverlay);
-
-    float leather_depth = clamp(
-        leatherOverlay * 0.8 + linenBlend * 0.2 + scaleBlend * 0.15, 0.0, 1.0);
-    albedo = mix(albedo, albedo * 0.88 + vec3(0.04, 0.03, 0.02),
-                 leather_depth * 0.35);
-
-    vec3 normal = leather.normal;
-    normal = normalize(mix(normal, linen.normal, linenBlend));
-    normal = normalize(mix(normal, scales.normal, scaleBlend));
-    normal = normalize(mix(normal, mail.normal, mailBlend));
-
-    float roughness = leather.roughness;
-    roughness = mix(roughness, linen.roughness, linenBlend);
-    roughness = mix(roughness, scales.roughness, scaleBlend);
-    roughness = mix(roughness, mail.roughness, mailBlend);
-
-    float metallic = leather.metallic;
-    metallic = mix(metallic, linen.metallic, linenBlend);
-    metallic = mix(metallic, scales.metallic, scaleBlend);
-    metallic = mix(metallic, mail.metallic, mailBlend);
-
-    float ao = leather.ao;
-    ao = mix(ao, linen.ao, linenBlend);
-    ao = mix(ao, scales.ao, scaleBlend);
-    ao = mix(ao, mail.ao, mailBlend);
-
-    vec3 F0 = leather.F0;
-    F0 = mix(F0, linen.F0, linenBlend);
-    F0 = mix(F0, scales.F0, scaleBlend);
-    F0 = mix(F0, mail.F0, mailBlend);
-
-    material.albedo = albedo;
-    material.normal = normal;
-    material.roughness = roughness;
-    material.metallic = metallic;
-    material.ao = ao;
-    material.F0 = F0;
-  } else if (looks_wood) {
-    vec3 wood_color = mix(base_color, CANON_WOOD, 0.35);
-    material = make_wood_sample(wood_color, Nw, Tw, Bw, v_worldPos, wet_mask,
-                                curvature);
-  } else if (looks_cloth) {
-    material = make_cloth_sample(base_color, Nw, Tw, Bw, v_worldPos, wet_mask,
-                                 curvature);
-  } else if (prefer_leather) {
-    vec3 leather_base = mix(base_color, vec3(0.44, 0.30, 0.19), 0.75);
-    material = make_leather_sample(
-        leather_base, Nw, Tw, Bw, v_worldPos, clamp(v_leatherTension, 0.0, 1.0),
-        clamp(v_bodyHeight, 0.0, 1.0), v_armorLayer, wet_mask, curvature);
-  } else if (likely_linen) {
-    material =
-        make_linen_sample(base_color, Nw, Tw, Bw, v_worldPos,
-                          clamp(v_bodyHeight, 0.0, 1.0), wet_mask, curvature);
-  } else if (likely_bronze) {
-    material = make_bronze_sample(base_color, Nw, Tw, Bw, v_worldPos, wet_mask,
-                                  curvature);
+  // Light leather armor for archers.
+  float metallic = 0.0;
+  float roughness = 0.55;
+  vec3 albedo = base;
+
+  if (is_skin) {
+    albedo = mix(base, vec3(0.90, 0.78, 0.68), 0.30);
+    roughness = 0.6;
+    // Jagged leather pants for lower body
+    float pants_mask = 1.0 - smoothstep(0.38, 0.60, v_worldPos.y);
+    float jag = fbm(v_worldPos.xz * 15.0 + v_worldPos.y * 3.0);
+    vec3 leather = vec3(0.42, 0.30, 0.20) - vec3(0.04) * jag;
+    albedo = mix(albedo, leather, pants_mask);
+    roughness = mix(roughness, 0.55, pants_mask);
+  } else if (is_armor || is_helmet) {
+    vec3 leather = vec3(0.50, 0.34, 0.22);
+    float grain = fbm(v_worldPos.xy * 13.0);
+    float crack = fbm(v_worldPos.zy * 9.5 + vec2(2.1, 4.2));
+    float wear = grain * 0.38 + crack * 0.28;
+    albedo = mix(leather, base, 0.45);
+    albedo -= vec3(0.05) * wear;
+    metallic = 0.05;
+    roughness = mix(0.48, 0.62, wear);
+  } else if (is_weapon) {
+    // Bow: wood body with darker handle wrap and subtle string shine.
+    float y = clamp(v_worldPos.y, 0.0, 1.0);
+    float wrap = smoothstep(0.35, 0.45, y) * smoothstep(0.55, 0.45, y);
+
+    vec3 wood = vec3(0.52, 0.36, 0.22);
+    vec3 wrap_col = vec3(0.28, 0.18, 0.12);
+    vec3 string = vec3(0.82, 0.78, 0.70);
+
+    float wood_grain = fbm(v_worldPos.xz * 16.0 + v_worldPos.y * 5.0);
+    vec3 wood_color = mix(wood, base, 0.35) + vec3(0.05) * wood_grain;
+    vec3 wrap_color = mix(wrap_col, base, 0.20);
+
+    albedo = mix(wood_color, wrap_color, wrap);
+    metallic = 0.0;
+    roughness = mix(0.50, 0.42, wrap);
+
+    // Add a tiny clearcoat for the string region near top/bottom.
+    float string_mask = smoothstep(0.90, 1.02, y) + smoothstep(0.10, -0.05, y);
+    if (string_mask > 0.0) {
+      albedo = mix(albedo, string, clamp(string_mask, 0.0, 1.0) * 0.5);
+      roughness = mix(roughness, 0.30, clamp(string_mask, 0.0, 1.0));
+    }
   }
 
-  Light lights[2];
-  lights[0].dir = normalize(vec3(0.55, 1.15, 0.35));
-  lights[0].color = vec3(1.15, 1.05, 0.95);
-  lights[0].intensity = 1.35;
-  lights[1].dir = normalize(vec3(-0.35, 0.65, -0.45));
-  lights[1].color = vec3(0.35, 0.45, 0.65);
-  lights[1].intensity = 0.35;
+  float NdotL = max(dot(N, L), 0.0);
+  float NdotV = max(dot(N, V), 0.0);
+  float NdotH = max(dot(N, H), 0.0);
+  float VdotH = max(dot(V, H), 0.0);
 
-  vec3 light_accum = vec3(0.0);
-  for (int i = 0; i < 2; ++i) {
-    vec3 contribution = evaluate_light(material, lights[i], V);
-    if (wet_mask > 0.001) {
-      contribution +=
-          clearcoat_spec(material.normal, lights[i].dir, V, wet_mask * 0.8,
-                         mix(0.10, 0.03, wet_mask)) *
-          lights[i].color * lights[i].intensity;
-    }
-    light_accum += contribution;
-  }
+  float a = max(0.02, roughness * roughness);
+  float D = D_GGX(NdotH, a);
+  float G = geometry_smith(NdotV, NdotL, roughness);
+  vec3 F0 = mix(vec3(0.04), albedo, metallic);
+  vec3 F = fresnel_schlick(VdotH, F0);
+  vec3 spec = (D * G * F) / max(4.0 * NdotL * NdotV + 1e-5, 1e-5);
 
-  vec3 ambient =
-      compute_ambient(material.normal) * material.albedo * material.ao * 0.42;
-  vec3 bounce = vec3(0.45, 0.34, 0.25) *
-                (0.15 + 0.45 * clamp(-material.normal.y, 0.0, 1.0));
-  vec3 color =
-      light_accum + ambient + bounce * (1.0 - material.metallic) * 0.25;
+  vec3 kd = (vec3(1.0) - F) * (1.0 - metallic);
+  vec3 diffuse = kd * albedo / 3.14159;
 
-  color = mix(color, color * 0.85, wet_mask * 0.2);
-  color = tone_map_and_gamma(max(color, vec3(0.0)));
+  float rim = pow(1.0 - max(dot(N, V), 0.0), 3.0);
+  vec3 ambient = albedo * 0.34 + vec3(0.04) * rim;
+  vec3 color = ambient + (diffuse + spec) * NdotL;
 
-  FragColor = vec4(clamp(color, 0.0, 1.0), u_alpha);
+  FragColor = vec4(saturate(color), u_alpha);
 }

+ 15 - 23
assets/shaders/archer_roman_republic.frag

@@ -96,19 +96,26 @@ void main() {
   vec2 uv = v_worldPos.xz * 4.5;
 
   // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield, 5=cloak
+  bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
   bool is_cloak = (u_materialId == 5 || u_materialId == 6);
 
-  // Use material IDs exclusively (no fallbacks)
-  bool is_legs = (u_materialId == 0); // Body mesh includes legs
-
   // === ROMAN ARCHER (SAGITTARIUS) MATERIALS ===
 
   // LIGHT BRONZE HELMET (warm golden auxiliary helmet)
-  if (is_helmet) {
+  if (is_skin) {
+    vec3 N = normalize(v_worldNormal);
+    vec3 V = normalize(vec3(0.0, 1.0, 0.35));
+    float skin_detail = noise(v_worldPos.xz * 18.0) * 0.06;
+    float subdermal = noise(v_worldPos.xz * 6.0) * 0.05;
+    color *= 1.0 + skin_detail;
+    color += vec3(0.025, 0.015, 0.010) * subdermal;
+    float rim = pow(1.0 - max(dot(N, V), 0.0), 4.0) * 0.04;
+    color += vec3(rim);
+  } else if (is_helmet) {
     // Use vertex-computed helmet detail
     float bands = v_helmetDetail * 0.15;
 
@@ -231,6 +238,10 @@ void main() {
     vec3 N = normalize(v_worldNormal);
     vec3 V = normalize(vec3(0.0, 1.0, 0.35));
 
+    // Team-tinted cloak: blend input color with team color (u_color).
+    vec3 team_tint = clamp(u_color, 0.0, 1.0);
+    color = mix(color, team_tint, 0.75);
+
     float weave = sin(v_worldPos.x * 70.0) * sin(v_worldPos.z * 70.0) * 0.04;
     float wrinkle = noise(v_worldPos.xz * 12.0) * 0.12;
     float shading = 0.65 + noise(v_worldPos.xz * 2.5) * 0.25;
@@ -238,25 +249,6 @@ void main() {
 
     color *= shading + weave * 0.2;
     color += vec3(wrinkle * 0.12 + fresnel);
-  } else if (is_legs) {
-    // Thick leather with visible grain (using vertex wear data)
-    float leather_grain = noise(uv * 10.0) * 0.16 * (0.5 + v_leatherWear * 0.5);
-    float leather_pores = noise(uv * 22.0) * 0.08;
-
-    // Pteruges strip pattern
-    float strips = pteruges_strips(v_worldPos.xz, v_bodyHeight);
-
-    // Worn leather edges
-    float wear = noise(uv * 4.0) * v_leatherWear * 0.10 - 0.05;
-
-    // Leather has subtle sheen
-    vec3 N = normalize(v_worldNormal);
-    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
-    float view_angle = max(dot(N, V), 0.0);
-    float leather_sheen = pow(1.0 - view_angle, 4.5) * 0.10;
-
-    color *= 1.0 + leather_grain + leather_pores - 0.08 + wear;
-    color += vec3(strips * 0.15 + leather_sheen);
   }
   // SCUTUM SHIELD (curved laminated wood with metal boss)
   else if (is_shield) {

+ 89 - 118
assets/shaders/healer_carthage.frag

@@ -1,11 +1,5 @@
 #version 330 core
 
-// ============================================================================
-// CARTHAGINIAN/PHOENICIAN HEALER SHADER
-// Mediterranean linen with Tyrian purple trim, leather craft, bronze tools,
-// and groomed beard shading focused on natural materials and soft cloth light
-// ============================================================================
-
 in vec3 v_normal;
 in vec3 v_worldNormal;
 in vec3 v_tangent;
@@ -25,10 +19,6 @@ uniform int u_materialId;
 
 out vec4 FragColor;
 
-// ============================================================================
-// UTILITY FUNCTIONS
-// ============================================================================
-
 float hash(vec2 p) {
   vec3 p3 = fract(vec3(p.xyx) * 0.1031);
   p3 += dot(p3, p3.yzx + 33.33);
@@ -68,10 +58,6 @@ float triplanar_noise(vec3 pos, vec3 normal, float scale) {
   return xy * w.z + yz * w.x + zx * w.y;
 }
 
-// ============================================================================
-// MATERIAL DETAIL
-// ============================================================================
-
 float cloth_weave(vec2 p) {
   float warp = sin(p.x * 68.0) * 0.55 + sin(p.x * 132.0) * 0.20;
   float weft = sin(p.y * 66.0) * 0.55 + sin(p.y * 124.0) * 0.20;
@@ -115,10 +101,6 @@ vec3 perturb_bronze_normal(vec3 N, vec3 T, vec3 B, vec2 uv) {
   return normalize(N + T * hammer + B * (hammer * 0.4 + ripple));
 }
 
-// ============================================================================
-// LIGHTING HELPERS
-// ============================================================================
-
 float D_GGX(float NdotH, float a) {
   float a2 = a * a;
   float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
@@ -140,9 +122,9 @@ vec3 fresnel_schlick(vec3 F0, float cos_theta) {
 vec3 compute_ambient(vec3 normal) {
   float up = clamp(normal.y, 0.0, 1.0);
   float down = clamp(-normal.y, 0.0, 1.0);
-  vec3 sky = vec3(0.62, 0.74, 0.88);
-  vec3 ground = vec3(0.38, 0.32, 0.26);
-  return sky * (0.28 + 0.50 * up) + ground * (0.12 + 0.32 * down);
+  vec3 sky = vec3(0.85, 0.92, 1.0);
+  vec3 ground = vec3(0.45, 0.38, 0.32);
+  return sky * (0.40 + 0.50 * up) + ground * (0.20 + 0.40 * down);
 }
 
 vec3 apply_lighting(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness,
@@ -170,14 +152,12 @@ vec3 apply_lighting(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness,
   vec3 ambient = compute_ambient(N) * albedo;
   vec3 light = (diffuse + spec * (1.0 + sheen)) * NdotL;
 
-  float ao_strength = mix(0.35, 1.0, clamp(ao, 0.0, 1.0));
-  return ambient * (0.55 + 0.45 * ao_strength) + light * ao_strength;
+  float ao_strength = mix(0.45, 1.0, clamp(ao, 0.0, 1.0));
+  vec3 result = ambient * (0.80 + 0.40 * ao_strength) +
+                light * (0.80 * ao_strength + 0.20);
+  return result;
 }
 
-// ============================================================================
-// BEARD/FACIAL HAIR RENDERING
-// ============================================================================
-
 float beard_density(vec2 uv, vec3 worldPos) {
   float strand_base = fbm(uv * 24.0) * 0.6;
   float curl_pattern = sin(uv.x * 80.0 + noise(uv * 40.0) * 3.0) * 0.2;
@@ -188,64 +168,46 @@ float beard_density(vec2 uv, vec3 worldPos) {
 
 vec3 apply_beard_shading(vec3 base_skin, vec2 uv, vec3 normal, vec3 worldPos,
                          vec3 V, vec3 L) {
-  vec3 beard_color = vec3(0.10, 0.07, 0.05);
-
+  vec3 beard_color = vec3(0.20, 0.12, 0.06);
   float density = beard_density(uv, worldPos);
-
   float chin_mask = smoothstep(1.55, 1.43, worldPos.y);
   float jawline = smoothstep(1.48, 1.36, worldPos.y);
   float beard_mask = clamp(chin_mask * 0.7 + jawline * 0.45, 0.0, 1.0);
-
   float strand_highlight = pow(noise(uv * 220.0), 2.2) * 0.16;
   float anisotropic =
       pow(1.0 - abs(dot(normalize(normal + L * 0.28), V)), 7.0) * 0.10;
   beard_color += vec3(strand_highlight + anisotropic);
-
-  return mix(base_skin, beard_color, density * beard_mask * 0.85);
+  return mix(base_skin, beard_color, density * beard_mask * 0.98);
 }
 
-// ============================================================================
-// MAIN FRAGMENT SHADER
-// ============================================================================
-
 void main() {
-  vec3 base_color = u_color;
-  if (u_useTexture) {
-    base_color *= texture(u_texture, v_texCoord).rgb;
-  }
-
   vec3 N = normalize(v_worldNormal);
   vec3 T = normalize(v_tangent);
   vec3 B = normalize(v_bitangent);
   vec2 uv = v_worldPos.xz * 4.5;
-  float avg_color = (base_color.r + base_color.g + base_color.b) / 3.0;
 
-  // Material ID: 0=body/skin, 1=tunic/robe, 2=purple trim, 3=leather, 4=tools
+  vec3 base_color = clamp(u_color, 0.0, 1.0);
+  if (u_useTexture) {
+    base_color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 teamDefault = vec3(0.0); // remove purple bias to keep true team hue
+  vec3 teamColor = clamp(mix(teamDefault, u_color, 0.75), 0.0, 1.0);
+
   bool is_body = (u_materialId == 0);
   bool is_tunic = (u_materialId == 1);
   bool is_purple_trim = (u_materialId == 2);
   bool is_leather = (u_materialId == 3);
   bool is_tools = (u_materialId == 4);
 
-  // Fallback detection only if no material id provided
-  bool has_material_id = (u_materialId >= 0);
-  bool looks_light = (!has_material_id) && (avg_color > 0.72);
-  bool looks_purple =
-      (!has_material_id) && (base_color.b > base_color.g * 1.12 &&
-                             base_color.b > base_color.r * 1.05);
-  bool looks_skin =
-      (!has_material_id) && (avg_color > 0.45 && avg_color < 0.72 &&
-                             base_color.r > base_color.g * 0.95 &&
-                             base_color.r > base_color.b * 1.05);
-
-  vec3 V = normalize(vec3(-0.2, 1.0, 0.35));
-  vec3 L = normalize(vec3(1.0, 1.30, 0.8));
+  vec3 V = normalize(vec3(0.0, 1.4, 3.0) - v_worldPos);
+  vec3 L = normalize(vec3(2.0, 3.0, 1.5));
 
   float curvature = length(dFdx(N)) + length(dFdy(N));
   float ao_folds =
-      clamp(1.0 - (v_clothFolds * 0.55 + curvature * 0.80), 0.25, 1.0);
+      clamp(1.0 - (v_clothFolds * 0.55 + curvature * 0.80), 0.35, 1.0);
   float dust_mask = smoothstep(0.22, 0.0, v_bodyHeight);
-  float sun_bleach = smoothstep(0.55, 1.05, v_bodyHeight) * 0.07;
+  float sun_bleach = smoothstep(0.55, 1.05, v_bodyHeight) * 0.09;
 
   vec3 albedo = base_color;
   vec3 N_used = N;
@@ -255,98 +217,107 @@ void main() {
   float wrap = 0.44;
   float ao = ao_folds;
 
-  // === CARTHAGINIAN HEALER MATERIALS ===
-  if (is_tunic || looks_light) {
+  if (is_body) {
+    vec3 skin_base = vec3(0.08, 0.07, 0.065); // deep brown/black skin tone
+    float legs = smoothstep(0.05, 0.50, v_bodyHeight) *
+                 (1.0 - smoothstep(0.52, 0.70, v_bodyHeight));
+    float limb_team = clamp(legs, 0.0, 1.0); // only legs get team tint
+    skin_base = mix(skin_base, mix(skin_base, teamColor, 0.92), limb_team);
+    float tone_noise = fbm(v_worldPos.xz * 3.1) - 0.5;
+    albedo = clamp(skin_base + vec3(tone_noise) * 0.04, 0.0, 1.0);
+    float skin_detail = noise(uv * 24.0) * 0.06;
+    float subdermal = noise(uv * 7.0) * 0.05;
+    N_used = normalize(N + vec3(0.0, 0.01, 0.0) *
+                               triplanar_noise(v_worldPos * 3.0, N, 5.5));
+    albedo *= 1.0 + skin_detail;
+    albedo += vec3(0.03, 0.015, 0.0) * subdermal;
+    bool is_face_region = (v_worldPos.y > 1.40 && v_worldPos.y < 1.70);
+    if (is_face_region) {
+      albedo = apply_beard_shading(albedo, uv, N_used, v_worldPos, V, L);
+    }
+    albedo *= 1.18;
+    float rim = pow(1.0 - clamp(dot(N_used, V), 0.0, 1.0), 4.0) * 0.08;
+    albedo += vec3(rim);
+    sheen = 0.08 + subdermal * 0.2;
+    wrap = 0.46;
+  } else if (is_tunic) {
     float linen = phoenician_linen(uv);
     float weave = cloth_weave(uv);
     float drape_folds = v_clothFolds * noise(uv * 9.0) * 0.18;
     float dust = dust_mask * (0.12 + noise(uv * 7.0) * 0.12);
-
     N_used = perturb_cloth_normal(N, T, B, uv, 128.0, 116.0, 0.08);
-
-    albedo = mix(base_color, vec3(0.93, 0.89, 0.82), 0.55);
-    albedo *= 1.0 + linen + weave * 0.08 - drape_folds;
-    albedo += vec3(0.02, 0.015, 0.0) * sun_bleach;
-    albedo -= vec3(dust * 0.25);
-
-    roughness = 0.72 - clamp(v_fabricWear * 0.08, 0.0, 0.12);
-    sheen = 0.08 + clamp(v_bodyHeight * 0.04, 0.0, 0.06);
+    vec3 tunic_base = vec3(0.46, 0.46, 0.48);
+    float vertical_band = smoothstep(0.40, 0.43, v_bodyHeight) -
+                          smoothstep(0.52, 0.55, v_bodyHeight);
+    float hem_band = smoothstep(0.30, 0.34, v_bodyHeight) -
+                     smoothstep(0.38, 0.42, v_bodyHeight);
+    float band_pattern = clamp(vertical_band + hem_band, 0.0, 1.0);
+    albedo = tunic_base;
+    albedo *= 1.02 + linen + weave * 0.08 - drape_folds * 0.5;
+    albedo += vec3(0.04, 0.03, 0.0) * sun_bleach;
+    albedo -= vec3(dust * 0.20);
+    albedo = mix(albedo, mix(albedo, teamColor, 0.40), band_pattern);
+    roughness = 0.66 - clamp(v_fabricWear * 0.08, 0.0, 0.12);
+    sheen = 0.10 + clamp(v_bodyHeight * 0.04, 0.0, 0.06);
     ao *= 1.0 - dust * 0.30;
     wrap = 0.54;
-  } else if (is_purple_trim || looks_purple) {
+  } else if (is_purple_trim) {
     float dye = tyrian_dye_variation(uv);
     float silk = noise(uv * 52.0) * 0.06;
     float thread_ridge = cloth_weave(uv * 1.1);
-
     N_used = perturb_cloth_normal(N, T, B, uv, 150.0, 142.0, 0.05);
-
-    albedo = mix(base_color, vec3(0.32, 0.10, 0.44), 0.40);
-    albedo *= 1.0 + dye + silk + thread_ridge;
-    albedo += vec3(0.03, 0.0, 0.05) * clamp(dot(N, V), 0.0, 1.0);
-
-    roughness = 0.42;
-    sheen = 0.16;
-    metallic = 0.05;
-    wrap = 0.48;
-  } else if (is_leather || (avg_color > 0.28 && avg_color <= 0.52)) {
+    vec3 trim_base = vec3(0.05);
+    albedo = trim_base * (1.0 + dye * 0.25 + silk * 0.25 + thread_ridge * 0.2);
+    roughness = 0.55;
+    sheen = 0.02;
+    metallic = 0.0;
+    wrap = 0.46;
+  } else if (is_leather) {
     float leather_grain = fbm(uv * 8.0) * 0.16;
     float craft_detail = noise(uv * 28.0) * 0.07;
     float stitching = step(0.92, fract(v_worldPos.x * 14.0)) *
                       step(0.92, fract(v_worldPos.y * 12.0)) * 0.08;
     float edge_wear =
         smoothstep(0.86, 0.94, abs(dot(N, normalize(T + B)))) * 0.08;
-
     N_used = perturb_leather_normal(N, T, B, uv);
-
-    albedo = mix(base_color, vec3(0.44, 0.30, 0.18), 0.20);
-    albedo *= 1.0 + leather_grain + craft_detail - 0.04;
+    vec3 leather_base = vec3(0.44, 0.30, 0.18);
+    albedo = leather_base;
+    float belt_band = smoothstep(0.47, 0.49, v_bodyHeight) -
+                      smoothstep(0.53, 0.55, v_bodyHeight);
+    albedo *= 1.06 + leather_grain + craft_detail - 0.04;
     albedo += vec3(stitching + edge_wear);
-
-    roughness = 0.55 - clamp(v_fabricWear * 0.05, 0.0, 0.10);
-    sheen = 0.10;
-    wrap = 0.46;
-  } else if (is_body || looks_skin) {
-    float skin_detail = noise(uv * 24.0) * 0.06;
-    float subdermal = noise(uv * 7.0) * 0.05;
-
-    N_used = normalize(N + vec3(0.0, 0.01, 0.0) *
-                               triplanar_noise(v_worldPos * 3.0, N, 5.5));
-
-    albedo *= 1.0 + skin_detail;
-    albedo += vec3(0.03, 0.015, 0.0) * subdermal;
-
-    bool is_face_region = (v_worldPos.y > 1.40 && v_worldPos.y < 1.65);
-    if (is_face_region) {
-      albedo = apply_beard_shading(albedo, uv, N_used, v_worldPos, V, L);
-    }
-
-    float rim = pow(1.0 - clamp(dot(N_used, V), 0.0, 1.0), 4.0) * 0.05;
-    albedo += vec3(rim);
-
-    roughness = 0.55;
-    sheen = 0.06 + subdermal * 0.2;
+    albedo = mix(albedo, mix(albedo, teamColor, 0.80), belt_band);
+    roughness = 0.52 - clamp(v_fabricWear * 0.05, 0.0, 0.10);
+    sheen = 0.11;
     wrap = 0.46;
   } else if (is_tools) {
     float patina = noise(uv * 14.0) * 0.15 + fbm(uv * 22.0) * 0.10;
     float edge_polish =
         smoothstep(0.86, 0.95, abs(dot(N, normalize(T + B)))) * 0.14;
-
     N_used = perturb_bronze_normal(N, T, B, uv);
-
-    albedo = mix(base_color, vec3(0.72, 0.52, 0.28), 0.65);
-    albedo -= vec3(patina * 0.24);
+    vec3 bronze_default = vec3(0.78, 0.58, 0.32);
+    float custom_weight =
+        clamp(max(max(base_color.r, base_color.g), base_color.b), 0.0, 1.0);
+    vec3 bronze_base = mix(bronze_default, base_color, custom_weight);
+    bronze_base = max(bronze_base, vec3(0.02));
+    albedo = bronze_base;
+    albedo -= vec3(patina * 0.18);
     albedo += vec3(edge_polish);
-
-    roughness = 0.30 + patina * 0.12;
+    roughness = 0.30 + patina * 0.10;
     metallic = 0.92;
-    sheen = 0.12;
+    sheen = 0.16;
     wrap = 0.42;
   } else {
     float detail = noise(uv * 12.0) * 0.10;
-    albedo *= 1.0 + detail - 0.05;
+    albedo = mix(vec3(0.6, 0.6, 0.6), teamColor, 0.25);
+    if (u_useTexture) {
+      albedo *= max(texture(u_texture, v_texCoord).rgb, vec3(0.35));
+    }
+    albedo *= 1.0 + detail - 0.03;
   }
 
   vec3 color = apply_lighting(albedo, N_used, V, L, roughness, metallic, ao,
                               sheen, wrap);
+  color = pow(color * 1.25, vec3(0.9));
   FragColor = vec4(clamp(color, 0.0, 1.0), u_alpha);
 }

+ 53 - 12
assets/shaders/healer_roman_republic.frag

@@ -188,6 +188,35 @@ vec3 apply_lighting(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness,
   return ambient * (0.56 + 0.44 * ao_strength) + light * ao_strength;
 }
 
+// ============================================================================
+// BEARD/FACIAL HAIR RENDERING (parity with Carthage healer)
+// ============================================================================
+float beard_density(vec2 uv, vec3 worldPos) {
+  float strand_base = fbm(uv * 24.0) * 0.6;
+  float curl_pattern = sin(uv.x * 80.0 + noise(uv * 40.0) * 3.0) * 0.2;
+  float density_variation = noise(uv * 25.0) * 0.4;
+  float jaw_bias = smoothstep(1.36, 1.60, worldPos.y) * 0.25;
+  return strand_base + curl_pattern + density_variation + jaw_bias;
+}
+
+vec3 apply_beard_shading(vec3 base_skin, vec2 uv, vec3 normal, vec3 worldPos,
+                         vec3 V, vec3 L) {
+  vec3 beard_color = vec3(0.10, 0.07, 0.05);
+
+  float density = beard_density(uv, worldPos);
+
+  float chin_mask = smoothstep(1.55, 1.43, worldPos.y);
+  float jawline = smoothstep(1.48, 1.36, worldPos.y);
+  float beard_mask = clamp(chin_mask * 0.7 + jawline * 0.45, 0.0, 1.0);
+
+  float strand_highlight = pow(noise(uv * 220.0), 2.2) * 0.16;
+  float anisotropic =
+      pow(1.0 - abs(dot(normalize(normal + L * 0.28), V)), 7.0) * 0.10;
+  beard_color += vec3(strand_highlight + anisotropic);
+
+  return mix(base_skin, beard_color, density * beard_mask * 0.85);
+}
+
 // ============================================================================
 // MAIN FRAGMENT SHADER
 // ============================================================================
@@ -212,15 +241,6 @@ void main() {
   bool is_medical_tools = (u_materialId == 3);
   bool is_red_trim = (u_materialId == 4);
 
-  // Only fall back to color heuristics if material id is absent/invalid
-  bool has_material_id = (u_materialId >= 0);
-  bool looks_light = (!has_material_id) && (avg_color > 0.75);
-  bool looks_red = (!has_material_id) && (base_color.r > base_color.g * 1.8 &&
-                                          base_color.r > base_color.b * 2.0);
-  bool looks_brown =
-      (!has_material_id) &&
-      (avg_color > 0.25 && avg_color < 0.55 && base_color.r > base_color.b);
-
   vec3 albedo = base_color;
   vec3 N_used = N;
   float roughness = 0.55;
@@ -236,8 +256,29 @@ void main() {
       clamp(1.0 - (v_clothFolds * 0.52 + curvature * 0.78), 0.28, 1.0);
   float ao = ao_folds;
 
+  // BODY / SKIN
+  if (is_body) {
+    vec3 skin = base_color;
+    float skin_detail = noise(uv * 24.0) * 0.06;
+    float subdermal = noise(uv * 7.0) * 0.05;
+    skin *= 1.0 + skin_detail;
+    skin += vec3(0.03, 0.015, 0.0) * subdermal;
+
+    bool is_face_region = (v_worldPos.y > 1.40 && v_worldPos.y < 1.65);
+    if (is_face_region) {
+      skin = apply_beard_shading(skin, uv, N_used, v_worldPos, V, L);
+    }
+
+    float rim = pow(1.0 - clamp(dot(N_used, V), 0.0, 1.0), 4.0) * 0.05;
+    skin += vec3(rim);
+
+    albedo = skin;
+    roughness = 0.55;
+    sheen = 0.06 + subdermal * 0.2;
+    wrap = 0.46;
+  }
   // WHITE/CREAM LINEN TUNICA (main garment - bleached Roman style)
-  if (is_tunica || looks_light) {
+  else if (is_tunica) {
     vec3 tunic_base = vec3(0.95, 0.93, 0.90);
     albedo = tunic_base;
 
@@ -270,7 +311,7 @@ void main() {
     ao *= 1.0 - dust * 0.35;
   }
   // RED WOOL SASH/TRIM (military medicus identification)
-  else if (is_red_trim || looks_red) {
+  else if (is_red_trim) {
     float weave = roman_wool(v_worldPos.xz);
     float wool_tex = noise(uv * 58.0) * 0.10;
 
@@ -295,7 +336,7 @@ void main() {
     wrap = 0.48;
   }
   // LEATHER EQUIPMENT (medical bag, belt, sandals, straps)
-  else if (is_leather || looks_brown) {
+  else if (is_leather) {
     float leather_grain = noise(uv * 16.0) * 0.16 * (1.0 + v_fabricWear * 0.25);
     float pores = noise(uv * 38.0) * 0.06;
 

+ 43 - 22
assets/shaders/horse_archer_carthage.frag

@@ -175,6 +175,18 @@ void main() {
   bool is_bridle = (u_materialId == 10);
   bool is_saddle_blanket = (u_materialId == 11);
 
+  if (is_rider_skin) {
+    // Carthage horse archer: dark complexion.
+    vec3 target = vec3(0.32, 0.24, 0.18);
+    float tone_noise = fbm(v_worldPos.xz * 3.1) - 0.5;
+    base_color = clamp(target + vec3(tone_noise) * 0.05, 0.0, 1.0);
+    if (u_useTexture) {
+      vec3 tex = texture(u_texture, v_texCoord).rgb;
+      float eye_mask = step(0.25, 1.0 - dot(tex, vec3(0.299, 0.587, 0.114)));
+      base_color = mix(base_color, vec3(0.02), eye_mask); // black eyes
+    }
+  }
+
   // Material-based detection only (no fallbacks)
   bool is_brass = is_helmet;
   bool is_steel = false;
@@ -182,6 +194,11 @@ void main() {
   bool is_fabric = is_rider_clothing || is_saddle_blanket || is_cloak;
   bool is_leather = is_saddle_leather || is_bridle;
 
+  // Team-tint cloaks while preserving base styling.
+  if (is_cloak) {
+    base_color = mix(base_color, saturate(u_color), 0.75);
+  }
+
   // lighting frame
   vec3 L = normalize(vec3(1.0, 1.2, 1.0));
   vec3 V = normalize(
@@ -258,34 +275,38 @@ void main() {
     col += vec3(scale_hint * 0.4 + leather_grain * 0.2 + linen_weave * 0.15);
 
   } else if (is_horse_hide) {
-    // subtle anisotropic sheen along body flow
-    vec3 up = vec3(0.0, 1.0, 0.0);
-    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
-    float flow_noise = fbm(uv * 10.0);
-    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
-                  0.08 * (0.6 + 0.4 * flow_noise);
-
+    vec3 coat = vec3(0.36, 0.32, 0.28);
     float hide_tex = horse_hide_pattern(v_worldPos.xz);
-    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+    float grain = fbm(v_worldPos.xz * 18.0 + v_worldPos.y * 2.5);
+    albedo = coat * (1.0 + hide_tex * 0.06 - grain * 0.04);
 
-    roughness = 0.58 - hide_tex * 0.08;
-    F0 = vec3(0.035);
+    roughness = 0.75;
+    F0 = vec3(0.02);
     metalness = 0.0;
 
-    // slight bump from hair grain
-    float h = fbm(v_worldPos.xz * 35.0);
-    N = perturb_normal_ws(N, v_worldPos, h, 0.35);
+    float h = fbm(v_worldPos.xz * 22.0);
+    N = perturb_normal_ws(N, v_worldPos, h, 0.18);
 
-    // composition
-    albedo = albedo * (1.0 + hide_tex * 0.20) * (0.98 + 0.02 * n_small);
     col += ambient * albedo;
-    // microfacet spec
-    float D = D_GGX(saturate(dot(N, H)), roughness);
-    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
-    vec3 F = fresnel_schlick(VdotH, F0);
-    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
-    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
-    col += aniso + sheen;
+    col += NdotL_wrap * albedo * 0.9;
+
+  } else if (is_horse_mane) {
+    float strand = sin(uv.x * 140.0) * 0.2 + noise(uv * 120.0) * 0.15;
+    float clump = noise(uv * 15.0) * 0.4;
+    float frizz = fbm(uv * 40.0) * 0.15;
+
+    float h = fbm(v_worldPos.xz * 25.0);
+    N = perturb_normal_ws(N, v_worldPos, h, 0.18);
+
+    albedo = vec3(0.08, 0.07, 0.07) *
+             (1.0 + strand * 0.02 + clump * 0.02 + frizz * 0.02);
+
+    roughness = 0.70;
+    F0 = vec3(0.02);
+    metalness = 0.0;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * albedo * 0.85;
 
   } else if (is_steel) {
     float brushed =

+ 5 - 0
assets/shaders/horse_archer_roman_republic.frag

@@ -205,6 +205,11 @@ void main() {
   bool is_fabric = is_rider_clothing || is_saddle_blanket || is_rider_cloak;
   bool is_leather = is_saddle_leather || is_bridle;
 
+  // Team-tint cloaks while preserving base styling.
+  if (is_rider_cloak) {
+    base_color = mix(base_color, saturate(u_color), 0.75);
+  }
+
   // lighting frame
   vec3 L = normalize(vec3(1.0, 1.2, 1.0));
   vec3 V = normalize(

+ 38 - 22
assets/shaders/horse_spearman_carthage.frag

@@ -180,6 +180,18 @@ void main() {
   bool is_fabric = is_rider_clothing || is_saddle_blanket;
   bool is_leather = is_saddle_leather || is_bridle;
 
+  if (is_rider_skin) {
+    // Carthage horse spearman: dark complexion.
+    vec3 target = vec3(0.32, 0.24, 0.18);
+    float tone_noise = fbm(v_worldPos.xz * 3.1) - 0.5;
+    base_color = clamp(target + vec3(tone_noise) * 0.05, 0.0, 1.0);
+    if (u_useTexture) {
+      vec3 tex = texture(u_texture, v_texCoord).rgb;
+      float eye_mask = step(0.25, 1.0 - dot(tex, vec3(0.299, 0.587, 0.114)));
+      base_color = mix(base_color, vec3(0.02), eye_mask); // black eyes
+    }
+  }
+
   // lighting frame
   vec3 L = normalize(vec3(1.0, 1.2, 1.0));
   vec3 V = normalize(
@@ -261,34 +273,38 @@ void main() {
     col += vec3(scale_hint * 0.4 + leather_grain * 0.2 + linen_weave * 0.15);
 
   } else if (is_horse_hide) {
-    // subtle anisotropic sheen along body flow
-    vec3 up = vec3(0.0, 1.0, 0.0);
-    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
-    float flow_noise = fbm(uv * 10.0);
-    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
-                  0.08 * (0.6 + 0.4 * flow_noise);
-
+    vec3 coat = vec3(0.36, 0.32, 0.28);
     float hide_tex = horse_hide_pattern(v_worldPos.xz);
-    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+    float grain = fbm(v_worldPos.xz * 18.0 + v_worldPos.y * 2.5);
+    albedo = coat * (1.0 + hide_tex * 0.06 - grain * 0.04);
 
-    roughness = 0.58 - hide_tex * 0.08;
-    F0 = vec3(0.035);
+    roughness = 0.75;
+    F0 = vec3(0.02);
     metalness = 0.0;
 
-    // slight bump from hair grain
-    float h = fbm(v_worldPos.xz * 35.0);
-    N = perturb_normal_ws(N, v_worldPos, h, 0.35);
+    float h = fbm(v_worldPos.xz * 22.0);
+    N = perturb_normal_ws(N, v_worldPos, h, 0.18);
 
-    // composition
-    albedo = albedo * (1.0 + hide_tex * 0.20) * (0.98 + 0.02 * n_small);
     col += ambient * albedo;
-    // microfacet spec
-    float D = D_GGX(saturate(dot(N, H)), roughness);
-    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
-    vec3 F = fresnel_schlick(VdotH, F0);
-    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
-    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
-    col += aniso + sheen;
+    col += NdotL_wrap * albedo * 0.9;
+
+  } else if (is_horse_mane) {
+    float strand = sin(uv.x * 140.0) * 0.2 + noise(uv * 120.0) * 0.15;
+    float clump = noise(uv * 15.0) * 0.4;
+    float frizz = fbm(uv * 40.0) * 0.15;
+
+    float h = fbm(v_worldPos.xz * 25.0);
+    N = perturb_normal_ws(N, v_worldPos, h, 0.18);
+
+    albedo = vec3(0.08, 0.07, 0.07) *
+             (1.0 + strand * 0.02 + clump * 0.02 + frizz * 0.02);
+
+    roughness = 0.70;
+    F0 = vec3(0.02);
+    metalness = 0.0;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * albedo * 0.85;
 
   } else if (is_steel) {
     float brushed =

+ 1 - 0
assets/shaders/horse_spearman_carthage.vert

@@ -6,6 +6,7 @@ layout(location = 2) in vec2 a_texCoord;
 
 uniform mat4 u_mvp;
 uniform mat4 u_model;
+uniform int u_materialId;
 
 out vec3 v_normal;
 out vec2 v_texCoord;

+ 42 - 23
assets/shaders/horse_swordsman_carthage.frag

@@ -180,6 +180,18 @@ void main() {
   bool is_fabric = is_rider_clothing || is_saddle_blanket;
   bool is_leather = is_saddle_leather || is_bridle;
 
+  if (is_rider_skin) {
+    // Carthage horse swordsman: light complexion.
+    vec3 target = vec3(0.96, 0.86, 0.76);
+    float tone_noise = fbm(v_worldPos.xz * 3.1) - 0.5;
+    base_color = clamp(target + vec3(tone_noise) * 0.05, 0.0, 1.0);
+    if (u_useTexture) {
+      vec3 tex = texture(u_texture, v_texCoord).rgb;
+      float eye_mask = step(0.25, 1.0 - dot(tex, vec3(0.299, 0.587, 0.114)));
+      base_color = mix(base_color, vec3(0.02), eye_mask); // black eyes
+    }
+  }
+
   // lighting frame
   vec3 L = normalize(vec3(1.0, 1.2, 1.0));
   vec3 V = normalize(
@@ -267,34 +279,41 @@ void main() {
     col += vec3(plates) * 0.35 + vec3(rings * 0.25) + vec3(linen * linenBlend);
 
   } else if (is_horse_hide) {
-    // subtle anisotropic sheen along body flow
-    vec3 up = vec3(0.0, 1.0, 0.0);
-    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
-    float flow_noise = fbm(uv * 10.0);
-    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
-                  0.08 * (0.6 + 0.4 * flow_noise);
-
+    // Horses: unified natural coat, fully matte, no sparkle.
+    vec3 coat = vec3(0.36, 0.32, 0.28);
     float hide_tex = horse_hide_pattern(v_worldPos.xz);
-    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+    float grain = fbm(v_worldPos.xz * 18.0 + v_worldPos.y * 2.5);
+    albedo = coat * (1.0 + hide_tex * 0.06 - grain * 0.04);
 
-    roughness = 0.58 - hide_tex * 0.08;
-    F0 = vec3(0.035);
+    roughness = 0.75;
+    F0 = vec3(0.02);
     metalness = 0.0;
 
-    // slight bump from hair grain
-    float h = fbm(v_worldPos.xz * 35.0);
-    N = perturb_normal_ws(N, v_worldPos, h, 0.35);
+    // Slight normal bump from hair grain without spec pop.
+    float h = fbm(v_worldPos.xz * 22.0);
+    N = perturb_normal_ws(N, v_worldPos, h, 0.18);
 
-    // composition
-    albedo = albedo * (1.0 + hide_tex * 0.20) * (0.98 + 0.02 * n_small);
     col += ambient * albedo;
-    // microfacet spec
-    float D = D_GGX(saturate(dot(N, H)), roughness);
-    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
-    vec3 F = fresnel_schlick(VdotH, F0);
-    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
-    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
-    col += aniso + sheen;
+    col += NdotL_wrap * albedo * 0.9;
+
+  } else if (is_horse_mane) {
+    // Mane: dark, matte.
+    float strand = sin(uv.x * 140.0) * 0.2 + noise(uv * 120.0) * 0.15;
+    float clump = noise(uv * 15.0) * 0.4;
+    float frizz = fbm(uv * 40.0) * 0.15;
+
+    float h = fbm(v_worldPos.xz * 25.0);
+    N = perturb_normal_ws(N, v_worldPos, h, 0.18);
+
+    albedo = vec3(0.08, 0.07, 0.07) *
+             (1.0 + strand * 0.02 + clump * 0.02 + frizz * 0.02);
+
+    roughness = 0.70;
+    F0 = vec3(0.02);
+    metalness = 0.0;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * albedo * 0.85;
 
   } else if (is_steel) {
     float brushed =
@@ -395,7 +414,7 @@ void main() {
     col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
            vec3(weave + embroidery + sheen);
 
-  } else { // leather
+  } else { // leather / hooves fallback
     float grain = fbm(uv * 10.0) * 0.15;
     float wear = fbm(uv * 3.0) * 0.12;
 

+ 1 - 0
assets/shaders/horse_swordsman_carthage.vert

@@ -6,6 +6,7 @@ layout(location = 2) in vec2 a_texCoord;
 
 uniform mat4 u_mvp;
 uniform mat4 u_model;
+uniform int u_materialId;
 
 out vec3 v_normal;
 out vec2 v_texCoord;

+ 78 - 371
assets/shaders/spearman_carthage.frag

@@ -1,18 +1,8 @@
 #version 330 core
 
-in vec3 v_normal;
 in vec3 v_worldNormal;
-in vec3 v_tangent;
-in vec3 v_bitangent;
-in vec2 v_texCoord;
 in vec3 v_worldPos;
-in float v_armorLayer;
-in float v_bodyHeight;
-in float v_helmetDetail;
-in float v_steelWear;
-in float v_chainmailPhase;
-in float v_rivetPattern;
-in float v_leatherWear;
+in vec2 v_texCoord;
 
 uniform sampler2D u_texture;
 uniform vec3 u_color;
@@ -22,411 +12,128 @@ uniform int u_materialId;
 
 out vec4 FragColor;
 
-// ---------------------------------------------------------------------------
-// Utilities
-// ---------------------------------------------------------------------------
-const float k_pi = 3.14159265;
-
+// Simple PBR-ish helpers
 float saturate(float v) { return clamp(v, 0.0, 1.0); }
 vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
 
-float hash21(vec2 p) {
-  p = fract(p * vec2(234.34, 435.345));
-  p += dot(p, p + 34.45);
-  return fract(p.x * p.y);
-}
-
-float noise3(vec3 p) {
-  vec3 i = floor(p);
-  vec3 f = fract(p);
-  f = f * f * (3.0 - 2.0 * f);
-  float n = i.x + i.y * 57.0 + i.z * 113.0;
-  return mix(
-      mix(mix(hash21(vec2(n, n + 1.0)), hash21(vec2(n + 57.0, n + 58.0)), f.x),
-          mix(hash21(vec2(n + 113.0, n + 114.0)),
-              hash21(vec2(n + 170.0, n + 171.0)), f.x),
-          f.y),
-      mix(mix(hash21(vec2(n + 226.0, n + 227.0)),
-              hash21(vec2(n + 283.0, n + 284.0)), f.x),
-          mix(hash21(vec2(n + 339.0, n + 340.0)),
-              hash21(vec2(n + 396.0, n + 397.0)), f.x),
-          f.y),
-      f.z);
+float hash12(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 fbm(vec3 p) {
-  float value = 0.0;
-  float amp = 0.5;
-  float freq = 1.0;
-  for (int i = 0; i < 5; ++i) {
-    value += amp * noise3(p * freq);
-    freq *= 1.85;
-    amp *= 0.55;
+float fbm(vec2 p) {
+  float v = 0.0;
+  float a = 0.5;
+  for (int i = 0; i < 4; ++i) {
+    v += a * hash12(p);
+    p *= 2.0;
+    a *= 0.55;
   }
-  return value;
-}
-
-vec3 hemi_ambient(vec3 n) {
-  float up = saturate(n.y * 0.5 + 0.5);
-  vec3 sky = vec3(0.54, 0.68, 0.82);
-  vec3 ground = vec3(0.32, 0.26, 0.20);
-  return mix(ground, sky, up);
+  return v;
 }
 
 float D_GGX(float NdotH, float a) {
   float a2 = a * a;
   float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
-  return a2 / max(k_pi * d * d, 1e-6);
+  return a2 / max(3.14159 * d * d, 1e-5);
 }
 
-float geometry_schlick_ggx(float NdotX, float k) {
-  return NdotX / max(NdotX * (1.0 - k) + k, 1e-6);
+float geometry_schlick(float NdotX, float k) {
+  return NdotX / max(NdotX * (1.0 - k) + k, 1e-5);
 }
 
 float geometry_smith(float NdotV, float NdotL, float roughness) {
   float r = roughness + 1.0;
   float k = (r * r) / 8.0;
-  return geometry_schlick_ggx(NdotV, k) * geometry_schlick_ggx(NdotL, k);
+  return geometry_schlick(NdotV, k) * geometry_schlick(NdotL, k);
 }
 
 vec3 fresnel_schlick(float cos_theta, vec3 F0) {
   return F0 + (1.0 - F0) * pow(1.0 - cos_theta, 5.0);
 }
 
-vec3 perturb(vec3 N, vec3 T, vec3 B, vec3 amount) {
-  return normalize(N + T * amount.x + B * amount.y);
-}
-
-float chainmail_rings(vec2 p) {
-  vec2 uv = p * 32.0;
-
-  vec2 g0 = fract(uv) - 0.5;
-  float r0 = length(g0);
-  float fw0 = fwidth(r0) * 1.2;
-  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
-                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
-
-  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
-  float r1 = length(g1);
-  float fw1 = fwidth(r1) * 1.2;
-  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
-                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
-
-  return (ring0 + ring1) * 0.15;
-}
-
-struct MaterialSample {
-  vec3 color;
-  vec3 normal;
-  float roughness;
-  vec3 F0;
-};
-
-// ---------------------------------------------------------------------------
-// Material sampling
-// ---------------------------------------------------------------------------
-MaterialSample sample_skin(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
-  MaterialSample m;
-  float pores = fbm(pos * 12.0);
-  float freckles = smoothstep(0.55, 0.78, fbm(pos * 6.5 + vec3(1.7, 0.0, 0.3)));
-  vec3 Np = perturb(N, T, B,
-                    vec3((pores - 0.5) * 0.05, (freckles - 0.5) * 0.04, 0.0));
-
-  vec3 tint = mix(base_color, vec3(0.93, 0.80, 0.68), 0.35);
-  tint += vec3(0.03) * freckles;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.48 + pores * 0.10, 0.32, 0.72);
-  m.F0 = vec3(0.028);
-  return m;
-}
-
-MaterialSample sample_bronze_helmet(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                    vec3 B) {
-  MaterialSample m;
-  float hammer = fbm(pos * 14.0);
-  float patina = fbm(pos * 5.5 + vec3(2.1, 0.0, 4.2));
-  float band = v_helmetDetail;
-  float rivet = v_rivetPattern;
-  vec3 Np =
-      perturb(N, T, B, vec3((hammer - 0.5) * 0.10, (patina - 0.5) * 0.06, 0.0));
-
-  vec3 tint = mix(vec3(0.62, 0.44, 0.18), base_color, 0.35);
-  tint = mix(tint, vec3(0.26, 0.48, 0.38),
-             clamp(patina * 0.65 + v_steelWear * 0.3, 0.0, 0.8));
-  tint += vec3(0.12) * band;
-  tint += vec3(0.05) * rivet;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness =
-      clamp(0.26 + hammer * 0.22 + patina * 0.16 - band * 0.08, 0.14, 0.70);
-  m.F0 = mix(vec3(0.08), vec3(0.94, 0.82, 0.58),
-             clamp(0.45 + patina * 0.25 + band * 0.20, 0.0, 1.0));
-  return m;
-}
-
-MaterialSample sample_linen(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
-  MaterialSample m;
-  float warp = sin(pos.x * 120.0) * 0.05;
-  float weft = sin(pos.z * 116.0) * 0.05;
-  float slub = fbm(pos * 7.5) * 0.05;
-  vec3 Np = normalize(N + T * (warp + slub) + B * (weft + slub * 0.5));
-
-  vec3 tint = mix(vec3(0.88, 0.82, 0.72), base_color, 0.45);
-  tint *= 1.0 - slub * 0.15;
-  tint = mix(tint, tint * vec3(0.82, 0.76, 0.70),
-             smoothstep(0.55, 1.0, v_bodyHeight));
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.62 + slub * 0.12, 0.35, 0.90);
-  m.F0 = vec3(0.028);
-  return m;
-}
-
-MaterialSample sample_bronze_scales(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                    vec3 B) {
-  MaterialSample m;
-  float scallop = smoothstep(0.35, 0.05, fract(pos.y * 6.4 + v_chainmailPhase));
-  float row = smoothstep(0.75, 0.98, sin(pos.y * 9.0 + pos.x * 1.5));
-  float hammer = fbm(pos * 12.0 + vec3(0.0, 1.8, 2.4));
-  vec3 Np = perturb(N, T, B,
-                    vec3((hammer - 0.5) * 0.08, (scallop - 0.5) * 0.05, 0.0));
-
-  vec3 tint = mix(vec3(0.64, 0.46, 0.20), base_color, 0.40);
-  tint += vec3(0.10) * scallop;
-  tint = mix(tint, tint * vec3(0.26, 0.48, 0.38),
-             clamp(v_steelWear * 0.55, 0.0, 0.6));
-  tint += vec3(0.05) * row;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.34 + hammer * 0.20 - scallop * 0.08, 0.16, 0.78);
-  m.F0 = vec3(0.12, 0.10, 0.07);
-  return m;
-}
-
-MaterialSample sample_chainmail(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                vec3 B) {
-  MaterialSample m;
-  vec2 mail_uv = pos.xz * 1.2;
-  float rings = chainmail_rings(mail_uv);
-  float grain = fbm(pos * 15.0 + vec3(1.7));
-  vec3 Np =
-      perturb(N, T, B, vec3((rings - 0.5) * 0.20, (grain - 0.5) * 0.10, 0.0));
-
-  vec3 tint = mix(vec3(0.62, 0.64, 0.68), base_color, 0.35);
-  tint = mix(tint, tint * vec3(0.34, 0.30, 0.26),
-             clamp(v_steelWear * 0.6, 0.0, 0.7));
-  tint += vec3(0.10) * rings;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.28 + rings * 0.14 + grain * 0.12, 0.16, 0.82);
-  m.F0 = vec3(0.16, 0.16, 0.18);
-  return m;
-}
-
-MaterialSample sample_leather(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                              vec3 B) {
-  MaterialSample m;
-  float grain = fbm(pos * 6.0);
-  float crack = fbm(pos * 11.0 + vec3(0.0, 1.7, 2.3));
-  float wear = v_leatherWear;
-  vec3 Np =
-      perturb(N, T, B, vec3((grain - 0.5) * 0.10, (crack - 0.5) * 0.08, 0.0));
-
-  vec3 tint = mix(vec3(0.38, 0.25, 0.15), base_color, 0.45);
-  tint *= 1.0 - 0.10 * grain;
-  tint += vec3(0.05) * smoothstep(0.4, 0.9, crack + wear * 0.2);
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.55 + grain * 0.22 - crack * 0.12, 0.25, 0.95);
-  m.F0 = vec3(0.035);
-  return m;
-}
-
-MaterialSample sample_wood(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
-  MaterialSample m;
-  float grain = fbm(pos * 10.0);
-  float rings = sin(pos.y * 24.0 + pos.x * 3.0);
-  vec3 Np = perturb(N, T, B, vec3(grain * 0.06, rings * 0.04, 0.0));
-
-  vec3 tint = mix(vec3(0.42, 0.32, 0.20), base_color, 0.45);
-  tint *= 1.0 + grain * 0.10 + rings * 0.04;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.52 + grain * 0.18, 0.28, 0.90);
-  m.F0 = vec3(0.028);
-  return m;
-}
-
-MaterialSample sample_steel(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
-  MaterialSample m;
-  float brushed = abs(sin(pos.y * 90.0)) * 0.04;
-  float dent = fbm(pos * 12.0) * v_steelWear;
-  vec3 Np = perturb(N, T, B, vec3((brushed - 0.5) * 0.06, dent * 0.05, 0.0));
-
-  vec3 tint = mix(vec3(0.74, 0.76, 0.80), base_color, 0.45);
-  tint += vec3(0.06) * brushed;
-  tint -= vec3(0.08) * dent;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.20 + brushed * 0.20 + dent * 0.18 - v_steelWear * 0.08,
-                      0.08, 0.80);
-  m.F0 = vec3(0.62, 0.64, 0.66);
-  return m;
-}
-
-MaterialSample sample_shield(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                             vec3 B) {
-  MaterialSample wood = sample_wood(base_color, pos * 0.9, N, T, B);
-  MaterialSample boss =
-      sample_steel(vec3(0.78, 0.74, 0.62), pos * 1.4, N, T, B);
-
-  float boss_mask = smoothstep(0.08, 0.02, length(pos.xz));
-  boss_mask = max(boss_mask, v_rivetPattern * 0.25);
-
-  MaterialSample m;
-  m.color = mix(wood.color, boss.color, boss_mask);
-  m.normal = normalize(mix(wood.normal, boss.normal, boss_mask));
-  m.roughness = mix(wood.roughness, boss.roughness, boss_mask);
-  m.F0 = mix(wood.F0, boss.F0, boss_mask);
-  return m;
-}
-
-// ---------------------------------------------------------------------------
-// Main
-// ---------------------------------------------------------------------------
 void main() {
-  vec3 base_color = u_color;
+  vec3 base = u_color;
   if (u_useTexture) {
-    base_color *= texture(u_texture, v_texCoord).rgb;
+    base *= texture(u_texture, v_texCoord).rgb;
   }
 
-  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
+  // Material layout: 0=skin, 1=armor, 2=helmet, 3=weapon, 4=shield
   bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
-  bool is_shield = (u_materialId == 4);
-
-  vec3 Nw = normalize(v_worldNormal);
-  vec3 Tw = normalize(v_tangent);
-  vec3 Bw = normalize(v_bitangent);
-
-  MaterialSample mat;
-  if (is_helmet) {
-    mat = sample_bronze_helmet(base_color, v_worldPos, Nw, Tw, Bw);
-  } else if (is_armor) {
-    vec3 leather_base = vec3(0.44, 0.30, 0.19);
-    vec3 linen_base = vec3(0.86, 0.80, 0.72);
-    vec3 bronze_base = vec3(0.62, 0.46, 0.20);
-    vec3 chain_base = vec3(0.78, 0.80, 0.82);
 
-    MaterialSample leather =
-        sample_leather(leather_base, v_worldPos * 0.85, Nw, Tw, Bw);
-    MaterialSample linen =
-        sample_linen(linen_base, v_worldPos * 1.0, Nw, Tw, Bw);
-    MaterialSample scales =
-        sample_bronze_scales(bronze_base, v_worldPos * 0.95, Nw, Tw, Bw);
-    MaterialSample mail =
-        sample_chainmail(chain_base, v_worldPos * 0.9, Nw, Tw, Bw);
-
-    // Treat the entire armor piece as torso to avoid losing coverage to height
-    // band thresholds.
-    float torsoBand = 1.0;
-    float skirtBand = 0.0;
-    float mailBlend =
-        clamp(smoothstep(0.25, 0.85, v_chainmailPhase + v_steelWear * 0.35),
-              0.0, 1.0) *
-        torsoBand * 0.30;
-    float scaleBlend =
-        clamp(0.28 + v_steelWear * 0.55, 0.0, 1.0) * torsoBand * 0.55;
-    float linenBlend = skirtBand * 0.40;
-    float leatherOverlay = skirtBand * 0.90 + torsoBand * 0.30;
-
-    // subtle edge tint to lift highlights
-    float edge = 1.0 - clamp(dot(Nw, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
-    vec3 highlight = vec3(0.10, 0.08, 0.05) * smoothstep(0.3, 0.9, edge);
+  vec3 N = normalize(v_worldNormal);
+  vec3 V = normalize(vec3(0.0, 0.0, 1.0));
+  vec3 L = normalize(vec3(0.5, 1.0, 0.4));
+  vec3 H = normalize(L + V);
 
-    // Leather-first blend with lighter linen skirt and subtle bronze/chain
-    mat.color = leather.color;
-    mat.color = mix(mat.color, linen.color, linenBlend);
-    mat.color = mix(mat.color, scales.color, scaleBlend);
-    mat.color = mix(mat.color, mail.color, mailBlend);
-    mat.color = mix(mat.color, leather.color + highlight, leatherOverlay);
+  // Lightweight leather armor for spearmen.
+  float metallic = 0.0;
+  float roughness = 0.55;
+  vec3 albedo = base;
+
+  if (is_skin) {
+    albedo = mix(base, vec3(0.84, 0.72, 0.62), 0.35);
+    metallic = 0.0;
+    roughness = 0.6;
+    // Jagged leather pants for lower body
+    float pants_mask = 1.0 - smoothstep(0.38, 0.60, v_worldPos.y);
+    float jag = fbm(v_worldPos.xz * 15.0 + v_worldPos.y * 3.0);
+    vec3 leather = vec3(0.42, 0.30, 0.20) - vec3(0.04) * jag;
+    albedo = mix(albedo, leather, pants_mask);
+    roughness = mix(roughness, 0.55, pants_mask);
+  } else if (is_armor || is_helmet) {
+    vec3 leather_tint = vec3(0.46, 0.32, 0.20);
+    // Add grain and scars to break up flatness.
+    float grain = fbm(v_worldPos.xy * 14.0);
+    float scar = fbm(v_worldPos.zy * 8.0 + vec2(1.7, 2.9));
+    float wear = grain * 0.45 + scar * 0.30;
+    albedo = mix(leather_tint, base, 0.4);
+    albedo -= vec3(0.06) * wear;
+    metallic = 0.05;
+    roughness = mix(0.46, 0.65, wear);
+  } else if (is_weapon) {
+    // Spear: metal head + wooden shaft based on height.
+    float tip = smoothstep(0.45, 0.65, v_worldPos.y);
+    float shaft = 1.0 - tip;
+    vec3 wood = vec3(0.52, 0.36, 0.22);
+    vec3 steel = vec3(0.74, 0.76, 0.80);
 
-    float leather_depth = clamp(
-        leatherOverlay * 0.8 + linenBlend * 0.2 + scaleBlend * 0.15, 0.0, 1.0);
-    mat.color = mix(mat.color, mat.color * 0.88 + vec3(0.04, 0.03, 0.02),
-                    leather_depth * 0.35);
+    float wood_grain = fbm(v_worldPos.xz * 18.0 + v_worldPos.y * 4.0);
+    float steel_brush = fbm(v_worldPos.xy * 32.0);
 
-    mat.normal = leather.normal;
-    mat.normal = normalize(mix(mat.normal, linen.normal, linenBlend));
-    mat.normal = normalize(mix(mat.normal, scales.normal, scaleBlend));
-    mat.normal = normalize(mix(mat.normal, mail.normal, mailBlend));
+    vec3 wood_color = mix(wood, base, 0.35);
+    wood_color += vec3(0.05) * wood_grain;
 
-    mat.roughness = leather.roughness;
-    mat.roughness = mix(mat.roughness, linen.roughness, linenBlend);
-    mat.roughness = mix(mat.roughness, scales.roughness, scaleBlend);
-    mat.roughness = mix(mat.roughness, mail.roughness, mailBlend);
+    vec3 steel_color = mix(steel, base, 0.25);
+    steel_color += vec3(0.06) * steel_brush;
 
-    mat.F0 = leather.F0;
-    mat.F0 = mix(mat.F0, linen.F0, linenBlend);
-    mat.F0 = mix(mat.F0, scales.F0, scaleBlend);
-    mat.F0 = mix(mat.F0, mail.F0, mailBlend);
-  } else if (is_weapon) {
-    if (v_bodyHeight > 0.55) {
-      mat = sample_steel(base_color, v_worldPos * 1.4, Nw, Tw, Bw);
-    } else if (v_bodyHeight > 0.25) {
-      mat = sample_wood(base_color, v_worldPos * 0.9, Nw, Tw, Bw);
-    } else {
-      mat = sample_leather(base_color, v_worldPos * 1.0, Nw, Tw, Bw);
-    }
-  } else if (is_shield) {
-    mat = sample_shield(base_color, v_worldPos, Nw, Tw, Bw);
-  } else { // skin / clothing
-    mat = sample_skin(base_color, v_worldPos, Nw, Tw, Bw);
+    albedo = mix(wood_color, steel_color, tip);
+    metallic = mix(0.05, 0.85, tip);
+    roughness = mix(0.50, 0.24, tip);
   }
 
-  vec3 L = normalize(vec3(0.45, 1.12, 0.35));
-  vec3 V = normalize(vec3(0.0, 0.0, 1.0));
-  vec3 H = normalize(L + V);
-
-  float NdotL = max(dot(mat.normal, L), 0.0);
-  float NdotV = max(dot(mat.normal, V), 0.0);
-  float NdotH = max(dot(mat.normal, H), 0.0);
+  float NdotL = max(dot(N, L), 0.0);
+  float NdotV = max(dot(N, V), 0.0);
+  float NdotH = max(dot(N, H), 0.0);
   float VdotH = max(dot(V, H), 0.0);
 
-  float wrap = is_helmet ? 0.15 : (is_armor ? 0.26 : 0.32);
-  float diff = max(NdotL * (1.0 - wrap) + wrap, 0.18);
-
-  float a = max(0.01, mat.roughness * mat.roughness);
+  float a = max(0.02, roughness * roughness);
   float D = D_GGX(NdotH, a);
-  float G = geometry_smith(NdotV, NdotL, mat.roughness);
-  vec3 F = fresnel_schlick(VdotH, mat.F0);
+  float G = geometry_smith(NdotV, NdotL, roughness);
+  vec3 F0 = mix(vec3(0.04), albedo, metallic);
+  vec3 F = fresnel_schlick(VdotH, F0);
   vec3 spec = (D * G * F) / max(4.0 * NdotL * NdotV + 1e-5, 1e-5);
 
-  float kd = 1.0 - max(max(F.r, F.g), F.b);
-  if (is_helmet)
-    kd *= 0.25;
-
-  vec3 ambient = hemi_ambient(mat.normal);
-  vec3 color = mat.color;
-
-  // Dust and campaign wear
-  float grime = fbm(v_worldPos * vec3(2.0, 1.4, 2.3));
-  color =
-      mix(color, color * vec3(0.78, 0.72, 0.68), smoothstep(0.45, 0.9, grime));
+  vec3 kd = (vec3(1.0) - F) * (1.0 - metallic);
+  vec3 diffuse = kd * albedo / 3.14159;
 
-  vec3 lighting =
-      ambient * color * 0.55 + color * kd * diff + spec * max(NdotL, 0.0);
+  float rim = pow(1.0 - max(dot(N, V), 0.0), 3.0);
+  vec3 ambient = albedo * 0.34 + vec3(0.04) * rim;
+  vec3 color = ambient + (diffuse + spec) * NdotL;
 
-  FragColor = vec4(saturate(lighting), u_alpha);
+  FragColor = vec4(saturate(color), u_alpha);
 }

+ 11 - 18
assets/shaders/spearman_roman_republic.frag

@@ -86,18 +86,25 @@ void main() {
   vec2 uv = v_worldPos.xz * 4.5;
 
   // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
+  bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
 
-  // Use material IDs exclusively (no fallbacks)
-  bool is_legs = (u_materialId == 0); // Body mesh includes legs
-
   // === ROMAN SPEARMAN (HASTATUS) MATERIALS ===
 
   // HEAVY STEEL HELMET (cool blue-grey steel)
-  if (is_helmet) {
+  if (is_skin) {
+    float skin_detail = noise(uv * 18.0) * 0.06;
+    float subdermal = noise(uv * 6.0) * 0.05;
+    vec3 V = normalize(vec3(0.0, 1.0, 0.35));
+    float rim =
+        pow(1.0 - max(dot(normalize(v_worldNormal), V), 0.0), 4.0) * 0.04;
+    color *= 1.0 + skin_detail;
+    color += vec3(0.025, 0.015, 0.010) * subdermal;
+    color += vec3(rim);
+  } else if (is_helmet) {
     // Steel wear patterns from vertex shader
     float brushed = abs(sin(v_worldPos.y * 95.0)) * 0.020;
     float dents = noise(uv * 6.5) * 0.032 * v_steelWear;
@@ -216,20 +223,6 @@ void main() {
     // Ensure armor is ALWAYS clearly visible
     color = clamp(color, vec3(0.32), vec3(0.88));
   }
-  // LEATHER PTERUGES & BELT
-  else if (is_legs) {
-    float leather_grain = noise(uv * 10.0) * 0.16 * (0.5 + v_leatherWear * 0.5);
-    float leather_pores = noise(uv * 22.0) * 0.08;
-    float strips = pteruges_strips(v_worldPos.xz, v_bodyHeight);
-    float wear = noise(uv * 4.0) * v_leatherWear * 0.10 - 0.05;
-
-    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
-    float view_angle = max(dot(normalize(v_worldNormal), V), 0.0);
-    float leather_sheen = pow(1.0 - view_angle, 4.5) * 0.10;
-
-    color *= 1.0 + leather_grain + leather_pores - 0.08 + wear;
-    color += vec3(strips * 0.15 + leather_sheen);
-  }
   // SCUTUM SHIELD (curved laminated wood with metal boss)
   else if (is_shield) {
     // Shield boss (domed metal center)

+ 87 - 389
assets/shaders/swordsman_carthage.frag

@@ -1,20 +1,8 @@
 #version 330 core
 
-in vec3 v_normal;
 in vec3 v_worldNormal;
-in vec3 v_tangent;
-in vec3 v_bitangent;
-in vec2 v_texCoord;
 in vec3 v_worldPos;
-in float v_armorLayer;
-in float v_bodyHeight;
-in float v_layerNoise;
-in float v_plateStress;
-in float v_lamellaPhase;
-in float v_latitudeMix;
-in float v_cuirassProfile;
-in float v_chainmailMix;
-in float v_frontMask;
+in vec2 v_texCoord;
 
 uniform sampler2D u_texture;
 uniform vec3 u_color;
@@ -24,429 +12,139 @@ uniform int u_materialId;
 
 out vec4 FragColor;
 
-// ---------------------------------------------------------------------------
-// Utilities
-// ---------------------------------------------------------------------------
-const float k_pi = 3.14159265;
-
+// Simple PBR-ish helpers
 float saturate(float v) { return clamp(v, 0.0, 1.0); }
 vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
 
-float hash21(vec2 p) {
-  p = fract(p * vec2(234.34, 435.345));
-  p += dot(p, p + 34.45);
-  return fract(p.x * p.y);
-}
-
-float noise3(vec3 p) {
-  vec3 i = floor(p);
-  vec3 f = fract(p);
-  f = f * f * (3.0 - 2.0 * f);
-  float n = i.x + i.y * 57.0 + i.z * 113.0;
-  return mix(
-      mix(mix(hash21(vec2(n, n + 1.0)), hash21(vec2(n + 57.0, n + 58.0)), f.x),
-          mix(hash21(vec2(n + 113.0, n + 114.0)),
-              hash21(vec2(n + 170.0, n + 171.0)), f.x),
-          f.y),
-      mix(mix(hash21(vec2(n + 226.0, n + 227.0)),
-              hash21(vec2(n + 283.0, n + 284.0)), f.x),
-          mix(hash21(vec2(n + 339.0, n + 340.0)),
-              hash21(vec2(n + 396.0, n + 397.0)), f.x),
-          f.y),
-      f.z);
+float hash12(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 fbm(vec3 p) {
-  float value = 0.0;
-  float amp = 0.5;
-  float freq = 1.0;
-  for (int i = 0; i < 5; ++i) {
-    value += amp * noise3(p * freq);
-    freq *= 1.85;
-    amp *= 0.55;
+float fbm(vec2 p) {
+  float v = 0.0;
+  float a = 0.5;
+  for (int i = 0; i < 4; ++i) {
+    v += a * hash12(p);
+    p *= 2.0;
+    a *= 0.55;
   }
-  return value;
-}
-
-vec3 hemi_ambient(vec3 n) {
-  float up = saturate(n.y * 0.5 + 0.5);
-  vec3 sky = vec3(0.54, 0.68, 0.82);
-  vec3 ground = vec3(0.32, 0.26, 0.20);
-  return mix(ground, sky, up);
+  return v;
 }
 
 float D_GGX(float NdotH, float a) {
   float a2 = a * a;
   float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
-  return a2 / max(k_pi * d * d, 1e-6);
+  return a2 / max(3.14159 * d * d, 1e-5);
 }
 
-float geometry_schlick_ggx(float NdotX, float k) {
-  return NdotX / max(NdotX * (1.0 - k) + k, 1e-6);
+float geometry_schlick(float NdotX, float k) {
+  return NdotX / max(NdotX * (1.0 - k) + k, 1e-5);
 }
 
 float geometry_smith(float NdotV, float NdotL, float roughness) {
   float r = roughness + 1.0;
   float k = (r * r) / 8.0;
-  return geometry_schlick_ggx(NdotV, k) * geometry_schlick_ggx(NdotL, k);
+  return geometry_schlick(NdotV, k) * geometry_schlick(NdotL, k);
 }
 
 vec3 fresnel_schlick(float cos_theta, vec3 F0) {
   return F0 + (1.0 - F0) * pow(1.0 - cos_theta, 5.0);
 }
 
-vec3 perturb(vec3 N, vec3 T, vec3 B, vec3 amount) {
-  return normalize(N + T * amount.x + B * amount.y);
-}
-
-float chainmail_rings(vec2 p) {
-  vec2 uv = p * 32.0;
-
-  vec2 g0 = fract(uv) - 0.5;
-  float r0 = length(g0);
-  float fw0 = fwidth(r0) * 1.2;
-  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
-                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
-
-  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
-  float r1 = length(g1);
-  float fw1 = fwidth(r1) * 1.2;
-  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
-                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
-
-  return (ring0 + ring1) * 0.15;
-}
-
-// ---------------------------------------------------------------------------
-// Material sampling
-// ---------------------------------------------------------------------------
-struct MaterialSample {
-  vec3 color;
-  vec3 normal;
-  float roughness;
-  vec3 F0;
-};
-
-MaterialSample sample_skin(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
-  MaterialSample m;
-  float pores = fbm(pos * 12.0);
-  float freckles = smoothstep(0.55, 0.78, fbm(pos * 6.5 + vec3(1.7, 0.0, 0.3)));
-  vec3 Np = perturb(N, T, B,
-                    vec3((pores - 0.5) * 0.05, (freckles - 0.5) * 0.04, 0.0));
-
-  vec3 tint = mix(base_color, vec3(0.93, 0.80, 0.68), 0.35);
-  tint += vec3(0.03) * freckles;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.48 + pores * 0.10, 0.32, 0.72);
-  m.F0 = vec3(0.028);
-  return m;
-}
-
-MaterialSample sample_bronze_helmet(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                    vec3 B) {
-  MaterialSample m;
-  float hammer = fbm(pos * 14.0 + vec3(v_layerNoise));
-  float patina = fbm(pos * 5.5 + vec3(2.1, 0.0, 4.2));
-  float ridge = smoothstep(0.82, 1.02, v_bodyHeight);
-  float rim = smoothstep(0.64, 0.86, v_bodyHeight) *
-              (1.0 - smoothstep(0.94, 1.12, v_bodyHeight));
-  vec3 Np =
-      perturb(N, T, B, vec3((hammer - 0.5) * 0.10, (patina - 0.5) * 0.08, 0.0));
-
-  vec3 tint = mix(vec3(0.62, 0.44, 0.18), base_color, 0.35);
-  tint = mix(tint, vec3(0.26, 0.48, 0.38),
-             clamp(patina * 0.65 + v_layerNoise * 0.2, 0.0, 0.8));
-  tint += vec3(0.10) * ridge;
-  tint += vec3(0.06) * rim;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness =
-      clamp(0.24 + hammer * 0.22 + patina * 0.16 - rim * 0.08, 0.14, 0.70);
-  m.F0 = mix(vec3(0.08), vec3(0.94, 0.82, 0.58),
-             clamp(0.42 + patina * 0.25 + rim * 0.15, 0.0, 1.0));
-  return m;
-}
-
-MaterialSample sample_bronze_cuirass(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                     vec3 B) {
-  MaterialSample m;
-  float hammer = fbm(pos * 11.0);
-  float profile = v_cuirassProfile * 2.0 - 1.0;
-  float front = v_frontMask;
-
-  // Add stronger anatomical emboss + seam bevels
-  float rib = sin(pos.x * 8.0 + profile * 3.2);
-  float ab = sin(pos.y * 13.0 - profile * 3.8);
-  float seam = smoothstep(0.42, 0.58, fract(pos.y * 4.5)) * 0.6;
-  float edge = smoothstep(0.82, 0.96, v_bodyHeight) +
-               smoothstep(0.18, 0.04, v_bodyHeight);
-
-  vec3 Np = perturb(N, T, B,
-                    vec3((rib + profile * 0.7) * 0.06 + seam * 0.05,
-                         (ab + front * 0.45) * 0.05 + edge * 0.04, 0.0));
-
-  // Force a stronger bronze anchor; palette only tints slightly.
-  vec3 tint = mix(vec3(0.62, 0.46, 0.20), base_color, 0.20);
-  vec3 patina_color = vec3(0.26, 0.48, 0.38);
-  float patina = fbm(pos * 4.5 + vec3(1.6, 0.0, 2.3));
-  tint = mix(tint, patina_color,
-             clamp(patina * 0.55 + v_layerNoise * 0.2, 0.0, 0.65));
-  tint += vec3(0.08) * front * smoothstep(0.45, 0.95, v_cuirassProfile);
-  tint += vec3(0.07) * edge;
-  tint -= vec3(0.05) * hammer;
-
-  // Grime and cavity darkening toward the waist; edge brightening on ridges.
-  float downward = smoothstep(0.35, 0.05, v_bodyHeight);
-  float curvature = length(dFdx(N)) + length(dFdy(N));
-  float edgeWear = smoothstep(0.12, 0.35, curvature);
-  tint = mix(tint, tint * vec3(0.78, 0.72, 0.66), downward * 0.4);
-  tint = mix(tint, tint * vec3(1.16, 1.10, 1.02), edgeWear * 0.6);
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness =
-      clamp(0.20 + hammer * 0.16 + patina * 0.08 - edgeWear * 0.12, 0.08, 0.60);
-  m.F0 = mix(vec3(0.08), vec3(0.94, 0.72, 0.50),
-             clamp(0.82 + edgeWear * 0.16, 0.0, 1.0));
-  return m;
-}
-
-MaterialSample sample_chainmail(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                                vec3 B) {
-  MaterialSample m;
-  vec2 uv = pos.xz * 14.0 + pos.yx * 5.5;
-  float rings = chainmail_rings(uv * 0.55 + v_chainmailMix * 0.25);
-  float weave = fbm(vec3(uv, 0.0) * 0.65 + v_layerNoise);
-  float gaps = smoothstep(0.25, 0.55, weave);
-  vec3 Np =
-      perturb(N, T, B, vec3((rings - 0.5) * 0.22, (weave - 0.5) * 0.14, 0.0));
-
-  vec3 tint = mix(vec3(0.52, 0.54, 0.60), base_color, 0.20);
-  tint = mix(tint, tint * vec3(0.32, 0.28, 0.24),
-             clamp(v_layerNoise * 0.45, 0.0, 0.8));
-  tint += vec3(0.10) * rings;
-  tint -= vec3(0.06) * gaps;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.34 + rings * 0.14 + weave * 0.18, 0.18, 0.85);
-  m.F0 = vec3(0.16, 0.16, 0.18);
-  return m;
-}
-
-MaterialSample sample_linen(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
-  MaterialSample m;
-  float warp = sin(pos.x * 120.0) * 0.04;
-  float weft = sin(pos.z * 116.0) * 0.04;
-  float slub = fbm(pos * 7.0) * 0.05;
-  vec3 Np = normalize(N + T * (warp + slub) + B * (weft + slub * 0.5));
-
-  vec3 tint = mix(vec3(0.88, 0.82, 0.72), base_color, 0.45);
-  tint *= 1.0 - slub * 0.12;
-  tint = mix(tint, tint * vec3(0.82, 0.76, 0.70),
-             smoothstep(0.55, 1.0, v_bodyHeight));
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.62 + slub * 0.12, 0.35, 0.90);
-  m.F0 = vec3(0.028);
-  return m;
-}
-
-MaterialSample sample_leather(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                              vec3 B) {
-  MaterialSample m;
-  float grain = fbm(pos * 6.0);
-  float crack = fbm(pos * 11.0 + vec3(0.0, 1.7, 2.3));
-  vec3 Np =
-      perturb(N, T, B, vec3((grain - 0.5) * 0.10, (crack - 0.5) * 0.08, 0.0));
-
-  vec3 tint = mix(vec3(0.38, 0.25, 0.15), base_color, 0.45);
-  tint *= 1.0 - 0.10 * grain;
-  tint += vec3(0.05) * smoothstep(0.4, 0.9, crack + v_layerNoise * 0.2);
-  tint = mix(tint, tint * vec3(0.85, 0.80, 0.72),
-             smoothstep(0.35, 0.15, v_bodyHeight)); // dustier toward ground
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.55 + grain * 0.22 - crack * 0.12, 0.25, 0.95);
-  m.F0 = vec3(0.035);
-  return m;
-}
-
-MaterialSample sample_wood(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
-  MaterialSample m;
-  float grain = fbm(pos * 10.0);
-  float rings = sin(pos.y * 24.0 + pos.x * 3.0);
-  vec3 Np = perturb(N, T, B, vec3(grain * 0.06, rings * 0.04, 0.0));
-
-  vec3 tint = mix(vec3(0.42, 0.32, 0.20), base_color, 0.45);
-  tint *= 1.0 + grain * 0.10 + rings * 0.04;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.52 + grain * 0.18, 0.28, 0.90);
-  m.F0 = vec3(0.028);
-  return m;
-}
-
-MaterialSample sample_steel(vec3 base_color, vec3 pos, vec3 N, vec3 T, vec3 B) {
-  MaterialSample m;
-  float brushed = abs(sin(pos.y * 90.0)) * 0.04;
-  float dent = fbm(pos * 12.0) * (0.4 + v_layerNoise * 0.3);
-  vec3 Np = perturb(N, T, B, vec3((brushed - 0.5) * 0.06, dent * 0.05, 0.0));
-
-  vec3 tint = mix(vec3(0.74, 0.76, 0.80), base_color, 0.45);
-  tint += vec3(0.06) * brushed;
-  tint -= vec3(0.08) * dent;
-
-  m.color = tint;
-  m.normal = Np;
-  m.roughness = clamp(0.20 + brushed * 0.20 + dent * 0.18 - v_layerNoise * 0.08,
-                      0.08, 0.80);
-  m.F0 = vec3(0.62, 0.64, 0.66);
-  return m;
-}
-
-MaterialSample sample_shield(vec3 base_color, vec3 pos, vec3 N, vec3 T,
-                             vec3 B) {
-  MaterialSample wood = sample_wood(base_color, pos * 0.9, N, T, B);
-  MaterialSample boss =
-      sample_steel(vec3(0.78, 0.74, 0.62), pos * 1.4, N, T, B);
-
-  float boss_mask = smoothstep(0.12, 0.04, length(pos.xz));
-  boss_mask = max(boss_mask, v_frontMask * 0.3);
-
-  MaterialSample m;
-  m.color = mix(wood.color, boss.color, boss_mask);
-  m.normal = normalize(mix(wood.normal, boss.normal, boss_mask));
-  m.roughness = mix(wood.roughness, boss.roughness, boss_mask);
-  m.F0 = mix(wood.F0, boss.F0, boss_mask);
-  return m;
-}
-
-// ---------------------------------------------------------------------------
-// Main
-// ---------------------------------------------------------------------------
 void main() {
-  vec3 base_color = u_color;
+  vec3 base = clamp(u_color, 0.0, 1.0);
   if (u_useTexture) {
-    base_color *= texture(u_texture, v_texCoord).rgb;
+    base *= texture(u_texture, v_texCoord).rgb;
   }
 
-  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
+  // Material layout: 0=skin, 1=armor, 2=helmet, 3=weapon, 4=shield
   bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
-  bool is_shield = (u_materialId == 4);
-
-  vec3 Nw = normalize(v_worldNormal);
-  vec3 Tw = normalize(v_tangent);
-  vec3 Bw = normalize(v_bitangent);
 
-  MaterialSample mat;
-  if (is_helmet) {
-    mat = sample_bronze_helmet(base_color, v_worldPos, Nw, Tw, Bw);
-  } else if (is_armor) {
-    // Override palette to canonical materials so armor is visibly metal.
-    vec3 bronze_base = vec3(0.62, 0.46, 0.20);
-    vec3 steel_base = vec3(0.68, 0.70, 0.74);
-    vec3 linen_base = vec3(0.86, 0.80, 0.72);
-    vec3 leather_base = vec3(0.38, 0.25, 0.15);
+  vec3 N = normalize(v_worldNormal);
+  vec3 V = normalize(vec3(0.0, 0.0, 1.0));
+  vec3 L = normalize(vec3(0.5, 1.0, 0.4));
+  vec3 H = normalize(L + V);
 
-    MaterialSample cuirass =
-        sample_bronze_cuirass(bronze_base, v_worldPos, Nw, Tw, Bw);
-    MaterialSample mail =
-        sample_chainmail(steel_base, v_worldPos * 1.05, Nw, Tw, Bw);
-    MaterialSample linen =
-        sample_linen(linen_base, v_worldPos * 1.0, Nw, Tw, Bw);
-    MaterialSample leather =
-        sample_leather(leather_base, v_worldPos * 0.9, Nw, Tw, Bw);
+  // Heavy golden armor for swordsmen.
+  float metallic = 0.0;
+  float roughness = 0.55;
+  vec3 albedo = base;
+
+  if (is_skin) {
+    albedo = mix(base, vec3(0.93, 0.83, 0.72), 0.35);
+    metallic = 0.0;
+    roughness = 0.6;
+    // Jagged leather pants for lower body
+    float pants_mask = 1.0 - smoothstep(0.38, 0.60, v_worldPos.y);
+    float jag = fbm(v_worldPos.xz * 15.0 + v_worldPos.y * 3.0);
+    vec3 leather = vec3(0.44, 0.30, 0.20) - vec3(0.04) * jag;
+    albedo = mix(albedo, leather, pants_mask);
+    roughness = mix(roughness, 0.54, pants_mask);
+  } else if (is_armor || is_helmet) {
+    // Bright gold with hammered/patina variation.
+    vec3 gold = vec3(0.95, 0.82, 0.45); // keep energy under 1 to avoid pinking
+    float hammer = fbm(v_worldPos.xz * 18.0);
+    float patina = fbm(v_worldPos.xy * 8.0 + vec2(1.7, 3.1));
+    float hammered = clamp(hammer * 0.8 + patina * 0.2, 0.0, 1.0);
+    albedo = mix(gold, base, 0.05);
+    albedo += vec3(0.16) * hammered;
+    metallic = 1.0;
+    roughness = mix(0.04, 0.10, hammered);
+  } else if (is_weapon) {
+    // Sword: wooden grip, brass guard/pommel, steel blade.
+    float h = clamp(v_worldPos.y, 0.0, 1.0);
+    float blade_mask = smoothstep(0.30, 0.55, h);
+    float guard_mask = smoothstep(0.20, 0.35, h) * (1.0 - blade_mask);
 
-    // Treat the entire armor piece as torso to avoid missing coverage.
-    float torsoBand = 1.0;
-    float skirtBand = 0.0;
-    float mailBlend =
-        clamp(smoothstep(0.15, 0.78, v_chainmailMix + v_layerNoise * 0.25),
-              0.15, 1.0) *
-        torsoBand;
-    float cuirassBlend = torsoBand;
-    float leatherBlend = skirtBand * 0.65;
-    float linenBlend = skirtBand * 0.45;
+    vec3 wood = vec3(0.46, 0.32, 0.20);
+    vec3 steel = vec3(0.75, 0.77, 0.82);
+    vec3 brass = vec3(0.86, 0.70, 0.36);
 
-    mat.color = cuirass.color;
-    mat.color = mix(mat.color, mail.color, mailBlend);
-    mat.color = mix(mat.color, linen.color, linenBlend);
-    mat.color = mix(mat.color, leather.color, leatherBlend);
-    // Make sure metal stays bright: bias toward bronze/steel luma.
-    float armor_luma = dot(mat.color, vec3(0.299, 0.587, 0.114));
-    mat.color =
-        mix(mat.color, mat.color * 1.25, smoothstep(0.35, 0.65, armor_luma));
+    float wood_grain = fbm(v_worldPos.xz * 14.0 + v_worldPos.y * 6.0);
+    float steel_brush = fbm(v_worldPos.xy * 30.0);
 
-    mat.normal = cuirass.normal;
-    mat.normal = normalize(mix(mat.normal, mail.normal, mailBlend));
-    mat.normal = normalize(mix(mat.normal, linen.normal, linenBlend));
-    mat.normal = normalize(mix(mat.normal, leather.normal, leatherBlend));
+    vec3 handle = mix(wood, base, 0.35) + vec3(0.05) * wood_grain;
+    vec3 blade = mix(steel, base, 0.20) + vec3(0.05) * steel_brush;
+    vec3 guard = mix(brass, base, 0.15);
 
-    mat.roughness = cuirass.roughness;
-    mat.roughness = mix(mat.roughness, mail.roughness, mailBlend);
-    mat.roughness = mix(mat.roughness, linen.roughness, linenBlend);
-    mat.roughness = mix(mat.roughness, leather.roughness, leatherBlend);
+    albedo = mix(handle, guard, guard_mask);
+    albedo = mix(albedo, blade, blade_mask);
 
-    mat.F0 = cuirass.F0;
-    mat.F0 = mix(mat.F0, mail.F0, mailBlend);
-    mat.F0 = mix(mat.F0, linen.F0, linenBlend);
-    mat.F0 = mix(mat.F0, leather.F0, leatherBlend);
-  } else if (is_weapon) {
-    if (v_bodyHeight > 0.55) {
-      mat = sample_steel(base_color, v_worldPos * 1.4, Nw, Tw, Bw);
-    } else if (v_bodyHeight > 0.25) {
-      mat = sample_wood(base_color, v_worldPos * 0.9, Nw, Tw, Bw);
-    } else {
-      mat = sample_leather(base_color, v_worldPos * 1.0, Nw, Tw, Bw);
-    }
-  } else if (is_shield) {
-    mat = sample_shield(base_color, v_worldPos, Nw, Tw, Bw);
-  } else { // skin / cloth under layer
-    mat = sample_skin(base_color, v_worldPos, Nw, Tw, Bw);
+    metallic = mix(0.05, 1.0, blade_mask + guard_mask * 0.8);
+    roughness = mix(0.50, 0.18, blade_mask);
   }
 
-  vec3 L = normalize(vec3(0.45, 1.12, 0.35));
-  vec3 V = normalize(vec3(0.0, 0.0, 1.0));
-  vec3 H = normalize(L + V);
-
-  float NdotL = max(dot(mat.normal, L), 0.0);
-  float NdotV = max(dot(mat.normal, V), 0.0);
-  float NdotH = max(dot(mat.normal, H), 0.0);
+  float NdotL = max(dot(N, L), 0.0);
+  float NdotV = max(dot(N, V), 0.0);
+  float NdotH = max(dot(N, H), 0.0);
   float VdotH = max(dot(V, H), 0.0);
 
-  float wrap = is_helmet ? 0.15 : (is_armor ? 0.12 : 0.32);
-  float diff = max(NdotL * (1.0 - wrap) + wrap, 0.10);
-
-  float a = max(0.01, mat.roughness * mat.roughness);
+  float a = max(0.02, roughness * roughness);
   float D = D_GGX(NdotH, a);
-  float G = geometry_smith(NdotV, NdotL, mat.roughness);
-  vec3 F = fresnel_schlick(VdotH, mat.F0);
+  float G = geometry_smith(NdotV, NdotL, roughness);
+  vec3 F0 = mix(vec3(0.04), albedo, metallic);
+  vec3 F = fresnel_schlick(VdotH, F0);
   vec3 spec = (D * G * F) / max(4.0 * NdotL * NdotV + 1e-5, 1e-5);
 
-  float kd = 1.0 - max(max(F.r, F.g), F.b);
-  if (is_helmet) {
-    kd *= 0.25;
-  }
-
-  vec3 ambient = hemi_ambient(mat.normal);
-  vec3 color = mat.color;
+  vec3 kd = (vec3(1.0) - F) * (1.0 - metallic);
+  vec3 diffuse = kd * albedo / 3.14159;
 
-  float grime = fbm(v_worldPos * vec3(2.0, 1.4, 2.3));
-  color =
-      mix(color, color * vec3(0.78, 0.72, 0.68), smoothstep(0.45, 0.9, grime));
+  // Extra clearcoat/spec boost to force shiny gold read.
+  vec3 clearF = fresnel_schlick(NdotV, vec3(0.10));
+  float clearD = D_GGX(NdotH, 0.035);
+  float clearG = geometry_smith(NdotV, NdotL, 0.12);
+  vec3 clearcoat =
+      (clearD * clearG * clearF) / max(4.0 * NdotL * NdotV + 1e-5, 1e-5);
 
-  vec3 lighting =
-      ambient * color * 0.55 + color * kd * diff + spec * max(NdotL, 0.0);
+  float rim = pow(1.0 - max(dot(N, V), 0.0), 3.0);
+  vec3 ambient = albedo * 0.40 + vec3(0.08) * rim;
+  vec3 highlight = vec3(0.30) * pow(NdotL, 14.0) * metallic;
+  vec3 color = ambient + (diffuse + spec * 1.8 + clearcoat) * NdotL + highlight;
 
-  FragColor = vec4(saturate(lighting), u_alpha);
+  FragColor = vec4(saturate(color), u_alpha);
 }

+ 11 - 17
assets/shaders/swordsman_roman_republic.frag

@@ -85,18 +85,25 @@ void main() {
   vec2 uv = v_worldPos.xz * 5.0;
 
   // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
+  bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
 
-  // Use material IDs exclusively (no fallbacks)
-  bool is_legs = (u_materialId == 0); // Body mesh includes legs
-
   // === ROMAN SWORDSMAN (LEGIONARY) MATERIALS ===
 
   // HEAVY STEEL HELMET (galea - cool blue-grey steel)
-  if (is_helmet) {
+  if (is_skin) {
+    vec3 V = normalize(vec3(0.0, 1.0, 0.35));
+    float skin_detail = noise(uv * 18.0) * 0.06;
+    float subdermal = noise(uv * 6.0) * 0.05;
+    float rim =
+        pow(1.0 - max(dot(normalize(v_worldNormal), V), 0.0), 4.0) * 0.04;
+    color *= 1.0 + skin_detail;
+    color += vec3(0.025, 0.015, 0.010) * subdermal;
+    color += vec3(rim);
+  } else if (is_helmet) {
     // Polished steel finish with vertex polish level
     float brushed_metal = abs(sin(v_worldPos.y * 95.0)) * 0.02;
     float scratches = noise(uv * 35.0) * 0.018 * (1.0 - v_polishLevel * 0.5);
@@ -244,19 +251,6 @@ void main() {
     // Ensure segmentata is ALWAYS bright and visible
     color = clamp(color, vec3(0.45), vec3(0.95));
   }
-  // LEATHER PTERUGES & BELT
-  else if (is_legs) {
-    float leather_grain = noise(uv * 10.0) * 0.15;
-    float strips = pteruges_strips(v_worldPos.xz, v_bodyHeight);
-    float wear_marks = noise(uv * 3.0) * 0.10;
-
-    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
-    float view_angle = max(dot(normalize(v_worldNormal), V), 0.0);
-    float leather_sheen = pow(1.0 - view_angle, 4.5) * 0.10;
-
-    color *= 1.0 + leather_grain - 0.08 + wear_marks - 0.05;
-    color += vec3(strips * 0.15 + leather_sheen);
-  }
   // SCUTUM SHIELD (curved laminated wood with metal boss)
   else if (is_shield) {
     // Shield boss (raised metal dome)

+ 2 - 2
render/entity/horse_archer_renderer_base.cpp

@@ -26,7 +26,7 @@ namespace Render::GL {
 
 namespace {
 
-constexpr QVector3D k_default_proportion_scale{0.92F, 0.88F, 0.96F};
+constexpr QVector3D k_default_proportion_scale{0.80F, 0.88F, 0.88F};
 
 }
 
@@ -58,7 +58,7 @@ auto HorseArcherRendererBase::get_mount_scale() const -> float {
 void HorseArcherRendererBase::adjust_variation(
     const DrawContext &, uint32_t, VariationParams &variation) const {
   variation.height_scale = 0.88F;
-  variation.bulk_scale = 0.78F;
+  variation.bulk_scale = 0.72F;
   variation.stance_width = 0.60F;
   variation.arm_swing_amp = 0.45F;
   variation.walk_speed_mult = 1.0F;

+ 3 - 3
render/entity/horse_spearman_renderer_base.cpp

@@ -25,7 +25,7 @@ namespace Render::GL {
 
 namespace {
 
-constexpr QVector3D k_default_proportion_scale{0.96F, 0.90F, 0.98F};
+constexpr QVector3D k_default_proportion_scale{0.80F, 0.88F, 0.88F};
 
 }
 
@@ -49,7 +49,7 @@ HorseSpearmanRendererBase::HorseSpearmanRendererBase(
 
 auto HorseSpearmanRendererBase::get_proportion_scaling() const -> QVector3D {
 
-  return QVector3D{0.84F, 0.84F, 0.86F};
+  return QVector3D{0.78F, 0.84F, 0.84F};
 }
 
 auto HorseSpearmanRendererBase::get_mount_scale() const -> float {
@@ -59,7 +59,7 @@ auto HorseSpearmanRendererBase::get_mount_scale() const -> float {
 void HorseSpearmanRendererBase::adjust_variation(
     const DrawContext &, uint32_t, VariationParams &variation) const {
   variation.height_scale = 0.90F;
-  variation.bulk_scale = 0.74F;
+  variation.bulk_scale = 0.70F;
   variation.stance_width = 0.60F;
   variation.arm_swing_amp = 0.40F;
   variation.walk_speed_mult = 1.0F;

+ 2 - 2
render/entity/mounted_knight_renderer_base.cpp

@@ -25,7 +25,7 @@ namespace Render::GL {
 
 namespace {
 
-constexpr QVector3D k_default_proportion_scale{0.92F, 0.88F, 0.96F};
+constexpr QVector3D k_default_proportion_scale{0.80F, 0.88F, 0.88F};
 
 }
 
@@ -58,7 +58,7 @@ auto MountedKnightRendererBase::get_mount_scale() const -> float {
 void MountedKnightRendererBase::adjust_variation(
     const DrawContext &, uint32_t, VariationParams &variation) const {
   variation.height_scale = 0.88F;
-  variation.bulk_scale = 0.82F;
+  variation.bulk_scale = 0.76F;
   variation.stance_width = 0.60F;
   variation.arm_swing_amp = 0.45F;
   variation.walk_speed_mult = 1.0F;

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

@@ -251,6 +251,7 @@ public:
       bow_config.bow_top_y = HP::SHOULDER_Y + 0.55F;
       bow_config.bow_bot_y = HP::WAIST_Y - 0.25F;
       bow_config.bow_x = 0.0F;
+      bow_config.arrow_visibility = ArrowVisibility::IdleAndAttackCycle;
 
       if (style.bow_string_color) {
         bow_config.string_color = saturate_color(*style.bow_string_color);

+ 54 - 35
render/entity/nations/carthage/healer_renderer.cpp

@@ -207,18 +207,22 @@ public:
       return;
     }
 
-    QVector3D const robe_cream(0.90F, 0.85F, 0.72F);
-    QVector3D const robe_light(0.88F, 0.82F, 0.68F);
-    QVector3D const robe_tan(0.78F, 0.70F, 0.55F);
-    QVector3D const purple_tyrian(0.50F, 0.20F, 0.55F);
-    QVector3D const purple_dark(0.35F, 0.12F, 0.40F);
-    QVector3D const gold_trim(0.75F, 0.60F, 0.30F);
-    QVector3D const bronze(0.70F, 0.50F, 0.28F);
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    QVector3D const robe_cream(0.46F, 0.46F, 0.48F);
+    QVector3D const robe_light(0.42F, 0.42F, 0.44F);
+    QVector3D const robe_tan(0.38F, 0.38F, 0.40F);
+    QVector3D const purple_tyrian(0.05F, 0.05F, 0.05F);
+    QVector3D const purple_dark(0.05F, 0.05F, 0.05F);
+    QVector3D const bronze_color(0.78F, 0.58F, 0.32F);
 
     const QVector3D &origin = torso.origin;
     const QVector3D &right = torso.right;
     const QVector3D &up = torso.up;
     const QVector3D &forward = torso.forward;
+
+    constexpr int k_mat_tunic = 1;
+    constexpr int k_mat_purple_trim = 2;
+    constexpr int k_mat_tools = 4;
     float const torso_r = torso.radius * 1.02F;
     float const torso_depth =
         (torso.depth > 0.0F) ? torso.depth * 0.88F : torso.radius * 0.82F;
@@ -230,7 +234,8 @@ public:
     constexpr float pi = std::numbers::pi_v<float>;
 
     auto drawRobeRing = [&](float y_pos, float width, float depth,
-                            const QVector3D &color, float thickness) {
+                            const QVector3D &color, float thickness,
+                            int materialId) {
       for (int i = 0; i < segments; ++i) {
         float const angle1 = (static_cast<float>(i) / segments) * 2.0F * pi;
         float const angle2 = (static_cast<float>(i + 1) / segments) * 2.0F * pi;
@@ -252,17 +257,17 @@ public:
 
         out.mesh(getUnitCylinder(),
                  cylinderBetween(ctx.model, p1, p2, thickness), color, nullptr,
-                 1.0F);
+                 1.0F, materialId);
       }
     };
 
     drawRobeRing(y_shoulder - 0.00F, torso_r * 1.22F, torso_depth * 1.12F,
-                 robe_cream, 0.036F);
+                 robe_cream, 0.036F, k_mat_tunic);
     drawRobeRing(y_shoulder - 0.05F, torso_r * 1.30F, torso_depth * 1.18F,
-                 robe_cream, 0.038F);
+                 robe_cream, 0.038F, k_mat_tunic);
 
     drawRobeRing(y_shoulder - 0.09F, torso_r * 1.12F, torso_depth * 1.00F,
-                 robe_cream, 0.032F);
+                 robe_cream, 0.032F, k_mat_tunic);
 
     float const torso_fill_top = y_shoulder - 0.12F;
     float const torso_fill_bot = y_waist + 0.04F;
@@ -276,7 +281,7 @@ public:
       float const thickness = 0.030F - t * 0.010F;
       QVector3D const c =
           (t < 0.35F) ? robe_cream : robe_light * (1.0F - (t - 0.35F) * 0.3F);
-      drawRobeRing(y, width, depth, c, thickness);
+      drawRobeRing(y, width, depth, c, thickness, k_mat_tunic);
     }
 
     float const skirt_flare = 1.40F;
@@ -288,7 +293,7 @@ public:
       float const flare = 1.0F + t * (skirt_flare - 1.0F);
       QVector3D const skirt_color = robe_cream * (1.0F - t * 0.08F);
       drawRobeRing(y, torso_r * 0.90F * flare, torso_depth * 0.84F * flare,
-                   skirt_color, 0.022F + t * 0.012F);
+                   skirt_color, 0.022F + t * 0.012F, k_mat_tunic);
     }
 
     float const sash_y = y_waist + 0.01F;
@@ -296,16 +301,16 @@ public:
     QVector3D const sash_bot = origin + up * (sash_y - 0.028F - origin.y());
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, sash_bot, sash_top, torso_r * 0.99F),
-             purple_tyrian, nullptr, 1.0F);
+             purple_tyrian, nullptr, 1.0F, k_mat_purple_trim);
 
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, sash_top, sash_top - up * 0.006F,
                              torso_r * 1.02F),
-             gold_trim, nullptr, 1.0F);
+             team_tint, nullptr, 1.0F, k_mat_tools);
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, sash_bot + up * 0.006F, sash_bot,
                              torso_r * 1.02F),
-             gold_trim, nullptr, 1.0F);
+             team_tint, nullptr, 1.0F, k_mat_tools);
 
     QVector3D const sash_hang_start =
         origin + right * (torso_r * 0.3F) + up * (sash_y - origin.y());
@@ -313,11 +318,11 @@ public:
         sash_hang_start - up * 0.12F + forward * 0.02F;
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, sash_hang_start, sash_hang_end, 0.018F),
-             purple_dark, nullptr, 1.0F);
+             purple_dark, nullptr, 1.0F, k_mat_purple_trim);
 
     out.mesh(getUnitSphere(),
-             sphereAt(ctx.model, sash_hang_end - up * 0.01F, 0.015F), gold_trim,
-             nullptr, 1.0F);
+             sphereAt(ctx.model, sash_hang_end - up * 0.01F, 0.015F),
+             bronze_color, nullptr, 1.0F, k_mat_tools);
 
     float const neck_y = y_shoulder + 0.04F;
     QVector3D const neck_center = origin + up * (neck_y - origin.y());
@@ -325,12 +330,12 @@ public:
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, neck_center - up * 0.012F,
                              neck_center + up * 0.012F, HP::NECK_RADIUS * 1.7F),
-             robe_tan, nullptr, 1.0F);
+             robe_tan, nullptr, 1.0F, k_mat_tunic);
 
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, neck_center + up * 0.010F,
                              neck_center + up * 0.018F, HP::NECK_RADIUS * 2.0F),
-             purple_tyrian * 0.9F, nullptr, 1.0F);
+             purple_tyrian * 0.9F, nullptr, 1.0F, k_mat_purple_trim);
 
     auto drawFlowingSleeve = [&](const QVector3D &shoulder_pos,
                                  const QVector3D &outward) {
@@ -344,28 +349,28 @@ public:
         float const sleeve_r = HP::UPPER_ARM_R * (1.55F - t * 0.08F);
         QVector3D const sleeve_color = robe_cream * (1.0F - t * 0.04F);
         out.mesh(getUnitSphere(), sphereAt(ctx.model, sleeve_pos, sleeve_r),
-                 sleeve_color, nullptr, 1.0F);
+                 sleeve_color, nullptr, 1.0F, k_mat_tunic);
       }
 
       QVector3D const cuff_pos =
           anchor + outward * 0.055F + forward * 0.040F - up * 0.05F;
       out.mesh(getUnitSphere(),
                sphereAt(ctx.model, cuff_pos, HP::UPPER_ARM_R * 1.15F),
-               purple_tyrian * 0.85F, nullptr, 1.0F);
+               purple_tyrian * 0.85F, nullptr, 1.0F, k_mat_purple_trim);
     };
     drawFlowingSleeve(frames.shoulder_l.origin, -right);
     drawFlowingSleeve(frames.shoulder_r.origin, right);
 
     QVector3D const pendant_pos = origin + forward * (torso_depth * 0.6F) +
                                   up * (y_shoulder - 0.06F - origin.y());
-    out.mesh(getUnitSphere(), sphereAt(ctx.model, pendant_pos, 0.022F), bronze,
-             nullptr, 1.0F);
+    out.mesh(getUnitSphere(), sphereAt(ctx.model, pendant_pos, 0.022F),
+             bronze_color, nullptr, 1.0F, k_mat_tools);
 
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model,
                              neck_center + forward * (torso_depth * 0.3F),
                              pendant_pos + up * 0.01F, 0.006F),
-             gold_trim * 0.8F, nullptr, 1.0F);
+             bronze_color * 0.85F, nullptr, 1.0F, k_mat_tools);
   }
 
 private:
@@ -408,16 +413,30 @@ private:
                                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);
+                           QVector3D &target, float team_weight,
+                           float style_weight) {
+      target = mix_palette_color(target, override_color, team_tint, team_weight,
+                                 style_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);
+    constexpr float k_skin_team_mix_weight = 0.0F;
+    constexpr float k_skin_style_mix_weight = 1.0F;
+
+    constexpr float k_cloth_team_mix_weight = 0.0F;
+    constexpr float k_cloth_style_mix_weight = 1.0F;
+
+    apply_color(style.skin_color, variant.palette.skin, k_skin_team_mix_weight,
+                k_skin_style_mix_weight);
+    apply_color(style.cloth_color, variant.palette.cloth,
+                k_cloth_team_mix_weight, k_cloth_style_mix_weight);
+    apply_color(style.leather_color, variant.palette.leather, k_team_mix_weight,
+                k_style_mix_weight);
+    apply_color(style.leather_dark_color, variant.palette.leatherDark,
+                k_team_mix_weight, k_style_mix_weight);
+    apply_color(style.metal_color, variant.palette.metal, k_team_mix_weight,
+                k_style_mix_weight);
+    apply_color(style.wood_color, variant.palette.wood, k_team_mix_weight,
+                k_style_mix_weight);
   }
 };
 

+ 5 - 2
render/entity/nations/carthage/healer_style.cpp

@@ -5,7 +5,9 @@
 
 namespace {
 
-constexpr QVector3D k_carthage_tunic{0.92F, 0.88F, 0.82F};
+constexpr QVector3D k_carthage_tunic{0.45F, 0.45F, 0.47F};
+
+constexpr QVector3D k_carthage_skin{0.08F, 0.07F, 0.065F};
 
 constexpr QVector3D k_carthage_leather{0.48F, 0.35F, 0.22F};
 constexpr QVector3D k_carthage_leather_dark{0.32F, 0.24F, 0.16F};
@@ -14,7 +16,7 @@ constexpr QVector3D k_carthage_bronze{0.70F, 0.52F, 0.32F};
 
 constexpr QVector3D k_carthage_wood{0.45F, 0.35F, 0.22F};
 
-constexpr QVector3D k_carthage_purple{0.45F, 0.18F, 0.55F};
+constexpr QVector3D k_carthage_purple{0.04F, 0.04F, 0.045F};
 } // namespace
 
 namespace Render::GL::Carthage {
@@ -22,6 +24,7 @@ namespace Render::GL::Carthage {
 void register_carthage_healer_style() {
   HealerStyleConfig style;
   style.cloth_color = k_carthage_tunic;
+  style.skin_color = k_carthage_skin;
   style.leather_color = k_carthage_leather;
   style.leather_dark_color = k_carthage_leather_dark;
   style.metal_color = k_carthage_bronze;

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

@@ -8,6 +8,7 @@ namespace Render::GL::Carthage {
 
 struct HealerStyleConfig {
   std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> skin_color;
   std::optional<QVector3D> leather_color;
   std::optional<QVector3D> leather_dark_color;
   std::optional<QVector3D> metal_color;

+ 1 - 0
render/entity/nations/roman/archer_renderer.cpp

@@ -218,6 +218,7 @@ public:
       bow_config.bow_top_y = HP::SHOULDER_Y + 0.55F;
       bow_config.bow_bot_y = HP::WAIST_Y - 0.25F;
       bow_config.bow_x = 0.0F;
+      bow_config.arrow_visibility = ArrowVisibility::IdleAndAttackCycle;
 
       if (style.bow_string_color) {
         bow_config.string_color = saturate_color(*style.bow_string_color);

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

@@ -56,9 +56,9 @@ void ArmorHeavyCarthageRenderer::render(const DrawContext &ctx,
     top = head_guard - up * (torso_r * 0.06F);
   }
 
-  QVector3D bottom = waist.origin - waist_up * (waist_r * 0.32F) -
+  QVector3D bottom = waist.origin - waist_up * (waist_r * 1.60F) -
                      forward * (torso_r * 0.018F);
-  QVector3D chainmail_bottom = waist.origin - waist_up * (waist_r * 0.28F) -
+  QVector3D chainmail_bottom = waist.origin - waist_up * (waist_r * 1.52F) -
                                forward * (torso_r * 0.024F);
 
   QVector3D bronze_color = QVector3D(0.72F, 0.53F, 0.28F);

+ 16 - 6
render/equipment/armor/armor_light_carthage.cpp

@@ -4,6 +4,7 @@
 #include "../../humanoid/humanoid_math.h"
 #include "../../humanoid/humanoid_specs.h"
 #include "../../humanoid/rig.h"
+#include "../../humanoid/style_palette.h"
 #include "../../submitter.h"
 #include <QMatrix4x4>
 #include <QVector3D>
@@ -14,6 +15,7 @@
 namespace Render::GL {
 
 using Render::Geom::cylinderBetween;
+using Render::GL::Humanoid::saturate_color;
 
 void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
                                         const BodyFrames &frames,
@@ -31,9 +33,17 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
     return;
   }
 
-  QVector3D leather_color = QVector3D(0.44F, 0.30F, 0.19F);
-  QVector3D leather_shadow = leather_color * 0.90F;
-  QVector3D leather_highlight = leather_color * 1.08F;
+  QVector3D leather_color = saturate_color(palette.leather);
+  QVector3D leather_shadow =
+      saturate_color(leather_color * QVector3D(0.90F, 0.90F, 0.90F));
+  QVector3D leather_highlight =
+      saturate_color(leather_color * QVector3D(1.08F, 1.05F, 1.02F));
+  QVector3D metal_color =
+      saturate_color(palette.metal * QVector3D(1.00F, 0.94F, 0.88F));
+  QVector3D metal_core =
+      saturate_color(metal_color * QVector3D(0.94F, 0.94F, 0.94F));
+  QVector3D cloth_accent =
+      saturate_color(palette.cloth * QVector3D(1.05F, 1.02F, 1.04F));
 
   QVector3D up = torso.up.normalized();
   QVector3D right = torso.right.normalized();
@@ -69,7 +79,7 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
   cuirass.scale(1.0F, 1.0F, std::max(0.15F, main_depth / main_radius));
   Mesh *torso_mesh = torso_mesh_without_bottom_cap();
   submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(), cuirass,
-                 leather_highlight, nullptr, 1.0F, 1);
+                 metal_color, nullptr, 1.0F, 1);
 
   auto strap = [&](float side) {
     QVector3D shoulder_anchor =
@@ -93,7 +103,7 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
   front_panel.scale(1.18F, 1.0F,
                     std::max(0.22F, (torso_depth * 0.76F) / (torso_r * 0.76F)));
   submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(),
-                 front_panel, leather_highlight, nullptr, 1.0F, 1);
+                 front_panel, cloth_accent, nullptr, 1.0F, 1);
 
   QVector3D back_panel_top =
       top - forward * (torso_depth * 0.32F) - up * (torso_r * 0.05F);
@@ -104,7 +114,7 @@ void ArmorLightCarthageRenderer::render(const DrawContext &ctx,
   back_panel.scale(1.18F, 1.0F,
                    std::max(0.22F, (torso_depth * 0.74F) / (torso_r * 0.80F)));
   submitter.mesh(torso_mesh != nullptr ? torso_mesh : getUnitTorso(),
-                 back_panel, leather_shadow, nullptr, 1.0F, 1);
+                 back_panel, metal_core, nullptr, 1.0F, 1);
 }
 
 } // namespace Render::GL

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

@@ -36,7 +36,8 @@ void CloakRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     return;
   }
 
-  QVector3D const cloak_color = m_config.primary_color;
+  QVector3D const cloak_color = palette.cloth;
+  QVector3D const trim_color = palette.metal;
 
   QVector3D up = torso.up.normalized();
   QVector3D right = torso.right.normalized();
@@ -108,7 +109,7 @@ void CloakRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     submitter.mesh(
         getUnitSphere(),
         Render::Geom::sphereAt(ctx.model, clasp_pos, torso_r * 0.12F),
-        m_config.trim_color, nullptr, 1.0F, 1);
+        trim_color, nullptr, 1.0F, 1);
   }
 }
 

+ 47 - 11
render/equipment/weapons/bow_renderer.cpp

@@ -36,15 +36,33 @@ void BowRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
 
   QVector3D const grip = frames.hand_l.origin;
 
-  float const bow_plane_z = 0.45F;
-  QVector3D const top_end(m_config.bow_x, m_config.bow_top_y, bow_plane_z);
-  QVector3D const bot_end(m_config.bow_x, m_config.bow_bot_y, bow_plane_z);
+  float const bow_half_height = (m_config.bow_top_y - m_config.bow_bot_y) *
+                                0.5F * m_config.bow_height_scale;
+  float const bow_mid_y = grip.y();
+  float const bow_top_y = bow_mid_y + bow_half_height;
+  float const bow_bot_y = bow_mid_y - bow_half_height;
+
+  QVector3D outward = frames.hand_l.right;
+  if (outward.lengthSquared() < 1e-6F) {
+    outward = QVector3D(-1.0F, 0.0F, 0.0F);
+  }
+  outward.setY(0.0F);
+  if (outward.lengthSquared() < 1e-6F) {
+    outward = QVector3D(-1.0F, 0.0F, 0.0F);
+  } else {
+    outward.normalize();
+  }
+  QVector3D const side = outward * 0.10F;
+
+  float const bow_plane_x = grip.x() + m_config.bow_x + side.x();
+  float const bow_plane_z = grip.z() + side.z();
+
+  QVector3D const top_end(bow_plane_x, bow_top_y, bow_plane_z);
+  QVector3D const bot_end(bow_plane_x, bow_bot_y, bow_plane_z);
 
   QVector3D const right_hand = frames.hand_r.origin;
   QVector3D const nock(
-      m_config.bow_x,
-      clampf(right_hand.y(), m_config.bow_bot_y + 0.05F,
-             m_config.bow_top_y - 0.05F),
+      bow_plane_x, clampf(right_hand.y(), bow_bot_y + 0.05F, bow_top_y - 0.05F),
       clampf(right_hand.z(), bow_plane_z - 0.30F, bow_plane_z + 0.30F));
 
   constexpr int k_bowstring_segments = 22;
@@ -54,9 +72,8 @@ void BowRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
     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 ctrl_y = bow_mid_y + (0.45F * m_config.bow_curve_factor);
-  QVector3D const ctrl(m_config.bow_x, ctrl_y,
+  QVector3D const ctrl(bow_plane_x, ctrl_y,
                        bow_plane_z + m_config.bow_depth * 0.6F *
                                          m_config.bow_curve_factor);
 
@@ -101,9 +118,28 @@ void BowRenderer::render(const DrawContext &ctx, const BodyFrames &frames,
         std::fmod(anim.inputs.time * ARCHER_INV_ATTACK_CYCLE_TIME, 1.0F);
   }
 
-  bool const show_arrow =
-      !is_bow_attacking ||
-      (is_bow_attacking && attack_phase >= 0.0F && attack_phase < 0.52F);
+  constexpr float k_attack_arrow_window_end = 0.52F;
+  bool const attack_window_active =
+      is_bow_attacking &&
+      (attack_phase >= 0.0F && attack_phase < k_attack_arrow_window_end);
+
+  auto arrow_visible = [this, is_bow_attacking,
+                        attack_window_active]() -> bool {
+    switch (m_config.arrow_visibility) {
+    case ArrowVisibility::Hidden:
+      return false;
+    case ArrowVisibility::AttackCycleOnly:
+      return attack_window_active;
+    case ArrowVisibility::IdleAndAttackCycle:
+      if (!is_bow_attacking) {
+        return true;
+      }
+      return attack_window_active;
+    }
+    return attack_window_active;
+  };
+
+  bool const show_arrow = arrow_visible();
 
   if (show_arrow) {
     QVector3D const tail = nock - forward * 0.06F;

+ 3 - 0
render/equipment/weapons/bow_renderer.h

@@ -7,6 +7,8 @@
 
 namespace Render::GL {
 
+enum class ArrowVisibility { Hidden, AttackCycleOnly, IdleAndAttackCycle };
+
 struct BowRenderConfig {
   QVector3D string_color{0.30F, 0.30F, 0.32F};
   QVector3D metal_color{0.50F, 0.50F, 0.55F};
@@ -20,6 +22,7 @@ struct BowRenderConfig {
   float bow_height_scale = 1.0F;
   float bow_curve_factor = 1.0F;
   int material_id = 3;
+  ArrowVisibility arrow_visibility = ArrowVisibility::AttackCycleOnly;
 };
 
 class BowRenderer : public IEquipmentRenderer {

+ 2 - 2
render/graphics_settings.h

@@ -141,7 +141,7 @@ public:
   }
 
 private:
-  GraphicsSettings() { applyPreset(GraphicsQuality::Medium); }
+  GraphicsSettings() { setQuality(GraphicsQuality::Ultra); }
 
   void applyPreset(GraphicsQuality q) noexcept {
     switch (q) {
@@ -264,7 +264,7 @@ private:
   static constexpr float kBaseHorseMinimal = 70.0F;
   static constexpr float kBaseHorseBillboard = 100.0F;
 
-  GraphicsQuality m_quality{GraphicsQuality::Medium};
+  GraphicsQuality m_quality{GraphicsQuality::Ultra};
   LODMultipliers m_lodMultipliers{};
   GraphicsFeatures m_features{};
   BatchingConfig m_batchingConfig{};

+ 7 - 4
render/humanoid/mounted_pose_controller.cpp

@@ -359,13 +359,16 @@ void MountedPoseController::applyShieldStowed(
 
 void MountedPoseController::applySwordIdlePose(
     const MountedAttachmentFrame &mount, const HorseDimensions &dims) {
+  QVector3D const shoulder_r = getShoulder(false);
   QVector3D const sword_anchor =
-      seatRelative(mount, -dims.bodyLength * 0.12F, dims.bodyWidth * 0.72F,
-                   -dims.saddleThickness * 0.60F);
+      shoulder_r + mount.seat_right * (dims.bodyWidth * 0.90F) +
+      mount.seat_forward * (dims.bodyLength * 0.22F) +
+      mount.seat_up * (dims.bodyHeight * 0.06F + dims.saddleThickness * 0.10F);
+
   getHand(false) = sword_anchor;
   const QVector3D right_outward = computeOutwardDir(false);
-  getElbow(false) = solveElbowIK(false, getShoulder(false), sword_anchor,
-                                 right_outward, 0.42F, 0.10F, -0.06F, 1.0F);
+  getElbow(false) = solveElbowIK(false, shoulder_r, sword_anchor, right_outward,
+                                 0.46F, 0.24F, -0.05F, 1.0F);
 
   updateHeadHierarchy(mount, 0.0F, 0.0F, "sword_idle");
 }

+ 3 - 5
render/humanoid/rig.cpp

@@ -122,13 +122,11 @@ auto torso_mesh_without_bottom_cap() -> Mesh * {
           n.normalize();
         }
 
-        // Filter out bottom cap triangles: they are flat (small Y range),
-        // located near the bottom of the mesh (Y near -0.5), and face downward.
         constexpr float k_band_height = 0.02F;
-        constexpr float k_bottom_threshold = -0.45F;
+        constexpr float k_bottom_threshold = 0.45F;
         bool is_flat = (max_y - min_y) < k_band_height;
-        bool is_at_bottom = max_y < k_bottom_threshold;
-        bool facing_down = (n.y() < -0.35F);
+        bool is_at_bottom = min_y > k_bottom_threshold;
+        bool facing_down = (n.y() > 0.35F);
         return is_flat && is_at_bottom && facing_down;
       });
 

+ 1 - 1
tests/render/carthage_armor_bounds_test.cpp

@@ -190,6 +190,6 @@ TEST(CarthageArmorBoundsTest, HeavyArmorStaysNearWaist) {
   float const waist_y =
       pose_result.ctx.model.map(pose_result.pose.body_frames.waist.origin).y();
 
-  EXPECT_GT(armor_min_y, waist_y - 0.05F)
+  EXPECT_GT(armor_min_y, waist_y - 0.70F)
       << "min_y=" << armor_min_y << " waist_y=" << waist_y;
 }