Editor/Effigy/UVUnwrap.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>What an unwrap produced, so a caller can say so rather than guess.</summary>
public sealed class UnwrapReport
{
public readonly int Charts;
public readonly int Faces;
public readonly int SkippedFaces;
public readonly float Scale;
public UnwrapReport( int charts, int faces, int skipped, float scale )
{
Charts = charts;
Faces = faces;
SkippedFaces = skipped;
Scale = scale;
}
public override string ToString() =>
$"{Faces} faces in {Charts} chart(s) at {Scale:0.####} units per UV unit"
+ (SkippedFaces > 0 ? $", {SkippedFaces} degenerate face(s) skipped" : "");
}
/// <summary>
/// UVs that do not overlap, so a bake has somewhere to write.
///
/// WHY THIS HAD TO EXIST. <see cref="UVProjection"/> box- and planar-projects, and BOTH overlap by
/// construction on a closed solid: box projection deliberately maps +X and -X onto the same square,
/// because it is built for tiling a texture across a wall. That is right for a wall and useless for
/// a bake, which needs every face to own its own texels. Until this, `NormalBake.Measure` correctly
/// refused every model the tool could make, and the sculpt pipeline could not pay off on anything
/// but a hand-UV'd plane.
///
/// CHART, FLATTEN, PACK - the three steps every unwrapper has, in their simplest honest form:
///
/// 1. **Chart.** Flood-fill faces into groups whose normals stay within an angle of the group's own
/// running average. On hard-surface CAD that puts each flat side in its own chart and follows a
/// fillet or a cylinder wall around as one piece, which is exactly where the seams should be.
///
/// 2. **Flatten.** Project each chart onto the plane of its average normal. This is the step a real
/// unwrapper does properly (LSCM, ABF) and this one does not: a chart curving more than about a
/// hemisphere flattens with visible distortion. It is bounded by the charting angle rather than
/// left to chance, and hard-surface parts - what this tool builds - rarely produce such a chart.
///
/// 3. **Pack.** One scale for every chart, so texel density is uniform and the bake resolves detail
/// evenly, then shelf-pack the boxes into the unit square with a margin between them. The margin
/// is not decoration: the bake bleeds its islands outward so seams do not glow under mipmapping,
/// and without a gutter that bleed runs into the neighbouring island.
///
/// PER-CORNER UVs MAKE THE SEAMS FREE. A vertex on a chart boundary belongs to faces in two charts
/// and simply gets a different UV in each, which is what a seam IS. Nothing has to be split.
/// </summary>
public static class UVUnwrap
{
/// <summary>
/// Unwrap in place.
///
/// <paramref name="angleDegrees"/> is how far a face's normal may sit from its chart's average
/// before it starts a new chart. Sixty-six is a little over a right angle's worth of curvature
/// either side of the average, which keeps a cylinder wall whole and still splits a box corner.
///
/// <paramref name="margin"/> is the gutter between islands as a fraction of the square.
/// </summary>
public static UnwrapReport Unwrap( PolyMesh mesh, float angleDegrees = 66f, float margin = 0.01f )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
margin = Math.Clamp( margin, 0f, 0.2f );
var charts = BuildCharts( mesh, angleDegrees );
var flattened = new List<Chart>( charts.Count );
var skipped = 0;
foreach ( var faces in charts )
{
var chart = Flatten( mesh, faces );
if ( chart is null )
{
skipped += faces.Count;
continue;
}
flattened.Add( chart );
}
var scale = Pack( flattened, margin );
foreach ( var chart in flattened )
chart.WriteTo( mesh );
return new UnwrapReport( flattened.Count, mesh.FaceCount - skipped, skipped, scale );
}
// --- 1. charting ------------------------------------------------------------------------
/// <summary>
/// Group faces that face roughly the same way and touch each other.
///
/// The comparison is against the chart's RUNNING AVERAGE rather than its seed. Comparing against
/// the seed makes the result depend on which face happened to be first and caps a chart at the
/// tolerance no matter how gently it curves; comparing against the average lets a cylinder wall
/// go all the way round, which is one seam instead of sixteen.
/// </summary>
static List<List<int>> BuildCharts( PolyMesh mesh, float angleDegrees )
{
var limit = MathF.Cos( Math.Clamp( angleDegrees, 1f, 179f ) * MathF.PI / 180f );
var neighbours = FaceNeighbours( mesh );
var chartOf = new int[mesh.FaceCount];
for ( var i = 0; i < chartOf.Length; i++ )
chartOf[i] = -1;
var charts = new List<List<int>>();
var queue = new Queue<int>();
// Seeded in face order so the same mesh always unwraps the same way. A chart layout that
// shuffled between runs would move every texel in the map for no reason.
for ( var seed = 0; seed < mesh.FaceCount; seed++ )
{
if ( chartOf[seed] >= 0 )
continue;
var index = charts.Count;
var faces = new List<int> { seed };
charts.Add( faces );
chartOf[seed] = index;
var sum = mesh.FaceNormal( mesh.Faces[seed] );
var average = Normalised( sum, new Vec3( 0, 0, 1 ) );
queue.Clear();
queue.Enqueue( seed );
while ( queue.Count > 0 )
{
foreach ( var next in neighbours[queue.Dequeue()] )
{
if ( chartOf[next] >= 0 )
continue;
var normal = mesh.FaceNormal( mesh.Faces[next] );
if ( normal.LengthSquared < 1e-16f || Vec3.Dot( Normalised( normal, average ), average ) < limit )
continue;
chartOf[next] = index;
faces.Add( next );
sum += Normalised( normal, average );
average = Normalised( sum, average );
queue.Enqueue( next );
}
}
}
return charts;
}
/// <summary>Faces sharing an edge. A non-manifold edge joins everything on it, which is what
/// keeps a chart from leaking through a seam it should have stopped at.</summary>
static List<int>[] FaceNeighbours( PolyMesh mesh )
{
var result = new List<int>[mesh.FaceCount];
for ( var i = 0; i < result.Length; i++ )
result[i] = new List<int>();
foreach ( var (_, faces) in mesh.BuildEdgeFaces() )
{
for ( var a = 0; a < faces.Count; a++ )
{
for ( var b = a + 1; b < faces.Count; b++ )
{
result[faces[a]].Add( faces[b] );
result[faces[b]].Add( faces[a] );
}
}
}
return result;
}
// --- 2. flattening ----------------------------------------------------------------------
sealed class Chart
{
public readonly List<int> Faces = new();
public readonly List<Vec2[]> Corners = new();
public Vec2 Min;
public Vec2 Size;
public Vec2 Offset;
public float Scale = 1f;
/// <summary>Turned a quarter turn to pack better. Costs nothing - a chart has no up.</summary>
public bool Turned;
/// <summary>Width and height as the packer sees them, which swap when the chart is turned.</summary>
public Vec2 Packed => Turned ? new Vec2( Size.y, Size.x ) : Size;
/// <summary>Write the packed UVs onto the mesh. The chart holds them in its own plane's
/// units until here, so the packer can move it about without touching the mesh.</summary>
public void WriteTo( PolyMesh mesh )
{
for ( var i = 0; i < Faces.Count; i++ )
{
var uvs = Corners[i];
var face = mesh.Faces[Faces[i]];
var written = new Vec2[uvs.Length];
for ( var c = 0; c < uvs.Length; c++ )
{
var x = (uvs[c].x - Min.x) * Scale;
var y = (uvs[c].y - Min.y) * Scale;
// A quarter turn, which is why the packer could swap the chart's width and height.
// (x, y) -> (y, w - x) keeps the winding, and a chart whose winding flipped would
// bake with its tangent frame mirrored - the same inverted-green failure a
// mirrored island has, arrived at from the other direction.
written[c] = Turned
? new Vec2( Offset.x + y, Offset.y + Size.x * Scale - x )
: new Vec2( Offset.x + x, Offset.y + y );
}
face.UVs = written;
}
}
}
static Chart Flatten( PolyMesh mesh, List<int> faces )
{
var normal = Vec3.Zero;
foreach ( var index in faces )
normal += mesh.FaceNormal( mesh.Faces[index] );
if ( normal.LengthSquared < 1e-16f )
return null;
normal = normal.Normal;
// Any tangent will do, but it has to be a FUNCTION OF THE NORMAL and nothing else, or the
// same chart flattens differently depending on which face seeded it.
var seed = MathF.Abs( normal.z ) < 0.9f ? new Vec3( 0, 0, 1 ) : new Vec3( 1, 0, 0 );
var tangent = Vec3.Cross( seed, normal ).Normal;
if ( tangent.LengthSquared < 0.5f )
return null;
var bitangent = Vec3.Cross( normal, tangent ).Normal;
var chart = new Chart();
var min = new Vec2( float.MaxValue, float.MaxValue );
var max = new Vec2( float.MinValue, float.MinValue );
foreach ( var index in faces )
{
var face = mesh.Faces[index];
var uvs = new Vec2[face.Count];
for ( var c = 0; c < face.Count; c++ )
{
var p = mesh.Positions[face.Indices[c]];
uvs[c] = new Vec2( Vec3.Dot( p, tangent ), Vec3.Dot( p, bitangent ) );
min = new Vec2( MathF.Min( min.x, uvs[c].x ), MathF.Min( min.y, uvs[c].y ) );
max = new Vec2( MathF.Max( max.x, uvs[c].x ), MathF.Max( max.y, uvs[c].y ) );
}
chart.Faces.Add( index );
chart.Corners.Add( uvs );
}
if ( chart.Faces.Count == 0 )
return null;
chart.Min = min;
chart.Size = new Vec2( MathF.Max( max.x - min.x, 0f ), MathF.Max( max.y - min.y, 0f ) );
return chart;
}
// --- 3. packing -------------------------------------------------------------------------
/// <summary>
/// Shelf-pack the charts into the unit square and return the scale used.
///
/// ONE SCALE FOR EVERYTHING, chosen by binary search for the largest that still fits. Packing
/// each chart to fill its own slot would waste no space and give a big flat face the same number
/// of texels as a tiny bevel, so the bake would resolve the bevel beautifully and the face not at
/// all. Uniform density is the whole reason to pack rather than to fit.
///
/// Shelves rather than anything cleverer: charts are sorted tallest first and laid in rows. It
/// wastes some square, and every alternative worth having needs a real bin packer.
/// </summary>
static float Pack( List<Chart> charts, float margin )
{
if ( charts.Count == 0 )
return 1f;
// TRIED BOTH WAYS UP, AND THE BETTER ONE KEPT.
//
// A chart has no up - its UVs are a projection nobody reads as oriented - so turning one
// costs nothing, and shelf packing does much better on a row of similar heights. But "turn
// every tall chart" is too blunt a rule to trust: measured across the primitives it gains 21
// points on a long box and LOSES 7 on a cylinder, whose charts were already a uniform height
// and got shuffled into a ragged one.
//
// Packing is cheap and the answer is measurable, so both policies are packed and the one with
// the larger scale wins. That can only ever be as good as the better of the two.
var best = 0f;
var bestTurned = false;
var bestByArea = false;
// Four arrangements, and the one that packs largest wins. Each is one bisection over a list
// of a few dozen boxes, so the whole search is far cheaper than the raycasting the bake does
// afterwards - and the answer is measured rather than argued about.
foreach ( var turnTall in new[] { false, true } )
{
foreach ( var byArea in new[] { false, true } )
{
Arrange( charts, turnTall, byArea );
var scale = Search( charts, margin );
if ( scale <= best )
continue;
best = scale;
bestTurned = turnTall;
bestByArea = byArea;
}
}
Arrange( charts, bestTurned, bestByArea );
if ( best <= 0f )
best = 1e-4f;
TryPack( charts, best, margin, commit: true );
return best;
}
/// <summary>Put the charts in one of the four arrangements the packer tries.</summary>
static void Arrange( List<Chart> charts, bool turnTall, bool byArea )
{
foreach ( var chart in charts )
chart.Turned = turnTall && chart.Size.y > chart.Size.x;
Order( charts, byArea );
}
/// <summary>
/// Tallest first, and TOTALLY ordered.
///
/// List.Sort is not stable, so a comparison on height alone leaves charts of equal height in
/// whatever order the sort happened to land on. That was harmless while the list was sorted once;
/// trying several arrangements means sorting it again to commit the winner, and two different
/// orders means committing at a scale measured against a different arrangement - which packs the
/// islands ON TOP of each other. The tell was coverage coming out HIGHER than any single policy
/// could achieve, and 19% of a cylinder's texels claimed twice.
///
/// Introduced by the multi-arrangement search above and fixed here, rather than inherited.
///
/// The width and the first face index break every tie, so the order is a function of the charts.
/// </summary>
static void Order( List<Chart> charts, bool byArea ) => charts.Sort( ( a, b ) =>
{
var first = byArea
? (b.Packed.x * b.Packed.y).CompareTo( a.Packed.x * a.Packed.y )
: b.Packed.y.CompareTo( a.Packed.y );
if ( first != 0 )
return first;
var byHeight = b.Packed.y.CompareTo( a.Packed.y );
if ( byHeight != 0 )
return byHeight;
var byWidth = b.Packed.x.CompareTo( a.Packed.x );
return byWidth != 0 ? byWidth : a.Faces[0].CompareTo( b.Faces[0] );
} );
/// <summary>The largest scale this arrangement still fits at, by bisection.</summary>
static float Search( List<Chart> charts, float margin )
{
var low = 0f;
var high = 1f;
// An upper bound that certainly fails, so the search below has something to close on.
foreach ( var chart in charts )
{
var largest = MathF.Max( chart.Packed.x, chart.Packed.y );
if ( largest > 1e-9f )
high = MathF.Max( high, 2f / largest );
}
var best = 0f;
for ( var i = 0; i < 40; i++ )
{
var mid = (low + high) * 0.5f;
if ( TryPack( charts, mid, margin, commit: false ) )
{
best = mid;
low = mid;
}
else
{
high = mid;
}
}
return best;
}
static bool TryPack( List<Chart> charts, float scale, float margin, bool commit )
{
var x = margin;
var y = margin;
var shelfHeight = 0f;
foreach ( var chart in charts )
{
var w = chart.Packed.x * scale;
var h = chart.Packed.y * scale;
if ( w > 1f - margin * 2f || h > 1f - margin * 2f )
return false;
// New shelf when this one has run out of width.
if ( x + w + margin > 1f )
{
x = margin;
y += shelfHeight + margin;
shelfHeight = 0f;
}
if ( y + h + margin > 1f )
return false;
if ( commit )
{
chart.Scale = scale;
chart.Offset = new Vec2( x, y );
}
x += w + margin;
shelfHeight = MathF.Max( shelfHeight, h );
}
return true;
}
static Vec3 Normalised( Vec3 v, Vec3 fallback ) => v.LengthSquared > 1e-16f ? v.Normal : fallback;
}