Editor/Effigy/NormalBake.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>What a bake produced. RGB8, row 0 at v = 0.</summary>
public sealed class BakedMap
{
public readonly int Width;
public readonly int Height;
/// <summary>
/// Three bytes per texel, row-major, with row 0 at v = 0.
///
/// WHICH END OF THE IMAGE v = 0 LANDS AT IS THE CALLER'S PROBLEM, and it is a real one: writing
/// row 0 first puts +v down the image, which half the tools in the world disagree with. An
/// upside-down normal map lights exactly as wrongly as one with the green channel inverted, and
/// neither is visible in a thumbnail. See BakeOptions.FlipGreen for the other half of the same
/// coin flip.
/// </summary>
public readonly byte[] Rgb;
/// <summary>Which texels a ray actually landed on. Everything else is padding or flat.</summary>
public readonly bool[] Filled;
public BakedMap( int width, int height )
{
Width = width;
Height = height;
Rgb = new byte[width * height * 3];
Filled = new bool[width * height];
}
public int FilledCount
{
get
{
var n = 0;
foreach ( var f in Filled )
{
if ( f )
n++;
}
return n;
}
}
public (byte R, byte G, byte B) At( int x, int y )
{
var i = (y * Width + x) * 3;
return (Rgb[i], Rgb[i + 1], Rgb[i + 2]);
}
/// <summary>The texel decoded back to a unit vector, which is what a test wants to reason about.</summary>
public Vec3 NormalAt( int x, int y )
{
var (r, g, b) = At( x, y );
return new Vec3( r / 127.5f - 1f, g / 127.5f - 1f, b / 127.5f - 1f ).Normal;
}
}
public sealed class BakeOptions
{
/// <summary>
/// How far either side of the cage surface to look for the sculpt. Zero means "work it out from
/// the cage's size".
///
/// THIS IS THE KNOB THAT PRODUCES BAD BAKES. Too small and a tall detail is missed, leaving flat
/// patches. Too large and a ray fired from outside meets something else entirely first — the far
/// side of a sphere, the neighbouring finger — and paints that surface's normal onto this one.
/// Real bakers solve it with an explicit cage envelope; this takes a distance, which is the same
/// decision with fewer controls.
/// </summary>
public float MaxDistance;
/// <summary>
/// Flip the green channel. Two conventions exist and differ only in the sign of Y: OpenGL-style
/// (+Y up, the default here) and DirectX-style. The wrong one lights every dent as a bump and is
/// invisible in a thumbnail, so this is a switch rather than a guess. Which one s&box wants has
/// to be confirmed by looking at a bake in the engine.
/// </summary>
public bool FlipGreen;
/// <summary>
/// How many texels to bleed the edges outward. A bake stops at the island's edge, and a shader
/// filtering across that edge picks up whatever is outside it, so seams glow at a distance once
/// mipmaps get involved. Four is the usual figure.
/// </summary>
public int Padding = 4;
}
/// <summary>
/// Cage + sculpted mesh + the cage's UVs, in; a tangent-space normal map, out.
///
/// THIS IS WHERE THE PIPELINE PAYS OFF. Everything before it exists to get a coarse quad cage that is
/// still parametric and a dense sculpted surface that rides it. This is the step that puts the second
/// one onto the first as texture, so the model that ships is the cage — a few hundred faces, already
/// unwrapped, already rigged — wearing the detail of something a thousand times heavier.
///
/// For each texel: find the cage point that texel belongs to, fire a ray along the cage normal, hit
/// the sculpt, and write the difference between the two surfaces as a direction in the cage's own
/// tangent frame. Tangent-space rather than object-space because the cage moves: it deforms with the
/// rig, and an object-space map would be wrong the moment a bone turned.
///
/// UVS MUST NOT OVERLAP. Two faces sharing texels bake over each other and the last one wins. Box
/// projection, the tool's default, overlaps by construction — it maps +X and -X onto the same square
/// on purpose, which is exactly right for tiling a wall and exactly wrong for a bake. Ask
/// <see cref="Measure"/> before trusting a map.
/// </summary>
public static class NormalBake
{
public static BakedMap Bake( PolyMesh cage, PolyMesh sculpted, int size, BakeOptions options = null ) =>
Bake( cage, sculpted, size, size, options );
public static BakedMap Bake( PolyMesh cage, PolyMesh sculpted, int width, int height, BakeOptions options = null )
{
if ( cage is null )
throw new ArgumentNullException( nameof( cage ) );
if ( sculpted is null )
throw new ArgumentNullException( nameof( sculpted ) );
if ( width < 1 || height < 1 )
throw new ArgumentOutOfRangeException( nameof( width ), "A map needs at least one texel." );
options ??= new BakeOptions();
var map = new BakedMap( width, height );
// Flat everywhere first. An unhit texel reading as "no change from the cage" is the harmless
// answer; leaving it black would light as a crease.
for ( var i = 0; i < width * height; i++ )
Encode( map, i, new Vec3( 0, 0, 1 ), options.FlipGreen );
var cageNormals = cage.ComputeVertexNormals();
var sculptNormals = sculpted.ComputeVertexNormals();
var bvh = MeshBVH.Build( sculpted );
var reach = options.MaxDistance > 0f ? options.MaxDistance : MeasuredReach( cage, sculpted, bvh );
foreach ( var face in cage.Faces )
{
if ( face.Count < 3 || face.UVs is null || face.UVs.Length != face.Count )
continue;
// Fan from corner 0, the same triangulation everything else here uses.
for ( var i = 1; i < face.Count - 1; i++ )
{
var t = new Triangle(
cage.Positions[face.Indices[0]], cage.Positions[face.Indices[i]], cage.Positions[face.Indices[i + 1]],
cageNormals[face.Indices[0]], cageNormals[face.Indices[i]], cageNormals[face.Indices[i + 1]],
face.UVs[0], face.UVs[i], face.UVs[i + 1] );
BakeTriangle( map, t, sculpted, sculptNormals, bvh, reach, options );
}
}
Dilate( map, options.Padding );
return map;
}
readonly struct Triangle
{
public readonly Vec3 P0, P1, P2;
public readonly Vec3 N0, N1, N2;
public readonly Vec2 U0, U1, U2;
public Triangle( Vec3 p0, Vec3 p1, Vec3 p2, Vec3 n0, Vec3 n1, Vec3 n2, Vec2 u0, Vec2 u1, Vec2 u2 )
{
P0 = p0; P1 = p1; P2 = p2;
N0 = n0; N1 = n1; N2 = n2;
U0 = u0; U1 = u1; U2 = u2;
}
}
static void BakeTriangle( BakedMap map, Triangle t, PolyMesh sculpted, Vec3[] sculptNormals,
MeshBVH bvh, float reach, BakeOptions options )
{
if ( !TangentFrame( t, out var tangent, out var bitangent ) )
return;
Rasterise( t.U0, t.U1, t.U2, map.Width, map.Height, ( x, y, a, b, c ) =>
{
var point = t.P0 * a + t.P1 * b + t.P2 * c;
var normal = (t.N0 * a + t.N1 * b + t.N2 * c).Normal;
if ( normal.LengthSquared < 0.5f )
return;
// Fired from outside the surface inward, so the nearest hit is the topmost sculpted
// surface rather than whatever is behind it.
var hit = bvh.Raycast( sculpted, point + normal * reach, -normal );
if ( hit is null || hit.Value.Distance > reach * 2f )
return;
var sculptedNormal = SmoothNormal( sculpted, sculptNormals, hit.Value );
// Orthonormalise the frame against THIS texel's interpolated normal, not the flat
// triangle's, or every texel in the triangle shares one frame and the map facets.
var tt = (tangent - normal * Vec3.Dot( tangent, normal )).Normal;
if ( tt.LengthSquared < 0.5f )
return;
var bb = Vec3.Cross( normal, tt ).Normal;
if ( Vec3.Dot( bb, bitangent ) < 0f )
bb = -bb;
var local = new Vec3(
Vec3.Dot( sculptedNormal, tt ),
Vec3.Dot( sculptedNormal, bb ),
Vec3.Dot( sculptedNormal, normal ) ).Normal;
if ( local.LengthSquared < 0.5f )
return;
var index = y * map.Width + x;
Encode( map, index, local, options.FlipGreen );
map.Filled[index] = true;
} );
}
/// <summary>
/// Tangent and bitangent from the UV gradient — the standard derivation, and the one a shader
/// assumes on the other end. A triangle whose UVs are degenerate has no frame to give and is
/// skipped rather than guessed at.
/// </summary>
static bool TangentFrame( Triangle t, out Vec3 tangent, out Vec3 bitangent )
{
tangent = Vec3.Zero;
bitangent = Vec3.Zero;
var e1 = t.P1 - t.P0;
var e2 = t.P2 - t.P0;
var d1 = new Vec2( t.U1.x - t.U0.x, t.U1.y - t.U0.y );
var d2 = new Vec2( t.U2.x - t.U0.x, t.U2.y - t.U0.y );
var det = d1.x * d2.y - d2.x * d1.y;
if ( MathF.Abs( det ) < 1e-12f )
return false;
var r = 1f / det;
tangent = (e1 * d2.y - e2 * d1.y) * r;
bitangent = (e2 * d1.x - e1 * d2.x) * r;
return tangent.LengthSquared > 1e-16f && bitangent.LengthSquared > 1e-16f;
}
/// <summary>
/// The sculpted surface's normal where the ray landed, interpolated across the face rather than
/// taken flat from it. A face normal would bake the sculpt's own faceting into the map, which is
/// the one thing a normal map exists to avoid.
/// </summary>
static Vec3 SmoothNormal( PolyMesh mesh, Vec3[] normals, MeshHit hit )
{
var face = mesh.Faces[hit.FaceIndex];
var corners = new List<Vec3>( face.Count );
for ( var c = 0; c < face.Count; c++ )
corners.Add( mesh.Positions[face.Indices[c]] );
// THE SAME TRIANGULATION THE RAYCAST USED, not a fan from corner 0.
//
// Triangulate.Face splits a quad along whichever diagonal suits its shape, and a sculpted quad
// is rarely planar. Fanning from corner 0 instead can put the hit point outside every triangle
// this looks at, in which case the search finds nothing and the fallback writes the FACE
// normal into that texel — a faceted speck in a map whose whole purpose is to not be faceted.
//
// Honest note: this was changed on the reasoning above, not on a measurement. Swapping it made
// no visible difference to the smoothness numbers on the plane fixture, whose quads are near
// enough planar that both triangulations agree. It is the correct thing to do and the fallback
// is real; how often it fires on a heavily sculpted model has not been measured.
foreach ( var (ia, ib, ic) in Triangulate.Face( corners ) )
{
if ( !Barycentric( hit.Point, corners[ia], corners[ib], corners[ic], out var u, out var v, out var w ) )
continue;
var n = normals[face.Indices[ia]] * u + normals[face.Indices[ib]] * v + normals[face.Indices[ic]] * w;
if ( n.LengthSquared > 1e-12f )
return n.Normal;
}
return hit.Normal;
}
/// <summary>Barycentric coordinates of a point already known to lie in the triangle's plane.</summary>
static bool Barycentric( Vec3 p, Vec3 a, Vec3 b, Vec3 c, out float u, out float v, out float w )
{
u = v = w = 0f;
var v0 = b - a;
var v1 = c - a;
var v2 = p - a;
var d00 = Vec3.Dot( v0, v0 );
var d01 = Vec3.Dot( v0, v1 );
var d11 = Vec3.Dot( v1, v1 );
var d20 = Vec3.Dot( v2, v0 );
var d21 = Vec3.Dot( v2, v1 );
var denom = d00 * d11 - d01 * d01;
if ( MathF.Abs( denom ) < 1e-20f )
return false;
v = (d11 * d20 - d01 * d21) / denom;
w = (d00 * d21 - d01 * d20) / denom;
u = 1f - v - w;
const float slack = 1e-4f;
return u >= -slack && v >= -slack && w >= -slack;
}
/// <summary>
/// Walk the texels a UV triangle covers, sampling at texel centres. Shared by the bake and by
/// <see cref="Measure"/>, so the two can never disagree about which texels a face owns.
/// </summary>
static void Rasterise( Vec2 a, Vec2 b, Vec2 c, int width, int height, Action<int, int, float, float, float> texel )
{
var minX = (int)MathF.Floor( MathF.Min( a.x, MathF.Min( b.x, c.x ) ) * width - 1f );
var maxX = (int)MathF.Ceiling( MathF.Max( a.x, MathF.Max( b.x, c.x ) ) * width + 1f );
var minY = (int)MathF.Floor( MathF.Min( a.y, MathF.Min( b.y, c.y ) ) * height - 1f );
var maxY = (int)MathF.Ceiling( MathF.Max( a.y, MathF.Max( b.y, c.y ) ) * height + 1f );
minX = Math.Max( minX, 0 );
minY = Math.Max( minY, 0 );
maxX = Math.Min( maxX, width - 1 );
maxY = Math.Min( maxY, height - 1 );
var area = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
if ( MathF.Abs( area ) < 1e-16f )
return;
// Wound consistently so the fill rule below has a fixed sense of "inside".
var flipped = area < 0f;
var p1 = flipped ? c : b;
var p2 = flipped ? b : c;
var doubled = MathF.Abs( area );
for ( var y = minY; y <= maxY; y++ )
{
for ( var x = minX; x <= maxX; x++ )
{
var px = (x + 0.5f) / width;
var py = (y + 0.5f) / height;
var e0 = Edge( p1, p2, px, py );
var e1 = Edge( p2, a, px, py );
var e2 = Edge( a, p1, px, py );
if ( !Covers( e0, p1, p2 ) || !Covers( e1, p2, a ) || !Covers( e2, a, p1 ) )
continue;
var w0 = e0 / doubled;
var wp1 = e1 / doubled;
var wp2 = e2 / doubled;
texel( x, y, w0, flipped ? wp2 : wp1, flipped ? wp1 : wp2 );
}
}
}
static float Edge( Vec2 u, Vec2 v, float px, float py ) =>
(v.x - u.x) * (py - u.y) - (v.y - u.y) * (px - u.x);
/// <summary>
/// The top-left fill rule, which is what makes a shared edge belong to exactly ONE face.
///
/// THIS REPLACED A TOLERANCE, AND THE TOLERANCE WAS WRONG. Accepting any texel within a slack of
/// the triangle meant a texel centre landing on the edge between two coplanar faces satisfied
/// both of them — so `Measure` reported overlapping UVs on a mesh whose UVs were perfect, and the
/// bake wrote the same texel twice. It showed up as exactly one texel on a quadsphere, which is
/// the kind of number that invites tuning the threshold instead of fixing the rule.
///
/// A point exactly on an edge is awarded to the triangle for which that edge is a left or a top
/// edge; the neighbour, walking the same edge the other way, declines it. Standard, and the only
/// answer that is exact rather than nearly exact.
/// </summary>
static bool Covers( float e, Vec2 u, Vec2 v )
{
if ( e > 0f )
return true;
if ( e < 0f )
return false;
// y is up here, and the winding above is counter-clockwise, so the interior lies to the left
// of each directed edge: a left edge climbs, a top edge runs right to left.
var dy = v.y - u.y;
return dy > 0f || (dy == 0f && v.x < u.x);
}
static void Encode( BakedMap map, int index, Vec3 n, bool flipGreen )
{
var y = flipGreen ? -n.y : n.y;
var i = index * 3;
map.Rgb[i] = Byte( n.x );
map.Rgb[i + 1] = Byte( y );
map.Rgb[i + 2] = Byte( n.z );
}
static byte Byte( float v ) => (byte)Math.Clamp( MathF.Round( (v + 1f) * 127.5f ), 0f, 255f );
/// <summary>
/// Bleed filled texels outward, so a shader filtering across an island's edge finds something
/// sensible there. Each pass takes the average of the filled neighbours; without it seams glow
/// once mipmaps start mixing in whatever sat outside the island.
/// </summary>
static void Dilate( BakedMap map, int passes )
{
if ( passes <= 0 )
return;
var filled = (bool[])map.Filled.Clone();
for ( var pass = 0; pass < passes; pass++ )
{
var added = new List<(int Index, int R, int G, int B)>();
for ( var y = 0; y < map.Height; y++ )
{
for ( var x = 0; x < map.Width; x++ )
{
var index = y * map.Width + x;
if ( filled[index] )
continue;
int r = 0, g = 0, b = 0, n = 0;
for ( var dy = -1; dy <= 1; dy++ )
{
for ( var dx = -1; dx <= 1; dx++ )
{
var nx = x + dx;
var ny = y + dy;
if ( nx < 0 || ny < 0 || nx >= map.Width || ny >= map.Height )
continue;
var other = ny * map.Width + nx;
if ( !filled[other] )
continue;
r += map.Rgb[other * 3];
g += map.Rgb[other * 3 + 1];
b += map.Rgb[other * 3 + 2];
n++;
}
}
if ( n > 0 )
added.Add( (index, r / n, g / n, b / n) );
}
}
if ( added.Count == 0 )
return;
foreach ( var (index, r, g, b) in added )
{
map.Rgb[index * 3] = (byte)r;
map.Rgb[index * 3 + 1] = (byte)g;
map.Rgb[index * 3 + 2] = (byte)b;
filled[index] = true;
}
}
}
/// <summary>
/// How far to search, MEASURED off the two surfaces rather than guessed from the model's size.
///
/// A FRACTION OF THE DIAGONAL IS NOT GOOD ENOUGH, and the case that proves it is the ordinary one:
/// a SculptFeature's cage IS the coarse body and the sculpt IS its Catmull-Clark subdivision, and
/// subdivision pulls a cube's corners a very long way in. On a 2x2x2 box the two surfaces are 2.6
/// units apart at the corners while a tenth of the diagonal is 0.35 — so the old default missed
/// three quarters of the map and the bake came out mostly flat. Nothing about that is exotic; it
/// is what pressing Bake on a box does.
///
/// So: probe from every cage vertex along its own normal, far enough to cross both models, and
/// take the largest separation actually found. Times 1.5, because texels sit between vertices and
/// the surface can bow further out between two of them than at either.
///
/// Still bounded above by the cage's own diagonal. The failure at the other end — a reach so long
/// that a ray meets the far side of the model and paints its normal onto this one — is worse than
/// a flat patch, because it looks like detail.
/// </summary>
static float MeasuredReach( PolyMesh cage, PolyMesh sculpted, MeshBVH bvh )
{
var diagonal = cage.BoundsDiagonal;
if ( diagonal <= 1e-6f )
return 1f;
var probe = diagonal + sculpted.BoundsDiagonal;
var normals = cage.ComputeVertexNormals();
var worst = 0f;
for ( var i = 0; i < cage.VertexCount; i++ )
{
var normal = normals[i];
if ( normal.LengthSquared < 0.5f )
continue;
var hit = bvh.Raycast( sculpted, cage.Positions[i] + normal * probe, -normal );
if ( hit is null )
continue;
worst = MathF.Max( worst, MathF.Abs( hit.Value.Distance - probe ) );
}
// The floor covers a cage sitting exactly on its sculpt, where every probe returns zero and a
// reach of zero would find nothing at all.
return Math.Clamp( worst * 1.5f, diagonal * 0.1f, diagonal );
}
// --- the check the bake depends on ----------------------------------------------------------
/// <summary>
/// What a mesh's UVs look like as an atlas: how much of the square they cover, how much they
/// cover twice, and whether they stay inside it at all.
/// </summary>
public static UVCoverage Measure( PolyMesh mesh, int resolution = 256 )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( resolution < 1 )
throw new ArgumentOutOfRangeException( nameof( resolution ) );
var counts = new int[resolution * resolution];
var outside = 0;
foreach ( var face in mesh.Faces )
{
if ( face.Count < 3 || face.UVs is null || face.UVs.Length != face.Count )
continue;
var escapes = false;
foreach ( var uv in face.UVs )
{
if ( uv.x < -1e-4f || uv.x > 1f + 1e-4f || uv.y < -1e-4f || uv.y > 1f + 1e-4f )
escapes = true;
}
if ( escapes )
outside++;
// One face must not count a texel twice, however many fan triangles cover it.
var mine = new HashSet<int>();
for ( var i = 1; i < face.Count - 1; i++ )
{
Rasterise( face.UVs[0], face.UVs[i], face.UVs[i + 1], resolution, resolution,
( x, y, _, _, _ ) => mine.Add( y * resolution + x ) );
}
foreach ( var index in mine )
counts[index]++;
}
var covered = 0;
var overlapping = 0;
foreach ( var n in counts )
{
if ( n > 0 )
covered++;
if ( n > 1 )
overlapping++;
}
return new UVCoverage( resolution, covered, overlapping, outside, mesh.FaceCount );
}
}
/// <summary>
/// The verdict on a mesh's UVs as a bake target.
///
/// The plan has said since it was written that a bake needs non-overlapping UVs and that nothing
/// checked it. This is that check. It is worth running before a bake rather than after, because an
/// overlapping bake does not fail — it produces a map that looks plausible and is wrong wherever two
/// faces shared a texel.
/// </summary>
public sealed class UVCoverage
{
public readonly int Resolution;
public readonly int CoveredTexels;
public readonly int OverlappingTexels;
public readonly int FacesOutsideTheSquare;
public readonly int FaceCount;
public UVCoverage( int resolution, int covered, int overlapping, int outside, int faces )
{
Resolution = resolution;
CoveredTexels = covered;
OverlappingTexels = overlapping;
FacesOutsideTheSquare = outside;
FaceCount = faces;
}
public float CoveredFraction => (float)CoveredTexels / (Resolution * Resolution);
public float OverlapFraction => CoveredTexels == 0 ? 0f : (float)OverlappingTexels / CoveredTexels;
/// <summary>Whether these UVs can carry a bake at all.</summary>
public bool CanBake => OverlappingTexels == 0 && FacesOutsideTheSquare == 0 && CoveredTexels > 0;
/// <summary>The refusal, in the shape a diagnostic wants: what is wrong, with this model's numbers.</summary>
public string Problem
{
get
{
if ( CoveredTexels == 0 )
return "These UVs cover none of the texture square, so a bake would have nowhere to write.";
if ( FacesOutsideTheSquare > 0 )
return $"{FacesOutsideTheSquare} of {FaceCount} faces have UVs outside the 0-1 square. "
+ "Box projection tiles by design and is meant for repeating a texture, not for a bake.";
if ( OverlappingTexels > 0 )
return $"{OverlappingTexels} texels ({OverlapFraction:P0} of those covered) are claimed by more "
+ "than one face. A bake writes both and keeps whichever ran last.";
return null;
}
}
}