Browse Source

Add Roman greaves with metallic bronze shader to Roman infantry units

djeada 1 week ago
parent
commit
9b470bdbcf

+ 49 - 3
assets/shaders/archer_roman_republic.frag

@@ -95,13 +95,14 @@ void main() {
   vec3 normal = normalize(v_normal);
   vec3 normal = normalize(v_normal);
   vec2 uv = v_worldPos.xz * 4.5;
   vec2 uv = v_worldPos.xz * 4.5;
 
 
-  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield, 5=cloak
+  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield, 5=cloak/greaves, 6=cloak_back
   bool is_skin = (u_materialId == 0);
   bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
   bool is_shield = (u_materialId == 4);
   bool is_cloak = (u_materialId == 5 || u_materialId == 6);
   bool is_cloak = (u_materialId == 5 || u_materialId == 6);
+  bool is_greaves = (u_materialId == 5);
 
 
   // === ROMAN ARCHER (SAGITTARIUS) MATERIALS ===
   // === ROMAN ARCHER (SAGITTARIUS) MATERIALS ===
 
 
@@ -318,6 +319,51 @@ void main() {
       color *= 1.0 + fiber_twist;
       color *= 1.0 + fiber_twist;
     }
     }
   }
   }
+  // BRONZE GREAVES (shin guards - polished bronze with high shine)
+  else if (is_greaves) {
+    // Rich polished bronze base (warm golden metallic)
+    vec3 bronze_base = vec3(0.88, 0.72, 0.45);
+    vec3 bronze_highlight = vec3(1.0, 0.92, 0.75);
+    vec3 bronze_shadow = vec3(0.55, 0.42, 0.25);
+    
+    // Apply bronze base
+    color = mix(color, bronze_base, 0.90);
+    
+    // Micro brushed metal texture (fine polish lines)
+    float brushed = abs(sin(v_worldPos.y * 120.0)) * 0.015;
+    
+    // View direction for specular
+    vec3 V = normalize(vec3(0.15, 0.85, 0.5));
+    vec3 N = normalize(v_worldNormal);
+    float NdotV = max(dot(N, V), 0.0);
+    
+    // Primary specular (sharp highlight - polished metal)
+    float spec_primary = pow(NdotV, 32.0) * 1.2;
+    
+    // Secondary specular (broader shine)
+    float spec_secondary = pow(NdotV, 8.0) * 0.45;
+    
+    // Metallic fresnel (bright edges)
+    float fresnel = pow(1.0 - NdotV, 3.0) * 0.65;
+    
+    // Anisotropic highlight (stretched along greave height)
+    float aniso = pow(abs(sin(v_worldPos.y * 40.0 + NdotV * 3.14)), 4.0) * 0.25;
+    
+    // Environment reflection (sky above, ground below)
+    float env_up = max(N.y, 0.0) * 0.35;
+    float env_side = (1.0 - abs(N.y)) * 0.20;
+    
+    // Combine all shine effects
+    vec3 shine = bronze_highlight * (spec_primary + spec_secondary + aniso);
+    shine += vec3(fresnel * 0.8, fresnel * 0.7, fresnel * 0.5);
+    shine += bronze_base * (env_up + env_side);
+    
+    color += shine;
+    color += vec3(brushed);
+    
+    // Ensure bright metallic finish
+    color = clamp(color, bronze_shadow, vec3(1.0));
+  }
 
 
   color = clamp(color, 0.0, 1.0);
   color = clamp(color, 0.0, 1.0);
 
 
@@ -352,10 +398,10 @@ void main() {
   vec3 light_dir = normalize(vec3(1.0, 1.15, 1.0));
   vec3 light_dir = normalize(vec3(1.0, 1.15, 1.0));
   float n_dot_l = dot(normal, light_dir);
   float n_dot_l = dot(normal, light_dir);
 
 
-  float wrap_amount = is_helmet ? 0.15 : (is_armor ? 0.22 : 0.38);
+  float wrap_amount = is_helmet ? 0.15 : (is_armor ? 0.22 : (is_greaves ? 0.15 : 0.38));
   float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.22);
   float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.22);
 
 
-  if (is_helmet) {
+  if (is_helmet || is_greaves) {
     diff = pow(diff, 0.90);
     diff = pow(diff, 0.90);
   }
   }
 
 

+ 1 - 1
assets/shaders/archer_roman_republic.vert

@@ -107,7 +107,7 @@ void main() {
 
 
   // Only add battle-wear deformation to armored pieces (not skin or cloth)
   // Only add battle-wear deformation to armored pieces (not skin or cloth)
   bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
   bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
-                      u_materialId == 4 || u_materialId == 3);
+                      u_materialId == 4 || u_materialId == 3 || u_materialId == 5);
 
 
   vec3 batteredPos = worldPos;
   vec3 batteredPos = worldPos;
   vec3 offsetPos = worldPos;
   vec3 offsetPos = worldPos;

+ 49 - 3
assets/shaders/spearman_roman_republic.frag

@@ -85,12 +85,13 @@ void main() {
   vec3 normal = normalize(v_normal);
   vec3 normal = normalize(v_normal);
   vec2 uv = v_worldPos.xz * 4.5;
   vec2 uv = v_worldPos.xz * 4.5;
 
 
-  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
+  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield, 5=greaves
   bool is_skin = (u_materialId == 0);
   bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
   bool is_shield = (u_materialId == 4);
+  bool is_greaves = (u_materialId == 5);
 
 
   // === ROMAN SPEARMAN (HASTATUS) MATERIALS ===
   // === ROMAN SPEARMAN (HASTATUS) MATERIALS ===
 
 
@@ -304,6 +305,51 @@ void main() {
       color += vec3(binding);
       color += vec3(binding);
     }
     }
   }
   }
+  // BRONZE GREAVES (shin guards - polished bronze with high shine)
+  else if (is_greaves) {
+    // Rich polished bronze base (warm golden metallic)
+    vec3 bronze_base = vec3(0.88, 0.72, 0.45);
+    vec3 bronze_highlight = vec3(1.0, 0.92, 0.75);
+    vec3 bronze_shadow = vec3(0.55, 0.42, 0.25);
+    
+    // Apply bronze base
+    color = mix(color, bronze_base, 0.90);
+    
+    // Micro brushed metal texture (fine polish lines)
+    float brushed = abs(sin(v_worldPos.y * 120.0)) * 0.015;
+    
+    // View direction for specular
+    vec3 V = normalize(vec3(0.15, 0.85, 0.5));
+    vec3 N = normalize(v_worldNormal);
+    float NdotV = max(dot(N, V), 0.0);
+    
+    // Primary specular (sharp highlight - polished metal)
+    float spec_primary = pow(NdotV, 32.0) * 1.2;
+    
+    // Secondary specular (broader shine)
+    float spec_secondary = pow(NdotV, 8.0) * 0.45;
+    
+    // Metallic fresnel (bright edges)
+    float fresnel = pow(1.0 - NdotV, 3.0) * 0.65;
+    
+    // Anisotropic highlight (stretched along greave height)
+    float aniso = pow(abs(sin(v_worldPos.y * 40.0 + NdotV * 3.14)), 4.0) * 0.25;
+    
+    // Environment reflection (sky above, ground below)
+    float env_up = max(N.y, 0.0) * 0.35;
+    float env_side = (1.0 - abs(N.y)) * 0.20;
+    
+    // Combine all shine effects
+    vec3 shine = bronze_highlight * (spec_primary + spec_secondary + aniso);
+    shine += vec3(fresnel * 0.8, fresnel * 0.7, fresnel * 0.5);
+    shine += bronze_base * (env_up + env_side);
+    
+    color += shine;
+    color += vec3(brushed);
+    
+    // Ensure bright metallic finish
+    color = clamp(color, bronze_shadow, vec3(1.0));
+  }
 
 
   color = clamp(color, 0.0, 1.0);
   color = clamp(color, 0.0, 1.0);
 
 
@@ -344,10 +390,10 @@ void main() {
   vec3 light_dir = normalize(vec3(1.0, 1.15, 1.0));
   vec3 light_dir = normalize(vec3(1.0, 1.15, 1.0));
   float n_dot_l = dot(normalize(v_worldNormal), light_dir);
   float n_dot_l = dot(normalize(v_worldNormal), light_dir);
 
 
-  float wrap_amount = is_helmet ? 0.12 : (is_armor ? 0.22 : 0.35);
+  float wrap_amount = is_helmet ? 0.12 : (is_armor ? 0.22 : (is_greaves ? 0.12 : 0.35));
   float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.20);
   float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.20);
 
 
-  if (is_helmet) {
+  if (is_helmet || is_greaves) {
     diff = pow(diff, 0.88);
     diff = pow(diff, 0.88);
   }
   }
 
 

+ 2 - 2
assets/shaders/spearman_roman_republic.vert

@@ -61,9 +61,9 @@ void main() {
   vec4 modelPos = u_model * vec4(position, 1.0);
   vec4 modelPos = u_model * vec4(position, 1.0);
   vec3 worldPos = modelPos.xyz;
   vec3 worldPos = modelPos.xyz;
 
 
-  // Restrict deformation to hard surfaces (armor, helmet, shield, weapons)
+  // Restrict deformation to hard surfaces (armor, helmet, shield, weapons, greaves)
   bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
   bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
-                      u_materialId == 4 || u_materialId == 3);
+                      u_materialId == 4 || u_materialId == 3 || u_materialId == 5);
 
 
   float dentSeed = 0.0;
   float dentSeed = 0.0;
   float hammerImpact = 0.0;
   float hammerImpact = 0.0;

+ 50 - 4
assets/shaders/swordsman_roman_republic.frag

@@ -84,12 +84,13 @@ void main() {
   vec3 normal = normalize(v_normal);
   vec3 normal = normalize(v_normal);
   vec2 uv = v_worldPos.xz * 5.0;
   vec2 uv = v_worldPos.xz * 5.0;
 
 
-  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
+  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield, 5=greaves
   bool is_skin = (u_materialId == 0);
   bool is_skin = (u_materialId == 0);
   bool is_armor = (u_materialId == 1);
   bool is_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
   bool is_shield = (u_materialId == 4);
+  bool is_greaves = (u_materialId == 5);
 
 
   // === ROMAN SWORDSMAN (LEGIONARY) MATERIALS ===
   // === ROMAN SWORDSMAN (LEGIONARY) MATERIALS ===
 
 
@@ -344,6 +345,51 @@ void main() {
       color += vec3(brass_sheen + decoration * 0.4);
       color += vec3(brass_sheen + decoration * 0.4);
     }
     }
   }
   }
+  // BRONZE GREAVES (shin guards - highly polished mirror-like bronze)
+  else if (is_greaves) {
+    // Rich polished bronze base (warm golden metallic)
+    vec3 bronze_base = vec3(0.88, 0.72, 0.45);
+    vec3 bronze_highlight = vec3(1.0, 0.92, 0.75);
+    vec3 bronze_shadow = vec3(0.55, 0.42, 0.25);
+    
+    // Apply bronze base
+    color = mix(color, bronze_base, 0.90);
+    
+    // Micro brushed metal texture (fine polish lines)
+    float brushed = abs(sin(v_worldPos.y * 120.0)) * 0.015;
+    
+    // View direction for specular
+    vec3 V = normalize(vec3(0.15, 0.85, 0.5));
+    vec3 N = normalize(v_worldNormal);
+    float NdotV = max(dot(N, V), 0.0);
+    
+    // Primary specular (sharp highlight - polished metal)
+    float spec_primary = pow(NdotV, 32.0) * 1.2;
+    
+    // Secondary specular (broader shine)
+    float spec_secondary = pow(NdotV, 8.0) * 0.45;
+    
+    // Metallic fresnel (bright edges)
+    float fresnel = pow(1.0 - NdotV, 3.0) * 0.65;
+    
+    // Anisotropic highlight (stretched along greave height)
+    float aniso = pow(abs(sin(v_worldPos.y * 40.0 + NdotV * 3.14)), 4.0) * 0.25;
+    
+    // Environment reflection (sky above, ground below)
+    float env_up = max(N.y, 0.0) * 0.35;
+    float env_side = (1.0 - abs(N.y)) * 0.20;
+    
+    // Combine all shine effects
+    vec3 shine = bronze_highlight * (spec_primary + spec_secondary + aniso);
+    shine += vec3(fresnel * 0.8, fresnel * 0.7, fresnel * 0.5);
+    shine += bronze_base * (env_up + env_side);
+    
+    color += shine;
+    color += vec3(brushed);
+    
+    // Ensure bright metallic finish
+    color = clamp(color, bronze_shadow, vec3(1.0));
+  }
 
 
   color = clamp(color, 0.0, 1.0);
   color = clamp(color, 0.0, 1.0);
 
 
@@ -391,11 +437,11 @@ void main() {
   vec3 light_dir = normalize(vec3(1.0, 1.2, 1.0));
   vec3 light_dir = normalize(vec3(1.0, 1.2, 1.0));
   float n_dot_l = dot(normalize(v_worldNormal), light_dir);
   float n_dot_l = dot(normalize(v_worldNormal), light_dir);
 
 
-  float wrap_amount = is_helmet ? 0.08 : (is_armor ? 0.08 : 0.30);
+  float wrap_amount = is_helmet ? 0.08 : (is_armor ? 0.08 : (is_greaves ? 0.08 : 0.30));
   float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.16);
   float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.16);
 
 
-  // Extra contrast for polished steel
-  if (is_helmet || is_armor) {
+  // Extra contrast for polished steel and bronze
+  if (is_helmet || is_armor || is_greaves) {
     diff = pow(diff, 0.85);
     diff = pow(diff, 0.85);
   }
   }
 
 

+ 1 - 1
assets/shaders/swordsman_roman_republic.vert

@@ -66,7 +66,7 @@ void main() {
 
 
   // Deform only armored pieces (not face/skin/clothing)
   // Deform only armored pieces (not face/skin/clothing)
   bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
   bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
-                      u_materialId == 4 || u_materialId == 3);
+                      u_materialId == 4 || u_materialId == 3 || u_materialId == 5);
 
 
   float dentSeed = 0.0;
   float dentSeed = 0.0;
   float combatStress = 0.0;
   float combatStress = 0.0;

+ 1 - 0
render/CMakeLists.txt

@@ -95,6 +95,7 @@ add_library(render_gl STATIC
     equipment/armor/roman_armor.cpp
     equipment/armor/roman_armor.cpp
     equipment/armor/armor_light_carthage.cpp
     equipment/armor/armor_light_carthage.cpp
     equipment/armor/armor_heavy_carthage.cpp
     equipment/armor/armor_heavy_carthage.cpp
+    equipment/armor/roman_greaves.cpp
     equipment/armor/roman_shoulder_cover.cpp
     equipment/armor/roman_shoulder_cover.cpp
     equipment/armor/carthage_shoulder_cover.cpp
     equipment/armor/carthage_shoulder_cover.cpp
     equipment/armor/cloak_renderer.cpp
     equipment/armor/cloak_renderer.cpp

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

@@ -265,13 +265,19 @@ public:
                   const HumanoidPose &pose,
                   const HumanoidPose &pose,
                   const HumanoidAnimationContext &anim,
                   const HumanoidAnimationContext &anim,
                   ISubmitter &out) const override {
                   ISubmitter &out) const override {
+    auto &registry = EquipmentRegistry::instance();
+
     if (resolve_style(ctx).show_armor) {
     if (resolve_style(ctx).show_armor) {
-      auto &registry = EquipmentRegistry::instance();
       auto armor = registry.get(EquipmentCategory::Armor, "roman_light_armor");
       auto armor = registry.get(EquipmentCategory::Armor, "roman_light_armor");
       if (armor) {
       if (armor) {
         armor->render(ctx, pose.body_frames, v.palette, anim, out);
         armor->render(ctx, pose.body_frames, v.palette, anim, out);
       }
       }
     }
     }
+
+    auto greaves = registry.get(EquipmentCategory::Armor, "roman_greaves");
+    if (greaves) {
+      greaves->render(ctx, pose.body_frames, v.palette, anim, out);
+    }
   }
   }
 
 
 private:
 private:

+ 5 - 0
render/entity/nations/roman/spearman_renderer.cpp

@@ -248,6 +248,11 @@ public:
     if (shoulder_cover) {
     if (shoulder_cover) {
       shoulder_cover->render(ctx, pose.body_frames, v.palette, anim, out);
       shoulder_cover->render(ctx, pose.body_frames, v.palette, anim, out);
     }
     }
+
+    auto greaves = registry.get(EquipmentCategory::Armor, "roman_greaves");
+    if (greaves) {
+      greaves->render(ctx, pose.body_frames, v.palette, anim, out);
+    }
   }
   }
 
 
 private:
 private:

+ 5 - 0
render/entity/nations/roman/swordsman_renderer.cpp

@@ -232,6 +232,11 @@ public:
     if (shoulder_cover) {
     if (shoulder_cover) {
       shoulder_cover->render(ctx, pose.body_frames, v.palette, anim, out);
       shoulder_cover->render(ctx, pose.body_frames, v.palette, anim, out);
     }
     }
+
+    auto greaves = registry.get(EquipmentCategory::Armor, "roman_greaves");
+    if (greaves) {
+      greaves->render(ctx, pose.body_frames, v.palette, anim, out);
+    }
   }
   }
 
 
 private:
 private:

+ 95 - 0
render/equipment/armor/roman_greaves.cpp

@@ -0,0 +1,95 @@
+#include "roman_greaves.h"
+#include "../../geom/transforms.h"
+#include "../../gl/primitives.h"
+#include "../../humanoid/humanoid_specs.h"
+#include "../../humanoid/style_palette.h"
+#include "../../submitter.h"
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <cmath>
+
+namespace Render::GL {
+
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+using Render::GL::Humanoid::saturate_color;
+
+void RomanGreavesRenderer::render(const DrawContext &ctx,
+                                   const BodyFrames &frames,
+                                   const HumanoidPalette &palette,
+                                   const HumanoidAnimationContext &anim,
+                                   ISubmitter &submitter) {
+  (void)anim;
+
+  using HP = HumanProportions;
+
+  // Polished bronze color for greaves
+  QVector3D const greaves_color =
+      saturate_color(palette.metal * QVector3D(0.95F, 0.88F, 0.68F));
+
+  // Simple bent sheet greave using shin frame
+  auto render_greave = [&](const AttachmentFrame &shin) {
+    float const shin_r = shin.radius;
+    float const shin_length = HP::LOWER_LEG_LEN;
+
+    // Greave coverage
+    float const greave_start = shin_length * 0.10F;
+    float const greave_end = shin_length * 0.92F;
+    float const greave_len = greave_end - greave_start;
+
+    // Greave positions along shin
+    QVector3D greave_top = shin.origin + shin.up * (shin_length - greave_start);
+    QVector3D greave_bottom = shin.origin + shin.up * (shin_length - greave_end);
+
+    // Use shin frame vectors for orientation
+    QVector3D const &plate_up = shin.up;
+    QVector3D const &plate_forward = shin.forward;
+    QVector3D const &plate_right = shin.right;
+
+    // Simple 3-segment bent sheet (center + two angled sides)
+    constexpr int NUM_SEGMENTS = 3;
+    float const angles[NUM_SEGMENTS] = {-0.8F, 0.0F, 0.8F};  // ~45° bend on each side
+    
+    float const greave_offset = shin_r * 1.08F;
+    float const greave_thickness = 0.006F;
+    float const segment_width = shin_r * 0.55F;
+
+    for (int seg = 0; seg < NUM_SEGMENTS; ++seg) {
+      float angle = angles[seg];
+      float cos_a = std::cos(angle);
+      float sin_a = std::sin(angle);
+
+      // Segment position curved around shin front
+      QVector3D segment_offset = plate_forward * (greave_offset * cos_a) +
+                                 plate_right * (greave_offset * sin_a);
+
+      QVector3D segment_top = greave_top + segment_offset;
+      QVector3D segment_bottom = greave_bottom + segment_offset;
+      QVector3D segment_center = (segment_top + segment_bottom) * 0.5F;
+
+      // Normal pointing outward
+      QVector3D segment_normal = (plate_forward * cos_a + plate_right * sin_a).normalized();
+      QVector3D seg_tangent = QVector3D::crossProduct(plate_up, segment_normal).normalized();
+
+      // Build transform
+      QMatrix4x4 seg_transform = ctx.model;
+      seg_transform.translate(segment_center);
+
+      QMatrix4x4 orient;
+      orient.setColumn(0, QVector4D(seg_tangent, 0.0F));
+      orient.setColumn(1, QVector4D(plate_up, 0.0F));
+      orient.setColumn(2, QVector4D(segment_normal, 0.0F));
+      orient.setColumn(3, QVector4D(0.0F, 0.0F, 0.0F, 1.0F));
+      seg_transform = seg_transform * orient;
+
+      seg_transform.scale(segment_width, greave_len * 0.5F, greave_thickness);
+
+      submitter.mesh(getUnitCube(), seg_transform, greaves_color, nullptr, 1.0F, 5);
+    }
+  };
+
+  render_greave(frames.shin_l);
+  render_greave(frames.shin_r);
+}
+
+} // namespace Render::GL

+ 18 - 0
render/equipment/armor/roman_greaves.h

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

+ 5 - 0
render/equipment/register_equipment.cpp

@@ -4,6 +4,7 @@
 #include "armor/chainmail_armor.h"
 #include "armor/chainmail_armor.h"
 #include "armor/cloak_renderer.h"
 #include "armor/cloak_renderer.h"
 #include "armor/roman_armor.h"
 #include "armor/roman_armor.h"
+#include "armor/roman_greaves.h"
 #include "armor/roman_shoulder_cover.h"
 #include "armor/roman_shoulder_cover.h"
 #include "equipment_registry.h"
 #include "equipment_registry.h"
 #include "helmets/carthage_heavy_helmet.h"
 #include "helmets/carthage_heavy_helmet.h"
@@ -97,6 +98,10 @@ void registerBuiltInEquipment() {
                              "roman_shoulder_cover_cavalry",
                              "roman_shoulder_cover_cavalry",
                              roman_shoulder_cover_cavalry);
                              roman_shoulder_cover_cavalry);
 
 
+  auto roman_greaves = std::make_shared<RomanGreavesRenderer>();
+  registry.registerEquipment(EquipmentCategory::Armor, "roman_greaves",
+                             roman_greaves);
+
   auto carthage_shoulder_cover =
   auto carthage_shoulder_cover =
       std::make_shared<CarthageShoulderCoverRenderer>();
       std::make_shared<CarthageShoulderCoverRenderer>();
   registry.registerEquipment(EquipmentCategory::Armor,
   registry.registerEquipment(EquipmentCategory::Armor,

+ 4 - 0
render/gl/humanoid/humanoid_types.h

@@ -44,6 +44,10 @@ struct BodyFrames {
   AttachmentFrame hand_r{};
   AttachmentFrame hand_r{};
   AttachmentFrame foot_l{};
   AttachmentFrame foot_l{};
   AttachmentFrame foot_r{};
   AttachmentFrame foot_r{};
+  // Shin frames for leg equipment (greaves, etc.)
+  // Origin at ankle, up points toward knee (shin direction)
+  AttachmentFrame shin_l{};
+  AttachmentFrame shin_r{};
 };
 };
 
 
 struct HumanoidPose {
 struct HumanoidPose {

+ 37 - 0
render/humanoid/rig.cpp

@@ -659,6 +659,43 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
   pose.body_frames.foot_r.forward = foot_forward_r;
   pose.body_frames.foot_r.forward = foot_forward_r;
   pose.body_frames.foot_r.radius = foot_radius;
   pose.body_frames.foot_r.radius = foot_radius;
 
 
+  // Shin frames for leg equipment (greaves, etc.)
+  // Origin at ankle, up points toward knee (actual shin direction)
+  auto computeShinFrame = [&](const QVector3D &ankle, const QVector3D &knee,
+                              float right_sign) -> AttachmentFrame {
+    AttachmentFrame shin{};
+    shin.origin = ankle;
+
+    // Up vector points from ankle toward knee (shin direction)
+    QVector3D shin_dir = knee - ankle;
+    float shin_len = shin_dir.length();
+    if (shin_len > 1e-6F) {
+      shin.up = shin_dir / shin_len;
+    } else {
+      shin.up = up_axis;
+    }
+
+    // Forward is perpendicular to shin and points toward front of leg
+    // Use torso forward as reference, then orthogonalize against shin up
+    QVector3D shin_forward = forward_axis;
+    shin_forward = shin_forward - shin.up * QVector3D::dotProduct(shin_forward, shin.up);
+    if (shin_forward.lengthSquared() > 1e-6F) {
+      shin_forward.normalize();
+    } else {
+      shin_forward = forward_axis;
+    }
+    shin.forward = shin_forward;
+
+    // Right is cross product of up and forward
+    shin.right = QVector3D::crossProduct(shin.up, shin.forward) * right_sign;
+    shin.radius = HP::LOWER_LEG_R;
+
+    return shin;
+  };
+
+  pose.body_frames.shin_l = computeShinFrame(pose.foot_l, pose.knee_l, -1.0F);
+  pose.body_frames.shin_r = computeShinFrame(pose.foot_r, pose.knee_r, 1.0F);
+
   QVector3D const iris = QVector3D(0.10F, 0.10F, 0.12F);
   QVector3D const iris = QVector3D(0.10F, 0.10F, 0.12F);
   auto eyePosition = [&](float lateral) {
   auto eyePosition = [&](float lateral) {
     QVector3D const local(lateral, 0.12F, 0.92F);
     QVector3D const local(lateral, 0.12F, 0.92F);