Effigy/MeshNormals.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Shared normal generation. Lives here rather than inside a writer because every export format
/// needs the same answer — OBJ and SMD differ in how they spell a normal, not in what it should
/// be. Two copies of this would eventually disagree, and the symptom would be a model that shades
/// differently depending on which format it was exported through.
/// </summary>
public static class MeshNormals
{
/// <summary>Faces meeting at a sharper angle than this keep a hard edge; anything softer gets
/// averaged. Without this a box comes out with rounded-looking corners and a cylinder comes out
/// faceted — one threshold fixes both.</summary>
public const float DefaultSmoothingAngleDegrees = 40f;
/// <summary>
/// One normal per face corner, averaged only across faces that agree to within the threshold.
///
/// The naive version — one averaged normal per vertex — rounds off every hard edge, so a box
/// renders like a pillow. The other naive version — one normal per face — facets every curve,
/// so a 32-segment cylinder looks like a nut. Thresholding gets both right from the same code
/// and means the parametric stage never has to hand-author smoothing groups.
/// </summary>
public static (int[][] CornerNormals, List<Vec3> Normals) ComputeCornerNormals( PolyMesh mesh, float angleDegrees )
{
var cosLimit = MathF.Cos( angleDegrees * MathF.PI / 180f );
var faceCount = mesh.FaceCount;
var faceNormals = new Vec3[faceCount];
var faceAreas = new float[faceCount];
// ONE PASS FOR BOTH, rather than FaceNormal then FaceArea.
//
// PolyMesh.FaceArea needs the centroid and the normal to project onto, so it computes both
// itself - which meant a face's Newell normal was computed twice and its centroid once
// more on top, three walks of the corners where two will do.
for ( var fi = 0; fi < faceCount; fi++ )
{
var f = mesh.Faces[fi];
var n = mesh.FaceNormal( f );
faceNormals[fi] = n;
faceAreas[fi] = mesh.FaceArea( f, n );
}
var vertexFaces = VertexFaces.Build( mesh );
// Presized to the vertex count, which is what the answer comes to for a fully smooth mesh
// and the right order of magnitude for any other. Both of these grew from empty otherwise,
// and on a dense body that is a couple of dozen doublings - each one reallocating and,
// for the dictionary, rehashing every entry so far.
var normals = new List<Vec3>( mesh.VertexCount );
var dedupe = new Dictionary<long, int>( mesh.VertexCount );
var cornerNormals = new int[faceCount][];
// THE ANSWER FOR A CORNER WHOSE WHOLE FAN AGREES, CACHED PER VERTEX.
//
// When every face meeting a vertex is inside the smoothing angle of the one being asked
// about, the sum is the whole fan's area-weighted total - which does not depend on WHICH
// face asked. So all four corners at an ordinary vertex of a smooth mesh compute the same
// vector, normalise it, and look it up in the dedupe table four times over.
//
// That is the common case by a wide margin: a subdivided or sculpted body is smooth almost
// everywhere, and the hard edges that break the rule are a thin seam through it. Caching
// the interned index the first time a corner at this vertex sees a full fan cuts the
// square roots and the dictionary probes to roughly one per vertex instead of one per
// corner, and leaves every crease to be worked out corner by corner exactly as before.
//
// EXACT, NOT AN APPROXIMATION. The cache is only read when the scan just established that
// this corner's fan is full too, so the vector it stands for is the one this corner would
// have computed.
var fullFanNormal = new int[mesh.VertexCount];
Array.Fill( fullFanNormal, -1 );
for ( var fi = 0; fi < faceCount; fi++ )
{
var f = mesh.Faces[fi];
var corners = new int[f.Count];
cornerNormals[fi] = corners;
var own = faceNormals[fi];
for ( var i = 0; i < f.Count; i++ )
{
var vertex = f.Indices[i];
var neighbours = vertexFaces[vertex];
var sum = Vec3.Zero;
var taken = 0;
for ( var k = 0; k < neighbours.Length; k++ )
{
var other = neighbours[k];
if ( Vec3.Dot( own, faceNormals[other] ) >= cosLimit )
{
sum += faceNormals[other] * faceAreas[other];
taken++;
}
}
var full = taken == neighbours.Length;
if ( full && fullFanNormal[vertex] >= 0 )
{
corners[i] = fullFanNormal[vertex];
continue;
}
var n = sum.Normal;
// A degenerate cluster can cancel to zero; fall back to the face's own normal
// rather than emitting (0,0,0), which some importers reject outright.
if ( n.LengthSquared < 0.5f )
n = own;
var id = Intern( n, dedupe, normals );
corners[i] = id;
if ( full )
fullFanNormal[vertex] = id;
}
}
return (cornerNormals, normals);
}
/// <summary>
/// Index of this normal in the shared list, adding it if it is new. Quantised to 1e-4 so two
/// corners that agree to within rounding share one entry - which is what keeps an exported
/// normal list the size of the smooth groups rather than the size of the corner count.
/// </summary>
static int Intern( Vec3 n, Dictionary<long, int> dedupe, List<Vec3> normals )
{
var key = Quantise( n );
if ( dedupe.TryGetValue( key, out var idx ) )
return idx;
idx = normals.Count;
normals.Add( n );
dedupe[key] = idx;
return idx;
}
/// <summary>
/// A normal's three rounded components packed into one long.
///
/// This was a Dictionary keyed on a (long, long, long) tuple, which is a 24-byte key hashed
/// through ValueTuple's combiner on every one of the mesh's corners - just under a million
/// lookups on a dense body, all of them on the viewport's rebuild path.
///
/// A unit normal's components are in [-1, 1], so rounding at 1e-4 lands in [-10000, 10000] and
/// each fits in 21 bits with room to spare. Three of those is 63 bits, so the whole key is one
/// long, hashed as one long. Same quantisation, same collision behaviour, no tuple.
///
/// Clamped rather than trusted: the caller normalises, but a NaN slipping through would
/// otherwise shift into a bit pattern that aliases some unrelated normal, and a silently wrong
/// shared normal is far harder to see than a clamped one.
/// </summary>
static long Quantise( Vec3 n )
{
return Pack( n.x ) | (Pack( n.y ) << 21) | (Pack( n.z ) << 42);
static long Pack( float v )
{
var q = (long)MathF.Round( Math.Clamp( v, -1f, 1f ) * 1e4f );
return (q + 1048576L) & 0x1FFFFF; // bias into 0..2^21-1
}
}
}