Procedural horn mesh generator for character editor. Computes a centerline path and radial profile from horn parameters, builds vertex and index buffers, creates LOD meshes, caches generated Model objects and evicts old entries.
using System;
using System.Runtime.InteropServices;
namespace CharacterEditor;
/// <summary>Rigid swept surfaces, built only when shape changes. No bones or per-frame vertex work.</summary>
public static class CharacterHornMesh
{
public const string MaterialPath = "materials/characterkit/horns/keratin.vmat";
public readonly record struct Key(string Family, float Length, float Thickness, float Curve, float Twist, float Ridges, bool Mirrored, int Revision);
static readonly Dictionary<Key, Model> cache = new();
static readonly Queue<Key> order = new();
public static int BuildCount { get; private set; }
public static int CachedModels => cache.Count;
public const int Revision = 7;
public static Key ShapeKey(CharacterHornData d, bool mirrored = false) => new(d.Family, d.Length, d.Thickness, d.Curve, d.Twist, d.Ridges, mirrored, Revision);
internal readonly record struct Path(Vector3[] Centers, Vector3[] Tangents, float[] Radii, float Length);
internal static Path CreatePath(CharacterHornData d, int rings = 64, bool mirrored = false)
{
var profile = HornProfiles.All[d.Family];
float size = .55f + .9f * d.Length, length = profile.Length * size;
// A short horn must also narrow: keeping the full base diameter made the
// short hook fold its own surface inside out before it left the scalp.
float radius = profile.Radius * (.6f + .8f * d.Thickness) * MathF.Sqrt(size);
float curve = d.Family switch
{
"ram" => .76f + (.18f + .08f * d.Length) * d.Curve,
"hooked" => .55f + .5f * d.Curve,
"spiral" => .8f + .35f * d.Curve,
_ => .65f + .6f * d.Curve
};
var centers = new Vector3[rings + 1];
var tangents = new Vector3[rings + 1];
var radii = new float[rings + 1];
for (int i = 0; i <= rings; i++)
{
float t = (float)i / rings;
var a = profile.Angles[i * 64 / rings];
// Curvature changes the bend, never the departure direction at the root.
float theta = (a.x - profile.Angles[0].x) * curve * MathF.PI / 180;
float phi = (a.y + (d.Twist - .5f) * 70 * t) * MathF.PI / 180;
tangents[i] = new(MathF.Sin(theta) * MathF.Cos(phi), MathF.Sin(theta) * MathF.Sin(phi), MathF.Cos(theta));
if (i > 0)
centers[i] = centers[i - 1] + (tangents[i - 1] + tangents[i]).Normal * (length / rings);
}
if (d.Family == "ram")
for (int i = 0; i <= rings; i++)
{
float t = (float)i / rings;
centers[i].y += 3.2f * size * t * t * (3 - 2 * t);
}
for (int i = 0; i <= rings; i++)
tangents[i] = (centers[Math.Min(rings, i + 1)] - centers[Math.Max(0, i - 1)]).Normal;
// Bound thickness by the local bend radius, including a short, fully curled
// horn. All LODs sample this same path so the silhouette stays consistent.
float safeRadius = radius;
for (int i = 1; i < rings; i++)
{
float bend = (tangents[i + 1] - tangents[i - 1]).Length / MathF.Max(.001f, (centers[i + 1] - centers[i - 1]).Length);
float taper = MathF.Pow(1 - (float)i / rings, .72f);
if (bend > .0001f)
safeRadius = MathF.Min(safeRadius, .62f / (bend * MathF.Max(.03f, taper) * 1.1f));
}
for (int i = 0; i <= rings; i++)
{
float t = (float)i / rings;
float phase = t * 18 * MathF.Tau + .35f * MathF.Sin(t * 31);
float ribs = .004f * d.Ridges * (MathF.Sin(phase) + .35f * MathF.Sin(phase * 1.63f)) * MathF.Min(1, t * 25);
radii[i] = safeRadius * MathF.Pow(1 - t, .72f) * (1 + ribs + .045f * MathF.Exp(-t * 35)) + .006f;
if (mirrored)
{
centers[i].y = -centers[i].y;
tangents[i].y = -tangents[i].y;
}
}
return new(centers, tangents, radii, length);
}
[StructLayout(LayoutKind.Sequential)]
struct HornVertex
{
[VertexLayout.Position]
public Vector3 Position;
[VertexLayout.Normal]
public Vector3 Normal;
[VertexLayout.Tangent]
public Vector4 Tangent;
[VertexLayout.TexCoord(0)]
public Vector2 UV;
}
public static Model Get(CharacterHornData d, bool mirrored = false)
{
var key = ShapeKey(d, mirrored);
if (cache.TryGetValue(key, out var found))
return found;
var builder = Model.Builder.WithName("horn:" + d.Family + ":" + BuildCount);
builder.AddMesh(Build(d, 64, 24, mirrored), 0);
builder.AddMesh(Build(d, 32, 12, mirrored), 1);
builder.AddMesh(Build(d, 16, 8, mirrored), 2);
builder.WithLodDistance(1, 450).WithLodDistance(2, 1000);
var model = builder.Create();
BuildCount++;
cache.Add(key, model);
order.Enqueue(key);
// Slider drags must not retain an unlimited number of GPU meshes. Renderers
// keep their own references when an older entry leaves this bounded cache.
while (order.Count > 48)
cache.Remove(order.Dequeue());
return model;
}
static Mesh Build(CharacterHornData d, int rings, int sides, bool mirrored)
{
var path = CreatePath(d);
var centers = path.Centers;
var tangents = path.Tangents;
var vertices = new List<HornVertex>();
var indices = new List<int>();
var u = Vector3.Left;
var frames = new Vector3[65];
for (int i = 0; i <= 64; i++)
{
if (i > 0)
u = Rotation.FromToRotation(tangents[i - 1], tangents[i]) * u;
u = (u - tangents[i] * Vector3.Dot(u, tangents[i])).Normal;
frames[i] = u;
}
for (int i = 0; i <= rings; i++)
{
float t = (float)i / rings;
int sample = i * 64 / rings;
var tangent = tangents[sample];
// Parallel transport at full resolution keeps grain and cross-sections
// aligned when the native renderer changes LOD.
u = frames[sample];
var v = Vector3.Cross(tangent, u);
float r = path.Radii[sample];
for (int j = 0; j <= sides; j++)
{
float angle = MathF.Tau * j / sides;
var radial = u * MathF.Cos(angle) + v * MathF.Sin(angle);
var around = -u * MathF.Sin(angle) + v * MathF.Cos(angle);
vertices.Add(new() { Position = centers[sample] + radial * r, UV = new((float)j / sides, t), Normal = radial, Tangent = new(around, 1) });
if (i < rings && j < sides)
{
int a = i * (sides + 1) + j, b = a + sides + 1;
indices.AddRange(new[] { a, a + 1, b, b, a + 1, b + 1 });
}
}
}
// Closed tip and buried root, including when viewed from inside another accessory.
for (int j = 1; j < sides - 1; j++)
{
indices.AddRange(new[] { 0, j + 1, j });
int b = rings * (sides + 1);
indices.AddRange(new[] { b, b + j, b + j + 1 });
}
if (mirrored)
{
for (int i = 0; i < vertices.Count; i++)
{
var v = vertices[i];
v.Position.y = -v.Position.y;
v.Tangent.y = -v.Tangent.y;
v.Tangent.w = -1;
vertices[i] = v;
}
for (int i = 0; i < indices.Count; i += 3)
(indices[i + 1], indices[i + 2]) = (indices[i + 2], indices[i + 1]);
}
var normals = new Vector3[vertices.Count];
for (int i = 0; i < indices.Count; i += 3)
{
int a = indices[i], b = indices[i + 1], c = indices[i + 2];
var n = Vector3.Cross(vertices[b].Position - vertices[a].Position, vertices[c].Position - vertices[a].Position);
normals[a] += n;
normals[b] += n;
normals[c] += n;
}
for (int i = 0; i <= rings; i++)
{
int a = i * (sides + 1), b = a + sides;
normals[a] = normals[b] = normals[a] + normals[b];
}
for (int i = 0; i < vertices.Count; i++)
{
var vertex = vertices[i];
vertex.Normal = normals[i].Normal;
vertices[i] = vertex;
}
var mesh = new Mesh(Material.Load(MaterialPath));
mesh.CreateVertexBuffer(vertices.Count, vertices);
mesh.CreateIndexBuffer(indices.Count, indices);
mesh.Bounds = new BBox(vertices.Select(v => v.Position).Aggregate((a, b) => Vector3.Min(a, b)), vertices.Select(v => v.Position).Aggregate((a, b) => Vector3.Max(a, b)));
return mesh;
}
}