From 9b75a414e1c4af81576f23854a62c24553bdd17a Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 16 Jun 2026 18:04:30 +0200 Subject: [PATCH] Perf: Optimize scene snapshot materialization Changed - Scene snapshot materialization now routes through a dedicated materializer in the rendering state layer instead of concentrating ordering, indexing, and edge cleanup inside the snapshot constructor - Scene snapshot construction now reuses lightweight ordered node and remapped edge views while replacing duplicated node snapshot indexing with compact id to position map - Rendering benchmarks and fixtures now exercise the materializer path directly, and scene snapshot regression tests cover node ordering, lookup, and edge remapping behavior Result Scene snapshot materialization keeps the same observable scene semantics while reducing constructor work, improving the 100000 event benchmark from 110.26 ms to 51.55 ms and lowering managed allocation from 26.77 MB to 3.85 MB. --- .../Rendering/RenderBenchmarkFixture.cs | 3 +- .../SceneSnapshotAssemblyBenchmarks.cs | 1 + .../SceneSnapshotComponentBenchmarks.cs | 3 +- .../Rendering/States/SceneSnapshotTests.cs | 63 +++++++++ src/Rendering/Interfaces/IAnimationSystem.cs | 5 +- src/Rendering/Snapshots/NodeSnapshot.cs | 1 - .../Snapshots/OrderedNodeSnapshotList.cs | 26 ++++ .../Snapshots/RemappedEdgeSnapshotList.cs | 37 +++++ src/Rendering/Snapshots/SceneSnapshot.cs | 79 +++-------- src/Rendering/Snapshots/SceneStats.cs | 2 +- src/Rendering/States/RenderStateAssembler.cs | 3 +- .../States/Scene/SceneSnapshotMaterializer.cs | 128 ++++++++++++++++++ 12 files changed, 284 insertions(+), 67 deletions(-) create mode 100644 Tests/Unit/Rendering/States/SceneSnapshotTests.cs create mode 100644 src/Rendering/Snapshots/OrderedNodeSnapshotList.cs create mode 100644 src/Rendering/Snapshots/RemappedEdgeSnapshotList.cs create mode 100644 src/Rendering/States/Scene/SceneSnapshotMaterializer.cs 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; +}