CharacterKit/CharacterHornMesh.cs

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.

File AccessNative Interop
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;
	}
}