Author SHA1 Message Date
max 6ad5aa3f2c Add profiler warmup overhead tracking and tooltip display 2026-08-05 14:04:21 +02:00
max d9582aecdd Enhance Profiler with memory allocation metrics 2026-08-05 13:25:14 +02:00
max 7853a768de Enhance Profiler with pre-allocated sort buffers and frame management improvements 2026-08-05 12:15:21 +02:00
max 83f77d1ebe Add thread metrics and category support to Profiler and visualizer 2026-08-05 11:47:21 +02:00
max 2d4139fb2c Enhance Profiler with Label Metrics and Rolling Windows
- Introduced LabelMetrics and RollingLabelMetrics structs to capture detailed profiling data.
- Implemented a RollingWindow class to maintain a rolling average of metrics.
- Updated Frame class to collect and store label metrics, including memory allocation and garbage collection statistics.
- Enhanced ScopeNode class to support self-time calculations and child duration tracking.
- Improved the Profiler class to manage frame budgets and rolling metrics.
- Refactored the ProfilerVisualizer to support new timeline rendering features, including zoom and pan functionality.
- Added tooltip support for detailed node information in the flame graph.
- Enhanced BoundedQueue to be thread-safe with proper locking mechanisms for concurrent access.
2026-08-04 17:19:00 +02:00
28 changed files with 1645 additions and 1630 deletions
-4
View File
@@ -1,4 +0,0 @@
# Copilot Instructions
## Project Guidelines
- In MoonTools.ECS, do not store plain references to `Entity` objects in long-lived collections outside the ECS world, because their underlying IDs can be reused or destroyed. Instead, query the ECS world to track or process entities based on their assigned components.
+6 -38
View File
@@ -1,6 +1,4 @@
using System.Diagnostics;
using System.Text.Json;
using Nerfed.Builder.Meta;
namespace Nerfed.Builder;
@@ -59,45 +57,15 @@ public class Builder : IDisposable
string outFile = $"{args.ResourceOutPath}/{relativeFile}{PathUtil.ImportedFileExtension}";
FileInfo inFileInfo = new FileInfo(inFile);
FileInfo outFileInfo = new FileInfo(outFile);
// =========================================================================
// STEP 1: GUID META FILE SYNC
// Ensure the source file has a backing .meta file generating its Guid
// =========================================================================
string metaFile = inFile + ".meta";
AssetMeta metaData;
if (!File.Exists(metaFile))
{
// Generate a brand new meta file to track this asset permanently
metaData = new AssetMeta(Guid.NewGuid());
string json = JsonSerializer.Serialize(metaData, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(metaFile, json);
Console.WriteLine($"[Meta] Generated new tracking ID '{metaData.Id}' for {relativeFile}");
}
else
{
// Load the existing guid
metaData = JsonSerializer.Deserialize<AssetMeta>(File.ReadAllText(metaFile))!;
}
// Change output file from Name.ext.bin -> /GUID.bin to completely anonymize the actual game package!
string cacheOutFile = $"{args.ResourceOutPath}/{metaData.Id}.bin";
FileInfo outFileInfo = new FileInfo(cacheOutFile);
// Rebuild if the source file changed, or if the meta file changed!
FileInfo metaFileInfo = new FileInfo(metaFile);
bool requiresCompile = !outFileInfo.Exists ||
FileUtil.IsNewer(inFileInfo, outFileInfo) ||
FileUtil.IsNewer(metaFileInfo, outFileInfo);
if (!requiresCompile)
if (!FileUtil.IsNewer(inFileInfo, outFileInfo))
{
// File has not changed since last build, no need to build this one.
return;
}
string outDir = Path.GetDirectoryName(cacheOutFile)!;
string outDir = Path.GetDirectoryName(outFile);
if (!Directory.Exists(outDir))
{
Directory.CreateDirectory(outDir);
@@ -106,14 +74,14 @@ public class Builder : IDisposable
string ext = Path.GetExtension(inFile).ToLower();
if (importers.TryGetValue(ext, out IImporter importer))
{
importer.Import(inFile, cacheOutFile); // Compile source directly to hash.bin
importer.Import(inFile, outFile);
}
else
{
rawFileImporter.Import(inFile, cacheOutFile);
rawFileImporter.Import(inFile, outFile);
}
Console.WriteLine($"Compiled {relativeFile} -> {metaData.Id}.bin");
Console.WriteLine(relativeFile);
}
catch (Exception e)
{
-30
View File
@@ -1,30 +0,0 @@
using System;
namespace Nerfed.Builder.Meta
{
/// <summary>
/// Foundation for JSON-serialized metadata files (e.g. hero.png.meta)
/// </summary>
public class AssetMeta
{
/// <summary>
/// The universally unique identifier for this asset, generated on first import.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The importer version. Useful to force re-imports if your engine updates how it parses textures.
/// </summary>
public int ImporterVersion { get; set; } = 1;
/// <summary>
/// Base constructor needed for JSON deserialization
/// </summary>
public AssetMeta() { }
public AssetMeta(Guid id)
{
Id = id;
}
}
}
+25 -25
View File
@@ -29,40 +29,40 @@ internal class Program
//systems.Add(new ParentSystem(world));
systems.Add(new LocalToWorldSystem(world));
editorSystems.Add(new EditorProfilerWindow(world));
// editorSystems.Add(new EditorHierarchyWindow(world));
editorSystems.Add(new EditorHierarchyWindow(world));
#if DEBUG
editorSystems.Add(new EditorInspectorWindow(world));
#endif
// Entity ent1 = world.CreateEntity("parent");
// world.Set(ent1, new Root());
// world.Set(ent1, new LocalTransform(new Vector3(1, 0, 0), Quaternion.Identity, Vector3.One));
//
// Entity ent2 = world.CreateEntity("child");
// world.Set(ent2, new LocalTransform(new Vector3(0, 1, 0), Quaternion.Identity, Vector3.One));
// Transform.SetParent(world, ent2, ent1);
//
// Entity ent3 = world.CreateEntity("entity3");
// world.Set(ent3, new Root());
// Transform.SetParent(world, ent3, ent2);
//
// Entity ent4 = world.CreateEntity("entity4");
// world.Set(ent4, new Root());
//
// Entity ent5 = world.CreateBaseEntity("entity5");
Entity ent1 = world.CreateEntity("parent");
world.Set(ent1, new Root());
world.Set(ent1, new LocalTransform(new Vector3(1, 0, 0), Quaternion.Identity, Vector3.One));
for (int i = 0; i < 1000000; i++)
Entity ent2 = world.CreateEntity("child");
world.Set(ent2, new LocalTransform(new Vector3(0, 1, 0), Quaternion.Identity, Vector3.One));
Transform.SetParent(world, ent2, ent1);
Entity ent3 = world.CreateEntity("entity3");
world.Set(ent3, new Root());
Transform.SetParent(world, ent3, ent2);
Entity ent4 = world.CreateEntity("entity4");
world.Set(ent4, new Root());
Entity ent5 = world.CreateBaseEntity("entity5");
for (int i = 0; i < 256; i++)
{
Entity newEnt = world.CreateBaseEntity();
world.Set(newEnt, new LocalTransform(new Vector3(i, i, i), Quaternion.Identity, Vector3.One));
// Entity parent = newEnt;
// for (int j = 0; j < 2; j++) {
// Entity newChildEnt = world.CreateEntity();
// world.Set(newChildEnt, new LocalTransform(new Vector3(i + j * i, i - j * i, j - i * i), Quaternion.Identity, Vector3.One));
// Transform.SetParent(world, newChildEnt, parent);
// parent = newChildEnt;
// }
Entity parent = newEnt;
for (int j = 0; j < 2; j++) {
Entity newChildEnt = world.CreateEntity();
world.Set(newChildEnt, new LocalTransform(new Vector3(i + j * i, i - j * i, j - i * i), Quaternion.Identity, Vector3.One));
Transform.SetParent(world, newChildEnt, parent);
parent = newChildEnt;
}
}
// Open project.
+184 -45
View File
@@ -1,6 +1,7 @@
using ImGuiNET;
using MoonTools.ECS;
using Nerfed.Runtime;
using System.Numerics;
namespace Nerfed.Editor.Systems
{
@@ -12,7 +13,10 @@ namespace Nerfed.Editor.Systems
private int selectedFrame = 0;
private int previousSelectedFrame = -1;
private IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> orderedCombinedData = null;
private IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>> orderedCombinedData = null;
private IOrderedEnumerable<KeyValuePair<int, Profiler.RollingThreadMetrics>> orderedThreadRollingData = null;
private readonly ProfilerVisualizer.TimelineState timelineState = new ProfilerVisualizer.TimelineState();
private readonly List<Profiler.Frame> frameSnapshot = new List<Profiler.Frame>(256);
public EditorProfilerWindow(World world) : base(world)
{
@@ -25,48 +29,107 @@ namespace Nerfed.Editor.Systems
return;
}
if (Profiler.CopyFramesTo(frameSnapshot) <= 0)
{
return;
}
selectedFrame = Math.Clamp(selectedFrame, 0, frameSnapshot.Count - 1);
timelineState.SelectedFrameIndex = Math.Clamp(timelineState.SelectedFrameIndex, -1, frameSnapshot.Count - 1);
timelineState.VisibleFrameCount = Math.Clamp(timelineState.VisibleFrameCount, 1, frameSnapshot.Count);
ImGui.Begin("Profiler");
ImGui.BeginChild("Toolbar", new System.Numerics.Vector2(0, 0), ImGuiChildFlags.AutoResizeY);
ImGui.BeginChild("Toolbar", new Vector2(0, 0), ImGuiChildFlags.AutoResizeY);
if (ImGui.RadioButton("Recording", Profiler.IsRecording))
{
Profiler.SetActive(!Profiler.IsRecording);
}
ImGui.SameLine();
ImGui.Text("Follow");
ImGui.SameLine();
ImGui.Checkbox("##follow-timeline", ref timelineState.FollowLatest);
ImGui.SameLine();
int visibleFrameCount = timelineState.VisibleFrameCount;
ImGui.SetNextItemWidth(130f);
if (ImGui.SliderInt("Window", ref visibleFrameCount, 1, frameSnapshot.Count))
{
timelineState.VisibleFrameCount = visibleFrameCount;
timelineState.FollowLatest = false;
}
ImGui.SameLine();
if (ImGui.Button("Reset Zoom"))
{
timelineState.Zoom = 1f;
timelineState.PanTicks = 0d;
timelineState.FollowLatest = true;
}
ImGui.SameLine();
int mode = (int)Profiler.Mode;
ImGui.SetNextItemWidth(130f);
if (ImGui.Combo("Mode", ref mode, "Instrumented\0Sampled\0"))
{
Profiler.Mode = (Profiler.CaptureMode)mode;
}
ImGui.SameLine();
int stride = Profiler.SamplingStride;
ImGui.SetNextItemWidth(90f);
if (ImGui.SliderInt("Stride", ref stride, 1, 64))
{
Profiler.SamplingStride = stride;
}
if (Profiler.IsRecording)
{
// Select last frame when recording to see latest frame data.
selectedFrame = Profiler.Frames.Count - 1;
}
if (ImGui.SliderInt(string.Empty, ref selectedFrame, 0, Profiler.Frames.Count - 1))
{
// Stop recording when browsing frames.
Profiler.SetActive(false);
selectedFrame = frameSnapshot.Count - 1;
}
Profiler.Frame frame = Profiler.Frames.ElementAt(selectedFrame);
int sliderFrame = selectedFrame;
if (ImGui.SliderInt("Frame", ref sliderFrame, 0, frameSnapshot.Count - 1))
{
selectedFrame = sliderFrame;
timelineState.SelectedFrameIndex = selectedFrame;
timelineState.FollowLatest = false;
}
Profiler.Frame frame = frameSnapshot[selectedFrame];
double ms = frame.ElapsedMilliseconds();
double s = 1000;
ImGui.Text($"Frame: {frame.FrameCount} ({ms:0.000} ms | {(s / ms):0} fps)");
ImGui.Text($"Budget: {frame.BudgetMilliseconds:0.00} ms ({(frame.OverBudget ? "over" : "within")})");
ImGui.Text($"Thread Budget: {Profiler.ThreadBudgetMilliseconds:0.00} ms | Capture: {Profiler.Mode}");
ImGui.Text($"Alloc: {frame.AllocatedBytesDelta / 1024d:0.0} KB | GC: G0 {frame.Gen0CollectionsDelta}, G1 {frame.Gen1CollectionsDelta}, G2 {frame.Gen2CollectionsDelta}");
ImGui.EndChild();
if (!Profiler.IsRecording) {
ProfilerVisualizer.TimelineRenderResult timelineResult = DrawFlameGraph(frameSnapshot, timelineState);
if (timelineResult.SelectionChanged)
{
selectedFrame = timelineResult.SelectedFrameIndex;
}
selectedFrame = Math.Clamp(selectedFrame, 0, frameSnapshot.Count - 1);
frame = frameSnapshot[selectedFrame];
if (previousSelectedFrame != selectedFrame)
{
previousSelectedFrame = selectedFrame;
orderedCombinedData = CalculateCombinedData(frame);
orderedThreadRollingData = CalculateThreadRollingData();
}
DrawFlameGraph(frame);
DrawThreadRolling(orderedThreadRollingData);
DrawHierachy(frame);
ImGui.SameLine();
DrawCombined(orderedCombinedData);
}
ImGui.End();
}
@@ -78,13 +141,18 @@ namespace Nerfed.Editor.Systems
return;
}
ImGui.BeginChild("Hierachy", new System.Numerics.Vector2(150, 0), ImGuiChildFlags.ResizeX);
ImGui.BeginChild("Hierachy", new Vector2(150, 0), ImGuiChildFlags.ResizeX);
if (ImGui.BeginTable("ProfilerData", 3, tableFlags, new System.Numerics.Vector2(0, 0)))
if (ImGui.BeginTable("ProfilerData", 8, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.8f, 0);
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.30f, 0);
ImGui.TableSetupColumn("category", ImGuiTableColumnFlags.WidthStretch, 0.12f, 1);
ImGui.TableSetupColumn("tags", ImGuiTableColumnFlags.WidthStretch, 0.08f, 2);
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.08f, 3);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.10f, 4);
ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.10f, 5);
ImGui.TableSetupColumn("alloc(B)", ImGuiTableColumnFlags.WidthStretch, 0.11f, 6);
ImGui.TableSetupColumn("self alloc", ImGuiTableColumnFlags.WidthStretch, 0.11f, 7);
ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible
ImGui.TableHeadersRow();
@@ -115,10 +183,29 @@ namespace Nerfed.Editor.Systems
isOpen = ImGui.TreeNodeEx(node.Label, treeNodeFlags);
}
ImGui.TableNextColumn();
ImGui.Text($"{node.Category}");
ImGui.TableNextColumn();
ImGui.Text($"0x{node.TagMask:X}");
ImGui.TableNextColumn();
ImGui.Text($"{node.ManagedThreadId}");
ImGui.TableNextColumn();
ImGui.Text($"{node.ElapsedMilliseconds():0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{node.SelfMilliseconds():0.000}");
ImGui.TableNextColumn();
if (node.ProfilerSetupBytes > 0)
{
ImGui.Text($"{node.AllocatedBytes} !");
if (ImGui.IsItemHovered())
ImGui.SetTooltip($"{node.ProfilerSetupBytes} B is profiler warmup overhead");
}
else
{
ImGui.Text($"{node.AllocatedBytes}");
}
ImGui.TableNextColumn();
ImGui.Text($"{node.SelfAllocatedBytes()}");
if (isOpen)
{
@@ -130,24 +217,29 @@ namespace Nerfed.Editor.Systems
}
}
private static void DrawCombined(in IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> orderedCombinedData)
private static void DrawCombined(in IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>> orderedCombinedData)
{
if(orderedCombinedData == null)
{
return;
}
ImGui.BeginChild("Combined", new System.Numerics.Vector2(0, 0));
ImGui.BeginChild("Combined", new Vector2(0, 0));
if (ImGui.BeginTable("ProfilerCombinedData", 3, tableFlags, new System.Numerics.Vector2(0, 0)))
if (ImGui.BeginTable("ProfilerCombinedData", 8, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.6f, 0);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("calls", ImGuiTableColumnFlags.WidthStretch, 0.2f, 2);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.32f, 0);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.11f, 1);
ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.11f, 2);
ImGui.TableSetupColumn("calls", ImGuiTableColumnFlags.WidthStretch, 0.08f, 3);
ImGui.TableSetupColumn("avg", ImGuiTableColumnFlags.WidthStretch, 0.09f, 4);
ImGui.TableSetupColumn("p95", ImGuiTableColumnFlags.WidthStretch, 0.09f, 5);
ImGui.TableSetupColumn("alloc(B)", ImGuiTableColumnFlags.WidthStretch, 0.10f, 6);
ImGui.TableSetupColumn("self alloc", ImGuiTableColumnFlags.WidthStretch, 0.10f, 7);
ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible
ImGui.TableHeadersRow();
foreach (KeyValuePair<string, (double ms, uint calls)> combinedData in orderedCombinedData)
foreach (KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)> combinedData in orderedCombinedData)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
@@ -155,7 +247,17 @@ namespace Nerfed.Editor.Systems
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.ms:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.selfMs:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.calls}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.avgMs:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.p95Ms:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.allocBytes}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.selfAllocBytes}");
}
ImGui.EndTable();
@@ -164,41 +266,78 @@ namespace Nerfed.Editor.Systems
ImGui.EndChild();
}
private static IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> CalculateCombinedData(Profiler.Frame frame)
private static IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>> CalculateCombinedData(Profiler.Frame frame)
{
Dictionary<string, (double ms, uint calls)> combinedRecordData = new Dictionary<string, (double ms, uint calls)>(128);
foreach (Profiler.ScopeNode node in frame.RootNodes)
IReadOnlyDictionary<string, Profiler.RollingLabelMetrics> rollingData = Profiler.GetRollingLabelMetricsSnapshot();
Dictionary<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)> combinedRecordData = new Dictionary<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>(128);
foreach (KeyValuePair<string, Profiler.LabelMetrics> metric in frame.LabelMetrics)
{
CalculateCombinedData(node, in combinedRecordData);
Profiler.RollingLabelMetrics rolling = default;
if (rollingData.TryGetValue(metric.Key, out Profiler.RollingLabelMetrics found))
{
rolling = found;
}
combinedRecordData[metric.Key] = (metric.Value.InclusiveMs, metric.Value.SelfMs, metric.Value.Calls, rolling.AverageMs, rolling.P95Ms, metric.Value.AllocatedBytes, metric.Value.SelfAllocatedBytes);
}
return combinedRecordData.OrderByDescending(x => x.Value.ms);
}
private static void CalculateCombinedData(Profiler.ScopeNode node, in Dictionary<string, (double ms, uint calls)> combinedRecordData)
private static IOrderedEnumerable<KeyValuePair<int, Profiler.RollingThreadMetrics>> CalculateThreadRollingData()
{
if (combinedRecordData.TryGetValue(node.Label, out (double ms, uint calls) combined))
{
combinedRecordData[node.Label] = (combined.ms + node.ElapsedMilliseconds(), combined.calls + 1);
}
else
{
combinedRecordData.Add(node.Label, (node.ElapsedMilliseconds(), 1));
IReadOnlyDictionary<int, Profiler.RollingThreadMetrics> rollingData = Profiler.GetRollingThreadMetricsSnapshot();
return rollingData.OrderByDescending(x => x.Value.P95Ms);
}
for (int i = 0; i < node.Children.Count; i++)
private static void DrawThreadRolling(in IOrderedEnumerable<KeyValuePair<int, Profiler.RollingThreadMetrics>> orderedThreadRollingData)
{
CalculateCombinedData(node.Children[i], combinedRecordData);
}
}
private static void DrawFlameGraph(Profiler.Frame frame)
{
if (frame == null)
if (orderedThreadRollingData == null)
{
return;
}
ProfilerVisualizer.RenderFlameGraph(frame);
ImGui.BeginChild("ThreadRolling", new Vector2(0, 140), ImGuiChildFlags.Border);
if (ImGui.BeginTable("ProfilerThreadRollingData", 6, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.15f, 0);
ImGui.TableSetupColumn("avg", ImGuiTableColumnFlags.WidthStretch, 0.20f, 1);
ImGui.TableSetupColumn("p95", ImGuiTableColumnFlags.WidthStretch, 0.20f, 2);
ImGui.TableSetupColumn("max", ImGuiTableColumnFlags.WidthStretch, 0.20f, 3);
ImGui.TableSetupColumn("samples", ImGuiTableColumnFlags.WidthStretch, 0.15f, 4);
ImGui.TableSetupColumn("misses", ImGuiTableColumnFlags.WidthStretch, 0.15f, 5);
ImGui.TableHeadersRow();
foreach (KeyValuePair<int, Profiler.RollingThreadMetrics> metric in orderedThreadRollingData)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
ImGui.Text($"T{metric.Key}");
ImGui.TableNextColumn();
ImGui.Text($"{metric.Value.AverageMs:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{metric.Value.P95Ms:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{metric.Value.MaxMs:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{metric.Value.Samples}");
ImGui.TableNextColumn();
ImGui.Text($"{metric.Value.BudgetMisses}");
}
ImGui.EndTable();
}
ImGui.EndChild();
}
private static ProfilerVisualizer.TimelineRenderResult DrawFlameGraph(IReadOnlyList<Profiler.Frame> frames, ProfilerVisualizer.TimelineState timelineState)
{
if (frames == null || frames.Count == 0)
{
return default;
}
return ProfilerVisualizer.RenderTimeline(frames, timelineState);
}
}
}
@@ -1,9 +1,7 @@
using System.Numerics;
using Nerfed.Runtime.Scene;
namespace Nerfed.Runtime.Components
{
[SceneComponent]
public readonly record struct LocalTransform(Vector3 position, Quaternion rotation, Vector3 scale)
{
public static readonly LocalTransform Identity = new(Vector3.Zero, Quaternion.Identity, Vector3.One);
+1 -4
View File
@@ -1,7 +1,4 @@
using Nerfed.Runtime.Scene;
namespace Nerfed.Runtime.Components
namespace Nerfed.Runtime.Components
{
[SceneComponent]
public readonly record struct Test();
}
+58 -30
View File
@@ -16,7 +16,7 @@ public static class Engine
public static bool VSync { get; set; }
public static GraphicsDevice GraphicsDevice { get; private set; }
//public static AudioDevice AudioDevice { get; private set; }
public static AudioDevice AudioDevice { get; private set; }
public static Window MainWindow { get; private set; }
public static TimeSpan Timestep { get; private set; }
@@ -44,16 +44,19 @@ public static class Engine
private const string WindowTitle = "Nerfed";
//..
public static void Run(string[] args) {
public static void Run(string[] args)
{
Timestep = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / TargetTimestep);
gameTimer = Stopwatch.StartNew();
SetFrameLimiter(new FrameLimiterSettings(FrameLimiterMode.Capped, MaxFps));
for(int i = 0; i < previousSleepTimes.Length; i += 1) {
for (int i = 0; i < previousSleepTimes.Length; i += 1)
{
previousSleepTimes[i] = TimeSpan.FromMilliseconds(1);
}
if(SDL.SDL_Init(SDL.SDL_INIT_VIDEO | SDL.SDL_INIT_TIMER | SDL.SDL_INIT_GAMECONTROLLER) < 0) {
if (SDL.SDL_Init(SDL.SDL_INIT_VIDEO | SDL.SDL_INIT_TIMER | SDL.SDL_INIT_GAMECONTROLLER) < 0)
{
throw new Exception("Failed to init SDL");
}
@@ -61,15 +64,17 @@ public static class Engine
GraphicsDevice.LoadDefaultPipelines();
MainWindow = new Window(GraphicsDevice, new WindowCreateInfo(WindowTitle, WindowWidth, WindowHeight, ScreenMode.Windowed));
if(!GraphicsDevice.ClaimWindow(MainWindow, SwapchainComposition.SDR, VSync ? PresentMode.VSync : PresentMode.Mailbox)) {
if (!GraphicsDevice.ClaimWindow(MainWindow, SwapchainComposition.SDR, VSync ? PresentMode.VSync : PresentMode.Mailbox))
{
throw new Exception("Failed to claim window");
}
//AudioDevice = new AudioDevice();
AudioDevice = new AudioDevice();
OnInitialize?.Invoke();
while(!quit) {
while (!quit)
{
Tick();
}
@@ -78,33 +83,40 @@ public static class Engine
GraphicsDevice.UnclaimWindow(MainWindow);
MainWindow.Dispose();
GraphicsDevice.Dispose();
//AudioDevice.Dispose();
AudioDevice.Dispose();
SDL.SDL_Quit();
}
/// <summary>
/// Updates the frame limiter settings.
/// </summary>
public static void SetFrameLimiter(FrameLimiterSettings settings) {
public static void SetFrameLimiter(FrameLimiterSettings settings)
{
framerateCapped = settings.Mode == FrameLimiterMode.Capped;
if(framerateCapped) {
if (framerateCapped)
{
framerateCapTimeSpan = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / settings.Cap);
} else {
}
else
{
framerateCapTimeSpan = TimeSpan.Zero;
}
}
public static void Quit() {
public static void Quit()
{
quit = true;
}
private static void Tick() {
private static void Tick()
{
Profiler.BeginFrame();
AdvanceElapsedTime();
if(framerateCapped) {
if (framerateCapped)
{
Profiler.BeginSample("framerateCapped");
/* We want to wait until the framerate cap,
@@ -112,7 +124,8 @@ public static class Engine
* seeing how long we actually slept for lets us estimate the worst case
* sleep precision so we don't oversleep the next frame.
*/
while(accumulatedDrawTime + worstCaseSleepPrecision < framerateCapTimeSpan) {
while (accumulatedDrawTime + worstCaseSleepPrecision < framerateCapTimeSpan)
{
Thread.Sleep(1);
TimeSpan timeAdvancedSinceSleeping = AdvanceElapsedTime();
UpdateEstimatedSleepPrecision(timeAdvancedSinceSleeping);
@@ -123,7 +136,8 @@ public static class Engine
* SpinWait(1) works by pausing the thread for very short intervals, so it is
* an efficient and time-accurate way to wait out the rest of the time.
*/
while(accumulatedDrawTime < framerateCapTimeSpan) {
while (accumulatedDrawTime < framerateCapTimeSpan)
{
Thread.SpinWait(1);
AdvanceElapsedTime();
}
@@ -132,12 +146,15 @@ public static class Engine
}
// Do not let any step take longer than our maximum.
if(accumulatedUpdateTime > MaxDeltaTime) {
if (accumulatedUpdateTime > MaxDeltaTime)
{
accumulatedUpdateTime = MaxDeltaTime;
}
if(!quit) {
while(accumulatedUpdateTime >= Timestep) {
if (!quit)
{
while (accumulatedUpdateTime >= Timestep)
{
Profiler.BeginSample("Update");
Keyboard.Update();
Mouse.Update();
@@ -150,7 +167,7 @@ public static class Engine
OnUpdate?.Invoke();
Profiler.EndSample();
//AudioDevice.WakeThread();
AudioDevice.WakeThread();
accumulatedUpdateTime -= Timestep;
Profiler.EndSample();
}
@@ -168,7 +185,8 @@ public static class Engine
Profiler.EndFrame();
}
private static TimeSpan AdvanceElapsedTime() {
private static TimeSpan AdvanceElapsedTime()
{
long currentTicks = gameTimer.Elapsed.Ticks;
TimeSpan timeAdvanced = TimeSpan.FromTicks(currentTicks - previousTicks);
accumulatedUpdateTime += timeAdvanced;
@@ -177,9 +195,12 @@ public static class Engine
return timeAdvanced;
}
private static void ProcessSDLEvents() {
while(SDL.SDL_PollEvent(out SDL.SDL_Event ev) == 1) {
switch(ev.type) {
private static void ProcessSDLEvents()
{
while (SDL.SDL_PollEvent(out SDL.SDL_Event ev) == 1)
{
switch (ev.type)
{
case SDL.SDL_EventType.SDL_QUIT:
Quit();
break;
@@ -215,14 +236,16 @@ public static class Engine
/* To calculate the sleep precision of the OS, we take the worst case
* time spent sleeping over the results of previous requests to sleep 1ms.
*/
private static void UpdateEstimatedSleepPrecision(TimeSpan timeSpentSleeping) {
private static void UpdateEstimatedSleepPrecision(TimeSpan timeSpentSleeping)
{
/* It is unlikely that the scheduler will actually be more imprecise than
* 4ms and we don't want to get wrecked by a single long sleep so we cap this
* value at 4ms for sanity.
*/
TimeSpan upperTimeBound = TimeSpan.FromMilliseconds(4);
if(timeSpentSleeping > upperTimeBound) {
if (timeSpentSleeping > upperTimeBound)
{
timeSpentSleeping = upperTimeBound;
}
@@ -231,12 +254,17 @@ public static class Engine
* is if we either 1) just got a new worst case, or 2) the worst case was
* the oldest entry on the list.
*/
if(timeSpentSleeping >= worstCaseSleepPrecision) {
if (timeSpentSleeping >= worstCaseSleepPrecision)
{
worstCaseSleepPrecision = timeSpentSleeping;
} else if(previousSleepTimes[sleepTimeIndex] == worstCaseSleepPrecision) {
}
else if (previousSleepTimes[sleepTimeIndex] == worstCaseSleepPrecision)
{
TimeSpan maxSleepTime = TimeSpan.MinValue;
for(int i = 0; i < previousSleepTimes.Length; i++) {
if(previousSleepTimes[i] > maxSleepTime) {
for (int i = 0; i < previousSleepTimes.Length; i++)
{
if (previousSleepTimes[i] > maxSleepTime)
{
maxSleepTime = previousSleepTimes[i];
}
}
+8 -8
View File
@@ -68,10 +68,10 @@ public class GraphicsDevice : IDisposable
internal void LoadDefaultPipelines()
{
FullscreenVertexShader = ResourceManager.Retain<Shader>("Shaders/Fullscreen.vert");
VideoFragmentShader = ResourceManager.Retain<Shader>("Shaders/Video.frag");
TextVertexShader = ResourceManager.Retain<Shader>("Shaders/Text.vert");
TextFragmentShader = ResourceManager.Retain<Shader>("Shaders/Text.frag");
FullscreenVertexShader = ResourceManager.Load<Shader>("Shaders/Fullscreen.vert");
VideoFragmentShader = ResourceManager.Load<Shader>("Shaders/Video.frag");
TextVertexShader = ResourceManager.Load<Shader>("Shaders/Text.vert");
TextFragmentShader = ResourceManager.Load<Shader>("Shaders/Text.frag");
VideoPipeline = new GraphicsPipeline(
this,
@@ -373,10 +373,10 @@ public class GraphicsDevice : IDisposable
resources.Clear();
}
ResourceManager.Release(FullscreenVertexShader);
ResourceManager.Release(TextFragmentShader);
ResourceManager.Release(TextVertexShader);
ResourceManager.Release(VideoFragmentShader);
ResourceManager.Unload(FullscreenVertexShader);
ResourceManager.Unload(TextFragmentShader);
ResourceManager.Unload(TextVertexShader);
ResourceManager.Unload(VideoFragmentShader);
}
Refresh.Refresh_DestroyDevice(Handle);
+4 -4
View File
@@ -60,8 +60,8 @@ public class GuiController : IDisposable
io.DisplaySize = new Vector2(mainWindow.Width, mainWindow.Height);
io.DisplayFramebufferScale = Vector2.One;
imGuiVertexShader = ResourceManager.Retain<Shader>("Shaders/ImGui.vert");
imGuiFragmentShader = ResourceManager.Retain<Shader>("Shaders/ImGui.frag");
imGuiVertexShader = ResourceManager.Load<Shader>("Shaders/ImGui.vert");
imGuiFragmentShader = ResourceManager.Load<Shader>("Shaders/ImGui.frag");
imGuiSampler = new Sampler(graphicsDevice, SamplerCreateInfo.LinearClamp);
@@ -630,8 +630,8 @@ public class GuiController : IDisposable
fontTexture?.Dispose();
imGuiVertexBuffer?.Dispose();
imGuiIndexBuffer?.Dispose();
ResourceManager.Release(imGuiVertexShader);
ResourceManager.Release(imGuiFragmentShader);
ResourceManager.Unload(imGuiVertexShader);
ResourceManager.Unload(imGuiFragmentShader);
imGuiPipeline?.Dispose();
imGuiSampler?.Dispose();
resourceUploader?.Dispose();
+695 -49
View File
@@ -10,6 +10,11 @@ public struct ProfilerScope : IDisposable
Profiler.BeginSample(label);
}
public ProfilerScope(string label, string category, ulong tagMask = 0)
{
Profiler.BeginSample(label, category, tagMask);
}
public void Dispose()
{
Profiler.EndSample();
@@ -18,18 +23,289 @@ public struct ProfilerScope : IDisposable
public static class Profiler
{
public class Frame(uint frameCount)
public enum CaptureMode
{
public uint FrameCount { get; } = frameCount;
public long StartTime { get; } = Stopwatch.GetTimestamp();
Instrumented = 0,
SampledInstrumentation = 1,
}
private sealed class ThreadProfilerState
{
public readonly Stack<ScopeNode> Scopes = new Stack<ScopeNode>();
public readonly Stack<bool> CaptureDecisions = new Stack<bool>();
public int SampleCursor;
public int ThreadId;
}
public readonly struct LabelMetrics
{
public LabelMetrics(double inclusiveMs, double selfMs, uint calls, double minInclusiveMs, double maxInclusiveMs, long allocatedBytes, long selfAllocatedBytes)
{
InclusiveMs = inclusiveMs;
SelfMs = selfMs;
Calls = calls;
MinInclusiveMs = minInclusiveMs;
MaxInclusiveMs = maxInclusiveMs;
AllocatedBytes = allocatedBytes;
SelfAllocatedBytes = selfAllocatedBytes;
}
public double InclusiveMs { get; }
public double SelfMs { get; }
public uint Calls { get; }
public double MinInclusiveMs { get; }
public double MaxInclusiveMs { get; }
public long AllocatedBytes { get; }
public long SelfAllocatedBytes { get; }
}
public readonly struct RollingLabelMetrics
{
public RollingLabelMetrics(double averageMs, double minMs, double maxMs, double p95Ms, int samples)
{
AverageMs = averageMs;
MinMs = minMs;
MaxMs = maxMs;
P95Ms = p95Ms;
Samples = samples;
}
public double AverageMs { get; }
public double MinMs { get; }
public double MaxMs { get; }
public double P95Ms { get; }
public int Samples { get; }
}
public readonly struct ThreadMetrics
{
public ThreadMetrics(double inclusiveMs, double selfMs, uint calls, bool overBudget)
{
InclusiveMs = inclusiveMs;
SelfMs = selfMs;
Calls = calls;
OverBudget = overBudget;
}
public double InclusiveMs { get; }
public double SelfMs { get; }
public uint Calls { get; }
public bool OverBudget { get; }
}
public readonly struct RollingThreadMetrics
{
public RollingThreadMetrics(double averageMs, double p95Ms, double maxMs, int samples, int budgetMisses)
{
AverageMs = averageMs;
P95Ms = p95Ms;
MaxMs = maxMs;
Samples = samples;
BudgetMisses = budgetMisses;
}
public double AverageMs { get; }
public double P95Ms { get; }
public double MaxMs { get; }
public int Samples { get; }
public int BudgetMisses { get; }
}
private sealed class RollingWindow
{
private readonly double[] values;
private readonly double[] sortBuffer; // pre-allocated; avoids per-snapshot heap allocation
private int index;
private int count;
public RollingWindow(int capacity)
{
int size = Math.Max(8, capacity);
values = new double[size];
sortBuffer = new double[size];
}
public void Add(double value)
{
values[index] = value;
index = (index + 1) % values.Length;
if (count < values.Length)
{
count++;
}
}
public RollingLabelMetrics Snapshot()
{
if (count == 0)
{
return default;
}
double sum = 0;
double min = double.MaxValue;
double max = double.MinValue;
int start = (index - count + values.Length) % values.Length;
for (int i = 0; i < count; i++)
{
double value = values[(start + i) % values.Length];
sortBuffer[i] = value;
sum += value;
min = Math.Min(min, value);
max = Math.Max(max, value);
}
Array.Sort(sortBuffer, 0, count);
int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d);
double p95 = sortBuffer[Math.Clamp(percentileIndex, 0, count - 1)];
return new RollingLabelMetrics(sum / count, min, max, p95, count);
}
}
private sealed class RollingThreadWindow
{
private readonly double[] durations;
private readonly double[] sortBuffer; // pre-allocated; avoids per-snapshot heap allocation
private readonly byte[] misses;
private int index;
private int count;
public RollingThreadWindow(int capacity)
{
int size = Math.Max(8, capacity);
durations = new double[size];
sortBuffer = new double[size];
misses = new byte[size];
}
public void Add(double durationMs, bool budgetMiss)
{
durations[index] = durationMs;
misses[index] = budgetMiss ? (byte)1 : (byte)0;
index = (index + 1) % durations.Length;
if (count < durations.Length)
{
count++;
}
}
public RollingThreadMetrics Snapshot()
{
if (count == 0)
{
return default;
}
double sum = 0;
double max = double.MinValue;
int budgetMisses = 0;
int start = (index - count + durations.Length) % durations.Length;
for (int i = 0; i < count; i++)
{
int at = (start + i) % durations.Length;
double value = durations[at];
sum += value;
max = Math.Max(max, value);
budgetMisses += misses[at];
sortBuffer[i] = value;
}
Array.Sort(sortBuffer, 0, count);
int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d);
double p95 = sortBuffer[Math.Clamp(percentileIndex, 0, count - 1)];
return new RollingThreadMetrics(sum / count, p95, max, count, budgetMisses);
}
}
public class Frame
{
private readonly List<ScopeNode> rootNodes = new List<ScopeNode>(8);
private readonly object rootNodesLock = new object();
private readonly Dictionary<string, LabelMetrics> labelMetrics = new Dictionary<string, LabelMetrics>(128, StringComparer.Ordinal);
private readonly Dictionary<string, LabelMetrics> categoryMetrics = new Dictionary<string, LabelMetrics>(32, StringComparer.Ordinal);
private readonly Dictionary<int, ThreadMetrics> threadMetrics = new Dictionary<int, ThreadMetrics>(16);
private readonly List<int> knownThreadIds = new List<int>(16); // avoids Keys.ToArray() in ApplyThreadBudgetFlags
public uint FrameCount { get; private set; }
public long StartTime { get; private set; }
public long EndTime { get; private set; }
// Use a concurrent list to collect all thread root nodes per frame.
public ConcurrentBag<ScopeNode> RootNodes = new ConcurrentBag<ScopeNode>();
public IReadOnlyList<ScopeNode> RootNodes => rootNodes;
public IReadOnlyDictionary<string, LabelMetrics> LabelMetrics => labelMetrics;
public IReadOnlyDictionary<string, LabelMetrics> CategoryMetrics => categoryMetrics;
public IReadOnlyDictionary<int, ThreadMetrics> ThreadMetrics => threadMetrics;
internal void End()
// Return concrete types so callers can use the struct enumerator and avoid boxing.
internal Dictionary<string, LabelMetrics> LabelMetricsRaw => labelMetrics;
internal Dictionary<int, ThreadMetrics> ThreadMetricsRaw => threadMetrics;
public long AllocatedBytesStart { get; private set; }
public long AllocatedBytesEnd { get; private set; }
public long AllocatedBytesDelta { get; private set; }
public int Gen0CollectionsStart { get; private set; }
public int Gen1CollectionsStart { get; private set; }
public int Gen2CollectionsStart { get; private set; }
public int Gen0CollectionsEnd { get; private set; }
public int Gen1CollectionsEnd { get; private set; }
public int Gen2CollectionsEnd { get; private set; }
public int Gen0CollectionsDelta { get; private set; }
public int Gen1CollectionsDelta { get; private set; }
public int Gen2CollectionsDelta { get; private set; }
public bool OverBudget { get; private set; }
public double BudgetMilliseconds { get; private set; }
internal void Reset(uint frameCount)
{
FrameCount = frameCount;
StartTime = Stopwatch.GetTimestamp();
EndTime = 0;
OverBudget = false;
BudgetMilliseconds = 0;
AllocatedBytesStart = GC.GetTotalAllocatedBytes(false);
AllocatedBytesEnd = 0;
AllocatedBytesDelta = 0;
Gen0CollectionsStart = GC.CollectionCount(0);
Gen1CollectionsStart = GC.CollectionCount(1);
Gen2CollectionsStart = GC.CollectionCount(2);
Gen0CollectionsEnd = 0;
Gen1CollectionsEnd = 0;
Gen2CollectionsEnd = 0;
Gen0CollectionsDelta = 0;
Gen1CollectionsDelta = 0;
Gen2CollectionsDelta = 0;
lock (rootNodesLock)
{
rootNodes.Clear();
}
}
internal void AddRootNode(ScopeNode rootNode)
{
lock (rootNodesLock)
{
rootNodes.Add(rootNode);
}
}
internal void End(double budgetMilliseconds)
{
EndTime = Stopwatch.GetTimestamp();
BudgetMilliseconds = budgetMilliseconds;
OverBudget = budgetMilliseconds > 0 && ElapsedMilliseconds() > budgetMilliseconds;
AllocatedBytesEnd = GC.GetTotalAllocatedBytes(false);
AllocatedBytesDelta = AllocatedBytesEnd - AllocatedBytesStart;
Gen0CollectionsEnd = GC.CollectionCount(0);
Gen1CollectionsEnd = GC.CollectionCount(1);
Gen2CollectionsEnd = GC.CollectionCount(2);
Gen0CollectionsDelta = Gen0CollectionsEnd - Gen0CollectionsStart;
Gen1CollectionsDelta = Gen1CollectionsEnd - Gen1CollectionsStart;
Gen2CollectionsDelta = Gen2CollectionsEnd - Gen2CollectionsStart;
BuildLabelMetrics();
}
public double ElapsedMilliseconds()
@@ -37,53 +313,280 @@ public static class Profiler
long elapsedTicks = EndTime - StartTime;
return ((double)(elapsedTicks * 1000)) / Stopwatch.Frequency;
}
private void BuildLabelMetrics()
{
labelMetrics.Clear();
categoryMetrics.Clear();
threadMetrics.Clear();
knownThreadIds.Clear();
lock (rootNodesLock)
{
for (int i = 0; i < rootNodes.Count; i++)
{
AccumulateLabelMetrics(rootNodes[i]);
}
public class ScopeNode(string label)
for (int i = 0; i < rootNodes.Count; i++)
{
public string Label { get; } = label;
public long StartTime { get; private set; } = Stopwatch.GetTimestamp(); // Start time in ticks
ScopeNode rootNode = rootNodes[i];
for (int j = 0; j < rootNode.Children.Count; j++)
{
AccumulateThreadMetrics(rootNode.ManagedThreadId, rootNode.Children[j]);
}
}
}
ApplyThreadBudgetFlags();
}
private void AccumulateLabelMetrics(ScopeNode node)
{
double inclusiveMs = node.ElapsedMilliseconds();
double selfMs = node.SelfMilliseconds();
long allocBytes = node.AllocatedBytes;
long selfAllocBytes = node.SelfAllocatedBytes();
if (labelMetrics.TryGetValue(node.Label, out LabelMetrics current))
{
labelMetrics[node.Label] = new LabelMetrics(
current.InclusiveMs + inclusiveMs,
current.SelfMs + selfMs,
current.Calls + 1,
Math.Min(current.MinInclusiveMs, inclusiveMs),
Math.Max(current.MaxInclusiveMs, inclusiveMs),
current.AllocatedBytes + allocBytes,
current.SelfAllocatedBytes + selfAllocBytes);
}
else
{
labelMetrics[node.Label] = new LabelMetrics(inclusiveMs, selfMs, 1, inclusiveMs, inclusiveMs, allocBytes, selfAllocBytes);
}
if (categoryMetrics.TryGetValue(node.Category, out LabelMetrics categoryCurrent))
{
categoryMetrics[node.Category] = new LabelMetrics(
categoryCurrent.InclusiveMs + inclusiveMs,
categoryCurrent.SelfMs + selfMs,
categoryCurrent.Calls + 1,
Math.Min(categoryCurrent.MinInclusiveMs, inclusiveMs),
Math.Max(categoryCurrent.MaxInclusiveMs, inclusiveMs),
categoryCurrent.AllocatedBytes + allocBytes,
categoryCurrent.SelfAllocatedBytes + selfAllocBytes);
}
else
{
categoryMetrics[node.Category] = new LabelMetrics(inclusiveMs, selfMs, 1, inclusiveMs, inclusiveMs, allocBytes, selfAllocBytes);
}
for (int i = 0; i < node.Children.Count; i++)
{
AccumulateLabelMetrics(node.Children[i]);
}
}
private void AccumulateThreadMetrics(int threadId, ScopeNode node)
{
double inclusiveMs = node.ElapsedMilliseconds();
double selfMs = node.SelfMilliseconds();
if (threadMetrics.TryGetValue(threadId, out ThreadMetrics current))
{
threadMetrics[threadId] = new ThreadMetrics(current.InclusiveMs + inclusiveMs, current.SelfMs + selfMs, current.Calls + 1, false);
}
else
{
threadMetrics[threadId] = new ThreadMetrics(inclusiveMs, selfMs, 1, false);
knownThreadIds.Add(threadId);
}
for (int i = 0; i < node.Children.Count; i++)
{
AccumulateThreadMetrics(threadId, node.Children[i]);
}
}
private void ApplyThreadBudgetFlags()
{
double perThreadBudget = Math.Max(0d, ThreadBudgetMilliseconds);
if (perThreadBudget <= 0d)
{
return;
}
// knownThreadIds avoids Keys.ToArray() allocation
for (int i = 0; i < knownThreadIds.Count; i++)
{
int key = knownThreadIds[i];
ThreadMetrics metric = threadMetrics[key];
threadMetrics[key] = new ThreadMetrics(metric.InclusiveMs, metric.SelfMs, metric.Calls, metric.InclusiveMs > perThreadBudget);
}
}
}
public class ScopeNode
{
public string Label { get; private set; } = string.Empty;
public string Category { get; private set; } = DefaultCategory;
public ulong TagMask { get; private set; }
public long StartTime { get; private set; }
public long EndTime { get; private set; }
public int ManagedThreadId { get; } = Environment.CurrentManagedThreadId;
public int ManagedThreadId { get; private set; }
public List<ScopeNode> Children { get; } = new List<ScopeNode>();
public long AllocatedBytes { get; private set; }
public long ProfilerSetupBytes { get; private set; }
internal ScopeNode Parent { get; private set; }
internal long ChildrenDurationTicks { get; private set; }
internal long ChildrenAllocatedBytes { get; private set; }
private long allocatedBytesAtStart;
internal void Reset(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent)
{
Label = label;
Category = string.IsNullOrEmpty(category) ? DefaultCategory : category;
TagMask = tagMask;
ManagedThreadId = managedThreadId;
Parent = parent;
StartTime = Stopwatch.GetTimestamp();
EndTime = 0;
ChildrenDurationTicks = 0;
ChildrenAllocatedBytes = 0;
AllocatedBytes = 0;
ProfilerSetupBytes = 0;
Children.Clear();
allocatedBytesAtStart = GC.GetAllocatedBytesForCurrentThread();
}
internal void End()
{
EndTime = Stopwatch.GetTimestamp(); // End time in ticks
if (EndTime != 0)
{
return;
}
EndTime = Stopwatch.GetTimestamp();
if (Parent != null)
{
// Root nodes are ended from FinalizeCurrentFrame on the main thread, so their
// GC counter would be from the wrong thread. Only track alloc on non-root nodes.
AllocatedBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - allocatedBytesAtStart);
Parent.ChildrenDurationTicks += Math.Max(0, EndTime - StartTime);
Parent.ChildrenAllocatedBytes += AllocatedBytes;
}
}
public double ElapsedMilliseconds()
{
return ((double)(EndTime - StartTime)) * 1000 / Stopwatch.Frequency; // Convert ticks to ms
return ((double)(Math.Max(0, EndTime - StartTime))) * 1000 / Stopwatch.Frequency;
}
// Add a child node (used for nested scopes)
internal ScopeNode AddChild(string label)
public double SelfMilliseconds()
{
ScopeNode child = new ScopeNode(label);
long elapsedTicks = Math.Max(0, EndTime - StartTime);
long selfTicks = Math.Max(0, elapsedTicks - ChildrenDurationTicks);
return ((double)selfTicks) * 1000 / Stopwatch.Frequency;
}
public long SelfAllocatedBytes()
{
return Math.Max(0, AllocatedBytes - ChildrenAllocatedBytes);
}
// Called after all profiler setup (Children.Add + scopes.Push) to measure overhead within this scope's window.
internal void NoteSetupOverhead()
{
ProfilerSetupBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - allocatedBytesAtStart);
}
internal ScopeNode AddChild(string label, string category, ulong tagMask)
{
ScopeNode child = RentNode(label, category, tagMask, ManagedThreadId, this);
Children.Add(child);
return child;
}
}
private const int maxFrames = 128;
private const int rollingWindowSize = 240;
private const string DefaultCategory = "General";
public static bool IsRecording { get; private set; } = true;
public static double FrameBudgetMilliseconds { get; set; } = 16.667;
public static double ThreadBudgetMilliseconds { get; set; } = 8.333;
public static CaptureMode Mode { get; set; } = CaptureMode.Instrumented;
public static int SamplingStride { get; set; } = 8;
// Store only the last x amount of frames in memory.
public static readonly BoundedQueue<Frame> Frames = new(maxFrames);
// Use ThreadLocal to store a stack of ScopeNodes per thread and enable tracking of thread-local values.
private static readonly ThreadLocal<Stack<ScopeNode>> threadLocalScopes = new ThreadLocal<Stack<ScopeNode>>(() => new Stack<ScopeNode>(), true);
// trackAllValues=false; registeredThreadStates avoids ThreadLocal.Values allocating a ReadOnlyCollection each call
private static readonly ThreadLocal<ThreadProfilerState> threadStates =
new ThreadLocal<ThreadProfilerState>(() =>
{
ThreadProfilerState state = new ThreadProfilerState();
lock (registeredThreadStatesLock)
{
registeredThreadStates.Add(state);
}
return state;
});
private static readonly List<ThreadProfilerState> registeredThreadStates = new List<ThreadProfilerState>(8);
private static readonly object registeredThreadStatesLock = new object();
private static readonly ConcurrentDictionary<int, string> threadRootLabelCache = new ConcurrentDictionary<int, string>();
private static readonly ConcurrentBag<ScopeNode> nodePool = new ConcurrentBag<ScopeNode>();
private static readonly ConcurrentBag<Frame> framePool = new ConcurrentBag<Frame>(); // pooled; avoids per-frame Frame allocation
private static readonly Dictionary<string, RollingWindow> rollingWindows = new Dictionary<string, RollingWindow>(256, StringComparer.Ordinal);
private static readonly Dictionary<int, RollingThreadWindow> rollingThreadWindows = new Dictionary<int, RollingThreadWindow>(16);
private static readonly object rollingWindowsLock = new object();
private static Frame currentFrame = null;
private static uint frameCount = 0;
public static void SetActive(bool isRecording)
{
if (IsRecording && !isRecording)
{
FinalizeCurrentFrame();
}
IsRecording = isRecording;
}
public static int CopyFramesTo(List<Frame> destination)
{
return Frames.CopyTo(destination);
}
public static IReadOnlyDictionary<string, RollingLabelMetrics> GetRollingLabelMetricsSnapshot()
{
lock (rollingWindowsLock)
{
Dictionary<string, RollingLabelMetrics> snapshot = new Dictionary<string, RollingLabelMetrics>(rollingWindows.Count, StringComparer.Ordinal);
foreach (KeyValuePair<string, RollingWindow> pair in rollingWindows)
{
snapshot[pair.Key] = pair.Value.Snapshot();
}
return snapshot;
}
}
public static IReadOnlyDictionary<int, RollingThreadMetrics> GetRollingThreadMetricsSnapshot()
{
lock (rollingWindowsLock)
{
Dictionary<int, RollingThreadMetrics> snapshot = new Dictionary<int, RollingThreadMetrics>(rollingThreadWindows.Count);
foreach (KeyValuePair<int, RollingThreadWindow> pair in rollingThreadWindows)
{
snapshot[pair.Key] = pair.Value.Snapshot();
}
return snapshot;
}
}
[Conditional("PROFILING")]
public static void BeginFrame()
{
@@ -92,7 +595,12 @@ public static class Profiler
return;
}
currentFrame = new Frame(frameCount);
if (currentFrame != null)
{
FinalizeCurrentFrame();
}
currentFrame = RentFrame(frameCount);
}
[Conditional("PROFILING")]
@@ -103,64 +611,202 @@ public static class Profiler
return;
}
foreach (Stack<ScopeNode> scopes in threadLocalScopes.Values)
{
if (scopes.Count > 0)
{
// Pop the left over root nodes.
ScopeNode currentScope = scopes.Pop();
currentScope.End();
}
// Clean up the thread-local stack to ensure it's empty for the next frame.
scopes.Clear();
}
currentFrame.End();
Frames.Enqueue(currentFrame);
frameCount++;
FinalizeCurrentFrame();
}
[Conditional("PROFILING")]
public static void BeginSample(string label)
{
if (!IsRecording)
BeginSample(label, DefaultCategory, 0);
}
[Conditional("PROFILING")]
public static void BeginSample(string label, string category, ulong tagMask = 0)
{
if (!IsRecording || currentFrame == null)
{
return;
}
Stack<ScopeNode> scopes = threadLocalScopes.Value; // Get the stack for the current thread
ThreadProfilerState state = threadStates.Value;
state.ThreadId = Environment.CurrentManagedThreadId;
bool parentCaptured = state.CaptureDecisions.Count > 0 && state.CaptureDecisions.Peek();
bool capture = parentCaptured || Mode == CaptureMode.Instrumented || ShouldSample(state);
state.CaptureDecisions.Push(capture);
if (!capture)
{
return;
}
Stack<ScopeNode> scopes = state.Scopes;
Frame frame = currentFrame;
if (frame == null)
{
state.CaptureDecisions.Pop();
return;
}
if (scopes.Count == 0)
{
// First scope for this thread (new root for this thread)
ScopeNode rootScopeNode = new ScopeNode($"Thread-{Environment.CurrentManagedThreadId}");
int threadId = state.ThreadId;
ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), DefaultCategory, 0, threadId, null);
scopes.Push(rootScopeNode);
currentFrame.RootNodes.Add(rootScopeNode); // Add root node to the frame list
frame.AddRootNode(rootScopeNode);
}
// Create a new child under the current top of the stack
ScopeNode newScope = scopes.Peek().AddChild(label);
scopes.Push(newScope); // Push new scope to the thread's stack
ScopeNode newScope = scopes.Peek().AddChild(label, category, tagMask);
scopes.Push(newScope);
newScope.NoteSetupOverhead();
}
[Conditional("PROFILING")]
public static void EndSample()
{
if (!IsRecording)
if (!IsRecording || currentFrame == null)
{
return;
}
Stack<ScopeNode> scopes = threadLocalScopes.Value;
ThreadProfilerState state = threadStates.Value;
if (state.CaptureDecisions.Count == 0)
{
return;
}
if (scopes.Count > 0)
bool captured = state.CaptureDecisions.Pop();
if (!captured)
{
return;
}
Stack<ScopeNode> scopes = state.Scopes;
if (scopes.Count > 1)
{
// Only pop if this is not the root node.
//ScopeNode currentScope = scopes.Count > 1 ? scopes.Pop() : scopes.Peek();
ScopeNode currentScope = scopes.Pop();
currentScope.End();
}
}
private static bool ShouldSample(ThreadProfilerState state)
{
int stride = Math.Max(1, SamplingStride);
state.SampleCursor++;
return state.SampleCursor % stride == 0;
}
private static string GetThreadRootLabel(int threadId)
{
return threadRootLabelCache.GetOrAdd(threadId, static id => $"Thread-{id}");
}
private static Frame RentFrame(uint count)
{
if (!framePool.TryTake(out Frame frame))
{
frame = new Frame();
}
frame.Reset(count);
return frame;
}
private static ScopeNode RentNode(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent)
{
if (!nodePool.TryTake(out ScopeNode node))
{
node = new ScopeNode();
}
node.Reset(label, category, tagMask, managedThreadId, parent);
return node;
}
private static void ReturnNodeTree(ScopeNode node)
{
for (int i = 0; i < node.Children.Count; i++)
{
ReturnNodeTree(node.Children[i]);
}
node.Reset(string.Empty, DefaultCategory, 0, 0, null);
nodePool.Add(node);
}
private static void FinalizeCurrentFrame()
{
Frame frame = currentFrame;
if (frame == null)
{
return;
}
lock (registeredThreadStatesLock)
{
for (int i = 0; i < registeredThreadStates.Count; i++)
{
ThreadProfilerState state = registeredThreadStates[i];
Stack<ScopeNode> scopes = state.Scopes;
while (scopes.Count > 0)
{
scopes.Pop().End();
}
state.CaptureDecisions.Clear();
}
}
frame.End(FrameBudgetMilliseconds);
if (Frames.Enqueue(frame, out Frame evictedFrame))
{
for (int i = 0; i < evictedFrame.RootNodes.Count; i++)
{
ReturnNodeTree(evictedFrame.RootNodes[i]);
}
framePool.Add(evictedFrame);
}
UpdateRollingWindows(frame);
UpdateRollingThreadWindows(frame);
frameCount++;
currentFrame = null;
}
private static void UpdateRollingWindows(Frame frame)
{
lock (rollingWindowsLock)
{
foreach (KeyValuePair<string, LabelMetrics> pair in frame.LabelMetricsRaw)
{
if (!rollingWindows.TryGetValue(pair.Key, out RollingWindow window))
{
window = new RollingWindow(rollingWindowSize);
rollingWindows.Add(pair.Key, window);
}
window.Add(pair.Value.InclusiveMs);
}
}
}
private static void UpdateRollingThreadWindows(Frame frame)
{
lock (rollingWindowsLock)
{
foreach (KeyValuePair<int, ThreadMetrics> pair in frame.ThreadMetricsRaw)
{
if (!rollingThreadWindows.TryGetValue(pair.Key, out RollingThreadWindow window))
{
window = new RollingThreadWindow(rollingWindowSize);
rollingThreadWindows.Add(pair.Key, window);
}
window.Add(pair.Value.InclusiveMs, pair.Value.OverBudget);
}
}
}
}
+506 -106
View File
@@ -5,152 +5,552 @@ namespace Nerfed.Runtime;
public static class ProfilerVisualizer
{
private const float barHeight = 20f;
private const float barPadding = 2f;
public sealed class TimelineState
{
public int SelectedFrameIndex = -1;
public int WindowStartIndex = 0;
public int VisibleFrameCount = 64;
public bool FollowLatest = true;
public float Zoom = 1f;
public double PanTicks = 0;
}
// Render the flame graph across multiple threads
public readonly struct TimelineRenderResult
{
public TimelineRenderResult(int selectedFrameIndex, bool selectionChanged, bool userNavigated)
{
SelectedFrameIndex = selectedFrameIndex;
SelectionChanged = selectionChanged;
UserNavigated = userNavigated;
}
public int SelectedFrameIndex { get; }
public bool SelectionChanged { get; }
public bool UserNavigated { get; }
}
private readonly struct HoverEntry
{
public HoverEntry(Profiler.ScopeNode node, Profiler.Frame frame, int frameIndex, int depth, double timelineStartTicks)
{
Node = node;
Frame = frame;
FrameIndex = frameIndex;
Depth = depth;
DurationMs = TicksToMilliseconds(node.EndTime - node.StartTime);
SelfMs = node.SelfMilliseconds();
StartInFrameMs = TicksToMilliseconds(node.StartTime - frame.StartTime);
EndInFrameMs = TicksToMilliseconds(node.EndTime - frame.StartTime);
StartInTimelineMs = TicksToMilliseconds(node.StartTime - timelineStartTicks);
EndInTimelineMs = TicksToMilliseconds(node.EndTime - timelineStartTicks);
}
public Profiler.ScopeNode Node { get; }
public Profiler.Frame Frame { get; }
public int FrameIndex { get; }
public int Depth { get; }
public double DurationMs { get; }
public double SelfMs { get; }
public double StartInFrameMs { get; }
public double EndInFrameMs { get; }
public double StartInTimelineMs { get; }
public double EndInTimelineMs { get; }
}
private const float BarHeight = 18f;
private const float BarPadding = 2f;
private const float ThreadGap = 8f;
private const float HeaderHeight = 28f;
private const float TimelineHeight = 220f;
private const float MinTextWidth = 36f;
private static readonly double TickToMs = 1000d / System.Diagnostics.Stopwatch.Frequency;
// Backwards-compatible entry point used by existing call sites.
public static void RenderFlameGraph(Profiler.Frame frame)
{
if (frame == null) return;
if (frame.RootNodes == null) return;
// Calculate the total timeline duration (max end time across all nodes)
double totalDuration = frame.EndTime - frame.StartTime;
double startTime = frame.StartTime;
// Precompute the maximum depth for each thread's call stack
Dictionary<int, int> threadMaxDepths = new Dictionary<int, int>();
foreach (IGrouping<int, Profiler.ScopeNode> threadGroup in frame.RootNodes.GroupBy(node => node.ManagedThreadId))
if (frame == null)
{
int maxDepth = 0;
foreach (Profiler.ScopeNode rootNode in threadGroup)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(rootNode, 0));
}
threadMaxDepths[threadGroup.Key] = maxDepth;
return;
}
// Start a child window to support scrolling
ImGui.BeginChild("FlameGraph", new Vector2(0, 64), ImGuiChildFlags.Border | ImGuiChildFlags.ResizeY, ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar);
List<Profiler.Frame> frames = new List<Profiler.Frame>(1)
{
frame
};
TimelineState state = new TimelineState
{
VisibleFrameCount = 1,
SelectedFrameIndex = 0,
FollowLatest = true
};
RenderTimeline(frames, state);
}
public static TimelineRenderResult RenderTimeline(IReadOnlyList<Profiler.Frame> frames, TimelineState state)
{
if (frames == null || frames.Count == 0 || state == null)
{
return default;
}
bool selectionChanged = false;
bool userNavigated = false;
int frameCount = frames.Count;
state.VisibleFrameCount = Math.Clamp(state.VisibleFrameCount, 1, frameCount);
state.Zoom = Math.Clamp(state.Zoom, 1f, 128f);
int maxStartIndex = Math.Max(0, frameCount - state.VisibleFrameCount);
if (state.FollowLatest)
{
state.WindowStartIndex = maxStartIndex;
}
else
{
state.WindowStartIndex = Math.Clamp(state.WindowStartIndex, 0, maxStartIndex);
}
int visibleStartIndex = state.WindowStartIndex;
int visibleEndIndex = visibleStartIndex + state.VisibleFrameCount - 1;
if (state.SelectedFrameIndex < 0)
{
state.SelectedFrameIndex = visibleEndIndex;
selectionChanged = true;
}
state.SelectedFrameIndex = Math.Clamp(state.SelectedFrameIndex, visibleStartIndex, visibleEndIndex);
Profiler.Frame firstFrame = frames[visibleStartIndex];
Profiler.Frame lastFrame = frames[visibleEndIndex];
double timelineStartTicks = firstFrame.StartTime;
double timelineEndTicks = Math.Max(lastFrame.EndTime, firstFrame.StartTime + 1);
double timelineDurationTicks = Math.Max(1d, timelineEndTicks - timelineStartTicks);
double visibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom);
double maxPanTicks = Math.Max(0d, timelineDurationTicks - visibleDurationTicks);
if (state.FollowLatest)
{
state.PanTicks = maxPanTicks;
}
else
{
state.PanTicks = Math.Clamp(state.PanTicks, 0d, maxPanTicks);
}
double visibleStartTicks = timelineStartTicks + state.PanTicks;
double visibleEndTicks = visibleStartTicks + visibleDurationTicks;
Dictionary<int, int> threadDepths = BuildThreadDepths(frames, visibleStartIndex, visibleEndIndex);
List<int> threadOrder = threadDepths.Keys.OrderBy(x => x).ToList();
Dictionary<int, float> threadBaseY = new Dictionary<int, float>(threadOrder.Count);
float yCursor = HeaderHeight;
for (int i = 0; i < threadOrder.Count; i++)
{
int threadId = threadOrder[i];
threadBaseY[threadId] = yCursor;
yCursor += ((threadDepths[threadId] + 1) * (BarHeight + BarPadding)) + ThreadGap;
}
float contentHeight = Math.Max(TimelineHeight, yCursor + 6f);
ImGui.BeginChild("ProfilerTimeline", new Vector2(0, TimelineHeight), ImGuiChildFlags.Border | ImGuiChildFlags.ResizeY, ImGuiWindowFlags.AlwaysVerticalScrollbar);
ImDrawListPtr drawList = ImGui.GetWindowDrawList();
Vector2 windowPos = ImGui.GetCursorScreenPos();
Vector2 origin = ImGui.GetCursorScreenPos();
Vector2 viewSize = ImGui.GetContentRegionAvail();
float canvasWidth = Math.Max(1f, viewSize.X);
// Sort nodes by ThreadID, ensuring main thread (Thread ID 1) is on top
IOrderedEnumerable<IGrouping<int, Profiler.ScopeNode>> threadGroups = frame.RootNodes.GroupBy(node => node.ManagedThreadId).OrderBy(g => g.Key);
uint frameBgColor = ImGui.GetColorU32(ImGuiCol.FrameBg);
uint frameBgHoveredColor = ImGui.GetColorU32(ImGuiCol.FrameBgHovered);
uint headerColor = ImGui.GetColorU32(ImGuiCol.Header);
uint headerHoveredColor = ImGui.GetColorU32(ImGuiCol.HeaderHovered);
uint textColor = ImGui.GetColorU32(ImGuiCol.Text);
uint borderColor = ImGui.GetColorU32(ImGuiCol.Border);
// Initial Y position for drawing
float baseY = windowPos.Y;
bool alternate = false;
float contentWidth = ImGui.GetContentRegionAvail().X;
float clipMinX = origin.X;
float clipMaxX = origin.X + canvasWidth;
float clipMinY = origin.Y;
float clipMaxY = origin.Y + Math.Max(1f, ImGui.GetWindowHeight());
// Draw each thread's flame graph row by row
foreach (IGrouping<int, Profiler.ScopeNode> threadGroup in threadGroups)
DrawTimelineHeader(drawList, origin, canvasWidth, timelineStartTicks, visibleStartTicks, visibleDurationTicks, textColor, borderColor);
HoverEntry? hovered = null;
for (int frameIndex = visibleStartIndex; frameIndex <= visibleEndIndex; frameIndex++)
{
int threadId = threadGroup.Key;
Profiler.Frame frame = frames[frameIndex];
float frameStartX = ToScreenX(frame.StartTime, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth);
float frameEndX = ToScreenX(frame.EndTime, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth);
// Compute the base Y position for this thread
float threadBaseY = baseY;
// Calculate the maximum height for this thread's flame graph
float threadHeight = (threadMaxDepths[threadId] + 1) * (barHeight + barPadding);
// Draw the alternating background for each thread row
uint backgroundColor = ImGui.ColorConvertFloat4ToU32(alternate ? new Vector4(0.2f, 0.2f, 0.2f, 1f) : new Vector4(0.1f, 0.1f, 0.1f, 1f));
drawList.AddRectFilled(new Vector2(windowPos.X, threadBaseY), new Vector2(windowPos.X + contentWidth, threadBaseY + threadHeight), backgroundColor);
alternate = !alternate;
// Draw each root node in the group (one per thread)
foreach (Profiler.ScopeNode rootNode in threadGroup)
if (frameEndX < clipMinX || frameStartX > clipMaxX)
{
RenderNode(drawList, rootNode, startTime, totalDuration, windowPos.X, threadBaseY, 0, contentWidth, false);
continue;
}
// Move to the next thread's row (max depth * height per level)
baseY += (threadMaxDepths[threadId] + 1) * (barHeight + barPadding);
bool isSelectedFrame = frameIndex == state.SelectedFrameIndex;
uint frameShadeColor = (frameIndex & 1) == 0 ? frameBgColor : frameBgHoveredColor;
if (isSelectedFrame)
{
frameShadeColor = LerpColor(headerColor, headerHoveredColor, 0.45f);
}
// Ensure that ImGui knows the size of the content.
ImGui.Dummy(new Vector2(contentWidth, baseY));
drawList.AddRectFilled(new Vector2(frameStartX, origin.Y + HeaderHeight), new Vector2(frameEndX, origin.Y + contentHeight), SetAlpha(frameShadeColor, isSelectedFrame ? 0.20f : 0.08f));
drawList.AddLine(new Vector2(frameStartX, origin.Y + HeaderHeight), new Vector2(frameStartX, origin.Y + contentHeight), SetAlpha(borderColor, 0.55f), 1f);
foreach (Profiler.ScopeNode root in frame.RootNodes)
{
if (!threadBaseY.TryGetValue(root.ManagedThreadId, out float baseY))
{
continue;
}
DrawThreadLabel(drawList, origin.X, origin.Y + baseY, root.ManagedThreadId, textColor);
for (int i = 0; i < root.Children.Count; i++)
{
RenderNode(
drawList,
root.Children[i],
frame,
frameIndex,
baseY,
0,
visibleStartTicks,
visibleDurationTicks,
origin.X,
origin.Y,
canvasWidth,
clipMinX,
clipMaxX,
clipMaxY,
ref hovered,
textColor,
headerColor,
headerHoveredColor,
frameIndex == state.SelectedFrameIndex);
}
}
drawList.AddLine(new Vector2(frameEndX, origin.Y + HeaderHeight), new Vector2(frameEndX, origin.Y + contentHeight), SetAlpha(borderColor, 0.30f), 1f);
}
ImGui.Dummy(new Vector2(canvasWidth, contentHeight));
bool windowHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows);
if (windowHovered)
{
HandleZoomAndPan(state, timelineStartTicks, timelineDurationTicks, visibleDurationTicks, origin.X, canvasWidth, ref userNavigated);
}
if (windowHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
{
int clickedFrame = FindFrameIndexByMouseX(frames, visibleStartIndex, visibleEndIndex, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth, ImGui.GetMousePos().X);
if (clickedFrame >= visibleStartIndex && clickedFrame <= visibleEndIndex && clickedFrame != state.SelectedFrameIndex)
{
state.SelectedFrameIndex = clickedFrame;
selectionChanged = true;
state.FollowLatest = false;
userNavigated = true;
}
}
if (hovered.HasValue)
{
if (ImGui.IsMouseClicked(ImGuiMouseButton.Left) && hovered.Value.FrameIndex != state.SelectedFrameIndex)
{
state.SelectedFrameIndex = hovered.Value.FrameIndex;
selectionChanged = true;
state.FollowLatest = false;
userNavigated = true;
}
DrawTooltip(hovered.Value);
}
ImGui.EndChild();
return new TimelineRenderResult(state.SelectedFrameIndex, selectionChanged, userNavigated);
}
private static void RenderNode(ImDrawListPtr drawList, Profiler.ScopeNode node, double startTime, double totalDuration, float startX, float baseY, int depth, float contentWidth, bool alternate)
private static void HandleZoomAndPan(TimelineState state, double timelineStartTicks, double timelineDurationTicks, double visibleDurationTicks, float originX, float canvasWidth, ref bool userNavigated)
{
if (node == null) return;
double nodeStartTime = node.StartTime - startTime;
double nodeEndTime = node.EndTime - startTime;
double nodeDuration = nodeEndTime - nodeStartTime;
// Calculate the position and width of the bar based on time
float xPos = (float)(startX + (nodeStartTime / totalDuration) * contentWidth);
float width = (float)((nodeDuration / totalDuration) * contentWidth);
// Calculate the Y position based on depth
float yPos = baseY + (depth * (barHeight + barPadding)) + (barPadding * 0.5f);
// Define the rectangle bounds for the node
Vector2 min = new Vector2(xPos, yPos);
Vector2 max = new Vector2(xPos + width, yPos + barHeight);
// Define color.
Vector4 barColor = alternate ? new Vector4(0.4f, 0.6f, 0.9f, 1f) : new Vector4(0.4f, 0.5f, 0.8f, 1f);
Vector4 textColor = new Vector4(1f, 1f, 1f, 1f);
if (depth != 0)
ImGuiIOPtr io = ImGui.GetIO();
if (Math.Abs(io.MouseWheel) < float.Epsilon)
{
// Draw the bar for the node (colored based on thread depth)
drawList.AddRectFilled(min, max, ImGui.ColorConvertFloat4ToU32(barColor));
// Draw the label if it fits inside the bar
string label = $"{node.Label} ({node.ElapsedMilliseconds():0.000} ms)";
if (width > ImGui.CalcTextSize(label).X)
{
drawList.AddText(new Vector2(xPos + barPadding, yPos + barPadding), ImGui.ColorConvertFloat4ToU32(textColor), label);
return;
}
// Add tooltip on hover
if (ImGui.IsMouseHoveringRect(min, max))
if (!io.KeyCtrl && !io.KeyShift)
{
// Show tooltip when hovering over the node
ImGui.BeginTooltip();
ImGui.Text($"{node.Label}");
ImGui.Text($"{node.ElapsedMilliseconds():0.000} ms");
ImGui.Text($"{node.ManagedThreadId}");
ImGui.EndTooltip();
return; // plain scroll goes to ImGui vertical scrolling
}
float wheel = io.MouseWheel;
io.MouseWheel = 0; // consume so the child window doesn't also scroll vertically
if (io.KeyCtrl)
{
float previousZoom = state.Zoom;
double visibleStartTicksBefore = timelineStartTicks + state.PanTicks;
double mouseT = Math.Clamp((ImGui.GetMousePos().X - originX) / Math.Max(1f, canvasWidth), 0f, 1f);
double pivotTick = visibleStartTicksBefore + (visibleDurationTicks * mouseT);
state.Zoom = Math.Clamp(state.Zoom * MathF.Pow(1.12f, wheel), 1f, 128f);
if (Math.Abs(previousZoom - state.Zoom) > float.Epsilon)
{
double newVisibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom);
double newVisibleStartTicks = pivotTick - (newVisibleDurationTicks * mouseT);
state.PanTicks = Math.Clamp(newVisibleStartTicks - timelineStartTicks, 0d, Math.Max(0d, timelineDurationTicks - newVisibleDurationTicks));
state.FollowLatest = false;
userNavigated = true;
}
}
else
{
// Aka root node.
string label = $"{node.Label}";
drawList.AddText(new Vector2(startX + barPadding, yPos + barPadding), ImGui.ColorConvertFloat4ToU32(textColor), label);
}
// Draw each child node under this node
foreach (Profiler.ScopeNode child in node.Children)
{
alternate = !alternate;
RenderNode(drawList, child, startTime, totalDuration, startX, baseY, depth + 1, contentWidth, alternate);
// Shift + scroll: horizontal pan
state.PanTicks -= wheel * (visibleDurationTicks * 0.10d);
state.FollowLatest = false;
userNavigated = true;
}
}
// Recursive function to calculate the maximum depth of the node tree
private static int GetMaxDepth(Profiler.ScopeNode node, int currentDepth)
private static int FindFrameIndexByMouseX(IReadOnlyList<Profiler.Frame> frames, int visibleStartIndex, int visibleEndIndex, double visibleStartTicks, double visibleDurationTicks, float originX, float width, float mouseX)
{
if (node.Children == null || node.Children.Count == 0)
double t = Math.Clamp((mouseX - originX) / Math.Max(1f, width), 0f, 1f);
double timelineTicks = visibleStartTicks + (visibleDurationTicks * t);
for (int i = visibleStartIndex; i <= visibleEndIndex; i++)
{
return currentDepth;
Profiler.Frame frame = frames[i];
if (timelineTicks >= frame.StartTime && timelineTicks <= frame.EndTime)
{
return i;
}
}
int maxDepth = currentDepth;
foreach (Profiler.ScopeNode child in node.Children)
return -1;
}
private static void DrawTimelineHeader(ImDrawListPtr drawList, Vector2 origin, float width, double timelineStartTicks, double visibleStartTicks, double visibleDurationTicks, uint textColor, uint borderColor)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(child, currentDepth + 1));
drawList.AddLine(new Vector2(origin.X, origin.Y + HeaderHeight), new Vector2(origin.X + width, origin.Y + HeaderHeight), SetAlpha(borderColor, 0.65f), 1f);
int tickCount = Math.Clamp((int)(width / 130f), 4, 20);
for (int i = 0; i <= tickCount; i++)
{
float t = i / (float)tickCount;
float x = origin.X + (t * width);
drawList.AddLine(new Vector2(x, origin.Y + HeaderHeight - 8f), new Vector2(x, origin.Y + HeaderHeight), SetAlpha(borderColor, 0.8f), 1f);
double ms = TicksToMilliseconds((visibleStartTicks - timelineStartTicks) + (visibleDurationTicks * t));
drawList.AddText(new Vector2(x + 2f, origin.Y + 4f), textColor, $"+{ms:0.0} ms");
}
}
private static void DrawThreadLabel(ImDrawListPtr drawList, float x, float y, int threadId, uint textColor)
{
drawList.AddText(new Vector2(x + 4f, y + 2f), SetAlpha(textColor, 0.85f), $"T{threadId}");
}
private static void DrawTooltip(HoverEntry hover)
{
ImGui.BeginTooltip();
ImGui.Text($"{hover.Node.Label}");
ImGui.Separator();
ImGui.Text($"Category: {hover.Node.Category}");
ImGui.Text($"Tags: 0x{hover.Node.TagMask:X}");
ImGui.Text($"Frame: {hover.Frame.FrameCount} (idx {hover.FrameIndex})");
ImGui.Text($"Thread: {hover.Node.ManagedThreadId}");
ImGui.Text($"Depth: {hover.Depth}");
ImGui.Text($"Duration: {hover.DurationMs:0.000} ms");
ImGui.Text($"Self: {hover.SelfMs:0.000} ms");
ImGui.Text($"Frame Start: {hover.StartInFrameMs:0.000} ms");
ImGui.Text($"Frame End: {hover.EndInFrameMs:0.000} ms");
ImGui.Text($"Timeline Start: {hover.StartInTimelineMs:0.000} ms");
ImGui.Text($"Timeline End: {hover.EndInTimelineMs:0.000} ms");
ImGui.Text($"Children: {hover.Node.Children.Count}");
if (hover.Node.ProfilerSetupBytes > 0)
{
ImGui.Separator();
ImGui.TextColored(new Vector4(1f, 0.75f, 0f, 1f), $"\u26a0 {hover.Node.ProfilerSetupBytes} B of alloc is profiler warmup overhead");
}
ImGui.EndTooltip();
}
private static void RenderNode(
ImDrawListPtr drawList,
Profiler.ScopeNode node,
Profiler.Frame frame,
int frameIndex,
float baseY,
int depth,
double visibleStartTicks,
double visibleDurationTicks,
float originX,
float originY,
float width,
float clipMinX,
float clipMaxX,
float clipMaxY,
ref HoverEntry? hovered,
uint textColor,
uint headerColor,
uint headerHoveredColor,
bool selectedFrame)
{
long nodeEndTime = Math.Max(node.EndTime, node.StartTime + 1);
if (nodeEndTime < visibleStartTicks || node.StartTime > visibleStartTicks + visibleDurationTicks)
{
return;
}
float y = originY + baseY + (depth * (BarHeight + BarPadding));
if (y > clipMaxY)
{
return;
}
float minX = ToScreenX(node.StartTime, visibleStartTicks, visibleDurationTicks, originX, width);
float maxX = ToScreenX(nodeEndTime, visibleStartTicks, visibleDurationTicks, originX, width);
if (maxX < clipMinX || minX > clipMaxX)
{
return;
}
float barWidth = Math.Max(1f, maxX - minX);
Vector2 min = new Vector2(minX, y + (BarPadding * 0.5f));
Vector2 max = new Vector2(minX + barWidth, y + (BarPadding * 0.5f) + BarHeight);
uint barColor = BuildBarColor(node.Label, depth, selectedFrame);
uint borderColor = LerpColor(headerColor, headerHoveredColor, 0.45f);
drawList.AddRectFilled(min, max, barColor, 3f);
drawList.AddRect(min, max, SetAlpha(borderColor, 0.55f), 3f, ImDrawFlags.None, 1f);
Vector2 mousePos = ImGui.GetMousePos();
bool isHovered = mousePos.X >= min.X && mousePos.X <= max.X && mousePos.Y >= min.Y && mousePos.Y <= max.Y;
if (isHovered)
{
hovered = new HoverEntry(node, frame, frameIndex, depth, visibleStartTicks);
drawList.AddRect(min, max, headerHoveredColor, 3f, ImDrawFlags.None, 1.5f);
}
if (barWidth > MinTextWidth)
{
string label = node.Label;
float textWidth = ImGui.CalcTextSize(label).X;
if (textWidth + 8f <= barWidth)
{
drawList.AddText(new Vector2(min.X + 4f, min.Y + 2f), textColor, label);
}
}
for (int i = 0; i < node.Children.Count; i++)
{
RenderNode(
drawList,
node.Children[i],
frame,
frameIndex,
baseY,
depth + 1,
visibleStartTicks,
visibleDurationTicks,
originX,
originY,
width,
clipMinX,
clipMaxX,
clipMaxY,
ref hovered,
textColor,
headerColor,
headerHoveredColor,
selectedFrame);
}
}
private static Dictionary<int, int> BuildThreadDepths(IReadOnlyList<Profiler.Frame> frames, int startIndex, int endIndex)
{
Dictionary<int, int> threadMaxDepths = new Dictionary<int, int>(8);
for (int frameIndex = startIndex; frameIndex <= endIndex; frameIndex++)
{
foreach (Profiler.ScopeNode root in frames[frameIndex].RootNodes)
{
int maxDepth = 0;
for (int i = 0; i < root.Children.Count; i++)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(root.Children[i], 0));
}
if (threadMaxDepths.TryGetValue(root.ManagedThreadId, out int currentMax))
{
if (maxDepth > currentMax)
{
threadMaxDepths[root.ManagedThreadId] = maxDepth;
}
}
else
{
threadMaxDepths[root.ManagedThreadId] = maxDepth;
}
}
}
return threadMaxDepths;
}
private static int GetMaxDepth(Profiler.ScopeNode node, int depth)
{
if (node.Children.Count == 0)
{
return depth;
}
int maxDepth = depth;
for (int i = 0; i < node.Children.Count; i++)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(node.Children[i], depth + 1));
}
return maxDepth;
}
private static uint BuildBarColor(string label, int depth, bool selectedFrame)
{
int hash = label.GetHashCode();
float hue = ((hash & 1023) / 1023f + (depth * 0.031f)) % 1f;
ImGui.ColorConvertHSVtoRGB(hue, 0.52f, selectedFrame ? 0.82f : 0.68f, out float r, out float g, out float b);
Vector4 frameBg = ImGui.ColorConvertU32ToFloat4(ImGui.GetColorU32(ImGuiCol.FrameBg));
Vector4 accent = new Vector4(r, g, b, 1f);
Vector4 mixed = Vector4.Lerp(frameBg, accent, 0.72f);
return ImGui.ColorConvertFloat4ToU32(mixed);
}
private static float ToScreenX(double ticks, double visibleStartTicks, double visibleDurationTicks, float startX, float width)
{
double normalized = (ticks - visibleStartTicks) / visibleDurationTicks;
return startX + (float)(normalized * width);
}
private static double TicksToMilliseconds(double ticks)
{
return ticks * TickToMs;
}
private static uint SetAlpha(uint color, float alpha)
{
Vector4 c = ImGui.ColorConvertU32ToFloat4(color);
c.W *= alpha;
return ImGui.ColorConvertFloat4ToU32(c);
}
private static uint LerpColor(uint a, uint b, float t)
{
Vector4 av = ImGui.ColorConvertU32ToFloat4(a);
Vector4 bv = ImGui.ColorConvertU32ToFloat4(b);
return ImGui.ColorConvertFloat4ToU32(Vector4.Lerp(av, bv, Math.Clamp(t, 0f, 1f)));
}
}
-22
View File
@@ -1,31 +1,9 @@
using System;
namespace Nerfed.Runtime;
public enum ResourceState
{
Unloaded,
Queued,
Loading,
Loaded,
Failed
}
public abstract class Resource
{
public Guid Id { get; internal set; }
public string Path { get; internal set; }
/// <summary>
/// Natively tracks if the resource is currently in RAM/VRAM.
/// </summary>
public ResourceState State { get; internal set; } = ResourceState.Unloaded;
/// <summary>
/// Tracks how many entities or systems currently need this loaded.
/// When it hits 0, the ResourceManager handles unloading natively.
/// </summary>
public int ReferenceCount { get; internal set; } = 0;
internal abstract void Load(Stream stream);
internal abstract void Unload();
}
@@ -1,19 +0,0 @@
namespace Nerfed.Runtime;
/// <summary>
/// Attach this component to an entity mapped to raw source-path strings.
/// Useful for testing, hardcoded assets, or before full editor-guided GUID injection.
/// </summary>
public readonly record struct AssetReferenceComponent(Guid AssetId);
/// <summary>
/// A strongly-typed version of an asset reference, preventing the user from accidentally
/// assigning a Shader GUID to a Texture component in the Editor.
/// </summary>
public readonly record struct TypedAssetReference<TRes>(Guid AssetId) where TRes : Resource;
/// <summary>
/// Added to an entity by the AssetStreamingSystem when the physical resource is fully
/// loaded in memory and ready to be used by the renderer or physics engine.
/// </summary>
public struct AssetLoadedTag { }
+25 -191
View File
@@ -1,209 +1,43 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
namespace Nerfed.Runtime;
/// <summary>
/// A highly scalable, multithreaded resource manager that handles asynchronous asset
/// loading and automatic reference-counted memory management.
/// </summary>
public static class ResourceManager
{
private const string RootName = "Resources";
private const string rootName = "Resources";
private static readonly Dictionary<string, Resource> loadedResources = new Dictionary<string, Resource>();
// Track resources by their Guid ID instead of simple strings.
private static readonly ConcurrentDictionary<Guid, Resource> _resourceCache = new();
// Mapping a string path to its runtime Guid identifier
private static readonly ConcurrentDictionary<string, Guid> _pathToGuid = new();
// Queues for background processing
private static readonly ConcurrentQueue<Resource> _loadQueue = new();
// Loader threads
private static readonly Thread _loaderThread;
private static bool _isRunning = true;
// A registry of how to create concrete Resource instances from a generic type without massive switch statements.
private static readonly Dictionary<Type, Func<Resource>> _resourceFactories = new()
public static T Load<T>(string resourcePath) where T : Resource
{
{ typeof(Shader), () => new Shader() }
};
static ResourceManager()
if (loadedResources.TryGetValue(resourcePath, out Resource resource))
{
_loaderThread = new Thread(LoaderWorkerLoop)
{
Name = "Nerfed Asset Loader",
IsBackground = true,
Priority = ThreadPriority.BelowNormal // Keeps CPU time focused on the main game loop
};
_loaderThread.Start();
}
/// <summary>
/// Synchronously shuts down the loader thread when the engine closes.
/// </summary>
public static void Shutdown()
{
_isRunning = false;
_loaderThread.Join();
}
/// <summary>
/// Registers a new resource type factory so the manager knows how to instantiate it.
/// Example: RegisterResourceType<Texture>(() => new Texture());
/// </summary>
public static void RegisterResourceType<T>(Func<T> factory) where T : Resource
{
_resourceFactories[typeof(T)] = factory;
}
/// <summary>
/// Gets the Guid associated with a specific asset path, making an initial id pass if required.
/// In a fully baked engine, the Guid is known at compile time or baked in the map data.
/// </summary>
public static Guid GetId(string resourcePath)
{
return _pathToGuid.GetOrAdd(resourcePath, _ => Guid.NewGuid());
}
/// <summary>
/// Begins an asynchronous load for a resource by its Guid.
/// In ECS systems, Entities should strictly prefer this overload over the string one.
/// </summary>
public static T Retain<T>(Guid id, string expectedPath) where T : Resource
{
var resource = _resourceCache.GetOrAdd(id, (assetId) =>
{
if (!_resourceFactories.TryGetValue(typeof(T), out var factory))
{
throw new Exception($"Failed to create resource. No factory registered for {typeof(T).Name}");
}
var newResource = factory();
newResource.Id = assetId;
// The path is still required so the background thread knows which file to open from disk.
newResource.Path = expectedPath;
newResource.State = ResourceState.Unloaded;
return newResource;
});
lock (resource)
{
resource.ReferenceCount++;
if (resource.State == ResourceState.Unloaded)
{
resource.State = ResourceState.Queued;
_loadQueue.Enqueue(resource);
}
}
return (T)resource;
}
/// <summary>
/// Begins an asynchronous load utilizing the string path to find the matching Guid.
/// This should generally be avoided in tight ECS loops.
/// </summary>
public static T Retain<T>(string resourcePath) where T : Resource
if (typeof(T) == typeof(Shader))
{
Guid id = GetId(resourcePath);
return Retain<T>(id, resourcePath);
}
/// <summary>
/// Gets the current loading state of a resource by its Guid without altering its reference count.
/// </summary>
public static ResourceState GetState(Guid id)
{
if (_resourceCache.TryGetValue(id, out var resource))
{
return resource.State;
}
return ResourceState.Unloaded;
}
/// <summary>
/// Decrements the reference count of a resource by its Guid.
/// </summary>
public static void Release(Guid id)
{
if (_resourceCache.TryGetValue(id, out var resource))
{
Release(resource);
}
}
/// <summary>
/// Decrements the reference count of a resource.
/// If the count reaches 0, the asset is automatically unloaded from memory.
/// </summary>
public static void Release(Resource resource)
{
if (resource == null) return;
lock (resource)
{
resource.ReferenceCount--;
if (resource.ReferenceCount <= 0)
{
// Fully unused! We should unload it safely.
if (resource.State == ResourceState.Loaded)
{
resource.Unload();
}
resource.State = ResourceState.Unloaded;
_resourceCache.TryRemove(resource.Id, out _);
}
}
}
/// <summary>
/// Background thread loop that pulls from the queue and does the slow file I/O operations.
/// </summary>
private static void LoaderWorkerLoop()
{
while (_isRunning)
{
if (_loadQueue.TryDequeue(out var resource))
{
// Safety check: Was the resource released before we even got around to loading it?
if (resource.ReferenceCount <= 0)
{
resource.State = ResourceState.Unloaded;
continue;
}
try
{
resource.State = ResourceState.Loading;
string fullPath = Path.Combine(AppContext.BaseDirectory, RootName, resource.Id.ToString()) + ".bin";
// Do the slow synchronous disk read
using var stream = StorageContainer.OpenStream(fullPath);
resource.Load(stream);
resource.State = ResourceState.Loaded;
}
catch (Exception e)
{
Log.Error($"Failed to background load asset '{resource.Path}': {e.Message}");
resource.State = ResourceState.Failed;
}
resource = new Shader();
}
else
{
// Sleep cleanly if queue is empty to avoid burning total CPU usage on an infinite while-loop
Thread.Sleep(10);
throw new Exception("Failed to create resource");
}
Assert.Always(resource != null);
resource.Path = resourcePath;
resource.Load(StorageContainer.OpenStream(Path.Combine(AppContext.BaseDirectory, rootName, resourcePath) + ".bin"));
loadedResources.Add(resourcePath, resource);
return (T)resource;
}
public static void Unload(Resource resource)
{
if (!loadedResources.ContainsKey(resource.Path))
{
return;
}
resource.Unload();
resource.Path = string.Empty;
loadedResources.Remove(resource.Path);
}
}
@@ -1,19 +0,0 @@
namespace Nerfed.Runtime.Resources;
/// <summary>
/// A sample component demonstrating how to use strongly-typed asset references
/// in a realistic scenario where an entity requires multiple distinct resources.
/// </summary>
public struct SampleMeshVisualComponent
{
// The user safely assigns a Mesh GUID in the Editor inspector.
public TypedAssetReference<Shader> VertexShader;
// The user safely assigns a Material GUID in the Editor inspector.
public TypedAssetReference<Shader> FragmentShader;
public SampleMeshVisualComponent(Guid vertexId, Guid fragId) {
VertexShader = new TypedAssetReference<Shader>(vertexId);
FragmentShader = new TypedAssetReference<Shader>(fragId);
}
}
@@ -1,53 +0,0 @@
using MoonTools.ECS;
using Nerfed.Runtime.Scene.Streaming;
using System;
namespace Nerfed.Runtime.Resources;
/// <summary>
/// A typical rendering preparation system that natively resolves and requests
/// asynchronous background loading for its own required assets, removing the
/// need for a monolithic generic AssetStreaming manager.
/// </summary>
public class SampleRenderSystem : MoonTools.ECS.System
{
private readonly Filter _meshVisualsFilter;
public SampleRenderSystem(World world) : base(world) {
_meshVisualsFilter = FilterBuilder
.Include<SampleMeshVisualComponent>()
// Always ignore chunk entities technically "unloading" from RAM
.Exclude<ChunkUnloadPendingTag>()
.Build();
}
public override void Update(TimeSpan delta) {
foreach(Entity entity in _meshVisualsFilter.Entities) {
SampleMeshVisualComponent visualComp = Get<SampleMeshVisualComponent>(entity);
// 1. Resolve State
ResourceState vertState = ResourceManager.GetState(visualComp.VertexShader.AssetId);
ResourceState fragState = ResourceManager.GetState(visualComp.FragmentShader.AssetId);
// 2. Asynchronously request assets if they don't exist in memory yet
if(vertState == ResourceState.Unloaded) {
ResourceManager.Retain<Shader>(visualComp.VertexShader.AssetId, "Unknown/Path");
}
if(fragState == ResourceState.Unloaded) {
ResourceManager.Retain<Shader>(visualComp.FragmentShader.AssetId, "Unknown/Path");
}
// 3. Prevent rendering logic unless ALL strictly required assets are fully mapped
bool isReadyToDraw = vertState == ResourceState.Loaded && fragState == ResourceState.Loaded;
if(isReadyToDraw) {
// At this exact point, you can safely assume:
// 1) The background loading threads are 100% finished processing these shaders.
// 2) The GraphicsDevice can safely extract the native handle.
// e.g. GraphicsDevice.BindShader(visualComp.VertexShader.AssetId);
// e.g. GraphicsDevice.DrawPolygons(...);
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
namespace Nerfed.Runtime.Scene;
/// <summary>
/// Abstraction over a concrete scene format (JSON, binary, …).
/// Implementations read and write <see cref="SceneData"/> to a <see cref="Stream"/>,
/// making it straightforward to add a compact binary format later without
/// changing any of the surrounding scene infrastructure.
/// </summary>
public interface ISceneSerializer
{
void Serialize(SceneData scene, Stream stream);
SceneData Deserialize(Stream stream);
}
-189
View File
@@ -1,189 +0,0 @@
using System.Numerics;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Nerfed.Runtime.Scene;
/// <summary>
/// Human-readable JSON scene serializer.
///
/// Example output:
/// <code>
/// {
/// "version": 1,
/// "name": "MyScene",
/// "entities": [
/// {
/// "id": "a1b2c3d4-...",
/// "tag": "Player",
/// "parentId": null,
/// "components": [
/// {
/// "type": "Nerfed.Runtime.Components.LocalTransform",
/// "data": {
/// "position": { "x": 0.0, "y": 0.0, "z": 0.0 },
/// "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 },
/// "scale": { "x": 1.0, "y": 1.0, "z": 1.0 }
/// }
/// }
/// ]
/// }
/// ],
/// "relations": [
/// {
/// "type": "Nerfed.Runtime.Components.OwnerRelation",
/// "entityA": "a1b2c3d4-...",
/// "entityB": "e5f6a7b8-...",
/// "data": {}
/// }
/// ]
/// }
/// </code>
/// </summary>
public sealed class JsonSceneSerializer : ISceneSerializer
{
private static readonly JsonSerializerOptions Options = new() {
WriteIndented = true,
Converters =
{
new Vector3JsonConverter(),
new QuaternionJsonConverter(),
new SceneComponentDataJsonConverter(),
new SceneRelationDataJsonConverter(),
},
};
public void Serialize(SceneData scene, Stream stream) {
JsonSerializer.Serialize(stream, scene, Options);
}
public SceneData Deserialize(Stream stream) {
return JsonSerializer.Deserialize<SceneData>(stream, Options)
?? throw new InvalidOperationException("Failed to deserialize scene: root element was null.");
}
// -------------------------------------------------------------------------
// Converters
// -------------------------------------------------------------------------
private sealed class Vector3JsonConverter : JsonConverter<Vector3>
{
public override Vector3 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
float x = 0f, y = 0f, z = 0f;
reader.Read(); // StartObject
while(reader.Read() && reader.TokenType != JsonTokenType.EndObject) {
string name = reader.GetString()!;
reader.Read();
switch(name) {
case "x": x = reader.GetSingle(); break;
case "y": y = reader.GetSingle(); break;
case "z": z = reader.GetSingle(); break;
}
}
return new Vector3(x, y, z);
}
public override void Write(Utf8JsonWriter writer, Vector3 value, JsonSerializerOptions options) {
writer.WriteStartObject();
writer.WriteNumber("x", value.X);
writer.WriteNumber("y", value.Y);
writer.WriteNumber("z", value.Z);
writer.WriteEndObject();
}
}
private sealed class QuaternionJsonConverter : JsonConverter<Quaternion>
{
public override Quaternion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
float x = 0f, y = 0f, z = 0f, w = 1f;
reader.Read(); // StartObject
while(reader.Read() && reader.TokenType != JsonTokenType.EndObject) {
string name = reader.GetString()!;
reader.Read();
switch(name) {
case "x": x = reader.GetSingle(); break;
case "y": y = reader.GetSingle(); break;
case "z": z = reader.GetSingle(); break;
case "w": w = reader.GetSingle(); break;
}
}
return new Quaternion(x, y, z, w);
}
public override void Write(Utf8JsonWriter writer, Quaternion value, JsonSerializerOptions options) {
writer.WriteStartObject();
writer.WriteNumber("x", value.X);
writer.WriteNumber("y", value.Y);
writer.WriteNumber("z", value.Z);
writer.WriteNumber("w", value.W);
writer.WriteEndObject();
}
}
/// <summary>
/// Buffers the full JSON object, resolves the CLR component type from the "type" field,
/// then deserializes "data" using that concrete type.
/// </summary>
private sealed class SceneComponentDataJsonConverter : JsonConverter<SceneComponentData>
{
public override SceneComponentData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
using JsonDocument doc = JsonDocument.ParseValue(ref reader);
JsonElement root = doc.RootElement;
string typeName = root.GetProperty("type").GetString()
?? throw new JsonException("Missing or null 'type' field in component data.");
Type componentType = SceneManager.GetComponentType(typeName)
?? throw new JsonException($"Unknown component type '{typeName}'. Ensure the struct is marked with [SceneComponent].");
string rawData = root.GetProperty("data").GetRawText();
ValueType value = (ValueType)JsonSerializer.Deserialize(rawData, componentType, options)!;
return new SceneComponentData { Type = typeName, Value = value };
}
public override void Write(Utf8JsonWriter writer, SceneComponentData value, JsonSerializerOptions options) {
writer.WriteStartObject();
writer.WriteString("type", value.Type);
writer.WritePropertyName("data");
JsonSerializer.Serialize(writer, value.Value, value.Value.GetType(), options);
writer.WriteEndObject();
}
}
/// <summary>
/// Same pattern as <see cref="SceneComponentDataJsonConverter"/> but for relation data.
/// Resolves the type via <see cref="SceneManager.GetRelationType"/>.
/// </summary>
private sealed class SceneRelationDataJsonConverter : JsonConverter<SceneRelationData>
{
public override SceneRelationData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
using JsonDocument doc = JsonDocument.ParseValue(ref reader);
JsonElement root = doc.RootElement;
string typeName = root.GetProperty("type").GetString()
?? throw new JsonException("Missing or null 'type' field in relation data.");
Type relationType = SceneManager.GetRelationType(typeName)
?? throw new JsonException($"Unknown relation type '{typeName}'. Ensure the struct is marked with [SceneRelation].");
Guid entityA = root.GetProperty("entityA").GetGuid();
Guid entityB = root.GetProperty("entityB").GetGuid();
string rawData = root.GetProperty("data").GetRawText();
ValueType value = (ValueType)JsonSerializer.Deserialize(rawData, relationType, options)!;
return new SceneRelationData { Type = typeName, EntityA = entityA, EntityB = entityB, Value = value };
}
public override void Write(Utf8JsonWriter writer, SceneRelationData value, JsonSerializerOptions options) {
writer.WriteStartObject();
writer.WriteString("type", value.Type);
writer.WriteString("entityA", value.EntityA);
writer.WriteString("entityB", value.EntityB);
writer.WritePropertyName("data");
JsonSerializer.Serialize(writer, value.Value, value.Value.GetType(), options);
writer.WriteEndObject();
}
}
}
@@ -1,8 +0,0 @@
namespace Nerfed.Runtime.Scene;
/// <summary>
/// Marks an unmanaged struct as a serializable scene component.
/// Only types with this attribute will be saved/loaded by the scene system.
/// </summary>
[AttributeUsage(AttributeTargets.Struct, Inherited = false)]
public sealed class SceneComponentAttribute : Attribute { }
-65
View File
@@ -1,65 +0,0 @@
namespace Nerfed.Runtime.Scene;
/// <summary>
/// Root data model for a scene. A scene and a prefab are the same thing —
/// there is no distinction between the two, mirroring Godot's design.
/// </summary>
public sealed class SceneData
{
/// <summary>Incremented when the file format changes in a breaking way.</summary>
public int Version { get; set; } = SceneData.CurrentVersion;
public string Name { get; set; } = string.Empty;
public List<SceneEntityData> Entities { get; set; } = new();
/// <summary>All user-defined relations between entities in this scene.</summary>
public List<SceneRelationData> Relations { get; set; } = new();
public const int CurrentVersion = 1;
}
/// <summary>
/// Serialized representation of a single entity.
/// The <see cref="Id"/> is a scene-local identifier that only exists in the
/// serialized data and is used to reconstruct parentchild and relation references.
/// It is never stored as a component on a live entity.
/// An entity is included if it owns at least one <see cref="SceneComponentAttribute"/> component
/// OR participates in at least one <see cref="SceneRelationAttribute"/> relation.
/// </summary>
public sealed class SceneEntityData
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Tag { get; set; } = string.Empty;
/// <summary>
/// Scene-local <see cref="Id"/> of this entity's <see cref="Components.ChildParentRelation"/>
/// parent, or <c>null</c> if this is a root entity.
/// </summary>
public Guid? ParentId { get; set; }
public List<SceneComponentData> Components { get; set; } = new();
}
/// <summary>
/// Serialized representation of a single component value on an entity.
/// <see cref="Type"/> is the fully-qualified CLR type name used to resolve the component on load.
/// <see cref="Value"/> is the boxed runtime value; each <see cref="ISceneSerializer"/> is
/// responsible for converting it to/from its wire format.
/// </summary>
public sealed class SceneComponentData
{
public string Type { get; set; } = string.Empty;
public ValueType Value { get; set; } = default!;
}
/// <summary>
/// Serialized representation of a relation between two entities.
/// <see cref="EntityA"/> and <see cref="EntityB"/> reference scene-local <see cref="SceneEntityData.Id"/> values.
/// <see cref="Type"/> identifies the relation kind (must be marked with <see cref="SceneRelationAttribute"/>).
/// <see cref="Value"/> holds the relation data payload (may be an empty struct).
/// </summary>
public sealed class SceneRelationData
{
public string Type { get; set; } = string.Empty;
public Guid EntityA { get; set; }
public Guid EntityB { get; set; }
public ValueType Value { get; set; } = default!;
}
-345
View File
@@ -1,345 +0,0 @@
using System.Reflection;
using MoonTools.ECS;
using Nerfed.Runtime.Components;
namespace Nerfed.Runtime.Scene;
/// <summary>
/// Central hub for scene serialization and deserialization.
///
/// On first use the static constructor scans all loaded assemblies for:
/// • Structs annotated with <see cref="SceneComponentAttribute"/> — serialized as per-entity component data.
/// • Structs annotated with <see cref="SceneRelationAttribute"/> — serialized as cross-entity relation data.
///
/// The <see cref="Components.ChildParentRelation"/> hierarchy is handled separately via
/// <see cref="SceneEntityData.ParentId"/> and does NOT need a <see cref="SceneRelationAttribute"/>.
///
/// Usage:
/// <code>
/// var serializer = new JsonSceneSerializer();
/// SceneManager.Save(world, "Assets/level1.scene", serializer, "Level 1");
/// SceneManager.Load(world, "Assets/level1.scene", serializer);
/// </code>
/// </summary>
public static class SceneManager
{
// Full CLR type name → Type
private static readonly Dictionary<string, Type> ComponentRegistry = new();
private static readonly Dictionary<string, Type> RelationRegistry = new();
// Reflection cache so we only build the delegates once per type.
private static readonly Dictionary<Type, Func<World, Entity, bool>> HasComponentCache = new();
private static readonly Dictionary<Type, Func<World, Entity, ValueType>> GetComponentCache = new();
private static readonly Dictionary<Type, Action<World, Entity, ValueType>> SetComponentCache = new();
private static readonly Dictionary<Type, Func<World, Entity, bool>> HasOutRelationCache = new();
private static readonly Dictionary<Type, Func<World, Entity, Entity[]>> OutRelationsCache = new();
private static readonly Dictionary<Type, Func<World, Entity, Entity, ValueType>> GetRelationDataCache = new();
private static readonly Dictionary<Type, Action<World, Entity, Entity, ValueType>> RelateCache = new();
static SceneManager() {
foreach(Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) {
Type[] types;
try { types = assembly.GetTypes(); } catch(ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
foreach(Type type in types) {
if(type.FullName is null) continue;
if(type.GetCustomAttribute<SceneComponentAttribute>() is not null)
ComponentRegistry[type.FullName] = type;
if(type.GetCustomAttribute<SceneRelationAttribute>() is not null)
RelationRegistry[type.FullName] = type;
}
}
}
// -------------------------------------------------------------------------
// Public registry accessors
// -------------------------------------------------------------------------
public static Type? GetComponentType(string fullName) {
ComponentRegistry.TryGetValue(fullName, out Type? type);
return type;
}
public static Type? GetRelationType(string fullName) {
RelationRegistry.TryGetValue(fullName, out Type? type);
return type;
}
public static IReadOnlyDictionary<string, Type> RegisteredComponentTypes => ComponentRegistry;
public static IReadOnlyDictionary<string, Type> RegisteredRelationTypes => RelationRegistry;
// -------------------------------------------------------------------------
// High-level Save / Load
// -------------------------------------------------------------------------
public static void Save(World world, string path, ISceneSerializer serializer, string sceneName = "") {
SceneData scene = Extract(world, sceneName);
string? directory = Path.GetDirectoryName(path);
if(!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
using FileStream stream = File.Open(path, FileMode.Create, FileAccess.Write);
serializer.Serialize(scene, stream);
}
public static Dictionary<Guid, Entity> Load(World world, string path, ISceneSerializer serializer) {
using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read);
SceneData scene = serializer.Deserialize(stream);
return Instantiate(world, scene);
}
// -------------------------------------------------------------------------
// Extract (world → SceneData)
// -------------------------------------------------------------------------
public static SceneData Extract(World world, string name = "") {
SceneData scene = new() { Name = name };
// ── 1. Collect entities ──────────────────────────────────────────────
// Include an entity if it has at least one scene component OR if it
// appears as an endpoint of at least one scene relation. This ensures
// pure grouping nodes and relation-only entities are not dropped.
Dictionary<uint, Guid> entityToGuid = new();
void EnsureEntity(Entity e) {
if(!entityToGuid.ContainsKey(e.ID))
entityToGuid[e.ID] = Guid.NewGuid();
}
foreach(Entity entity in world.GetAllEntities()) {
if(HasAnySceneComponent(world, entity))
EnsureEntity(entity);
}
// Walk all registered relation types and pull in both endpoints.
foreach(Type relationType in RelationRegistry.Values) {
foreach((Entity a, Entity b) in WorldAllRelations(world, relationType)) {
EnsureEntity(a);
EnsureEntity(b);
}
}
// Also include entities that are part of the ChildParentRelation hierarchy
// even if they carry no scene components and no user-defined relations.
foreach((Entity child, Entity parent) in world.Relations<ChildParentRelation>()) {
EnsureEntity(child);
EnsureEntity(parent);
}
// ── 2. Build entity records (parents must be known before children so
// we sort parents-before-children for readable output) ───────────
List<SceneEntityData> ordered = BuildSortedEntityList(world, entityToGuid);
scene.Entities.AddRange(ordered);
// ── 3. Build relation records ────────────────────────────────────────
foreach((string typeName, Type relationType) in RelationRegistry) {
foreach((Entity a, Entity b) in WorldAllRelations(world, relationType)) {
if(!entityToGuid.TryGetValue(a.ID, out Guid guidA) ||
!entityToGuid.TryGetValue(b.ID, out Guid guidB))
continue;
ValueType payload = WorldGetRelationData(world, a, b, relationType);
scene.Relations.Add(new SceneRelationData {
Type = typeName,
EntityA = guidA,
EntityB = guidB,
Value = payload,
});
}
}
return scene;
}
// -------------------------------------------------------------------------
// Instantiate (SceneData → world)
// -------------------------------------------------------------------------
public static Dictionary<Guid, Entity> Instantiate(World world, SceneData scene) {
Dictionary<Guid, Entity> guidToEntity = new(scene.Entities.Count);
// Pass 1 create all entities.
foreach(SceneEntityData entityData in scene.Entities) {
Entity entity = world.CreateEntity(entityData.Tag);
guidToEntity[entityData.Id] = entity;
}
// Pass 2 set components and wire up the ChildParentRelation hierarchy.
foreach(SceneEntityData entityData in scene.Entities) {
Entity entity = guidToEntity[entityData.Id];
foreach(SceneComponentData componentData in entityData.Components)
WorldSetComponent(world, entity, componentData.Type, componentData.Value);
if(entityData.ParentId is Guid parentGuid && guidToEntity.TryGetValue(parentGuid, out Entity parent)) {
world.Set(entity, new Child());
world.Relate(entity, parent, new ChildParentRelation());
} else {
world.Set(entity, new Root());
}
}
// Pass 3 restore all user-defined relations.
foreach(SceneRelationData relationData in scene.Relations) {
if(!guidToEntity.TryGetValue(relationData.EntityA, out Entity entityA) ||
!guidToEntity.TryGetValue(relationData.EntityB, out Entity entityB))
continue;
WorldRelate(world, entityA, entityB, relationData.Type, relationData.Value);
}
return guidToEntity;
}
// -------------------------------------------------------------------------
// Helpers entity ordering
// -------------------------------------------------------------------------
// Returns entities sorted so that a parent always appears before its children,
// making the JSON file human-readable and easier to diff.
private static List<SceneEntityData> BuildSortedEntityList(
World world,
Dictionary<uint, Guid> entityToGuid) {
// Build per-entity data (unsorted first).
Dictionary<Guid, SceneEntityData> byGuid = new(entityToGuid.Count);
foreach((uint entityId, Guid guid) in entityToGuid) {
Entity entity = new(entityId);
Guid? parentId = null;
if(world.HasOutRelation<ChildParentRelation>(entity)) {
// Iterate all out-relations — an entity may have multiple parents
// in theory, but ChildParentRelation is designed as singleton.
// We capture the first valid one here.
foreach(Entity parent in world.OutRelations<ChildParentRelation>(entity)) {
if(entityToGuid.TryGetValue(parent.ID, out Guid parentGuid)) {
parentId = parentGuid;
break;
}
}
}
List<SceneComponentData> components = new();
foreach((string typeName, Type componentType) in ComponentRegistry) {
if(!WorldHasComponent(world, entity, componentType)) continue;
ValueType value = WorldGetComponent(world, entity, componentType);
components.Add(new SceneComponentData { Type = typeName, Value = value });
}
byGuid[guid] = new SceneEntityData {
Id = guid,
Tag = world.GetTag(entity),
ParentId = parentId,
Components = components,
};
}
// Topological sort: parents before children.
List<SceneEntityData> sorted = new(byGuid.Count);
HashSet<Guid> visited = new(byGuid.Count);
void Visit(Guid id) {
if(!visited.Add(id)) return;
SceneEntityData data = byGuid[id];
if(data.ParentId is Guid pid && byGuid.ContainsKey(pid))
Visit(pid);
sorted.Add(data);
}
foreach(Guid id in byGuid.Keys)
Visit(id);
return sorted;
}
// -------------------------------------------------------------------------
// Reflection helpers components
// -------------------------------------------------------------------------
private static bool HasAnySceneComponent(World world, Entity entity) {
foreach(Type componentType in ComponentRegistry.Values) {
if(WorldHasComponent(world, entity, componentType)) return true;
}
return false;
}
private static bool WorldHasComponent(World world, Entity entity, Type componentType) {
if(!HasComponentCache.TryGetValue(componentType, out Func<World, Entity, bool>? fn)) {
MethodInfo method = FindGenericMethod(nameof(World.Has)).MakeGenericMethod(componentType);
fn = (w, e) => (bool)method.Invoke(w, new object[] { e })!;
HasComponentCache[componentType] = fn;
}
return fn(world, entity);
}
private static ValueType WorldGetComponent(World world, Entity entity, Type componentType) {
if(!GetComponentCache.TryGetValue(componentType, out Func<World, Entity, ValueType>? fn)) {
MethodInfo method = FindGenericMethod(nameof(World.Get)).MakeGenericMethod(componentType);
fn = (w, e) => (ValueType)method.Invoke(w, new object[] { e })!;
GetComponentCache[componentType] = fn;
}
return fn(world, entity);
}
private static void WorldSetComponent(World world, Entity entity, string typeName, ValueType value) {
if(!ComponentRegistry.TryGetValue(typeName, out Type? componentType)) return;
if(!SetComponentCache.TryGetValue(componentType, out Action<World, Entity, ValueType>? fn)) {
MethodInfo method = FindGenericMethod(nameof(World.Set)).MakeGenericMethod(componentType);
fn = (w, e, v) => method.Invoke(w, new object[] { e, v });
SetComponentCache[componentType] = fn;
}
fn(world, entity, value);
}
// -------------------------------------------------------------------------
// Reflection helpers relations
// -------------------------------------------------------------------------
private static IEnumerable<(Entity, Entity)> WorldAllRelations(World world, Type relationType) {
// World.Relations<T>() returns ReverseSpanEnumerator<(Entity,Entity)>.
// We materialise it into a list so the caller can iterate freely.
MethodInfo method = FindGenericMethod(nameof(World.Relations)).MakeGenericMethod(relationType);
// Returns a boxed ReverseSpanEnumerator; invoke MoveNext/Current via dynamic.
// Easiest: call via dynamic to avoid unsafe span-from-box issues.
dynamic enumerator = method.Invoke(world, null)!;
List<(Entity, Entity)> results = new();
while(enumerator.MoveNext())
results.Add(enumerator.Current);
return results;
}
private static ValueType WorldGetRelationData(World world, Entity a, Entity b, Type relationType) {
if(!GetRelationDataCache.TryGetValue(relationType, out Func<World, Entity, Entity, ValueType>? fn)) {
MethodInfo method = FindGenericMethod(nameof(World.GetRelationData)).MakeGenericMethod(relationType);
fn = (w, ea, eb) => (ValueType)method.Invoke(w, new object[] { ea, eb })!;
GetRelationDataCache[relationType] = fn;
}
return fn(world, a, b);
}
private static void WorldRelate(World world, Entity a, Entity b, string typeName, ValueType value) {
if(!RelationRegistry.TryGetValue(typeName, out Type? relationType)) return;
if(!RelateCache.TryGetValue(relationType, out Action<World, Entity, Entity, ValueType>? fn)) {
MethodInfo method = FindGenericMethod(nameof(World.Relate)).MakeGenericMethod(relationType);
fn = (w, ea, eb, v) => method.Invoke(w, new object[] { ea, eb, v });
RelateCache[relationType] = fn;
}
fn(world, a, b, value);
}
// -------------------------------------------------------------------------
// Utility
// -------------------------------------------------------------------------
private static MethodInfo FindGenericMethod(string name) {
foreach(MethodInfo m in typeof(World).GetMethods(BindingFlags.Public | BindingFlags.Instance)) {
if(m.Name == name && m.IsGenericMethodDefinition)
return m;
}
throw new InvalidOperationException($"Could not find generic method '{name}' on {nameof(World)}.");
}
}
@@ -1,10 +0,0 @@
namespace Nerfed.Runtime.Scene;
/// <summary>
/// Marks an unmanaged struct as a serializable scene relation kind.
/// Both endpoints and the data payload will be saved/loaded by the scene system.
/// The <see cref="Components.ChildParentRelation"/> hierarchy is handled separately via
/// <see cref="SceneEntityData.ParentId"/> and should NOT be marked with this attribute.
/// </summary>
[AttributeUsage(AttributeTargets.Struct, Inherited = false)]
public sealed class SceneRelationAttribute : Attribute { }
@@ -1,202 +0,0 @@
using MoonTools.ECS;
using System.Collections.Generic;
using System;
using System.Numerics;
using Nerfed.Runtime.Components;
namespace Nerfed.Runtime.Scene.Streaming;
/// <summary>
/// Status of a chunk in the streaming system.
/// </summary>
public enum ChunkState
{
Unloaded,
Loading,
Loaded,
Unloading
}
/// <summary>
/// A system that manages spatial partitioning. It determines which chunks should be loaded based on observers.
/// </summary>
public class ChunkStreamingSystem : MoonTools.ECS.System
{
private readonly struct ChunkCoord : IEquatable<ChunkCoord>
{
public readonly int X;
public readonly int Y;
public readonly int Z;
// Pre-calculated on creation
public readonly long Id;
public ChunkCoord(int x, int y, int z)
{
X = x;
Y = y;
Z = z;
// We allocate 21 bits per axis (allowing ~2 million chunks positive and negative).
var hashX = (long)x & 0x1FFFFF;
var hashY = (long)y & 0x1FFFFF;
var hashZ = (long)z & 0x1FFFFF;
Id = hashX | (hashY << 21) | (hashZ << 42);
}
public bool Equals(ChunkCoord other) => Id == other.Id;
public override bool Equals(object? obj) => obj is ChunkCoord other && Equals(other);
public override int GetHashCode() => Id.GetHashCode();
}
// Configurable size of a chunk in world coordinates.
public float ChunkSize { get; set; } = 64f;
private readonly Filter _observerFilter;
private readonly Filter _chunkMemberFilter;
private readonly Filter _unloadedFilter;
// Active loaded/loading chunks
private readonly Dictionary<ChunkCoord, ChunkState> _activeChunks = new();
// Queue of chunks waiting to be loaded
private readonly Queue<ChunkCoord> _pendingLoads = new();
// Queue of chunks waiting to be completely tagged for unloading
private readonly Queue<ChunkCoord> _pendingUnloads = new();
public ChunkStreamingSystem(World world) : base(world)
{
_observerFilter = FilterBuilder
.Include<ChunkObserverComponent>()
.Include<LocalToWorld>() // Needs a world position
.Exclude<ChunkUnloadPendingTag>() // Ignore dying observers
.Build();
_chunkMemberFilter = FilterBuilder
.Include<ChunkMemberComponent>()
.Exclude<ChunkUnloadPendingTag>() // Ignore entities already marked for death
.Build();
_unloadedFilter = FilterBuilder
.Include<ChunkUnloadPendingTag>()
.Build();
}
public override void Update(TimeSpan delta)
{
var requiredChunks = new HashSet<ChunkCoord>();
// 1. Find all chunks that should be loaded based on observers
foreach (var observerEntity in _observerFilter.Entities)
{
var observer = Get<ChunkObserverComponent>(observerEntity);
var transform = Get<LocalToWorld>(observerEntity);
// Convert world pos to grid coordinates
var worldPos = transform.localToWorldMatrix.Translation;
var centerChunk = GetChunkCoord(worldPos);
// Determine chunk radius based on observer radius and chunk size
int chunkRadius = (int)MathF.Ceiling(observer.ViewRadius / ChunkSize);
for (int x = -chunkRadius; x <= chunkRadius; x++)
{
for (int y = -chunkRadius; y <= chunkRadius; y++)
{
for (int z = -chunkRadius; z <= chunkRadius; z++)
{
var coord = new ChunkCoord(centerChunk.X + x, centerChunk.Y + y, centerChunk.Z + z);
requiredChunks.Add(coord);
}
}
}
}
// 2. Unload chunks that are active but no longer required
var chunksToUnload = new List<ChunkCoord>();
foreach (var activeChunk in _activeChunks.Keys)
{
if (!requiredChunks.Contains(activeChunk) && _activeChunks[activeChunk] != ChunkState.Unloading)
{
chunksToUnload.Add(activeChunk);
}
}
foreach (var coord in chunksToUnload)
{
_activeChunks[coord] = ChunkState.Unloading;
_pendingUnloads.Enqueue(coord);
}
// 3. Queue newly required chunks
foreach (var coord in requiredChunks)
{
if (!_activeChunks.ContainsKey(coord))
{
// Mark as unloaded so we don't queue it multiple times
_activeChunks[coord] = ChunkState.Unloaded;
_pendingLoads.Enqueue(coord);
}
}
// 4. Process only ONE chunk load per frame to prevent stuttering
if (_pendingLoads.Count > 0)
{
var chunkToLoad = _pendingLoads.Dequeue();
// Double check it wasn't unloaded before we got around to loading it
if (_activeChunks.TryGetValue(chunkToLoad, out var state) && state == ChunkState.Unloaded)
{
LoadChunk(chunkToLoad);
}
}
// 5. Process only ONE chunk unload tagging per frame
if (_pendingUnloads.Count > 0)
{
var chunkToUnload = _pendingUnloads.Dequeue();
UnloadChunk(chunkToUnload);
}
}
private ChunkCoord GetChunkCoord(Vector3 worldPos)
{
return new ChunkCoord(
(int)MathF.Floor(worldPos.X / ChunkSize),
(int)MathF.Floor(worldPos.Y / ChunkSize),
(int)MathF.Floor(worldPos.Z / ChunkSize)
);
}
private void LoadChunk(ChunkCoord coord)
{
_activeChunks[coord] = ChunkState.Loading;
// TODO: In a real system, you'd queue async I/O here to read SceneData for this chunk
// and spawn the entities. Once they are all spawned, set state to Loaded.
// For demonstration, immediately set to loaded.
_activeChunks[coord] = ChunkState.Loaded;
}
private void UnloadChunk(ChunkCoord coord)
{
// Instead of destroying everything instantly, we tag the entities as 'Unloaded'
// so that they stop participating in rendering/gameplay, and get destroyed slowly
// by the ChunkTeardownSystem.
long coordId = coord.Id;
foreach (var entity in _chunkMemberFilter.Entities)
{
var chunkMember = Get<ChunkMemberComponent>(entity);
if (chunkMember.ChunkId == coordId)
{
Set(entity, new ChunkUnloadPendingTag());
}
}
// Immediately remove it from the required grid so it can be re-loaded
// if the player turns around quickly, while older entities are just garbage collected.
_activeChunks.Remove(coord);
}
}
@@ -1,35 +0,0 @@
using MoonTools.ECS;
using System;
namespace Nerfed.Runtime.Scene.Streaming;
/// <summary>
/// A centralized cleanup system for slowly destroying chunk entities to avoid frame stutters.
/// </summary>
public class ChunkTeardownSystem : MoonTools.ECS.System
{
private readonly Filter _unloadedFilter;
// Adjustable limit to prevent massive stutters when unloading chunks.
public int MaxEntitiesToDestroyPerFrame { get; set; } = 250;
public ChunkTeardownSystem(World world) : base(world)
{
_unloadedFilter = FilterBuilder
.Include<ChunkUnloadPendingTag>()
.Build();
}
public override void Update(TimeSpan delta)
{
int destroyed = 0;
foreach (var entity in _unloadedFilter.Entities)
{
if (destroyed >= MaxEntitiesToDestroyPerFrame) break;
Destroy(entity);
destroyed++;
}
}
}
@@ -1,28 +0,0 @@
using MoonTools.ECS;
using System.Numerics;
namespace Nerfed.Runtime.Scene.Streaming;
/// <summary>
/// Marks an entity as a streaming observer (e.g. the player camera) that causes chunks
/// to be loaded around it.
/// </summary>
public struct ChunkObserverComponent
{
public float ViewRadius;
}
/// <summary>
/// Tags an entity as belonging to a specific chunk, allowing it to be unloaded when the chunk is out of range.
/// </summary>
public struct ChunkMemberComponent
{
// A 64-bit spatial hash combining the X, Y, and Z coordinates.
public long ChunkId;
}
/// <summary>
/// Added to entities that belong to a chunk that has been unloaded.
/// A dedicated system will process and destroy these slowly over multiple frames.
/// </summary>
public struct ChunkUnloadPendingTag { }
+12 -37
View File
@@ -1,8 +1,6 @@
using MoonTools.ECS;
using Nerfed.Runtime.Components;
using Nerfed.Runtime.Util;
using System;
using System.Collections.Generic;
using System.Numerics;
// TODO:
@@ -17,19 +15,10 @@ namespace Nerfed.Runtime.Systems
{
public class LocalToWorldSystem : MoonTools.ECS.System
{
public override IReadOnlySet<Type> ReadsComponents { get; } = new HashSet<Type> { typeof(LocalTransform) };
public override IReadOnlySet<Type> WritesComponents { get; } = new HashSet<Type> { typeof(LocalToWorld) };
private readonly bool useParallelFor = true;
private const int ParallelForMinCount = 32; // Below this, parallel overhead costs more than it saves.
private static readonly System.Threading.Tasks.ParallelOptions ParallelOptions = new()
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};
private readonly bool useParallelFor = true; // When having a low amount of transforms or when in debug mode this might be slower.
private readonly Filter rootEntitiesFilter;
private readonly Filter entitiesWithoutLocalToWorldFilter;
private readonly Action<int> updateWorldTransform;
private ParallelWriter<LocalToWorld> _parallelWriter;
public LocalToWorldSystem(World world) : base(world)
{
@@ -50,63 +39,49 @@ namespace Nerfed.Runtime.Systems
if (useParallelFor)
{
Profiler.BeginSample("ParallelFor.LocalToWorldCheck");
// This check is needed because some entities might not have a LocalToWorld component yet.
// Adding this during the loop will break.
Profiler.BeginSample("ParallelFor.LocalToWorldCheck");
foreach (Entity entity in entitiesWithoutLocalToWorldFilter.Entities) {
Set(entity, new LocalToWorld(Matrix4x4.Identity));
}
Profiler.EndSample();
// Acquire a ParallelWriter AFTER pre-allocation — all entities now have LocalToWorld.
// This writer only permits updating existing values; no structural mutations allowed.
_parallelWriter = World.GetParallelWriter<LocalToWorld>();
Profiler.BeginSample("ParallelFor.LocalToWorldUpdate");
if (rootEntitiesFilter.Count >= ParallelForMinCount)
{
Parallel.For(0, rootEntitiesFilter.Count, ParallelOptions, updateWorldTransform);
}
else
{
// Not enough work to justify thread overhead — run serially.
for (int i = 0; i < rootEntitiesFilter.Count; i++)
{
updateWorldTransform(i);
}
}
// This should only be used when the filter doesn't change by executing these functions!
// So no entity deletion or setting/removing of components used by the filters in this loop.
Parallel.For(0, rootEntitiesFilter.Count, updateWorldTransform);
Profiler.EndSample();
}
else
{
foreach (Entity entity in rootEntitiesFilter.Entities)
{
// Profiler.BeginSample("UpdateWorldTransform");
Profiler.BeginSample("UpdateWorldTransform");
UpdateWorldTransform(entity, Matrix4x4.Identity);
// Profiler.EndSample();
Profiler.EndSample();
}
}
}
private void UpdateWorldTransformByIndex(int entityFilterIndex)
{
// Profiler.BeginSample("UpdateWorldTransformByIndex");
Profiler.BeginSample("UpdateWorldTransformByIndex");
Entity entity = rootEntitiesFilter.NthEntity(entityFilterIndex);
UpdateWorldTransform(entity, Matrix4x4.Identity);
// Profiler.EndSample();
Profiler.EndSample();
}
private void UpdateWorldTransform(in Entity entity, Matrix4x4 localToWorldMatrix)
{
// TODO: Only update dirty transforms.
// If a parent is dirty all the children need to update their localToWorld matrix.
// How do we check if something is dirty? How do we know if a LocalTransform has been changed?
if (Has<LocalTransform>(entity))
{
LocalTransform localTransform = Get<LocalTransform>(entity);
localToWorldMatrix = Matrix4x4.Multiply(localToWorldMatrix, localTransform.TRS());
LocalToWorld localToWorld = new(localToWorldMatrix);
if (useParallelFor)
_parallelWriter.Set(entity, localToWorld); // thread-safe: direct write, no structural mutation
else
Set(entity, localToWorld);
}
+79 -7
View File
@@ -5,6 +5,7 @@ namespace Nerfed.Runtime;
public class BoundedQueue<T> : IEnumerable<T>, ICollection, IReadOnlyCollection<T>
{
private readonly Queue<T> queue = null;
private readonly object syncLock = new object();
private readonly int maxSize = 10;
private T lastAddedElement;
@@ -15,59 +16,130 @@ public class BoundedQueue<T> : IEnumerable<T>, ICollection, IReadOnlyCollection<
}
public void Enqueue(T item)
{
Enqueue(item, out _);
}
public bool Enqueue(T item, out T evictedItem)
{
lock (syncLock)
{
queue.Enqueue(item);
if (queue.Count > maxSize)
{
queue.Dequeue(); // Remove the oldest element
evictedItem = queue.Dequeue();
lastAddedElement = item;
return true;
}
evictedItem = default;
lastAddedElement = item;
return false;
}
}
public T Dequeue()
{
lock (syncLock)
{
return queue.Dequeue();
}
}
public T Peek()
{
lock (syncLock)
{
return queue.Peek();
}
}
public T LastAddedElement()
{
lock (syncLock)
{
return lastAddedElement;
}
}
public void Clear()
{
lock (syncLock)
{
queue.Clear();
}
}
public bool Contains(T item)
{
lock (syncLock)
{
return queue.Contains(item);
}
}
// Iterates the internal Queue<T> directly (struct enumerator, no allocation) under the lock.
public int CopyTo(List<T> destination)
{
lock (syncLock)
{
destination.Clear();
foreach (T item in queue)
{
destination.Add(item);
}
return destination.Count;
}
}
public IEnumerator<T> GetEnumerator()
{
return queue.GetEnumerator();
T[] snapshot;
lock (syncLock)
{
snapshot = queue.ToArray();
}
return ((IEnumerable<T>)snapshot).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return queue.GetEnumerator();
return GetEnumerator();
}
public void CopyTo(Array array, int index)
{
lock (syncLock)
{
((ICollection)queue).CopyTo(array, index);
}
}
public int Count
{
get
{
lock (syncLock)
{
return queue.Count;
}
}
}
public int Count => queue.Count;
public int Capacity => maxSize;
public bool IsSynchronized => ((ICollection)queue).IsSynchronized;
public object SyncRoot => ((ICollection)queue).SyncRoot;
int IReadOnlyCollection<T>.Count => queue.Count;
public bool IsSynchronized => true;
public object SyncRoot => syncLock;
int IReadOnlyCollection<T>.Count
{
get
{
lock (syncLock)
{
return queue.Count;
}
}
}
}