diff --git a/Benchmarks/Shared/Rendering/RenderBenchmarkFixture.cs b/Benchmarks/Shared/Rendering/RenderBenchmarkFixture.cs index 9869c54..62f026f 100644 --- a/Benchmarks/Shared/Rendering/RenderBenchmarkFixture.cs +++ b/Benchmarks/Shared/Rendering/RenderBenchmarkFixture.cs @@ -18,6 +18,7 @@ using ChangeTrace.Rendering.States.Edges; using ChangeTrace.Rendering.States.Nodes; using ChangeTrace.Rendering.States.Particles; +using ChangeTrace.Rendering.States.Scene; namespace ChangeTrace.Benchmarks.Shared.Rendering; @@ -155,7 +156,7 @@ public SceneSnapshot AssembleSceneSnapshot() var edges = _edgeSnapshots.Assemble(Scene, nodeIndex); var particles = _particleSnapshots.Assemble(Animation); - return new SceneSnapshot(nodes, avatars, edges, particles); + return SceneSnapshotMaterializer.Create(nodes, avatars, edges, particles); } /// diff --git a/Benchmarks/Subsystem/Rendering/SceneSnapshotAssemblyBenchmarks.cs b/Benchmarks/Subsystem/Rendering/SceneSnapshotAssemblyBenchmarks.cs index 23a3be2..391f330 100644 --- a/Benchmarks/Subsystem/Rendering/SceneSnapshotAssemblyBenchmarks.cs +++ b/Benchmarks/Subsystem/Rendering/SceneSnapshotAssemblyBenchmarks.cs @@ -2,6 +2,7 @@ using ChangeTrace.Benchmarks.Rendering; using ChangeTrace.Benchmarks.Shared.Rendering; using ChangeTrace.Rendering.Snapshots; +using ChangeTrace.Rendering.States.Scene; namespace ChangeTrace.Benchmarks.Subsystem.Rendering; diff --git a/Benchmarks/Subsystem/Rendering/SceneSnapshotComponentBenchmarks.cs b/Benchmarks/Subsystem/Rendering/SceneSnapshotComponentBenchmarks.cs index 2e3c292..87aba60 100644 --- a/Benchmarks/Subsystem/Rendering/SceneSnapshotComponentBenchmarks.cs +++ b/Benchmarks/Subsystem/Rendering/SceneSnapshotComponentBenchmarks.cs @@ -6,6 +6,7 @@ using ChangeTrace.Rendering.States.Edges; using ChangeTrace.Rendering.States.Nodes; using ChangeTrace.Rendering.States.Particles; +using ChangeTrace.Rendering.States.Scene; namespace ChangeTrace.Benchmarks.Subsystem.Rendering; @@ -89,7 +90,7 @@ public int AssembleParticleSnapshots() [Benchmark] public int MaterializeSceneSnapshot() { - var snapshot = new SceneSnapshot( + var snapshot = SceneSnapshotMaterializer.Create( _prebuiltNodes, _prebuiltAvatars, _prebuiltEdges, diff --git a/Tests/Unit/Rendering/States/SceneSnapshotTests.cs b/Tests/Unit/Rendering/States/SceneSnapshotTests.cs new file mode 100644 index 0000000..762370e --- /dev/null +++ b/Tests/Unit/Rendering/States/SceneSnapshotTests.cs @@ -0,0 +1,63 @@ +using System.Numerics; +using ChangeTrace.Rendering; +using ChangeTrace.Rendering.Enums; +using ChangeTrace.Rendering.Scene; +using ChangeTrace.Rendering.Snapshots; +using ChangeTrace.Rendering.States.Scene; +using Xunit; + +namespace ChangeTrace.Tests.Rendering.States; + +/// Tests immutable scene snapshot materialization behavior. +public sealed class SceneSnapshotTests +{ + [Fact] + public void Constructor_SortsNodesAndFindNodeUsesSortedIndex() + { + var nodes = new[] + { + new NodeSnapshot("src/Program.cs", new Vec2(2, 0), 4f, new Vector4(1f), 0.1f, NodeKind.File, "Program.cs", false, "src"), + new NodeSnapshot(SceneIds.Root, new Vec2(0, 0), 18f, new Vector4(1f), 1f, NodeKind.Root, SceneIds.RootLabel, true), + new NodeSnapshot("src", new Vec2(1, 0), 7f, new Vector4(1f), 0.2f, NodeKind.Branch, "src", true, "__root_files__") + }; + + var snapshot = SceneSnapshotMaterializer.Create(nodes, [], [], []); + + Assert.Collection( + snapshot.Nodes, + node => Assert.Equal(SceneIds.Root, node.Id), + node => Assert.Equal("src", node.Id), + node => Assert.Equal("src/Program.cs", node.Id)); + + var branch = snapshot.FindNode("src"); + Assert.NotNull(branch); + Assert.Equal(NodeKind.Branch, branch.Value.Kind); + } + + [Fact] + public void Constructor_RemapsEdgeIndexesAfterNodeSortingAndDropsInvalidDuplicates() + { + var nodes = new[] + { + new NodeSnapshot("src/Program.cs", new Vec2(2, 0), 4f, new Vector4(1f), 0.1f, NodeKind.File, "Program.cs", false, "src"), + new NodeSnapshot(SceneIds.Root, new Vec2(0, 0), 18f, new Vector4(1f), 1f, NodeKind.Root, SceneIds.RootLabel, true), + new NodeSnapshot("src", new Vec2(1, 0), 7f, new Vector4(1f), 0.2f, NodeKind.Branch, "src", true, "__root_files__") + }; + + var edges = new[] + { + new EdgeSnapshotIndexed(2, 0, EdgeKind.Hierarchy, 1f, new Vector4(1f)), + new EdgeSnapshotIndexed(2, 0, EdgeKind.Hierarchy, 1f, new Vector4(1f)), + new EdgeSnapshotIndexed(9, 0, EdgeKind.Hierarchy, 1f, new Vector4(1f)) + }; + + var snapshot = SceneSnapshotMaterializer.Create(nodes, [], edges, []); + + var edge = Assert.Single(snapshot.Edges); + Assert.Equal("src", snapshot.Nodes[edge.FromIndex].Id); + Assert.Equal("src/Program.cs", snapshot.Nodes[edge.ToIndex].Id); + + Assert.Equal("src", snapshot.Nodes[snapshot.Edges[0].FromIndex].Id); + Assert.Equal("src/Program.cs", snapshot.Nodes[snapshot.Edges[0].ToIndex].Id); + } +} diff --git a/src/Rendering/Interfaces/IAnimationSystem.cs b/src/Rendering/Interfaces/IAnimationSystem.cs index 2b4128d..d2e01b4 100644 --- a/src/Rendering/Interfaces/IAnimationSystem.cs +++ b/src/Rendering/Interfaces/IAnimationSystem.cs @@ -1,5 +1,6 @@ using System.Numerics; using ChangeTrace.Rendering.Animation; +using ChangeTrace.Rendering.Snapshots; namespace ChangeTrace.Rendering.Interfaces; @@ -12,7 +13,7 @@ internal interface IAnimationSystem /// /// Snapshots all active particles into the provided list without allocation. /// - void SnapshotParticles(List target); + void SnapshotParticles(List target); /// /// Gets the number of active particles. @@ -67,4 +68,4 @@ void TweenFloat(float from, float to, float duration, EasingFn easing, /// Resets system state for a fresh start. /// void Clear(); -} \ No newline at end of file +} diff --git a/src/Rendering/Snapshots/NodeSnapshot.cs b/src/Rendering/Snapshots/NodeSnapshot.cs index c99ce64..452e9ea 100644 --- a/src/Rendering/Snapshots/NodeSnapshot.cs +++ b/src/Rendering/Snapshots/NodeSnapshot.cs @@ -1,6 +1,5 @@ using System.Numerics; using ChangeTrace.Rendering.Enums; -using ChangeTrace.Rendering.Scene; namespace ChangeTrace.Rendering.Snapshots; diff --git a/src/Rendering/Snapshots/OrderedNodeSnapshotList.cs b/src/Rendering/Snapshots/OrderedNodeSnapshotList.cs new file mode 100644 index 0000000..c8a0cc0 --- /dev/null +++ b/src/Rendering/Snapshots/OrderedNodeSnapshotList.cs @@ -0,0 +1,26 @@ +using System.Collections; + +namespace ChangeTrace.Rendering.Snapshots; + +/// +/// Lightweight ordered node snapshot view over an existing node buffer. +/// +internal sealed class OrderedNodeSnapshotList( + IReadOnlyList source, + int[] order) + : IReadOnlyList +{ + public int Count => order.Length; + + public NodeSnapshot this[int index] => + source[order[index]]; + + public IEnumerator GetEnumerator() + { + foreach (var item in order) + yield return source[item]; + } + + IEnumerator IEnumerable.GetEnumerator() => + GetEnumerator(); +} diff --git a/src/Rendering/Snapshots/RemappedEdgeSnapshotList.cs b/src/Rendering/Snapshots/RemappedEdgeSnapshotList.cs new file mode 100644 index 0000000..797fbd2 --- /dev/null +++ b/src/Rendering/Snapshots/RemappedEdgeSnapshotList.cs @@ -0,0 +1,37 @@ +using System.Collections; + +namespace ChangeTrace.Rendering.Snapshots; + +/// +/// Lightweight remapped edge snapshot view over an existing edge buffer. +/// +internal sealed class RemappedEdgeSnapshotList( + IReadOnlyList source, + int[] edgeIndexes, + int[] remap) + : IReadOnlyList +{ + public int Count => edgeIndexes.Length; + + public EdgeSnapshotIndexed this[int index] + { + get + { + var edge = source[edgeIndexes[index]]; + return edge with + { + FromIndex = remap[edge.FromIndex], + ToIndex = remap[edge.ToIndex] + }; + } + } + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < edgeIndexes.Length; i++) + yield return this[i]; + } + + IEnumerator IEnumerable.GetEnumerator() => + GetEnumerator(); +} diff --git a/src/Rendering/Snapshots/SceneSnapshot.cs b/src/Rendering/Snapshots/SceneSnapshot.cs index abf5844..58b99c7 100644 --- a/src/Rendering/Snapshots/SceneSnapshot.cs +++ b/src/Rendering/Snapshots/SceneSnapshot.cs @@ -14,63 +14,27 @@ internal sealed class SceneSnapshot : ISceneSnapshot /// /// Fast node lookup index. /// - private readonly Dictionary _nodeIndex; private readonly Dictionary _nodePositionIndex; internal SceneSnapshot( IReadOnlyList nodes, IReadOnlyList avatars, IReadOnlyList edges, - IReadOnlyList particles) + IReadOnlyList particles, + Dictionary nodePositionIndex) { - _nodeIndex = - new Dictionary( - nodes.Count); - _nodePositionIndex = - new Dictionary( - nodes.Count); - - foreach (NodeSnapshot node in nodes) - _nodeIndex[node.Id] = node; - - Nodes = - _nodeIndex.Values - .OrderBy(n => n.Kind switch - { - NodeKind.Root => 0, - NodeKind.Branch => 1, - NodeKind.File => 2, - _ => 1 - }) - .ThenBy(n => n.Id) - .ToArray(); - - for (var i = 0; i < Nodes.Count; i++) - _nodePositionIndex[Nodes[i].Id] = i; - - Avatars = - avatars; - - Edges = - edges - .Where(e => - e.FromIndex >= 0 && - e.ToIndex >= 0 && - e.FromIndex < Nodes.Count && - e.ToIndex < Nodes.Count) - .DistinctBy(e => - (e.FromIndex, e.ToIndex, e.Kind)) - .ToArray(); - - Particles = - particles; + Nodes = nodes; + Avatars = avatars; + Edges = edges; + Particles = particles; + _nodePositionIndex = nodePositionIndex; } /// /// Empty reusable scene snapshot. /// internal static SceneSnapshot Empty { get; } = - new([], [], [], []); + new([], [], [], [], new Dictionary()); /// /// Scene nodes. @@ -145,7 +109,9 @@ internal SceneSnapshot( if (string.IsNullOrWhiteSpace(id)) return null; - return _nodeIndex.GetValueOrDefault(id); + return _nodePositionIndex.TryGetValue(id, out var index) + ? Nodes[index] + : null; } /// @@ -157,22 +123,19 @@ public IEnumerable NodesOfKind(NodeKind kind) => /// /// Returns glowing nodes above a threshold. /// - public IEnumerable GlowingNodes( - float threshold = 0.05f) => + public IEnumerable GlowingNodes(float threshold = 0.05f) => Nodes.Where(n => n.Glow > threshold); /// /// Returns active avatars above an activity threshold. /// - public IEnumerable ActiveAvatars( - float activityThreshold = 0.1f) => + public IEnumerable ActiveAvatars(float activityThreshold = 0.1f) => Avatars.Where(a => a.ActivityLevel > activityThreshold); /// /// Returns visible avatars above an alpha threshold. /// - public IEnumerable VisibleAvatars( - float alphaThreshold = 0.05f) => + public IEnumerable VisibleAvatars(float alphaThreshold = 0.05f) => Avatars.Where(a => a.Alpha * a.ActivityLevel > alphaThreshold); @@ -213,8 +176,7 @@ public IEnumerable EdgesOfKind(EdgeKind kind) => /// /// Returns visible edges above an alpha threshold. /// - public IEnumerable VisibleEdges( - float alphaThreshold = 0.02f) => + public IEnumerable VisibleEdges(float alphaThreshold = 0.02f) => Edges.Where(e => e.Alpha > alphaThreshold); /// @@ -225,9 +187,7 @@ public IEnumerable VisibleEdges( if (Nodes.Count == 0) return null; - Vec2 sum = - Vec2.Zero; - + Vec2 sum = Vec2.Zero; foreach (NodeSnapshot node in Nodes) sum += node.Position; @@ -245,13 +205,11 @@ public IEnumerable VisibleEdges( NodeSnapshot? best = null; - float bestDist = - float.MaxValue; + float bestDist = float.MaxValue; foreach (NodeSnapshot node in Nodes) { - float dist = - (node.Position - point).LengthSq; + float dist = (node.Position - point).LengthSq; if (!(dist < bestDist)) continue; @@ -278,4 +236,5 @@ public SceneStats ComputeStats() => Nodes.Count(n => n.Glow > 0.05f), VisibleEdges: Edges.Count(e => e.Alpha > 0.02f)); + } diff --git a/src/Rendering/Snapshots/SceneStats.cs b/src/Rendering/Snapshots/SceneStats.cs index cd1c30c..2bfe2a4 100644 --- a/src/Rendering/Snapshots/SceneStats.cs +++ b/src/Rendering/Snapshots/SceneStats.cs @@ -11,4 +11,4 @@ internal sealed record SceneStats( int ActiveAvatars, int GlowingNodes, int VisibleEdges -); \ No newline at end of file +); diff --git a/src/Rendering/States/RenderStateAssembler.cs b/src/Rendering/States/RenderStateAssembler.cs index e18666d..12dfebc 100644 --- a/src/Rendering/States/RenderStateAssembler.cs +++ b/src/Rendering/States/RenderStateAssembler.cs @@ -9,6 +9,7 @@ using ChangeTrace.Rendering.States.Hud; using ChangeTrace.Rendering.States.Nodes; using ChangeTrace.Rendering.States.Particles; +using ChangeTrace.Rendering.States.Scene; using Microsoft.Extensions.DependencyInjection; namespace ChangeTrace.Rendering.States; @@ -87,7 +88,7 @@ public RenderState Assemble( leaderboard); var sceneSnapshot = - new SceneSnapshot( + SceneSnapshotMaterializer.Create( nodeSnapshots, avatarSnapshots, edgeSnapshots, diff --git a/src/Rendering/States/Scene/SceneSnapshotMaterializer.cs b/src/Rendering/States/Scene/SceneSnapshotMaterializer.cs new file mode 100644 index 0000000..e88b484 --- /dev/null +++ b/src/Rendering/States/Scene/SceneSnapshotMaterializer.cs @@ -0,0 +1,128 @@ +using ChangeTrace.Rendering.Enums; +using ChangeTrace.Rendering.Snapshots; + +namespace ChangeTrace.Rendering.States.Scene; + +/// +/// Materializes an immutable scene snapshot from component snapshot collections. +/// +internal static class SceneSnapshotMaterializer +{ + internal static SceneSnapshot Create( + IReadOnlyList nodes, + IReadOnlyList avatars, + IReadOnlyList edges, + IReadOnlyList particles) + { + var orderedNodes = CreateOrderedNodes(nodes); + var nodePositionIndex = BuildNodePositionIndex(orderedNodes); + var originalToSortedIndex = BuildOriginalToSortedIndex(nodes, nodePositionIndex); + var remappedEdges = CreateEdges(edges, originalToSortedIndex); + + return new SceneSnapshot( + orderedNodes, + avatars, + remappedEdges, + particles, + nodePositionIndex); + } + + internal static IReadOnlyList CreateOrderedNodes( + IReadOnlyList nodes) + { + var order = new int[nodes.Count]; + + for (var i = 0; i < nodes.Count; i++) + order[i] = i; + + Array.Sort( + order, + (left, right) => + { + var kindOrder = + GetNodeOrder(nodes[left].Kind) + .CompareTo(GetNodeOrder(nodes[right].Kind)); + + if (kindOrder != 0) + return kindOrder; + + return string.CompareOrdinal(nodes[left].Id, nodes[right].Id); + }); + + return order.Length == 0 + ? [] + : new OrderedNodeSnapshotList(nodes, order); + } + + internal static Dictionary BuildNodePositionIndex( + IReadOnlyList nodes) + { + var index = new Dictionary(nodes.Count); + + for (var i = 0; i < nodes.Count; i++) + index[nodes[i].Id] = i; + + return index; + } + + internal static int[] BuildOriginalToSortedIndex( + IReadOnlyList nodes, + IReadOnlyDictionary nodePositionIndex) + { + var remap = new int[nodes.Count]; + + for (var i = 0; i < nodes.Count; i++) + remap[i] = nodePositionIndex[nodes[i].Id]; + + return remap; + } + + internal static IReadOnlyList CreateEdges( + IReadOnlyList edges, + int[] originalToSortedIndex) + { + var edgeIndexes = new List(edges.Count); + var seenEdges = new HashSet(edges.Count); + + for (var i = 0; i < edges.Count; i++) + { + var edge = edges[i]; + + if ((uint)edge.FromIndex >= (uint)originalToSortedIndex.Length || + (uint)edge.ToIndex >= (uint)originalToSortedIndex.Length) + { + continue; + } + + var remappedFromIndex = originalToSortedIndex[edge.FromIndex]; + var remappedToIndex = originalToSortedIndex[edge.ToIndex]; + var edgeKey = PackEdgeKey(remappedFromIndex, remappedToIndex, edge.Kind); + + if (!seenEdges.Add(edgeKey)) + continue; + + edgeIndexes.Add(i); + } + + return edgeIndexes.Count == 0 + ? [] + : new RemappedEdgeSnapshotList(edges, edgeIndexes.ToArray(), originalToSortedIndex); + } + + private static int GetNodeOrder(NodeKind kind) => + kind switch + { + NodeKind.Root => 0, + NodeKind.Branch => 1, + NodeKind.File => 2, + _ => 1 + }; + + private static ulong PackEdgeKey( + int fromIndex, + int toIndex, + EdgeKind kind) => + ((ulong)(uint)fromIndex << 32) | + ((ulong)(uint)toIndex << 8) | + (byte)kind; +}