diff --git a/Plugins/API/Components/Runtime/CSGModel.cs b/Plugins/API/Components/Runtime/CSGModel.cs index aebc135..db20696 100644 --- a/Plugins/API/Components/Runtime/CSGModel.cs +++ b/Plugins/API/Components/Runtime/CSGModel.cs @@ -20,6 +20,7 @@ public enum ModelSettingsFlags StitchLightmapSeams = 4096, IgnoreNormals = 8192, TwoSidedShadows = 16384, + AutoStitchCracks = 32768 } [Serializable] @@ -60,7 +61,8 @@ public sealed class CSGModel : CSGNode public bool SetColliderConvex { get { return (Settings & ModelSettingsFlags.SetColliderConvex) != (ModelSettingsFlags)0; } } public bool NeedAutoUpdateRigidBody { get { return (Settings & ModelSettingsFlags.AutoUpdateRigidBody) == (ModelSettingsFlags)0; } } public bool PreserveUVs { get { return (Settings & ModelSettingsFlags.PreserveUVs) != (ModelSettingsFlags)0; } } - public bool StitchLightmapSeams { get { return (Settings & ModelSettingsFlags.StitchLightmapSeams) != (ModelSettingsFlags)0; } } + public bool StitchLightmapSeams { get { return (Settings & ModelSettingsFlags.StitchLightmapSeams) != (ModelSettingsFlags)0; } } + public bool AutoStitchCracks { get { return (Settings & ModelSettingsFlags.AutoStitchCracks) != (ModelSettingsFlags)0; } } public bool AutoRebuildUVs { get { return (Settings & ModelSettingsFlags.AutoRebuildUVs) != (ModelSettingsFlags)0; } } public bool IgnoreNormals { get { return (Settings & ModelSettingsFlags.IgnoreNormals) != (ModelSettingsFlags)0; } } diff --git a/Plugins/Editor/Scripts/Control/Helpers/CracksStitching.cs b/Plugins/Editor/Scripts/Control/Helpers/CracksStitching.cs new file mode 100644 index 0000000..fa1d62d --- /dev/null +++ b/Plugins/Editor/Scripts/Control/Helpers/CracksStitching.cs @@ -0,0 +1,681 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using Debug = UnityEngine.Debug; + +// ReSharper disable CompareOfFloatsByEqualityOperator - I know what I am doing, all comparisons are on purpose and take into account precision issues. + +namespace RealtimeCSG +{ + public static class CracksStitching + { + public const float DefaultMaxDist = 1e-08f; + + /// + /// Stitch cracks and small holes in meshes, this can take a significant amount of time depending on the meshes + /// + /// The meshes to process + /// The maximum distance to cover when stitching cracks, squared, holes larger than this will be left as is + /// Object the process will dump debug information info, can be null + /// Token used to cancel this operation if required + /// The scenes which will be dertied once the process is completed + /// Name used for the asynchronous progress report + public static void RunAsync(IEnumerable meshes, float maxDist, Debugger? debugger, CancellationToken cancellationToken, Scene[] scenes, string progressName) + { + var meshDefs = meshes.Select(x => new CracksStitching.MeshDef(x)).ToArray(); + Task.Run(() => + { + try + { + CracksStitching.Run(meshDefs, maxDist, debugger, cancellationToken, scenes, progressName); + } + catch (Exception e) when (e is not OperationCanceledException) + { + Debug.LogException(e); + } + }, cancellationToken); + } + + static void Run(MeshDef[] meshes, float maxDist, Debugger? debugger, CancellationToken cancellationToken, Scene[] scenes, string progressName) + { + using var progress = new DisposableProgress(progressName); + + // Filter out edges that are part of a surface from those that are on the bounds of the surface or a hole in the surface + // We do so by filtering edge whose vertex position are unique as a pair, meaning that their positions are only used by a single triangle + + var sharedPosPair = new HashSet(new UniquePosPairComparer()); + var uniquePosPair = new HashSet(new UniquePosPairComparer()); + (int, int)[] edges = new (int, int)[3]; + foreach (var mesh in meshes) + { + for (int ofBuffer = 0; ofBuffer < mesh.IndexBuffers.Length; ofBuffer++) + { + var indexBuffer = mesh.IndexBuffers[ofBuffer]; + for (int inBuffer = 0; inBuffer < indexBuffer.Count; inBuffer += 3) + { + edges[0] = (inBuffer, inBuffer+1); + edges[1] = (inBuffer+1, inBuffer+2); + edges[2] = (inBuffer+2, inBuffer); + foreach (var (aInBuffer, bInBuffer) in edges) + { + EdgeDef edge = new EdgeDef(aInBuffer, bInBuffer, mesh, ofBuffer); + if (sharedPosPair.Contains(edge)) + continue; + + if (uniquePosPair.Add(edge) == false) // Another edge registered itself before, those position pairs are no longer unique + { + uniquePosPair.Remove(edge); + sharedPosPair.Add(edge); + } + } + } + + if (cancellationToken.IsCancellationRequested) + return; + } + } + + // Pre-compute best pairs for all edges + + { + var copy = uniquePosPair.ToArray(); + for (int i = 0; i < copy.Length; i++) + { + var edgeA = copy[i]; + for (int j = i+1; j < copy.Length; j++) + { + var edgeB = copy[j]; + ComputeScore(edgeA, edgeB, out float score); + edgeA.TryUnsafeInsert(edgeB, score); + edgeB.TryUnsafeInsert(edgeA, score); + } + } + } + + // Go through all edges, find the best pair, + // create a quad between them, remove those edges from the collection, + // append the two new edges made by the bridge to the collection if necessary + // repeat + + int initialUniquePosPair = uniquePosPair.Count; + while (uniquePosPair.Count > 0) + { + EdgeDef? bestEdge = null; + float bestEdgeScore = float.NegativeInfinity; + foreach (var sourceEdge in uniquePosPair) + { + if (sourceEdge.BestMatch.Score <= bestEdgeScore) + continue; + + while (uniquePosPair.Contains(sourceEdge.BestMatch.Edge) == false && uniquePosPair.Count > 1) + sourceEdge.Evict(sourceEdge.BestMatch.Edge, uniquePosPair); + + if (sourceEdge.BestMatch.Edge.BestMatch.Edge == sourceEdge) + { + bestEdge = sourceEdge; + bestEdgeScore = sourceEdge.BestMatch.Score; + } + } + + if (bestEdge is null) + { + Debug.LogError("Failed to resolve further"); + break; + } + + Merge(bestEdge, bestEdge.BestMatch.Edge, uniquePosPair, sharedPosPair, maxDist, debugger); + + if (cancellationToken.IsCancellationRequested) + return; + + progress.Report(1f - (uniquePosPair.Count / initialUniquePosPair)); + } + + if (debugger is not null) + { + foreach (var edgeDef in uniquePosPair) + { + debugger.DrawEdge(edgeDef, Color.red, 0f); + } + } + + foreach (var meshDef in meshes) + { + foreach (IGrouping grouping in meshDef.ToDiscard.GroupBy(x => x.IndexBuffer)) + { + var indexBuffer = meshDef.IndexBuffers[grouping.Key]; + foreach ((_, int Start) in grouping.OrderByDescending(x => x.Start)) + { + indexBuffer.RemoveRange(Start, 6); + } + } + } + + EditorApplication.CallbackFunction a = null!; + a = () => + { + EditorApplication.update -= a; + ApplyMeshChanges(cancellationToken, meshes, scenes); + }; + + EditorApplication.update += a; + } + + static void ApplyMeshChanges(CancellationToken cancellationToken, MeshDef[] meshes, Scene[] scenes) + { + if (cancellationToken.IsCancellationRequested) + return; + + foreach (var meshDef in meshes) + { + meshDef.Representation.SetVertices(meshDef.Pos); + meshDef.Representation.SetNormals(meshDef.Normals); + meshDef.Representation.SetTangents(meshDef.Tangents); + meshDef.Representation.SetUVs(0, meshDef.UV1); + meshDef.Representation.SetUVs(1, meshDef.UV2); + + for (int i = 0; i < meshDef.IndexBuffers.Length; i++) + meshDef.Representation.SetTriangles(meshDef.IndexBuffers[i], i); + } + + foreach (var scene in scenes) + EditorSceneManager.MarkSceneDirty(scene); + } + + static void Merge(EdgeDef sourceEdge, EdgeDef targetEdge, HashSet uniquePosPair, HashSet sharedPosPair, float maxDist, Debugger? debugger) + { + uniquePosPair.Remove(sourceEdge); + uniquePosPair.Remove(targetEdge); + sharedPosPair.Add(sourceEdge); + sharedPosPair.Add(targetEdge); + + // Let's draw a quad to join those two edges + + int indexOppositeA; + int indexOppositeB; + var dist1 = Vector3.SqrMagnitude(targetEdge.A - sourceEdge.A) + Vector3.SqrMagnitude(targetEdge.B - sourceEdge.B); + var dist2 = Vector3.SqrMagnitude(targetEdge.B - sourceEdge.A) + Vector3.SqrMagnitude(targetEdge.A - sourceEdge.B); + bool swapIndex = dist1 > dist2; // Figure out which vertices of the edges should get connected when bridging them, a with other a or a with other b + if (targetEdge.Mesh != sourceEdge.Mesh) // When the mesh don't match, we'll pick the opposite meshes' vertices and append them to the source's mesh + { + sourceEdge.Mesh.Pos.Add(targetEdge.Mesh.Pos[targetEdge.IndexA]); + sourceEdge.Mesh.Pos.Add(targetEdge.Mesh.Pos[targetEdge.IndexB]); + sourceEdge.Mesh.Normals.Add(targetEdge.Mesh.Normals[targetEdge.IndexA]); + sourceEdge.Mesh.Normals.Add(targetEdge.Mesh.Normals[targetEdge.IndexB]); + sourceEdge.Mesh.Tangents.Add(targetEdge.Mesh.Tangents[targetEdge.IndexA]); + sourceEdge.Mesh.Tangents.Add(targetEdge.Mesh.Tangents[targetEdge.IndexB]); + + // Use stuff from source instead, the uvs of target may map to something widely different, + // better to stretch the source uvs instead of setting wrong ones + var sourceA = swapIndex ? sourceEdge.IndexB : sourceEdge.IndexA; + var sourceB = swapIndex ? sourceEdge.IndexA : sourceEdge.IndexB; + sourceEdge.Mesh.UV1.Add(sourceEdge.Mesh.UV1[sourceA]); + sourceEdge.Mesh.UV1.Add(sourceEdge.Mesh.UV1[sourceB]); + if (sourceEdge.Mesh.UV2.Count > 0) + { + sourceEdge.Mesh.UV2.Add(sourceEdge.Mesh.UV2[sourceA]); + sourceEdge.Mesh.UV2.Add(sourceEdge.Mesh.UV2[sourceB]); + } + + indexOppositeA = sourceEdge.Mesh.Pos.Count - 2; + indexOppositeB = sourceEdge.Mesh.Pos.Count - 1; + } + else + { + indexOppositeA = targetEdge.IndexA; + indexOppositeB = targetEdge.IndexB; + } + + if (swapIndex) + { + (indexOppositeA, indexOppositeB) = (indexOppositeB, indexOppositeA); + } + + var indexBuffer = sourceEdge.Mesh.IndexBuffers[sourceEdge.IndexBuffer]; + + int baseIndex = indexBuffer.Count; + indexBuffer.Add(0); + indexBuffer.Add(0); + indexBuffer.Add(0); + + indexBuffer.Add(0); + indexBuffer.Add(0); + indexBuffer.Add(0); + + ComputeScore(sourceEdge, targetEdge, out _, out float distanceA, out float distanceB); + bool discard = distanceB > maxDist && distanceA > maxDist; + if (discard) + sourceEdge.Mesh.ToDiscard.Add((sourceEdge.IndexBuffer, baseIndex)); + + // This nonsense is to ensure that winding is correct, + // When two triangles share the same edge, their winding passes through the two vertices they share in opposite direction, + // e.g.: for vertices {a,b,c,d} one triangle goes {a,b,c} the other goes {b,a,d}: + // A + // ↙╮┆╭← + // ↙ ↑┆↓ ↖ + // ↙ ↑┆↓ ↖ + // B ╰┈→┈→┈╯┴╰→┈┈→┈╯ C + // D + + // Knowing that, we can figure out the winding for those two new triangles by copying the winding of the triangle the source edge is on + + int aOrder = sourceEdge.IndexAInBuffer % 3; // is 'a' the first, second or third element in the source triangle + int bOrder = sourceEdge.IndexBInBuffer % 3; // is 'b' the first, second or third element in the source triangle + int cOrder = 3 - (aOrder + bOrder); // Which spot has not been taken by the two others + + // Add the triangle that lays right against sourceEdge in opposite winding order ('2 - x' reverses winding) see comment above why + indexBuffer[baseIndex + (2 - aOrder)] = sourceEdge.IndexA; + indexBuffer[baseIndex + (2 - bOrder)] = sourceEdge.IndexB; + indexBuffer[baseIndex + (2 - cOrder)] = indexOppositeB; + + // Add the other triangle in normal winding order + indexBuffer[baseIndex + 3 + aOrder] = indexOppositeA; + indexBuffer[baseIndex + 3 + bOrder] = indexOppositeB; + indexBuffer[baseIndex + 3 + cOrder] = sourceEdge.IndexA; + + // Now that we created a bridge between the two edges, we need to add the two new edges we just created + var aToOppositeA = new EdgeDef(baseIndex + 3 + aOrder, baseIndex + 3 + cOrder, sourceEdge.Mesh, sourceEdge.IndexBuffer); + var bToOppositeB = new EdgeDef(baseIndex + (2 - bOrder), baseIndex + (2 - cOrder), sourceEdge.Mesh, sourceEdge.IndexBuffer); + + // aToOppositeA + // ↓ + // A ┬───────┬ oppositeA + // │ ╲ │ + // sourceEdge → │ ╲ │ ← targetEdge + // │ ╲ │ + // B ┴───────┴ oppositeB + // ↑ + // bToOppositeB + + if (debugger is not null) + { + if (discard) + { + debugger.DrawEdge(sourceEdge, Color.red); + debugger.DrawEdge(targetEdge, Color.red); + debugger.DrawEdge(aToOppositeA, Color.red); + debugger.DrawEdge(bToOppositeB, Color.red); + } + else + { + debugger.DrawEdge(sourceEdge, Color.blue); + debugger.DrawEdge(targetEdge, Color.green); + debugger.DrawEdge(aToOppositeA, Color.cyan); + debugger.DrawEdge(bToOppositeB, Color.cyan); + } + } + + if (aToOppositeA.A.Equals(aToOppositeA.B) == false && sharedPosPair.Contains(aToOppositeA) == false) + { + if (uniquePosPair.Add(aToOppositeA)) + { + foreach (var otherOtherCache in uniquePosPair) + { + if (ReferenceEquals(aToOppositeA, otherOtherCache)) + continue; + + ComputeScore(aToOppositeA, otherOtherCache, out var score); + aToOppositeA.TryUnsafeInsert(otherOtherCache, score); + otherOtherCache.TryGuardedInsert(aToOppositeA, score); + } + } + else // It already exists, meaning that we just solved another edge at the same time as this one + { + uniquePosPair.TryGetValue(aToOppositeA, out aToOppositeA); // may not map exactly and we need the exact ref for eviction + uniquePosPair.Remove(aToOppositeA); + sharedPosPair.Add(aToOppositeA); + } + } + + if (bToOppositeB.A.Equals(bToOppositeB.B) == false && sharedPosPair.Contains(bToOppositeB) == false) + { + if (uniquePosPair.Add(bToOppositeB)) + { + foreach (var otherOtherCache in uniquePosPair) + { + if (ReferenceEquals(bToOppositeB, otherOtherCache)) + continue; + + ComputeScore(bToOppositeB, otherOtherCache, out var score); + bToOppositeB.TryUnsafeInsert(otherOtherCache, score); + otherOtherCache.TryGuardedInsert(bToOppositeB, score); + } + } + else // It already exists, meaning that we just solved another edge at the same time as this one + { + uniquePosPair.TryGetValue(bToOppositeB, out bToOppositeB); // may not map exactly and we need the exact ref for eviction + uniquePosPair.Remove(bToOppositeB); + sharedPosPair.Add(bToOppositeB); + } + } + } + + static void ComputeScore(EdgeDef edge1, EdgeDef edge2, out float score) + { + ComputeScore(edge1, edge2, out float matchLength, out float distanceA, out float distanceB); + // This is the most important line in the whole file + // Measure the length of the match, the 1/x here to ensure the longer the match is the closer the difference is to zero + score = matchLength; + score /= 1f + (distanceA + distanceB); // Divide by projection distance, we don't want to prioritize edges that are similar but far away + } + + static void ComputeScore(EdgeDef edge1, EdgeDef edge2, out float matchLength, out float distanceA, out float distanceB) + { + EdgeDef smallest; + EdgeDef largest; + if (edge1.AToB.sqrMagnitude > edge2.AToB.sqrMagnitude) + { + smallest = edge2; + largest = edge1; + } + else + { + smallest = edge1; + largest = edge2; + } + + // This is the heart of the algorithm + // Here we're computing how close the two edges match by removing the parts of the two segments that don't touch and measuring it + // ────── x + // ──── y + // ─ subSegment + var subSegment = (a:largest.A, b:largest.B); + ClipBetweenSegment(ref subSegment.a, smallest.A, smallest.AToB); + ClipBetweenSegment(ref subSegment.b, smallest.A, smallest.AToB); + + var alongLargest = (a:smallest.A, b:smallest.B); + ClipBetweenSegment(ref alongLargest.a, largest.A, largest.AToB); + ClipBetweenSegment(ref alongLargest.b, largest.A, largest.AToB); + + matchLength = (subSegment.a - subSegment.b).sqrMagnitude; + distanceA = (alongLargest.a - smallest.A).sqrMagnitude; + distanceB = (alongLargest.b - smallest.B).sqrMagnitude; + } + + /// + /// Take a point and move it to the closest point on segment + /// + static void ClipBetweenSegment(ref Vector3 point, Vector3 segmentPos, Vector3 segmentDelta) + { + var delta = point - segmentPos; + float dot = Vector3.Dot(delta, segmentDelta); + if (dot < 0) + { + point = segmentPos; + } + else + { + float segDot = Vector3.Dot(segmentDelta, segmentDelta); + Vector3 projection; + if (segDot == 0f) + projection = Vector3.zero; + else + projection = segmentDelta * (dot / segDot); + + if (projection.sqrMagnitude > segmentDelta.sqrMagnitude) + point = segmentPos + segmentDelta; + else + point = segmentPos + projection; + } + } + + public class MeshDef + { + public readonly Mesh Representation; + public readonly List Pos; + public readonly List Normals; + public readonly List Tangents; + public readonly List UV1, UV2; + public readonly List[] IndexBuffers; + public readonly List<(int IndexBuffer, int Start)> ToDiscard = new(); + + public MeshDef(Mesh mesh) + { + Representation = mesh; + var vertCount = mesh.vertexCount; + Pos = new(vertCount); + Normals = new(vertCount); + Tangents = new(vertCount); + UV1 = new(vertCount); + UV2 = new(vertCount); + mesh.GetVertices(Pos); + mesh.GetNormals(Normals); + mesh.GetTangents(Tangents); + mesh.GetUVs(0, UV1); + mesh.GetUVs(1, UV2); + IndexBuffers = new List[mesh.subMeshCount]; + for (int i = 0; i < mesh.subMeshCount; i++) + { + var tris = new List(vertCount); + mesh.GetTriangles(tris, i); + IndexBuffers[i] = tris; + } + } + } + + public class EdgeDef + { + const int CacheSizeMax = 4; + + public readonly Vector3 A; + public readonly Vector3 B; + public readonly Vector3 AToB; + public readonly int IndexA; + public readonly int IndexB; + public readonly int IndexBuffer; + public readonly int IndexAInBuffer; + public readonly int IndexBInBuffer; + public readonly MeshDef Mesh; + + private readonly (EdgeDef Edge, float Score)[] BestMatches; + + private int _bestMatchesCount; + + /// + /// May not be entirely valid if the amount of edges in the pool + /// is less than two or of nothing was happended to it yet + /// + public (EdgeDef Edge, float Score) BestMatch => BestMatches[0]; + + public EdgeDef(int indexAInBuffer, int indexBInBuffer, MeshDef mesh, int indexBuffer) + { + BestMatches = new (EdgeDef Edge, float Score)[CacheSizeMax]; + var indexA = mesh.IndexBuffers[indexBuffer][indexAInBuffer]; + var indexB = mesh.IndexBuffers[indexBuffer][indexBInBuffer]; + Vector3 a = mesh.Pos[indexA]; + Vector3 b = mesh.Pos[indexB]; + + IndexBuffer = indexBuffer; + Mesh = mesh; + + bool ordered = false; + if (a.x < b.x) + { + ordered = true; + } + else if (a.x == b.x) + { + if (a.y < b.y || (a.y == b.y && a.z < b.z)) + ordered = true; + } + + // Ordering to guarantee deterministic comparison and hashcode given the same pair but in different order + // See UniquePosPairComparer for usage + A = ordered ? a : b; + B = ordered ? b : a; + IndexA = ordered ? indexA : indexB; + IndexB = ordered ? indexB : indexA; + IndexAInBuffer = ordered ? indexAInBuffer : indexBInBuffer; + IndexBInBuffer = ordered ? indexBInBuffer : indexAInBuffer; + AToB = B - A; + } + + /// + /// Insert this edge in the cache, should only be used when rebuilding this edge's cache entirely + /// + public void TryUnsafeInsert(EdgeDef otherEdge, float score) + { + if (_bestMatchesCount == 0) + { + BestMatches[_bestMatchesCount++] = (otherEdge, score); + return; + } + else if (score >= BestMatches[0].Score) + { + for (int i = Math.Min(_bestMatchesCount - 1, CacheSizeMax - 2); i >= 0; i--) + BestMatches[i + 1] = BestMatches[i]; + _bestMatchesCount = Math.Min(_bestMatchesCount + 1, CacheSizeMax); + BestMatches[0] = (otherEdge, score); + } + else + { + for (int i = _bestMatchesCount - 1; i >= 0; i--) + { + if (score > BestMatches[i].Score) + continue; + + if (i+1 == CacheSizeMax) + return; + + for (int j = Math.Min(_bestMatchesCount - 1, CacheSizeMax - 2); j >= i + 1; j--) + BestMatches[j + 1] = BestMatches[j]; + _bestMatchesCount = Math.Min(_bestMatchesCount + 1, CacheSizeMax); + + BestMatches[i+1] = (otherEdge, score); + break; + } + } + } + + /// + /// Try to append a newly introduced edge into the cache + /// + public void TryGuardedInsert(EdgeDef otherEdge, float score) + { + // Here we have to be very careful, if the cache is not filled up, any empty slot just means that we have no clue which edge *should* be in that slot + // We can't insert this new edges at an empty spot since it may be misleading, they may not actually be the fourth closest edge, + // another one in the pool that was the sixth at the time may very well be the fourth now that a couple of the closest one were evicted. + // We just haven't re-filled the cache since then as we didn't need to + + if (_bestMatchesCount == 0) + return; + + // Note the lack of 'if (BestEdges.Count == 0)', this wouldn't ever be hit given the if above + + // We can only insert in slots before the last *existing* value, not the one before the maximum amount of slots, see larger comment above + if (score < BestMatches[_bestMatchesCount-1].Score) + return; // So exit if we're larger than the last value + + if (score >= BestMatches[0].Score) + { + for (int i = Math.Min(_bestMatchesCount - 1, CacheSizeMax - 2); i >= 0; i--) + BestMatches[i + 1] = BestMatches[i]; + _bestMatchesCount = Math.Min(_bestMatchesCount + 1, CacheSizeMax); + BestMatches[0] = (otherEdge, score); + } + else + { + for (int i = _bestMatchesCount - 1; i >= 0; i--) + { + if (score > BestMatches[i].Score) + continue; + + if (i + 1 == CacheSizeMax) + return; + + for (int j = Math.Min(_bestMatchesCount - 1, CacheSizeMax - 2); j >= i + 1; j--) + BestMatches[j + 1] = BestMatches[j]; + _bestMatchesCount = Math.Min(_bestMatchesCount + 1, CacheSizeMax); + + BestMatches[i + 1] = (otherEdge, score); + break; + } + } + } + + /// + /// Evict an edge from the cache, potentially triggering the cache to be rebuilt + /// + public void Evict(EdgeDef edge, HashSet uniquePosPair) + { + for (int i = 0; i < _bestMatchesCount; i++) + { + if (ReferenceEquals(BestMatches[i].Edge, edge)) + { + for (int j = i; j < _bestMatchesCount-1; j++) + BestMatches[j] = BestMatches[j+1]; + _bestMatchesCount--; + break; + } + } + + if (_bestMatchesCount == 0) + { + foreach (var otherEdge in uniquePosPair) + { + if (ReferenceEquals(otherEdge, this)) + continue; + + ComputeScore(this, otherEdge, out var score); + TryUnsafeInsert(otherEdge, score); + } + } + } + } + + public class Debugger + { + public List<(Vector3 a, Vector3 b, Color color)> DebugLines = new(); + + public void DrawEdge(EdgeDef edge, Color color, float offset = 0.01f) + { + var pos = edge.Mesh.Pos; + var norms = edge.Mesh.Normals; + var p0 = pos[edge.IndexA] + norms[edge.IndexA] * offset; + var p1 = pos[edge.IndexB] + norms[edge.IndexB] * offset; + DebugLines.Add((p0, p1, color)); + } + } + + class UniquePosPairComparer : IEqualityComparer + { + public bool Equals(EdgeDef x, EdgeDef y) + { + return x.A.Equals(y.A) && x.B.Equals(y.B); // Using Vector3.Equals instead of '==' as the latter is not an exact equality, which we definitely want since we're resolving precision issues + } + + public int GetHashCode(EdgeDef obj) + { + return HashCode.Combine(obj.A, obj.B); + } + } + + class DisposableProgress : IDisposable + { + private int progressId; + + public DisposableProgress(string name) + { + progressId = Progress.Start(name); + } + + public void Report(float value) + { + Progress.Report(progressId, value); + } + + public void Dispose() + { + Progress.Remove(progressId); + } + } + } +} \ No newline at end of file diff --git a/Plugins/Editor/Scripts/Control/Helpers/CracksStitching.cs.meta b/Plugins/Editor/Scripts/Control/Helpers/CracksStitching.cs.meta new file mode 100644 index 0000000..10b06a4 --- /dev/null +++ b/Plugins/Editor/Scripts/Control/Helpers/CracksStitching.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 07022d34d8be4adfb7b1735fdbf88e2f +timeCreated: 1721076179 \ No newline at end of file diff --git a/Plugins/Editor/Scripts/Control/Managers/MeshInstanceManager.cs b/Plugins/Editor/Scripts/Control/Managers/MeshInstanceManager.cs index b5de7ac..a145b08 100644 --- a/Plugins/Editor/Scripts/Control/Managers/MeshInstanceManager.cs +++ b/Plugins/Editor/Scripts/Control/Managers/MeshInstanceManager.cs @@ -1,6 +1,7 @@ //#define SHOW_GENERATED_MESHES using System.Linq; using System.Collections.Generic; +using System.Threading; using UnityEngine; using UnityEngine.SceneManagement; using UnityEditor; @@ -926,6 +927,102 @@ private static bool NeedToGenerateLightmapUVsForInstance(GeneratedMeshInstance i return !instance.HasUV2 && instance.RenderSurfaceType == RenderSurfaceType.Normal; } + public static bool NeedToStitchCracksForModel(CSGModel model) + { + if (!ModelTraits.IsModelEditable(model)) + return false; + + if (!model.generatedMeshes) + return false; + + var container = model.generatedMeshes; + if (!container || container.owner != model) + return false; + + foreach (var instance in container.MeshInstances) + { + if (!instance) + continue; + + if (NeedToStitchCracksForInstance(instance)) + return true; + } + return false; + } + + public static void StitchCracksForModel(CSGModel model) + { + if (!ModelTraits.IsModelEditable(model)) + return; + + if (!model.generatedMeshes) + return; + + var container = model.generatedMeshes; + if (!container || !container.owner) + return; + + if (!container.HasMeshInstances) + return; + + foreach (var instance in container.MeshInstances) + { + if (!instance) + continue; + if (!instance.SharedMesh) + instance.FindMissingSharedMesh(); + } + + foreach (var grouping in from x in container.MeshInstances + where x.SharedMesh != null && x.SharedMesh.vertexCount > 0 + group x by x.RenderSurfaceType == RenderSurfaceType.Collider) + { + var meshes = new List(); + foreach (var instance in grouping) + { + instance.SharedMesh = instance.SharedMesh.Clone(); + meshes.Add(instance.SharedMesh); + instance.CracksSolverCancellation?.Invoke(); + } + + if (meshes.Count == 0) + continue; + + var tokenSource = new CancellationTokenSource(); + + foreach (var instance in grouping) + { + instance.CracksSolverCancellation += () => + { + tokenSource.Cancel(); + instance.CracksSolverCancellation = null!; + }; + } + + string progressName = $"Stitching {container.name}'s {(grouping.Key ? "Colliders" : "Meshes")}"; + var scenes = grouping.Select(x => x.gameObject.scene).ToArray(); + CracksStitching.RunAsync(meshes, CracksStitching.DefaultMaxDist, null, tokenSource.Token, scenes, progressName); + + foreach (var instance in grouping) + { + instance.CracksHashValue = instance.MeshDescription.geometryHashValue; + instance.HasNoCracks = true; + } + } + } + + private static bool NeedToStitchCracksForInstance(GeneratedMeshInstance instance) + { + return !instance.HasNoCracks; + } + + public static void ClearCrackStitching(GeneratedMeshInstance instance) + { + instance.CracksHashValue = 0; + instance.HasNoCracks = false; + instance.CracksSolverCancellation?.Invoke(); + } + private static bool AreBoundsEmpty(GeneratedMeshInstance instance) { return @@ -1204,6 +1301,22 @@ public static void Refresh(GeneratedMeshInstance instance, CSGModel owner, bool } } + if (instance.HasNoCracks && instance.CracksHashValue != instance.MeshDescription.geometryHashValue && meshRendererComponent) + { + instance.ResetStitchCracksTime = Time.realtimeSinceStartup; + if(instance.HasNoCracks) + ClearCrackStitching(instance); + } + + if (owner.AutoStitchCracks || postProcessScene) + { + if ((float.IsPositiveInfinity(instance.ResetStitchCracksTime) || Time.realtimeSinceStartup - instance.ResetStitchCracksTime > 2.0f) && + NeedToStitchCracksForModel(owner)) + { + StitchCracksForModel(owner); + } + } + if (!postProcessScene && meshFilterComponent.sharedMesh != instance.SharedMesh) { @@ -1345,6 +1458,7 @@ public static void Refresh(GeneratedMeshInstance instance, CSGModel owner, bool meshRendererComponent.hideFlags = HideFlags.None; UnityEngine.Object.DestroyImmediate(meshRendererComponent); instance.Dirty = true; } instance.LightingHashValue = instance.MeshDescription.geometryHashValue; + instance.CracksHashValue = instance.MeshDescription.geometryHashValue; meshFilterComponent = null; meshRendererComponent = null; instance.CachedMeshRendererSO = null; diff --git a/Plugins/Editor/Scripts/View/GUI/ComponentEditorWindows/CSGModelComponent.Inspector.GUI.cs b/Plugins/Editor/Scripts/View/GUI/ComponentEditorWindows/CSGModelComponent.Inspector.GUI.cs index b346168..4432d53 100644 --- a/Plugins/Editor/Scripts/View/GUI/ComponentEditorWindows/CSGModelComponent.Inspector.GUI.cs +++ b/Plugins/Editor/Scripts/View/GUI/ComponentEditorWindows/CSGModelComponent.Inspector.GUI.cs @@ -212,6 +212,7 @@ public static void OnInspectorGUI(UnityEngine.Object[] targets) bool? DoNotRender = (Settings & ModelSettingsFlags.DoNotRender) == ModelSettingsFlags.DoNotRender; bool? TwoSidedShadows = (Settings & ModelSettingsFlags.TwoSidedShadows) == ModelSettingsFlags.TwoSidedShadows; // bool? ReceiveShadows = !((settings & ModelSettingsFlags.DoNotReceiveShadows) == ModelSettingsFlags.DoNotReceiveShadows); + bool? AutoStitchCracks = (Settings & ModelSettingsFlags.AutoStitchCracks) == ModelSettingsFlags.AutoStitchCracks; bool? AutoRebuildUVs = (Settings & ModelSettingsFlags.AutoRebuildUVs) == ModelSettingsFlags.AutoRebuildUVs; bool? PreserveUVs = (Settings & ModelSettingsFlags.PreserveUVs) == ModelSettingsFlags.PreserveUVs; bool? StitchLightmapSeams = (Settings & ModelSettingsFlags.StitchLightmapSeams) == ModelSettingsFlags.StitchLightmapSeams; @@ -266,6 +267,7 @@ public static void OnInspectorGUI(UnityEngine.Object[] targets) bool currDoNotRender = (Settings & ModelSettingsFlags.DoNotRender) == ModelSettingsFlags.DoNotRender; bool currTwoSidedShadows = (Settings & ModelSettingsFlags.TwoSidedShadows) == ModelSettingsFlags.TwoSidedShadows; // bool currReceiveShadows = !((settings & ModelSettingsFlags.DoNotReceiveShadows) == ModelSettingsFlags.DoNotReceiveShadows); + bool currAutoStitchCracks = (Settings & ModelSettingsFlags.AutoStitchCracks) == ModelSettingsFlags.AutoStitchCracks; bool currAutoRebuildUVs = (Settings & ModelSettingsFlags.AutoRebuildUVs) == ModelSettingsFlags.AutoRebuildUVs; bool currPreserveUVs = (Settings & ModelSettingsFlags.PreserveUVs) == ModelSettingsFlags.PreserveUVs; bool currStitchLightmapSeams = (Settings & ModelSettingsFlags.StitchLightmapSeams) == ModelSettingsFlags.StitchLightmapSeams; @@ -303,6 +305,7 @@ public static void OnInspectorGUI(UnityEngine.Object[] targets) if (TwoSidedShadows .HasValue && TwoSidedShadows .Value != currTwoSidedShadows ) TwoSidedShadows = null; // if (ReceiveShadows .HasValue && ReceiveShadows .Value != currReceiveShadows ) ReceiveShadows = null; // if (ShadowCastingMode .HasValue && ShadowCastingMode .Value != currShadowCastingMode ) ShadowCastingMode = null; + if (AutoStitchCracks .HasValue && AutoStitchCracks .Value != currAutoStitchCracks ) AutoStitchCracks = null; if (AutoRebuildUVs .HasValue && AutoRebuildUVs .Value != currAutoRebuildUVs ) AutoRebuildUVs = null; if (PreserveUVs .HasValue && PreserveUVs .Value != currPreserveUVs ) PreserveUVs = null; if (StitchLightmapSeams .HasValue && StitchLightmapSeams .Value != currStitchLightmapSeams ) StitchLightmapSeams = null; @@ -1130,6 +1133,27 @@ public static void OnInspectorGUI(UnityEngine.Object[] targets) EditorApplication.RepaintHierarchyWindow(); EditorApplication.DirtyHierarchyWindowSorting(); } + { + var autoStitchCracks = AutoStitchCracks ?? false; + EditorGUI.BeginChangeCheck(); + { + EditorGUI.showMixedValue = !AutoStitchCracks.HasValue; + autoStitchCracks = EditorGUILayout.Toggle(StitchCracksContent, autoStitchCracks); + } + if (EditorGUI.EndChangeCheck()) + { + for (int i = 0; i < models.Length; i++) + { + if (autoStitchCracks) + models[i].Settings |= ModelSettingsFlags.AutoStitchCracks; + else + models[i].Settings &= ~ModelSettingsFlags.AutoStitchCracks; + MeshInstanceManager.Refresh(models[i], onlyFastRefreshes: false); + } + GUI.changed = true; + AutoStitchCracks = autoStitchCracks; + } + } #if UNITY_2017_3_OR_NEWER GUILayout.Space(10); diff --git a/Plugins/Editor/Scripts/View/GUI/ComponentEditorWindows/CSGModelComponent.Inspector.GUIContents.cs b/Plugins/Editor/Scripts/View/GUI/ComponentEditorWindows/CSGModelComponent.Inspector.GUIContents.cs index f3af4c2..df40b01 100644 --- a/Plugins/Editor/Scripts/View/GUI/ComponentEditorWindows/CSGModelComponent.Inspector.GUIContents.cs +++ b/Plugins/Editor/Scripts/View/GUI/ComponentEditorWindows/CSGModelComponent.Inspector.GUIContents.cs @@ -67,6 +67,8 @@ internal sealed partial class CSGModelComponentInspectorGUI private static readonly GUIContent MinimumChartSizeContent = new GUIContent("Min Chart Size", "Specifies the minimum texel size used for a UV chart. If stitching is required, a value of 4 will create a chart of 4x4 texels to store lighting and directionality. If stitching is not required, a value of 2 will reduce the texel density and provide better lighting build times and run time performance."); + private static readonly GUIContent StitchCracksContent = new GUIContent("Stitch Cracks", "Some imprecise CSG operations may leave thin gaps and holes, this feature fills in those holes. Increases the amount of triangles."); + public static int[] MinimumChartSizeValues = { 2, 4 }; public static GUIContent[] MinimumChartSizeStrings = { diff --git a/Plugins/Runtime/Scripts/Components/GeneratedMeshInstance.cs b/Plugins/Runtime/Scripts/Components/GeneratedMeshInstance.cs index 6dcf068..49506d5 100644 --- a/Plugins/Runtime/Scripts/Components/GeneratedMeshInstance.cs +++ b/Plugins/Runtime/Scripts/Components/GeneratedMeshInstance.cs @@ -130,10 +130,15 @@ public sealed class GeneratedMeshInstance : MonoBehaviour [HideInInspector] public bool HasGeneratedNormals = false; [HideInInspector] public bool HasUV2 = false; + [HideInInspector] public bool HasNoCracks = false; [NonSerialized] [HideInInspector] public float ResetUVTime = float.PositiveInfinity; + [NonSerialized] + [HideInInspector] public float ResetStitchCracksTime = float.PositiveInfinity; [HideInInspector] public Int64 LightingHashValue; + [HideInInspector] public Int64 CracksHashValue; + [NonSerialized] [HideInInspector] public Action CracksSolverCancellation; [NonSerialized] [HideInInspector] public bool Dirty = true; [NonSerialized] [HideInInspector] public MeshCollider CachedMeshCollider; [NonSerialized] [HideInInspector] public MeshFilter CachedMeshFilter; @@ -148,8 +153,11 @@ public void Reset() HasGeneratedNormals = false; HasUV2 = false; - ResetUVTime = float.PositiveInfinity; + ResetUVTime = float.PositiveInfinity; + ResetStitchCracksTime = float.PositiveInfinity; LightingHashValue = 0; + CracksHashValue = 0; + CracksSolverCancellation?.Invoke(); Dirty = true;